cerca

lean forum software (pmc local branch)
Log | Files | Refs | README | LICENSE

commit 553edadf17558020086ae9577c675bcbcfc58489
parent d9931cead4e0921dbf398e93fe9386a8c7d9d33f
Author: Alexander Cobleigh <cblgh@cblgh.org>
Date:   Sun, 21 Jul 2024 21:16:01 +0200

Merge pull request #66 from decentral1se/private-threads

feat: private threads
Diffstat:
MMIGRATIONS.md | 14++++++++++++++
Mcmd/migration-tool/main.go | 5++++-
Mdatabase/database.go | 31+++++++++++++++++++++++--------
Mdatabase/migrations.go | 31+++++++++++++++++++++++++++++++
Mhtml/head.html | 3+++
Mhtml/index.html | 5++++-
Mhtml/new-thread.html | 4++++
Mhtml/thread.html | 3+++
Mi18n/i18n.go | 2++
Mserver/server.go | 37++++++++++++++++++++++++++-----------
10 files changed, 114 insertions(+), 21 deletions(-)

diff --git a/MIGRATIONS.md b/MIGRATIONS.md @@ -3,6 +3,20 @@ This documents migrations for breaking database changes. These are intended to be as few as possible, but sometimes they are necessary. +## [2024-07-20] Private threads + +Add a column to `database.Thread` to signal whether or not the thread is private. + +For more details, see [database/migrations.go](./database/migrations.go). + +Build and then run the migration tool in `cmd/migration-tool` accordingly: + +``` +cd cmd/migration-tool +go build +./migration-tool --database path-to-your-forum.db --migration 2024-02-thread-private-migration +``` + ## [2024-01-16] Migrating password hash libraries To support 32 bit architectures, such as running Cerca on an older Raspberry Pi, the password diff --git a/cmd/migration-tool/main.go b/cmd/migration-tool/main.go @@ -25,7 +25,10 @@ func complain(msg string, args ...interface{}) { } func main() { - migrations := map[string]func(string) error{"2024-01-password-hash-migration": database.Migration20240116_PwhashChange} + migrations := map[string]func(string) error{ + "2024-01-password-hash-migration": database.Migration20240116_PwhashChange, + "2024-02-thread-private-migration": database.Migration20240720_ThreadPrivateChange, + } var dbPath, migration string var listMigrations bool diff --git a/database/database.go b/database/database.go @@ -150,6 +150,7 @@ func createTables(db *sql.DB) { publishtime DATE, topicid INTEGER, authorid INTEGER, + private INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(topicid) REFERENCES topics(id), FOREIGN KEY(authorid) REFERENCES users(id) ); @@ -189,19 +190,19 @@ func (d DB) Exec(stmt string, args ...interface{}) (sql.Result, error) { return d.db.Exec(stmt, args...) } -func (d DB) CreateThread(title, content string, authorid, topicid int) (int, error) { +func (d DB) CreateThread(title, content string, authorid, topicid int, private int) (int, error) { ed := util.Describe("create thread") // create the new thread in a transaction spanning two statements tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) // proper tx options? ed.Check(err, "start transaction") // first, create the new thread publish := time.Now() - threadStmt := `INSERT INTO threads (title, publishtime, topicid, authorid) VALUES (?, ?, ?, ?) + threadStmt := `INSERT INTO threads (title, publishtime, topicid, authorid, private) VALUES (?, ?, ?, ?, ?) RETURNING id` replyStmt := `INSERT INTO posts (content, publishtime, threadid, authorid) VALUES (?, ?, ?, ?)` var threadid int - err = tx.QueryRow(threadStmt, title, publish, topicid, authorid).Scan(&threadid) - if err = ed.Eout(err, "add thread %s by %d in topic %d", title, authorid, topicid); err != nil { + err = tx.QueryRow(threadStmt, title, publish, topicid, authorid, private).Scan(&threadid) + if err = ed.Eout(err, "add thread %s (private: %d) by %d in topic %d", title, private, authorid, topicid); err != nil { _ = tx.Rollback() log.Println(err, "rolling back") return -1, err @@ -290,6 +291,7 @@ type Thread struct { Title string Author string Slug string + Private int ID int Publish time.Time PostID int @@ -298,11 +300,12 @@ type Thread struct { // get a list of threads // NOTE: this query is setting thread.Author not by thread creator, but latest poster. if this becomes a problem, revert // its use and employ Thread.PostID to perform another query for each thread to get the post author name (wrt server.go:GenerateRSS) -func (d DB) ListThreads(sortByPost bool) []Thread { +func (d DB) ListThreads(sortByPost bool, private int) []Thread { query := ` - SELECT count(t.id), t.title, t.id, u.name, p.publishtime, p.id FROM threads t + SELECT count(t.id), t.title, t.id, t.private, u.name, p.publishtime, p.id FROM threads t INNER JOIN users u on u.id = p.authorid INNER JOIN posts p ON t.id = p.threadid + %s GROUP BY t.id %s ` @@ -311,7 +314,11 @@ func (d DB) ListThreads(sortByPost bool) []Thread { if sortByPost { orderBy = `ORDER BY max(p.id) DESC` } - query = fmt.Sprintf(query, orderBy) + where := `WHERE t.private IN (0,1)` + if private == 0 { + where = `WHERE t.private = 0` + } + query = fmt.Sprintf(query, where, orderBy) stmt, err := d.db.Prepare(query) util.Check(err, "list threads: prepare query") @@ -325,7 +332,7 @@ func (d DB) ListThreads(sortByPost bool) []Thread { var data Thread var threads []Thread for rows.Next() { - if err := rows.Scan(&postCount, &data.Title, &data.ID, &data.Author, &data.Publish, &data.PostID); err != nil { + if err := rows.Scan(&postCount, &data.Title, &data.ID, &data.Private, &data.Author, &data.Publish, &data.PostID); err != nil { log.Fatalln(util.Eout(err, "list threads: read in data via scan")) } data.Slug = util.GetThreadSlug(data.ID, data.Title, postCount) @@ -334,6 +341,14 @@ func (d DB) ListThreads(sortByPost bool) []Thread { return threads } +func (d DB) IsThreadPrivate(threadId int) int { + var private int + stmt := `SELECT private FROM threads where id = ?` + err := d.db.QueryRow(stmt, threadId).Scan(&private) + util.Check(err, "querying if private thread %d", threadId) + return private +} + func (d DB) AddPost(content string, threadid, authorid int) (postID int) { stmt := `INSERT INTO posts (content, publishtime, threadid, authorid) VALUES (?, ?, ?, ?) RETURNING id` publish := time.Now() diff --git a/database/migrations.go b/database/migrations.go @@ -214,3 +214,34 @@ func Migration20240116_PwhashChange(filepath string) (finalErr error) { _ = tx.Commit() return } + +func Migration20240720_ThreadPrivateChange(filepath string) (finalErr error) { + d := InitDB(filepath) + + // always perform migrations in a single transaction + tx, err := d.db.BeginTx(context.Background(), &sql.TxOptions{}) + rollbackOnErr := func(incomingErr error) bool { + if incomingErr != nil { + _ = tx.Rollback() + log.Println(incomingErr, "\nrolling back") + finalErr = incomingErr + return true + } + return false + } + + stmt := `ALTER TABLE threads + ADD COLUMN private INTEGER NOT NULL DEFAULT 0 + ` + + _, err = tx.Exec(stmt) + if err != nil { + if rollbackOnErr(err) { + return + } + } + + _ = tx.Commit() + + return nil +} diff --git a/html/head.html b/html/head.html @@ -78,6 +78,9 @@ display: block; width: 100%; } + #thread-private { + label { display: inline; } + } body { padding: 2rem; } * { margin-bottom: 1rem; } diff --git a/html/index.html b/html/index.html @@ -4,7 +4,10 @@ <p> There are currently no threads. </p> {{ end }} {{ range $index, $thread := .Data.Threads }} - <h2><a href="{{$thread.Slug}}">{{ $thread.Title }}</a></h2> + <h2> + <a href="{{$thread.Slug}}">{{ $thread.Title }}</a> + {{ if eq $thread.Private 1}} ⚿ {{ end }} + </h2> {{ end }} </main> {{ if .LoggedIn }} diff --git a/html/new-thread.html b/html/new-thread.html @@ -7,6 +7,10 @@ <input required name="title" type="text" id="Title"> <label for="content">{{ "Content" | translate }}:</label> <textarea required name="content" id="content" placeholder='{{ "TextareaPlaceholder" | translate }}'></textarea> + <div id="thread-private"> + <label for="isPrivate">{{ "Private" | translate }}</label> + <input type="checkbox" id="isPrivate" name="isPrivate" value="1" /> + </div> <button type="submit">{{ "Create" | translate }}</button> </div> </form> diff --git a/html/thread.html b/html/thread.html @@ -1,6 +1,9 @@ {{ template "head" . }} <main> <h1>{{ .Data.Title }}</h1> + {{ if eq .Data.Private 1}} + <p>{{ "PostPrivate" | translate }}</p> + {{ end }} {{ $userID := .LoggedInID }} {{ $threadURL := .Data.ThreadURL }} {{ range $index, $post := .Data.Posts }} diff --git a/i18n/i18n.go b/i18n/i18n.go @@ -103,6 +103,7 @@ var English = map[string]string{ "ThreadCreate": "Create thread", "Title": "Title", "Content": "Content", + "Private": "Private", "Create": "Create", "TextareaPlaceholder": "Tabula rasa", @@ -130,6 +131,7 @@ var English = map[string]string{ "NewThreadCreateError": "Error creating thread", "NewThreadCreateErrorMessage": "There was a database error when creating the thread, apologies.", "PostEdit": "Post preview", + "PostPrivate": "This is a private thread, only logged-in users can see it and read its posts", "AriaPostMeta": "Post meta", "AriaDeletePost": "Delete this post", diff --git a/server/server.go b/server/server.go @@ -81,6 +81,7 @@ type ThreadData struct { Title string Posts []database.Post ThreadURL string + Private int } type EditPostData struct { @@ -298,13 +299,12 @@ func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) loggedIn, userid := h.IsLoggedIn(req) isAdmin, _ := h.IsAdmin(req) + threadMissingData := GenericMessageData{ + Title: h.translator.Translate("ErrThread404"), + Message: h.translator.Translate("ErrThread404Message"), + } if !ok { - title := h.translator.Translate("ErrThread404") - data := GenericMessageData{ - Title: title, - Message: h.translator.Translate("ErrThread404Message"), - } - h.renderGenericMessage(res, req, data) + h.renderGenericMessage(res, req, threadMissingData) return } @@ -328,8 +328,13 @@ func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) } // TODO (2022-01-07): // * handle error + isPrivate := h.db.IsThreadPrivate(threadid) + if (isPrivate == 1) && !loggedIn { + h.renderGenericMessage(res, req, threadMissingData) + return + } thread := h.db.GetThread(threadid) - data := ThreadData{Posts: thread, ThreadURL: req.URL.Path} + data := ThreadData{Posts: thread, ThreadURL: req.URL.Path, Private: isPrivate} view := TemplateData{Data: &data, IsAdmin: isAdmin, QuickNav: loggedIn, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid} if len(thread) > 0 { data.Title = thread[0].ThreadTitle @@ -363,7 +368,11 @@ func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { mostRecentPost = sortby == "posts" } // show index listing - threads := h.db.ListThreads(mostRecentPost) + private := 0 + if loggedIn { + private = 1 + } + threads := h.db.ListThreads(mostRecentPost, private) view := TemplateData{Data: IndexData{threads}, IsAdmin: isAdmin, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Threads")} h.renderView(res, "index", view) } @@ -386,7 +395,7 @@ func GenerateRSS(db *database.DB, config types.Config) string { } // TODO (2022-12-08): augment ListThreads to choose getting author of latest post or thread creator (currently latest // post always) - threads := db.ListThreads(true) + threads := db.ListThreads(true, 0) entries := make([]string, len(threads)) for i, t := range threads { fulltime := t.Publish.Format(rfc822RSS) @@ -710,14 +719,20 @@ func (h *RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reque h.renderGenericMessage(res, req, data) return } - h.renderView(res, "new-thread", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("ThreadNew")}) + h.renderView(res, "new-thread", TemplateData{ + HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("ThreadNew")}) case "POST": // Handle POST (=> title := req.PostFormValue("title") content := req.PostFormValue("content") + + private := 0 + if req.PostFormValue("isPrivate") == "1" { + private = 1 + } // TODO (2022-01-10): unstub topicid, once we have other topics :) // the new thread was created: forward info to database - threadid, err := h.db.CreateThread(title, content, userid, 1) + threadid, err := h.db.CreateThread(title, content, userid, 1, private) if err != nil { data := GenericMessageData{ Title: h.translator.Translate("NewThreadCreateError"),