diff --git a/README.md b/README.md index 8b0f9ceb..fee3c381 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,7 @@ Welcome, fedinaut! localhost.localdomain:8443 is an instance of tootik, a federa 🔥 Hashtags 🔭 Find user 🔎 Search posts -💌 Post to mentioned users -🔔 Post to followers -📣 Post to public +📣 New post ⚙️ Settings 📊 Statistics 🛟 Help @@ -102,7 +100,7 @@ tootik is lightweight, private and accessible social network: * With support for [Mastodon's follower synchronization mechanism](https://docs.joinmastodon.org/spec/activitypub/#follower-synchronization-mechanism), aka [FEP-8fcf](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) * Multi-choice polls * Full-text search within posts -* Upload of user avatars, over [Titan](gemini://transjovian.org/titan) +* Upload of posts and user avatars, over [Titan](gemini://transjovian.org/titan) * Account migration, in both directions ## Using tootik diff --git a/front/bio.go b/front/bio.go index 1c189b45..a0ab3544 100644 --- a/front/bio.go +++ b/front/bio.go @@ -20,13 +20,12 @@ import ( "github.com/dimkr/tootik/front/text" "github.com/dimkr/tootik/front/text/plain" "github.com/dimkr/tootik/outbox" - "net/url" "strings" "time" "unicode/utf8" ) -func (h *Handler) bio(w text.Writer, r *request, args ...string) { +func (h *Handler) doBio(w text.Writer, r *request, readInput func(text.Writer, *request) (string, bool)) { if r.User == nil { w.Redirect("/users") return @@ -40,14 +39,8 @@ func (h *Handler) bio(w text.Writer, r *request, args ...string) { return } - if r.URL.RawQuery == "" { - w.Status(10, "Summary") - return - } - - summary, err := url.QueryUnescape(r.URL.RawQuery) - if err != nil { - w.Status(40, "Bad input") + summary, ok := readInput(w, r) + if !ok { return } @@ -90,3 +83,23 @@ func (h *Handler) bio(w text.Writer, r *request, args ...string) { w.Redirect("/users/outbox/" + strings.TrimPrefix(r.User.ID, "https://")) } + +func (h *Handler) bio(w text.Writer, r *request, args ...string) { + h.doBio( + w, + r, + func(w text.Writer, r *request) (string, bool) { + return readQuery(w, r, "Bio") + }, + ) +} + +func (h *Handler) uploadBio(w text.Writer, r *request, args ...string) { + h.doBio( + w, + r, + func(w text.Writer, r *request) (string, bool) { + return readBody(w, r, args) + }, + ) +} diff --git a/front/dm.go b/front/dm.go index ef281d93..56a7670d 100644 --- a/front/dm.go +++ b/front/dm.go @@ -25,5 +25,16 @@ func (h *Handler) dm(w text.Writer, r *request, args ...string) { to := ap.Audience{} cc := ap.Audience{} - h.post(w, r, nil, nil, to, cc, "", "Post content") + h.post(w, r, nil, nil, to, cc, "", func() (string, bool) { + return readQuery(w, r, "Post content") + }) +} + +func (h *Handler) uploadDM(w text.Writer, r *request, args ...string) { + to := ap.Audience{} + cc := ap.Audience{} + + h.post(w, r, nil, nil, to, cc, "", func() (string, bool) { + return readBody(w, r, args) + }) } diff --git a/front/edit.go b/front/edit.go index f02edf21..a943826e 100644 --- a/front/edit.go +++ b/front/edit.go @@ -22,33 +22,15 @@ import ( "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text" "math" - "net/url" "time" - "unicode/utf8" ) -func (h *Handler) edit(w text.Writer, r *request, args ...string) { +func (h *Handler) doEdit(w text.Writer, r *request, args []string, readInput inputFunc) { if r.User == nil { w.Redirect("/users") return } - if r.URL.RawQuery == "" { - w.Status(10, "Post content") - return - } - - content, err := url.QueryUnescape(r.URL.RawQuery) - if err != nil { - w.Status(40, "Bad input") - return - } - - if utf8.RuneCountInString(content) > h.Config.MaxPostsLength { - w.Status(40, "Post is too long") - return - } - postID := "https://" + args[1] var note ap.Object @@ -88,7 +70,7 @@ func (h *Handler) edit(w text.Writer, r *request, args ...string) { } if note.InReplyTo == "" { - h.post(w, r, ¬e, nil, note.To, note.CC, note.Audience, "Post content") + h.post(w, r, ¬e, nil, note.To, note.CC, note.Audience, readInput) return } @@ -102,5 +84,17 @@ func (h *Handler) edit(w text.Writer, r *request, args ...string) { } // the starting point is the original value of to and cc: recipients can be added but not removed when editing - h.post(w, r, ¬e, &parent, note.To, note.CC, note.Audience, "Post content") + h.post(w, r, ¬e, &parent, note.To, note.CC, note.Audience, readInput) +} + +func (h *Handler) edit(w text.Writer, r *request, args ...string) { + h.doEdit(w, r, args, func() (string, bool) { + return readQuery(w, r, "Post content") + }) +} + +func (h *Handler) editUpload(w text.Writer, r *request, args ...string) { + h.doEdit(w, r, args, func() (string, bool) { + return readBody(w, r, args[1:]) + }) } diff --git a/front/handler.go b/front/handler.go index 6346b980..b967d039 100644 --- a/front/handler.go +++ b/front/handler.go @@ -85,6 +85,7 @@ func NewHandler(domain string, closed bool, cfg *cfg.Config) (Handler, error) { h.handlers[regexp.MustCompile(`^/users/upload/avatar;([a-z]+)=([^;]+);([a-z]+)=([^;]+)`)] = h.uploadAvatar h.handlers[regexp.MustCompile(`^/users/bio$`)] = h.bio + h.handlers[regexp.MustCompile(`^/users/upload/bio;([a-z]+)=([^;]+);([a-z]+)=([^;]+)`)] = h.uploadBio h.handlers[regexp.MustCompile(`^/users/name$`)] = h.name h.handlers[regexp.MustCompile(`^/users/alias$`)] = h.alias h.handlers[regexp.MustCompile(`^/users/move$`)] = h.move @@ -107,6 +108,12 @@ func NewHandler(domain string, closed bool, cfg *cfg.Config) (Handler, error) { h.handlers[regexp.MustCompile(`^/users/edit/(\S+)`)] = h.edit h.handlers[regexp.MustCompile(`^/users/delete/(\S+)`)] = delete + h.handlers[regexp.MustCompile(`^/users/upload/dm;([a-z]+)=([^;]+);([a-z]+)=([^;]+)`)] = h.uploadDM + h.handlers[regexp.MustCompile(`^/users/upload/whisper;([a-z]+)=([^;]+);([a-z]+)=([^;]+)`)] = h.uploadWhisper + h.handlers[regexp.MustCompile(`^/users/upload/say;([a-z]+)=([^;]+);([a-z]+)=([^;]+)`)] = h.uploadSay + h.handlers[regexp.MustCompile(`^/users/upload/edit/([^;]+);([a-z]+)=([^;]+);([a-z]+)=([^;]+)`)] = h.editUpload + h.handlers[regexp.MustCompile(`^/users/upload/reply/([^;]+);([a-z]+)=([^;]+);([a-z]+)=([^;]+)`)] = h.replyUpload + h.handlers[regexp.MustCompile(`^/users/resolve$`)] = withUserMenu(h.resolve) h.handlers[regexp.MustCompile(`^/users/follow/(\S+)$`)] = withUserMenu(h.follow) diff --git a/front/input.go b/front/input.go new file mode 100644 index 00000000..0f85a1a7 --- /dev/null +++ b/front/input.go @@ -0,0 +1,103 @@ +/* +Copyright 2024 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 ( + "github.com/dimkr/tootik/front/text" + "io" + "net/url" + "strconv" +) + +// inputFunc is a callback that returns user-provided text or false +type inputFunc func() (string, bool) + +func readQuery(w text.Writer, r *request, prompt string) (string, bool) { + if r.URL.RawQuery == "" { + w.Status(10, prompt) + return "", false + } + + content, err := url.QueryUnescape(r.URL.RawQuery) + if err != nil { + w.Status(40, "Bad input") + return "", false + } + + return content, true +} + +func readBody(w text.Writer, r *request, args []string) (string, bool) { + if r.Body == nil { + w.Redirect("/users/oops") + return "", false + } + + var sizeStr, mimeType string + if args[1] == "size" && args[3] == "mime" { + sizeStr = args[2] + mimeType = args[4] + } else if args[1] == "mime" && args[3] == "size" { + sizeStr = args[4] + mimeType = args[2] + } else { + r.Log.Warn("Invalid parameters") + w.Status(40, "Invalid parameters") + return "", false + } + + if mimeType != "text/plain" { + r.Log.Warn("Content type is unsupported", "type", mimeType) + w.Status(40, "Only text/plain is supported") + return "", false + } + + size, err := strconv.ParseInt(sizeStr, 10, 64) + if err != nil { + r.Log.Warn("Failed to parse content size", "error", err) + w.Status(40, "Invalid size") + return "", false + } + + if size == 0 { + r.Log.Warn("Content is empty") + w.Status(40, "Content is empty") + return "", false + } + + if size > int64(r.Handler.Config.MaxPostsLength)*4 { + r.Log.Warn("Content is too big", "size", size) + w.Status(40, "Content is too big") + return "", false + } + + buf := make([]byte, size) + n, err := io.ReadFull(r.Body, buf) + if err != nil { + r.Log.Warn("Failed to read content", "error", err) + w.Error() + return "", false + } + + if int64(n) != size { + r.Log.Warn("Content is truncated") + w.Error() + return "", false + } + + return string(buf), true +} diff --git a/front/menu.go b/front/menu.go index 82fd5409..1b8d7a4f 100644 --- a/front/menu.go +++ b/front/menu.go @@ -52,9 +52,7 @@ func writeUserMenu(w text.Writer, user *ap.Actor) { if user == nil { w.Link("/users", "🔑 Sign in") } else { - w.Link("/users/dm", "💌 Post to mentioned users") - w.Link("/users/whisper", "🔔 Post to followers") - w.Link("/users/say", "📣 Post to public") + w.Link("/users/post", "📣 New post") w.Link("/users/settings", "⚙️ Settings") } diff --git a/front/post.go b/front/post.go index 35f668ff..2149c550 100644 --- a/front/post.go +++ b/front/post.go @@ -25,7 +25,6 @@ import ( "github.com/dimkr/tootik/front/text" "github.com/dimkr/tootik/front/text/plain" "github.com/dimkr/tootik/outbox" - "net/url" "regexp" "strings" "time" @@ -43,7 +42,7 @@ var ( pollRegex = regexp.MustCompile(`^\[(?:(?i)POLL)\s+(.+)\s*\]\s*(.+)`) ) -func (h *Handler) post(w text.Writer, r *request, oldNote *ap.Object, inReplyTo *ap.Object, to ap.Audience, cc ap.Audience, audience, prompt string) { +func (h *Handler) post(w text.Writer, r *request, oldNote *ap.Object, inReplyTo *ap.Object, to ap.Audience, cc ap.Audience, audience string, readInput inputFunc) { if r.User == nil { w.Redirect("/users") return @@ -76,14 +75,8 @@ func (h *Handler) post(w text.Writer, r *request, oldNote *ap.Object, inReplyTo } } - if r.URL.RawQuery == "" { - w.Status(10, prompt) - return - } - - content, err := url.QueryUnescape(r.URL.RawQuery) - if err != nil { - w.Error() + content, ok := readInput() + if !ok { return } @@ -228,6 +221,7 @@ func (h *Handler) post(w text.Writer, r *request, oldNote *ap.Object, inReplyTo note.Content = plain.ToHTML(note.Content, note.Tag) } + var err error if oldNote != nil { note.Published = oldNote.Published note.Updated = &now @@ -245,5 +239,9 @@ func (h *Handler) post(w text.Writer, r *request, oldNote *ap.Object, inReplyTo return } - w.Redirectf("/users/view/%s", strings.TrimPrefix(postID, "https://")) + if r.URL.Scheme == "titan" { + w.Redirectf("gemini://%s/users/view/%s", h.Domain, strings.TrimPrefix(postID, "https://")) + } else { + w.Redirectf("/users/view/%s", strings.TrimPrefix(postID, "https://")) + } } diff --git a/front/print.go b/front/print.go index 56536dd6..28b59ebd 100644 --- a/front/print.go +++ b/front/print.go @@ -411,6 +411,7 @@ func (r *request) PrintNote(w text.Writer, note *ap.Object, author *ap.Actor, sh if r.User != nil && note.AttributedTo == r.User.ID && note.Type != ap.Question && note.Name == "" { // polls and votes cannot be edited w.Link("/users/edit/"+strings.TrimPrefix(note.ID, "https://"), "🩹 Edit") + w.Link(fmt.Sprintf("titan://%s/users/upload/edit/%s", r.Handler.Domain, strings.TrimPrefix(note.ID, "https://")), "Upload edited post") } if r.User != nil && note.AttributedTo == r.User.ID { w.Link("/users/delete/"+strings.TrimPrefix(note.ID, "https://"), "💣 Delete") @@ -438,6 +439,7 @@ func (r *request) PrintNote(w text.Writer, note *ap.Object, author *ap.Actor, sh if r.User != nil { w.Link("/users/reply/"+strings.TrimPrefix(note.ID, "https://"), "💬 Reply") + w.Link(fmt.Sprintf("titan://%s/users/upload/reply/%s", r.Handler.Domain, strings.TrimPrefix(note.ID, "https://")), "Upload reply") } } } diff --git a/front/reply.go b/front/reply.go index b91c45aa..3bc42058 100644 --- a/front/reply.go +++ b/front/reply.go @@ -23,7 +23,7 @@ import ( "github.com/dimkr/tootik/front/text" ) -func (h *Handler) reply(w text.Writer, r *request, args ...string) { +func (h *Handler) doReply(w text.Writer, r *request, args []string, readInput inputFunc) { postID := "https://" + args[1] var note ap.Object @@ -65,5 +65,17 @@ func (h *Handler) reply(w text.Writer, r *request, args ...string) { }) } - h.post(w, r, nil, ¬e, to, cc, note.Audience, "Reply content") + h.post(w, r, nil, ¬e, to, cc, note.Audience, readInput) +} + +func (h *Handler) reply(w text.Writer, r *request, args ...string) { + h.doReply(w, r, args, func() (string, bool) { + return readQuery(w, r, "Reply content") + }) +} + +func (h *Handler) replyUpload(w text.Writer, r *request, args ...string) { + h.doReply(w, r, args, func() (string, bool) { + return readBody(w, r, args[1:]) + }) } diff --git a/front/say.go b/front/say.go index 3a59b6d7..2379e35a 100644 --- a/front/say.go +++ b/front/say.go @@ -28,5 +28,19 @@ func (h *Handler) say(w text.Writer, r *request, args ...string) { to.Add(ap.Public) cc.Add(r.User.Followers) - h.post(w, r, nil, nil, to, cc, "", "Post content") + h.post(w, r, nil, nil, to, cc, "", func() (string, bool) { + return readQuery(w, r, "Post content") + }) +} + +func (h *Handler) uploadSay(w text.Writer, r *request, args ...string) { + to := ap.Audience{} + cc := ap.Audience{} + + to.Add(ap.Public) + cc.Add(r.User.Followers) + + h.post(w, r, nil, nil, to, cc, "", func() (string, bool) { + return readBody(w, r, args) + }) } diff --git a/front/static/users/help.gmi b/front/static/users/help.gmi index 9ac1bae3..d3121676 100644 --- a/front/static/users/help.gmi +++ b/front/static/users/help.gmi @@ -56,17 +56,14 @@ Use this tool to locate a user in the fediverse and see the posts published by t This is a full-text search tool that lists posts containing keyword(s), ordered by relevance. -> 💌 Post to mentioned users +> 📣 New post -Follow this link to publish a post and send it to mentioned users only. +Follow this link to publish a post visible to: +* Mentioned users only, or +* Your followers and mentioned users, or +* Anyone -> 🔔 Post to followers - -Follow this link to publish a post and send it to your followers and mentioned users. - -> 📣 Post to public - -Follow this link to publish a public post visible to anyone. +You can upload a plain text file over Titan, instead of typing your post in the input prompt (use your client certificate for authentication). > ⚙️ Settings diff --git a/front/static/users/post.gmi b/front/static/users/post.gmi new file mode 100644 index 00000000..e5f96d7c --- /dev/null +++ b/front/static/users/post.gmi @@ -0,0 +1,12 @@ +# New Post + +Who should be able to see your new post? + +=> /users/dm 💌 Mentioned users only +=> titan://{{.Domain}}/users/upload/dm Upload text file + +=> /users/whisper 🔔 Your followers and mentioned users +=> titan://{{.Domain}}/users/upload/whisper Upload text file + +=> /users/say 📣 Anyone +=> titan://{{.Domain}}/users/upload/say Upload text file diff --git a/front/static/users/settings.gmi b/front/static/users/settings.gmi index 8dce6c6d..2010dfde 100644 --- a/front/static/users/settings.gmi +++ b/front/static/users/settings.gmi @@ -4,6 +4,7 @@ => /users/name 👺 Set display name => /users/bio 📜 Set bio +=> titan://{{.Domain}}/users/upload/bio Upload bio => titan://{{.Domain}}/users/upload/avatar Upload avatar ## Migration diff --git a/front/whisper.go b/front/whisper.go index 55d2e3e1..63e91fc1 100644 --- a/front/whisper.go +++ b/front/whisper.go @@ -27,5 +27,18 @@ func (h *Handler) whisper(w text.Writer, r *request, args ...string) { to.Add(r.User.Followers) - h.post(w, r, nil, nil, to, cc, "", "Post content") + h.post(w, r, nil, nil, to, cc, "", func() (string, bool) { + return readQuery(w, r, "Post content") + }) +} + +func (h *Handler) uploadWhisper(w text.Writer, r *request, args ...string) { + to := ap.Audience{} + cc := ap.Audience{} + + to.Add(r.User.Followers) + + h.post(w, r, nil, nil, to, cc, "", func() (string, bool) { + return readBody(w, r, args) + }) } diff --git a/test/server.go b/test/server.go index 5224d7d1..4c39f2a6 100644 --- a/test/server.go +++ b/test/server.go @@ -131,6 +131,7 @@ func (s *server) Upload(request string, user *ap.Actor, body []byte) string { if err != nil { panic(err) } + u.Scheme = "titan" var buf bytes.Buffer var wg sync.WaitGroup diff --git a/test/upload_edit_test.go b/test/upload_edit_test.go new file mode 100644 index 00000000..455589d3 --- /dev/null +++ b/test/upload_edit_test.go @@ -0,0 +1,217 @@ +/* +Copyright 2023, 2024 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 ( + "fmt" + "github.com/stretchr/testify/assert" + "strings" + "testing" + "time" +) + +func TestUploadEdit_HappyFlow(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + follow := server.Handle("/users/follow/"+strings.TrimPrefix(server.Bob.ID, "https://"), server.Alice) + assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), follow) + + users := server.Handle("/users", server.Alice) + assert.Contains(users, "No posts.") + assert.NotContains(users, "Hello followers") + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(`30 /users/view/(\S+)\r\n$`, whisper) + + id := whisper[15 : len(whisper)-2] + + _, err := server.db.Exec("update notes set inserted = inserted - 3600, object = json_set(object, '$.published', ?) where id = 'https://' || ?", time.Now().Add(-time.Hour).Format(time.RFC3339Nano), id) + assert.NoError(err) + + edit := server.Upload(fmt.Sprintf("/users/upload/edit/%s;mime=text/plain;size=15", id), server.Bob, []byte("Hello followers")) + assert.Equal(fmt.Sprintf("30 gemini://%s/users/view/%s\r\n", domain, id), edit) + + users = server.Handle("/users", server.Alice) + assert.NotContains(users, "No posts.") + assert.Contains(users, "Hello followers") + + edit = server.Upload(fmt.Sprintf("/users/upload/edit/%s;mime=text/plain;size=16", id), server.Bob, []byte("Hello, followers")) + assert.Equal("40 Please try again later\r\n", edit) + + users = server.Handle("/users", server.Alice) + assert.NotContains(users, "No posts.") + assert.Contains(users, "Hello followers") + + users = server.Handle("/users", server.Alice) + assert.NotContains(users, "No posts.") + assert.Contains(users, "Hello followers") +} + +func TestUploadEdit_Empty(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + follow := server.Handle("/users/follow/"+strings.TrimPrefix(server.Bob.ID, "https://"), server.Alice) + assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), follow) + + users := server.Handle("/users", server.Alice) + assert.Contains(users, "No posts.") + assert.NotContains(users, "Hello followers") + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(`^30 /users/view/(\S+)\r\n$`, whisper) + + id := whisper[15 : len(whisper)-2] + + _, err := server.db.Exec("update notes set inserted = inserted - 3600, object = json_set(object, '$.published', ?) where id = 'https://' || ?", time.Now().Add(-time.Hour).Format(time.RFC3339Nano), id) + assert.NoError(err) + + edit := server.Upload(fmt.Sprintf("/users/upload/edit/%s;mime=text/plain;size=0", id), server.Bob, []byte("Hello followers")) + assert.Equal("40 Content is empty\r\n", edit) +} + +func TestUploadEdit_SizeLimit(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + follow := server.Handle("/users/follow/"+strings.TrimPrefix(server.Bob.ID, "https://"), server.Alice) + assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), follow) + + users := server.Handle("/users", server.Alice) + assert.Contains(users, "No posts.") + assert.NotContains(users, "Hello followers") + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(`^30 /users/view/(\S+)\r\n$`, whisper) + + id := whisper[15 : len(whisper)-2] + + _, err := server.db.Exec("update notes set inserted = inserted - 3600, object = json_set(object, '$.published', ?) where id = 'https://' || ?", time.Now().Add(-time.Hour).Format(time.RFC3339Nano), id) + assert.NoError(err) + + server.cfg.MaxPostsLength = 14 + + edit := server.Upload(fmt.Sprintf("/users/upload/edit/%s;mime=text/plain;size=15", id), server.Bob, []byte("Hello followers")) + assert.Equal("40 Post is too long\r\n", edit) +} + +func TestUploadEdit_InvalidSize(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + follow := server.Handle("/users/follow/"+strings.TrimPrefix(server.Bob.ID, "https://"), server.Alice) + assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), follow) + + users := server.Handle("/users", server.Alice) + assert.Contains(users, "No posts.") + assert.NotContains(users, "Hello followers") + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(`^30 /users/view/(\S+)\r\n$`, whisper) + + id := whisper[15 : len(whisper)-2] + + _, err := server.db.Exec("update notes set inserted = inserted - 3600, object = json_set(object, '$.published', ?) where id = 'https://' || ?", time.Now().Add(-time.Hour).Format(time.RFC3339Nano), id) + assert.NoError(err) + + edit := server.Upload(fmt.Sprintf("/users/upload/edit/%s;mime=text/plain;size=abc", id), server.Bob, []byte("Hello followers")) + assert.Equal("40 Invalid size\r\n", edit) +} + +func TestUploadEdit_InvalidType(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + follow := server.Handle("/users/follow/"+strings.TrimPrefix(server.Bob.ID, "https://"), server.Alice) + assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), follow) + + users := server.Handle("/users", server.Alice) + assert.Contains(users, "No posts.") + assert.NotContains(users, "Hello followers") + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(`^30 /users/view/(\S+)\r\n$`, whisper) + + id := whisper[15 : len(whisper)-2] + + _, err := server.db.Exec("update notes set inserted = inserted - 3600, object = json_set(object, '$.published', ?) where id = 'https://' || ?", time.Now().Add(-time.Hour).Format(time.RFC3339Nano), id) + assert.NoError(err) + + edit := server.Upload(fmt.Sprintf("/users/upload/edit/%s;mime=text/gemini;size=15", id), server.Bob, []byte("Hello followers")) + assert.Equal("40 Only text/plain is supported\r\n", edit) +} + +func TestUploadEdit_NoSize(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + follow := server.Handle("/users/follow/"+strings.TrimPrefix(server.Bob.ID, "https://"), server.Alice) + assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), follow) + + users := server.Handle("/users", server.Alice) + assert.Contains(users, "No posts.") + assert.NotContains(users, "Hello followers") + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(`^30 /users/view/(\S+)\r\n$`, whisper) + + id := whisper[15 : len(whisper)-2] + + _, err := server.db.Exec("update notes set inserted = inserted - 3600, object = json_set(object, '$.published', ?) where id = 'https://' || ?", time.Now().Add(-time.Hour).Format(time.RFC3339Nano), id) + assert.NoError(err) + + edit := server.Upload(fmt.Sprintf("/users/upload/edit/%s;mime=text/plain;siz=15", id), server.Bob, []byte("Hello followers")) + assert.Equal("40 Invalid parameters\r\n", edit) +} + +func TestUploadEdit_NoType(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + follow := server.Handle("/users/follow/"+strings.TrimPrefix(server.Bob.ID, "https://"), server.Alice) + assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), follow) + + users := server.Handle("/users", server.Alice) + assert.Contains(users, "No posts.") + assert.NotContains(users, "Hello followers") + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(`^30 /users/view/(\S+)\r\n$`, whisper) + + id := whisper[15 : len(whisper)-2] + + _, err := server.db.Exec("update notes set inserted = inserted - 3600, object = json_set(object, '$.published', ?) where id = 'https://' || ?", time.Now().Add(-time.Hour).Format(time.RFC3339Nano), id) + assert.NoError(err) + + edit := server.Upload(fmt.Sprintf("/users/upload/edit/%s;mim=text/plain;size=15", id), server.Bob, []byte("Hello followers")) + assert.Equal("40 Invalid parameters\r\n", edit) +} diff --git a/test/upload_reply_test.go b/test/upload_reply_test.go new file mode 100644 index 00000000..0a91b662 --- /dev/null +++ b/test/upload_reply_test.go @@ -0,0 +1,57 @@ +/* +Copyright 2023, 2024 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 ( + "fmt" + "github.com/stretchr/testify/assert" + "strings" + "testing" +) + +func TestUploadReply_PostToFollowers(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + assert := assert.New(t) + + follow := server.Handle("/users/follow/"+strings.TrimPrefix(server.Bob.ID, "https://"), server.Alice) + assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), follow) + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(`^30 /users/view/\S+\r\n$`, whisper) + + id := whisper[15 : len(whisper)-2] + + view := server.Handle("/users/view/"+id, server.Bob) + assert.Contains(view, "Hello world") + assert.NotContains(view, "Welcome Bob") + + reply := server.Upload(fmt.Sprintf("/users/upload/reply/%s;mime=text/plain;size=11", id), server.Alice, []byte("Welcome Bob")) + assert.Regexp(fmt.Sprintf("^30 gemini://%s/users/view/\\S+\r\n$", domain), reply) + + view = server.Handle("/users/view/"+id, server.Alice) + assert.Contains(view, "Hello world") + assert.Contains(view, "Welcome Bob") + + users := server.Handle("/users", server.Bob) + assert.Contains(users, "Welcome Bob") + + local := server.Handle("/local", nil) + assert.NotContains(local, "Hello world") + assert.NotContains(local, "Welcome Bob") +}