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:
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"),