From 84a388d2bf5993492652b81dcc52441b4256cb84 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Tue, 17 Oct 2023 20:28:18 +0300 Subject: [PATCH] add thread view --- README.md | 4 +- front/handler.go | 3 + front/thread.go | 150 ++++++++++++++++++++++++++ front/view.go | 30 +++++- migrations/008_thread.go | 18 ++++ test/thread_test.go | 222 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 front/thread.go create mode 100644 migrations/008_thread.go create mode 100644 test/thread_test.go diff --git a/README.md b/README.md index 5dacbb6f..77807bcc 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ or, to build a static executable: * /hashtags shows a list of popular hashtags. * /stats shows statistics and server health metrics. -* /view shows a complete post with extra details like links in the post, a list mentioned users, a list of hashtags, a link to the author's outbox, a list of replies and a link to the parent post (if any). +* /view shows a complete post with extra details like links in the post, a list mentioned users, a list of hashtags, a link to the author's outbox, a list of replies and a link to the parent post (if found). +* /thread displays a tree of replies in a thread. * /outbox shows list of posts by a user. Users are authenticated using TLS client certificates; see [Gemini protocol specification](https://gemini.circumlunar.space/docs/specification.html) for more details. The following pages require authentication: @@ -93,6 +94,7 @@ Some clients generate a certificate for / (all pages of this capsule) when /foo * /users/hashtags * /users/stats * /users/view +* /users/thread This way, users who prefer not to provide a client certificate when browsing to /x can reply to public posts by using /users/x instead. diff --git a/front/handler.go b/front/handler.go index 1d15807d..dd8828ea 100644 --- a/front/handler.go +++ b/front/handler.go @@ -59,6 +59,9 @@ func NewHandler() Handler { h[regexp.MustCompile(`^/view/[0-9a-f]{64}$`)] = withUserMenu(view) h[regexp.MustCompile(`^/users/view/[0-9a-f]{64}$`)] = withUserMenu(view) + h[regexp.MustCompile(`^/thread/[0-9a-f]{64}$`)] = withUserMenu(thread) + h[regexp.MustCompile(`^/users/thread/[0-9a-f]{64}$`)] = withUserMenu(thread) + h[regexp.MustCompile(`^/users/whisper$`)] = whisper h[regexp.MustCompile(`^/users/say$`)] = say h[regexp.MustCompile(`^/users/dm/[0-9a-f]{64}`)] = dm diff --git a/front/thread.go b/front/thread.go new file mode 100644 index 00000000..ccc9555d --- /dev/null +++ b/front/thread.go @@ -0,0 +1,150 @@ +/* +Copyright 2023 Dima Krasner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package front + +import ( + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/front/text" + "path/filepath" + "strings" +) + +type threadNode struct { + Depth int + PostID, Inserted, AuthorUserName string +} + +func thread(w text.Writer, r *request) { + hash := filepath.Base(r.URL.Path) + + offset, err := getOffset(r.URL) + if err != nil { + r.Log.Info("Failed to parse query", "error", err) + w.Status(40, "Invalid query") + return + } + + r.Log.Info("Viewing thread", "hash", hash) + + var threadHead sql.NullString + if err := r.QueryRow(`with recursive thread(id, parent) as (select notes.id, notes.object->>'inReplyTo' as parent from notes where hash = ? union select notes.id, notes.object->>'inReplyTo' as parent from thread t join notes on notes.id = t.parent) select thread.id from thread where thread.parent is null limit 1`, hash).Scan(&threadHead); err != nil && !errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Failed to fetch thread head", "error", err) + w.Error() + return + } + + var rootAuthorID, rootAuthorType, rootAuthorUsername string + var rootAuthorName sql.NullString + if err := r.QueryRow(`select persons.id, persons.actor->>'type', persons.actor->>'preferredUsername', persons.actor->>'name' from notes join persons on persons.id = notes.author where notes.hash = ?`, hash).Scan(&rootAuthorID, &rootAuthorType, &rootAuthorUsername, &rootAuthorName); err != nil && !errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Failed to fetch thread root author", "error", err) + w.Error() + return + } + + rows, err := r.Query(`select thread.depth, thread.id, strftime('%Y-%m-%d', datetime(thread.inserted, 'unixepoch')), persons.actor->>'preferredUsername' from (with recursive thread(id, author, inserted, parent, depth, path) as (select notes.id, notes.author, notes.inserted, object->>'inReplyTo' as parent, 0 as depth, notes.id as path from notes where hash = $1 union select notes.id, notes.author, notes.inserted, notes.object->>'inReplyTo', t.depth + 1, t.path || ';' || notes.id from thread t join notes on notes.object->>'inReplyTo' = t.id where t.depth <= 5) select thread.depth, thread.id, thread.author, thread.inserted, thread.path from thread order by thread.path limit $2 offset $3) thread join persons on persons.id = thread.author order by thread.path`, hash, postsPerPage, offset) + if err != nil { + r.Log.Info("Failed to fetch thread", "hash", hash, "error", err) + w.Status(40, "Post not found") + return + } + defer rows.Close() + + count := 0 + nodes := make([]threadNode, postsPerPage) + + for rows.Next() { + if err := rows.Scan( + &nodes[count].Depth, + &nodes[count].PostID, + &nodes[count].Inserted, + &nodes[count].AuthorUserName, + ); err != nil { + r.Log.Info("Failed to scan post", "hash", hash, "error", err) + continue + } + + count++ + } + rows.Close() + + if count == 0 { + r.Log.Info("Failed to fetch any nodes in thread", "hash", hash) + w.Error() + return + } + + w.OK() + + var displayName string + if rootAuthorName.Valid { + displayName = getDisplayName(rootAuthorID, rootAuthorUsername, rootAuthorName.String, ap.ActorType(rootAuthorType), r.Log) + } else { + displayName = getDisplayName(rootAuthorID, rootAuthorUsername, "", ap.ActorType(rootAuthorType), r.Log) + } + w.Titlef("馃У Replies to %s", displayName) + + for _, node := range nodes[:count] { + var b strings.Builder + b.WriteString(node.Inserted) + b.WriteByte(' ') + if node.Depth > 0 { + for i := 0; i < node.Depth; i++ { + b.WriteRune('路') + } + b.WriteByte(' ') + } + b.WriteString(node.AuthorUserName) + + if r.User == nil { + w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(node.PostID))), b.String()) + } else { + w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(node.PostID))), b.String()) + } + } + + if (threadHead.Valid && count > 0 && threadHead.String != nodes[0].PostID) || offset >= postsPerPage || count == postsPerPage { + w.Separator() + } + + if threadHead.Valid && count > 0 && threadHead.String != nodes[0].PostID && r.User == nil { + w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(threadHead.String))), "View first post in thread") + } else if threadHead.Valid && count > 0 && threadHead.String != nodes[0].PostID { + w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(threadHead.String))), "View first post in thread") + } + + if offset > postsPerPage && r.User == nil { + w.Link("/thread/"+hash, "First page") + } else if offset > postsPerPage { + w.Link("/users/thread/"+hash, "First page") + } + + if offset >= postsPerPage && r.User == nil { + w.Linkf(fmt.Sprintf("/thread/%s?%d", hash, offset-postsPerPage), "Previous page (%d-%d)", offset-postsPerPage, offset) + } else if offset >= postsPerPage { + w.Linkf(fmt.Sprintf("/users/thread/%s?%d", hash, offset-postsPerPage), "Previous page (%d-%d)", offset-postsPerPage, offset) + } + + if count == postsPerPage && r.User == nil { + w.Linkf(fmt.Sprintf("/thread/%s?%d", hash, offset+postsPerPage), "Next page (%d-%d)", offset+postsPerPage, offset+2*postsPerPage) + } else if count == postsPerPage { + w.Linkf(fmt.Sprintf("/users/thread/%s?%d", hash, offset+postsPerPage), "Next page (%d-%d)", offset+postsPerPage, offset+2*postsPerPage) + } +} diff --git a/front/view.go b/front/view.go index c396ee30..669f5a38 100644 --- a/front/view.go +++ b/front/view.go @@ -162,20 +162,42 @@ func view(w text.Writer, r *request) { r.PrintNotes(w, replies, true, false) var originalPostExists int + var threadHead sql.NullString if note.InReplyTo != "" { if err := r.QueryRow(`select exists (select 1 from notes where id = ?)`, note.InReplyTo).Scan(&originalPostExists); err != nil { - r.Log.Warn("Failed to check if original post exists", "error", err) + r.Log.Warn("Failed to check if parent post exists", "error", err) } + + if err := r.QueryRow(`with recursive thread(id, parent, depth) as (select notes.id, notes.object->>'inReplyTo' as parent, 1 as depth from notes where id = ? union select notes.id, notes.object->>'inReplyTo' as parent, t.depth + 1 from thread t join notes on notes.id = t.parent) select id from thread order by depth desc limit 1`, note.InReplyTo).Scan(&threadHead); err != nil { + r.Log.Warn("Failed to fetch first post in thread", "error", err) + } + } + + var threadDepth int + if err := r.QueryRow(`with recursive thread(id, depth) as (select notes.id, 0 as depth from notes where id = ? union select notes.id, t.depth + 1 from thread t join notes on notes.object->>'inReplyTo' = t.id where t.depth <= 2) select max(thread.depth) from thread`, note.ID).Scan(&threadDepth); err != nil { + r.Log.Warn("Failed to query thread depth", "error", err) } - if originalPostExists == 1 || offset >= repliesPerPage || count == repliesPerPage { + if originalPostExists == 1 || threadHead.Valid || threadDepth > 1 || offset >= repliesPerPage || count == repliesPerPage { w.Separator() } if originalPostExists == 1 && r.User == nil { - w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(note.InReplyTo))), "View original post") + w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(note.InReplyTo))), "View parent post") } else if originalPostExists == 1 { - w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(note.InReplyTo))), "View original post") + w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(note.InReplyTo))), "View parent post") + } + + if threadHead.Valid && threadHead.String != note.ID && r.User == nil { + w.Link(fmt.Sprintf("/view/%x", sha256.Sum256([]byte(threadHead.String))), "View first post in thread") + } else if threadHead.Valid && threadHead.String != note.ID { + w.Link(fmt.Sprintf("/users/view/%x", sha256.Sum256([]byte(threadHead.String))), "View first post in thread") + } + + if threadDepth > 1 && r.User == nil { + w.Link("/thread/"+hash, "View thread") + } else if threadDepth > 1 { + w.Link("/users/thread/"+hash, "View thread") } if offset > repliesPerPage && r.User == nil { diff --git a/migrations/008_thread.go b/migrations/008_thread.go new file mode 100644 index 00000000..68285759 --- /dev/null +++ b/migrations/008_thread.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "context" + "database/sql" +) + +func thread(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `CREATE INDEX notesidinreplytoauthorinserted ON notes(id, object->>'inReplyTo', author, inserted)`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `DROP INDEX notesinreplyto`); err != nil { + return err + } + + return nil +} diff --git a/test/thread_test.go b/test/thread_test.go new file mode 100644 index 00000000..7ff5d57a --- /dev/null +++ b/test/thread_test.go @@ -0,0 +1,222 @@ +/* +Copyright 2023 Dima Krasner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package test + +import ( + "crypto/sha256" + "fmt" + "github.com/stretchr/testify/assert" + "strings" + "testing" +) + +func TestThread_TwoReplies(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + say := server.Handle("/users/say?Hello%20world", server.Bob) + assert.Regexp("30 /users/view/[0-9a-f]{64}", say) + + hash := say[15 : len(say)-2] + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Alice) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + reply = server.Handle(fmt.Sprintf("/users/reply/%s?Hi%%20Bob", hash), server.Carol) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + view := server.Handle("/users/view/"+reply[15:len(reply)-2], server.Alice) + assert.Contains(strings.Split(view, "\n"), fmt.Sprintf("=> /users/view/%s View first post in thread", hash)) + assert.NotContains(view, "View thread") + + thread := server.Handle("/users/thread/"+hash, server.Alice) + assert.Contains(thread, "Replies to 馃槇 bob") + assert.Contains(thread, " bob") + assert.Contains(thread, " 路 alice") + assert.Contains(thread, " 路 carol") +} + +func TestThread_NestedReplies(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + say := server.Handle("/users/say?Hello%20world", server.Bob) + assert.Regexp("30 /users/view/[0-9a-f]{64}", say) + + hash := say[15 : len(say)-2] + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Alice) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + reply = server.Handle(fmt.Sprintf("/users/reply/%s?Hi%%20Bob", reply[15:len(reply)-2]), server.Carol) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + view := server.Handle("/users/view/"+hash, server.Alice) + assert.NotContains(view, "View first post in thread") + assert.Contains(strings.Split(view, "\n"), fmt.Sprintf("=> /users/thread/%s View thread", hash)) + + thread := server.Handle("/users/thread/"+hash, server.Alice) + assert.Contains(thread, "Replies to 馃槇 bob") + assert.Contains(thread, " bob") + assert.Contains(thread, " 路 alice") + assert.Contains(thread, " 路路 carol") +} + +func TestThread_NestedReply(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + say := server.Handle("/users/say?Hello%20world", server.Bob) + assert.Regexp("30 /users/view/[0-9a-f]{64}", say) + + hash := say[15 : len(say)-2] + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Alice) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + view := server.Handle("/users/view/"+hash, server.Alice) + assert.NotContains(view, "View first post in thread") + assert.NotContains(view, "View thread") + + thread := server.Handle("/users/thread/"+hash, server.Alice) + assert.Contains(thread, "Replies to 馃槇 bob") + assert.Contains(thread, " bob") + assert.Contains(thread, " 路 alice") + assert.NotContains(thread, "carol") +} + +func TestThread_NoReplies(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + say := server.Handle("/users/say?Hello%20world", server.Bob) + assert.Regexp("30 /users/view/[0-9a-f]{64}", say) + + hash := say[15 : len(say)-2] + + view := server.Handle("/users/view/"+hash, server.Alice) + assert.NotContains(view, "View first post in thread") + assert.NotContains(view, "View thread") + + thread := server.Handle("/users/thread/"+hash, server.Alice) + assert.Contains(thread, "Replies to 馃槇 bob") + assert.Contains(thread, " bob") + assert.NotContains(thread, "alice") + assert.NotContains(thread, "carol") +} + +func TestThread_NestedRepliesFromBottom(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + say := server.Handle("/users/say?Hello%20world", server.Bob) + assert.Regexp("30 /users/view/[0-9a-f]{64}", say) + + hash := say[15 : len(say)-2] + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Alice) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + reply = server.Handle(fmt.Sprintf("/users/reply/%s?Hi%%20Bob", reply[15:len(reply)-2]), server.Carol) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + view := server.Handle("/users/view/"+reply[15:len(reply)-2], server.Alice) + assert.Contains(strings.Split(view, "\n"), fmt.Sprintf("=> /users/view/%s View first post in thread", hash)) + assert.NotContains(view, "View thread") + + thread := server.Handle("/users/thread/"+reply[15:len(reply)-2], server.Alice) + assert.Contains(thread, "Replies to 馃槇 carol") + assert.NotContains(thread, "路 bob") + assert.NotContains(thread, "alice") + assert.Contains(thread, "carol") +} + +func TestThread_NestedRepliesFromBottomMissingNode(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + say := server.Handle("/users/say?Hello%20world", server.Bob) + assert.Regexp("30 /users/view/[0-9a-f]{64}", say) + + hash := say[15 : len(say)-2] + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Alice) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + firstReplyHash := reply[15 : len(reply)-2] + + reply = server.Handle(fmt.Sprintf("/users/reply/%s?Hi%%20Bob", firstReplyHash), server.Carol) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + delete := server.Handle("/users/delete/"+firstReplyHash, server.Alice) + assert.Equal(fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Alice.ID))), delete) + + view := server.Handle("/users/view/"+reply[15:len(reply)-2], server.Alice) + assert.NotContains(view, "View first post in thread") + assert.NotContains(view, "View thread") + + thread := server.Handle("/users/thread/"+reply[15:len(reply)-2], server.Alice) + assert.Contains(thread, "Replies to 馃槇 carol") + assert.NotContains(thread, "bob") + assert.NotContains(thread, "alice") + assert.Contains(thread, "carol") +} + +func TestThread_NestedRepliesFromBottomMissingFirstNode(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + say := server.Handle("/users/say?Hello%20world", server.Bob) + assert.Regexp("30 /users/view/[0-9a-f]{64}", say) + + hash := say[15 : len(say)-2] + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Alice) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + parentReplyHash := reply[15 : len(reply)-2] + + reply = server.Handle(fmt.Sprintf("/users/reply/%s?Hi%%20Bob", parentReplyHash), server.Carol) + assert.Regexp("30 /users/view/[0-9a-f]{64}", reply) + + delete := server.Handle("/users/delete/"+hash, server.Bob) + assert.Equal(fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), delete) + + view := server.Handle("/users/view/"+reply[15:len(reply)-2], server.Alice) + assert.Contains(strings.Split(view, "\n"), fmt.Sprintf("=> /users/view/%s View first post in thread", parentReplyHash)) + assert.NotContains(view, "View thread") + + thread := server.Handle("/users/thread/"+reply[15:len(reply)-2], server.Alice) + assert.Contains(thread, "Replies to 馃槇 carol") + assert.NotContains(thread, "bob") + assert.NotContains(thread, "alice") + assert.Contains(thread, "carol") +}