From bd3088bfc900922520f9bb984dc29ba0225b592c Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sun, 17 Sep 2023 13:14:35 +0300 Subject: [PATCH 01/14] add basic support for post editing --- README.md | 5 ++ fed/edit.go | 84 +++++++++++++++++++++++++++++++ front/edit.go | 107 ++++++++++++++++++++++++++++++++++++++++ front/print.go | 7 +++ migrations/005_edits.go | 14 ++++++ migrations/add.sh | 2 +- 6 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 fed/edit.go create mode 100644 front/edit.go create mode 100644 migrations/005_edits.go diff --git a/README.md b/README.md index 2334e3b5..9480071f 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Users are authenticated using TLS client certificates; see [Gemini protocol spec * /users/whisper creates a post visible to followers. * /users/say creates a public post. * /users/reply replies to a post. +* /users/edit edits a post. * /users/follow sends a follow request to a user. * /users/unfollow deletes a follow request. * /users/outbox is equivalent to /outbox but also includes a link to /users/follow or /users/unfollow. @@ -133,6 +134,10 @@ User A is allowed to send a message to user B only if B follows A. | Post | Post author | Mentions and followers of reply author | | Public post | Post author | Mentions, followers of reply author and Public | +### Post Editing + +/users/edit only changes the content and the last update timestamp of a post. It does **not** change the post audience and mentioned users. + ## Implementation Details ### The "Nobody" User diff --git a/fed/edit.go b/fed/edit.go new file mode 100644 index 00000000..dc3b0a12 --- /dev/null +++ b/fed/edit.go @@ -0,0 +1,84 @@ +/* +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 fed + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/json" + "fmt" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/cfg" + "time" +) + +func Edit(ctx context.Context, db *sql.DB, note *ap.Object, newContent string) error { + now := time.Now() + + note.Content = newContent + note.Updated = &now + + body, err := json.Marshal(note) + if err != nil { + return fmt.Errorf("Failed to marshal note: %w", err) + } + + updateID := fmt.Sprintf("https://%s/update/%x", cfg.Domain, sha256.Sum256([]byte(fmt.Sprintf("%s|%d", note.ID, now.Unix())))) + + update, err := json.Marshal(ap.Activity{ + Context: "https://www.w3.org/ns/activitystreams", + ID: updateID, + Type: ap.UpdateActivity, + Actor: note.AttributedTo, + Object: note, + To: note.To, + CC: note.CC, + }) + if err != nil { + return fmt.Errorf("Failed to marshal update: %w", err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("Failed to begin transaction: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext( + ctx, + `UPDATE notes SET object = ? WHERE id = ?`, + string(body), + note.ID, + ); err != nil { + return fmt.Errorf("Failed to update note: %w", err) + } + + if _, err := tx.ExecContext( + ctx, + `INSERT INTO outbox (activity) VALUES(?)`, + string(update), + ); err != nil { + return fmt.Errorf("Failed to insert update activity: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("Failed to update note: %w", err) + } + + return nil +} diff --git a/front/edit.go b/front/edit.go new file mode 100644 index 00000000..bfc8c770 --- /dev/null +++ b/front/edit.go @@ -0,0 +1,107 @@ +/* +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 ( + "database/sql" + "encoding/json" + "errors" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/cfg" + "github.com/dimkr/tootik/fed" + "github.com/dimkr/tootik/text" + "github.com/dimkr/tootik/text/plain" + "math" + "net/url" + "path/filepath" + "regexp" + "time" +) + +func init() { + handlers[regexp.MustCompile(`^/users/edit/[0-9a-f]{64}`)] = edit +} + +func edit(w text.Writer, r *request) { + if r.User == nil { + w.Redirect("/users") + return + } + + if r.URL.RawQuery == "" { + w.Status(10, "Post content") + return + } + + hash := filepath.Base(r.URL.Path) + + var noteString string + if err := r.QueryRow(`select object from notes where hash = ? and author = ?`, hash, r.User.ID).Scan(¬eString); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to edit non-existing post", "hash", hash, "error", err) + w.Error() + return + } else if err != nil { + r.Log.Warn("Failed to fetch post to edit", "hash", hash, "error", err) + w.Error() + return + } + + var note ap.Object + if err := json.Unmarshal([]byte(noteString), ¬e); err != nil { + r.Log.Warn("Failed to unmarshal post to edit", "hash", hash, "error", err) + w.Error() + return + } + + var edits int + if err := r.QueryRow(`select count(*) from outbox where activity->>'object.id' = ? and (activity->>'type' = 'Update' or activity->>'type' = 'Create')`, note.ID).Scan(&edits); err != nil { + r.Log.Warn("Failed to count post edits", "hash", hash, "error", err) + w.Error() + return + } + + lastEditTime := note.Published + if note.Updated != nil && *note.Updated != (time.Time{}) { + lastEditTime = *note.Updated + } + + canEdit := lastEditTime.Add(time.Minute * time.Duration(math.Pow(4, float64(edits)))) + if time.Now().Before(canEdit) { + r.Log.Warn("Throttled request to edit post", "note", note.ID, "can", canEdit) + w.Status(40, "Please try again later") + return + } + + content, err := url.QueryUnescape(r.URL.RawQuery) + if err != nil { + w.Error() + return + } + + if len(content) > cfg.MaxPostsLength { + w.Status(40, "Post is too long") + return + } + + if err := fed.Edit(r.Context, r.DB, ¬e, plain.ToHTML(content)); err != nil { + r.Log.Error("Failed to update post", "note", note.ID, "error", err) + w.Error() + return + } + + w.Redirectf("/users/view/%s", hash) +} diff --git a/front/print.go b/front/print.go index f30a4eca..e24bd7b4 100644 --- a/front/print.go +++ b/front/print.go @@ -196,6 +196,10 @@ func (r *request) PrintNote(w text.Writer, note *ap.Object, author *ap.Actor, gr title = note.Published.Format(time.DateOnly) } + if note.Updated != nil && *note.Updated != (time.Time{}) { + title += " ┃ edited" + } + var parentAuthor ap.Actor if note.InReplyTo != "" { var parentAuthorString string @@ -312,6 +316,9 @@ func (r *request) PrintNote(w text.Writer, note *ap.Object, author *ap.Actor, gr return true }) + if r.User != nil && note.AttributedTo == r.User.ID { + w.Link(fmt.Sprintf("/users/edit/%x", sha256.Sum256([]byte(note.ID))), "😱 Edit") + } if r.User != nil { w.Link(fmt.Sprintf("/users/reply/%x", sha256.Sum256([]byte(note.ID))), "💬 Reply") } diff --git a/migrations/005_edits.go b/migrations/005_edits.go new file mode 100644 index 00000000..71017435 --- /dev/null +++ b/migrations/005_edits.go @@ -0,0 +1,14 @@ +package migrations + +import ( + "context" + "database/sql" +) + +func edits(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxobjectid ON outbox(activity->>'object.id')`); err != nil { + return err + } + + return nil +} diff --git a/migrations/add.sh b/migrations/add.sh index 0e4875a2..6920f44e 100755 --- a/migrations/add.sh +++ b/migrations/add.sh @@ -27,7 +27,7 @@ import ( "database/sql" ) -func $1(ctx context.Context, db *sql.DB) error { +func $1(ctx context.Context, tx *sql.Tx) error { // do stuff return nil From 9c909b46c6027bb85e2a38e26d95babf5f6564f7 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sat, 23 Sep 2023 12:53:05 +0300 Subject: [PATCH 02/14] add support for post deletion --- README.md | 1 + fed/delete.go | 80 +++++++++++++++++++++++++++++++++++ front/delete.go | 68 +++++++++++++++++++++++++++++ front/post.go | 2 +- front/print.go | 3 +- migrations/006_outboxactor.go | 14 ++++++ 6 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 fed/delete.go create mode 100644 front/delete.go create mode 100644 migrations/006_outboxactor.go diff --git a/README.md b/README.md index 9480071f..70de08da 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Users are authenticated using TLS client certificates; see [Gemini protocol spec * /users/say creates a public post. * /users/reply replies to a post. * /users/edit edits a post. +* /users/delete deletes a post. * /users/follow sends a follow request to a user. * /users/unfollow deletes a follow request. * /users/outbox is equivalent to /outbox but also includes a link to /users/follow or /users/unfollow. diff --git a/fed/delete.go b/fed/delete.go new file mode 100644 index 00000000..4bdc5f38 --- /dev/null +++ b/fed/delete.go @@ -0,0 +1,80 @@ +/* +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 fed + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "github.com/dimkr/tootik/ap" +) + +func Delete(ctx context.Context, db *sql.DB, note *ap.Object) error { + delete, err := json.Marshal(ap.Activity{ + Context: "https://www.w3.org/ns/activitystreams", + ID: note.ID + "#delete", + Type: ap.DeleteActivity, + Actor: note.AttributedTo, + Object: ap.Object{ + Type: note.Type, + ID: note.ID, + }, + To: note.To, + CC: note.CC, + }) + if err != nil { + return fmt.Errorf("Failed to marshal delete: %w", err) + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("Failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // mark this post as sent so recipients who haven't received it yet don't receive it + if _, err := tx.ExecContext( + ctx, + `UPDATE outbox SET sent = 1 WHERE activity->>'object.id' = ? and activity->>'type' = 'Create'`, + note.ID, + ); err != nil { + return fmt.Errorf("Failed to insert delete activity: %w", err) + } + + if _, err := tx.ExecContext( + ctx, + `DELETE FROM notes WHERE id = ?`, + note.ID, + ); err != nil { + return fmt.Errorf("Failed to delete note: %w", err) + } + + if _, err := tx.ExecContext( + ctx, + `INSERT INTO outbox (activity) VALUES (?)`, + string(delete), + ); err != nil { + return fmt.Errorf("Failed to insert delete activity: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("Failed to delete note: %w", err) + } + + return nil +} diff --git a/front/delete.go b/front/delete.go new file mode 100644 index 00000000..7a7eaeea --- /dev/null +++ b/front/delete.go @@ -0,0 +1,68 @@ +/* +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" + "encoding/json" + "errors" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/fed" + "github.com/dimkr/tootik/text" + "path/filepath" + "regexp" +) + +func init() { + handlers[regexp.MustCompile(`^/users/delete/[0-9a-f]{64}`)] = delete +} + +func delete(w text.Writer, r *request) { + if r.User == nil { + w.Redirect("/users") + return + } + + hash := filepath.Base(r.URL.Path) + + var noteString string + if err := r.QueryRow(`select object from notes where hash = ? and author = ?`, hash, r.User.ID).Scan(¬eString); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to delete a non-existing post", "hash", hash, "error", err) + w.Error() + return + } else if err != nil { + r.Log.Warn("Failed to fetch post to delete", "hash", hash, "error", err) + w.Error() + return + } + + var note ap.Object + if err := json.Unmarshal([]byte(noteString), ¬e); err != nil { + r.Log.Warn("Failed to unmarshal post to delete", "hash", hash, "error", err) + w.Error() + return + } + + if err := fed.Delete(r.Context, r.DB, ¬e); err != nil { + r.Log.Error("Failed to delete post", "note", note.ID, "error", err) + w.Error() + return + } + + w.Redirectf("/users/outbox/%x", sha256.Sum256([]byte(r.User.ID))) +} diff --git a/front/post.go b/front/post.go index 4efab321..72c23da1 100644 --- a/front/post.go +++ b/front/post.go @@ -45,7 +45,7 @@ func post(w text.Writer, r *request, inReplyTo *ap.Object, to ap.Audience, cc ap now := time.Now() var today, last sql.NullInt64 - if err := r.QueryRow(`select count(*), max(inserted) from notes where author = ? and inserted > ?`, r.User.ID, now.Add(-24*time.Hour).Unix()).Scan(&today, &last); err != nil { + if err := r.QueryRow(`select count(*), max(inserted) from outbox where activity->>'actor' = ? and activity->>'type' = 'Create' and inserted > ?`, r.User.ID, now.Add(-24*time.Hour).Unix()).Scan(&today, &last); err != nil { r.Log.Warn("Failed to check if new post needs to be throttled", "error", err) w.Error() return diff --git a/front/print.go b/front/print.go index e24bd7b4..94ac9e8e 100644 --- a/front/print.go +++ b/front/print.go @@ -317,7 +317,8 @@ func (r *request) PrintNote(w text.Writer, note *ap.Object, author *ap.Actor, gr }) if r.User != nil && note.AttributedTo == r.User.ID { - w.Link(fmt.Sprintf("/users/edit/%x", sha256.Sum256([]byte(note.ID))), "😱 Edit") + w.Link(fmt.Sprintf("/users/edit/%x", sha256.Sum256([]byte(note.ID))), "🩹 Edit") + w.Link(fmt.Sprintf("/users/delete/%x", sha256.Sum256([]byte(note.ID))), "💣 Delete") } if r.User != nil { w.Link(fmt.Sprintf("/users/reply/%x", sha256.Sum256([]byte(note.ID))), "💬 Reply") diff --git a/migrations/006_outboxactor.go b/migrations/006_outboxactor.go new file mode 100644 index 00000000..65e08cc7 --- /dev/null +++ b/migrations/006_outboxactor.go @@ -0,0 +1,14 @@ +package migrations + +import ( + "context" + "database/sql" +) + +func outboxactor(ctx context.Context, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `CREATE INDEX outboxactor ON outbox(activity->>'actor')`); err != nil { + return err + } + + return nil +} From 76267fb06632cdc437deeadbdfcdc79e07b21440 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sat, 23 Sep 2023 13:39:07 +0300 Subject: [PATCH 03/14] ignore undelivered activities in stats page --- fed/deliver.go | 6 +++--- front/stats.go | 11 ++++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/fed/deliver.go b/fed/deliver.go index 88fc89c2..a3114659 100644 --- a/fed/deliver.go +++ b/fed/deliver.go @@ -35,7 +35,7 @@ import ( const ( batchSize = 16 deliveryRetryInterval = int64((time.Hour / 2) / time.Second) - maxDeliveryAttempts = 5 + MaxDeliveryAttempts = 5 pollingInterval = time.Second * 5 deliveryTimeout = time.Minute * 5 maxDeliveryQueueSize = 128 @@ -63,7 +63,7 @@ func DeliverPosts(ctx context.Context, log *slog.Logger, db *sql.DB, resolver *R func deliverPosts(ctx context.Context, log *slog.Logger, db *sql.DB, resolver *Resolver) error { log.Debug("Polling delivery queue") - rows, err := db.QueryContext(ctx, `select outbox.attempts, outbox.activity, persons.actor from outbox join persons on persons.id = outbox.activity->>'actor' where outbox.sent = 0 and (outbox.attempts = 0 or (outbox.attempts < ? and outbox.last <= unixepoch() - ?)) order by outbox.attempts asc, outbox.last asc limit ?`, maxDeliveryAttempts, deliveryRetryInterval, batchSize) + rows, err := db.QueryContext(ctx, `select outbox.attempts, outbox.activity, persons.actor from outbox join persons on persons.id = outbox.activity->>'actor' where outbox.sent = 0 and (outbox.attempts = 0 or (outbox.attempts < ? and outbox.last <= unixepoch() - ?)) order by outbox.attempts asc, outbox.last asc limit ?`, MaxDeliveryAttempts, deliveryRetryInterval, batchSize) if err != nil { return fmt.Errorf("Failed to fetch posts to deliver: %w", err) } @@ -209,7 +209,7 @@ func Deliver(ctx context.Context, log *slog.Logger, db *sql.DB, post *ap.Object, } var queueSize int - if err := db.QueryRowContext(ctx, `select count (*) from outbox where sent = 0 and attempts < ?`, maxDeliveryAttempts).Scan(&queueSize); err != nil { + if err := db.QueryRowContext(ctx, `select count (*) from outbox where sent = 0 and attempts < ?`, MaxDeliveryAttempts).Scan(&queueSize); err != nil { return fmt.Errorf("Failed to query delivery queue size: %w", err) } diff --git a/front/stats.go b/front/stats.go index 73775b14..9d6a5288 100644 --- a/front/stats.go +++ b/front/stats.go @@ -20,6 +20,7 @@ import ( "fmt" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/graph" + "github.com/dimkr/tootik/fed" "github.com/dimkr/tootik/text" "regexp" "time" @@ -91,7 +92,7 @@ func stats(w text.Writer, r *request) { prefix := fmt.Sprintf("https://%s/%%", cfg.Domain) var usersCount, postsCount, postsToday, federatedPostsCount, federatedPostsToday, lastPost, lastFederatedPost, lastRegister, lastFederatedUser int64 - var deliveriesQueueSize, activitiesQueueSize int + var outboxSize, inboxSize int if err := r.QueryRow(`select count(*) from persons where id like ?`, prefix).Scan(&usersCount); err != nil { r.Log.Info("Failed to get users count", "error", err) @@ -147,13 +148,13 @@ func stats(w text.Writer, r *request) { return } - if err := r.QueryRow(`select count(*) from inbox`).Scan(&activitiesQueueSize); err != nil { + if err := r.QueryRow(`select count(*) from inbox`).Scan(&inboxSize); err != nil { r.Log.Info("Failed to get activities queue size", "error", err) w.Error() return } - if err := r.QueryRow(`select count(*) from outbox where sent = 0`).Scan(&deliveriesQueueSize); err != nil { + if err := r.QueryRow(`select count(*) from outbox where sent = 0 and attempts < ?`, fed.MaxDeliveryAttempts).Scan(&outboxSize); err != nil { r.Log.Info("Failed to get delivery queue size", "error", err) w.Error() return @@ -216,6 +217,6 @@ func stats(w text.Writer, r *request) { w.Itemf("Federated posts: %d", federatedPostsCount) w.Itemf("Newest user: %s", time.Unix(lastRegister, 0).Format(time.UnixDate)) w.Itemf("Latest federated user update: %s", time.Unix(lastFederatedUser, 0).Format(time.UnixDate)) - w.Itemf("Incoming posts queue size: %d", activitiesQueueSize) - w.Itemf("Outgoing posts queue size: %d", deliveriesQueueSize) + w.Itemf("Incoming activities queue size: %d", inboxSize) + w.Itemf("Outgoing activities queue size: %d", outboxSize) } From 0e850d807a7d3f8b992afa4232b0dafe895fa594 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sat, 23 Sep 2023 14:50:34 +0300 Subject: [PATCH 04/14] delete actors and follows from dead servers --- fed/deliver.go | 8 ++++++-- fed/resolve.go | 17 ++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/fed/deliver.go b/fed/deliver.go index a3114659..4fdbbc0e 100644 --- a/fed/deliver.go +++ b/fed/deliver.go @@ -174,10 +174,14 @@ func deliver(ctx context.Context, log *slog.Logger, db *sql.DB, activity *ap.Act if to, err := resolver.Resolve(ctx, log, db, actor, actorID, false); err != nil { log.Warn("Failed to resolve a recipient", "to", actorID, "activity", activity.ID, "error", err) - anyFailed = true + if !errors.Is(err, ErrActorGone) && !errors.Is(err, ErrBlockedDomain) { + anyFailed = true + } } else if err := Send(ctx, log, db, actor, resolver, to, buf); err != nil { log.Warn("Failed to send a post", "to", actorID, "activity", activity.ID, "error", err) - anyFailed = true + if !errors.Is(err, ErrBlockedDomain) { + anyFailed = true + } } return true diff --git a/fed/resolve.go b/fed/resolve.go index 7cf9c351..1a1af7a2 100644 --- a/fed/resolve.go +++ b/fed/resolve.go @@ -30,6 +30,7 @@ import ( "io" "io/ioutil" "log/slog" + "net" "net/http" "net/url" "path" @@ -40,6 +41,7 @@ import ( const ( resolverCacheTTL = time.Hour * 24 * 3 resolverMaxIdleConns = 128 + maxInstanceRecoveryTime = time.Hour * 24 * 30 resolverIdleConnTimeout = time.Minute ) @@ -123,10 +125,12 @@ func (r *Resolver) resolve(ctx context.Context, log *slog.Logger, db *sql.DB, fr var actorString string var updated int64 + var sinceLastUpdate time.Duration if err := db.QueryRowContext(ctx, `select actor, updated from persons where id = ?`, to).Scan(&actorString, &updated); err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("Failed to fetch %s cache: %w", to, err) } else if err == nil { - if !isLocal && !offline && time.Now().Sub(time.Unix(updated, 0)) > resolverCacheTTL { + sinceLastUpdate = time.Now().Sub(time.Unix(updated, 0)) + if !isLocal && !offline && sinceLastUpdate > resolverCacheTTL { log.Info("Updating old cache entry for actor", "to", to) update = true } else { @@ -163,6 +167,17 @@ func (r *Resolver) resolve(ctx context.Context, log *slog.Logger, db *sql.DB, fr return nil, fmt.Errorf("Failed to fetch %s: %w", finger, ErrActorGone) } + var ( + urlError *url.Error + opError *net.OpError + dnsError *net.DNSError + ) + // if it's been a while since the last update and the server's domain is expired (NXDOMAIN), actor is gone + if sinceLastUpdate > maxInstanceRecoveryTime && errors.As(err, &urlError) && errors.As(urlError.Err, &opError) && errors.As(opError.Err, &dnsError) && dnsError.IsNotFound { + log.Warn("Server is probably gone, deleting associated objects", "to", to) + deleteActor(ctx, log, db, to) + } + return nil, fmt.Errorf("Failed to fetch %s: %w", finger, err) } defer resp.Body.Close() From 025125d82902d0a534e3e4bad68a1bd26db62f86 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sat, 23 Sep 2023 15:45:06 +0300 Subject: [PATCH 05/14] delete Create and Update activities when deleting a post --- fed/delete.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/fed/delete.go b/fed/delete.go index 4bdc5f38..414a3939 100644 --- a/fed/delete.go +++ b/fed/delete.go @@ -64,6 +64,14 @@ func Delete(ctx context.Context, db *sql.DB, note *ap.Object) error { return fmt.Errorf("Failed to delete note: %w", err) } + if _, err := tx.ExecContext( + ctx, + `DELETE FROM outbox WHERE activity->>'object.id' = ?`, + note.ID, + ); err != nil { + return fmt.Errorf("Failed to delete activities: %w", err) + } + if _, err := tx.ExecContext( ctx, `INSERT INTO outbox (activity) VALUES (?)`, From c6af6688e3b40b914c86370c6693756a8f1bb388 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sat, 23 Sep 2023 19:59:32 +0300 Subject: [PATCH 06/14] add prebuilt container images --- .dockerignore | 6 ++++++ .github/workflows/image.yml | 23 +++++++++++++++++++++++ Dockerfile | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/workflows/image.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..1ef58c27 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +**/.git* +/README.md +/Dockerfile +/.dockerignore +/migrations/add.sh +/migrations/migrations.go diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 00000000..5870c9a6 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,23 @@ +name: image + +on: + workflow_run: + workflows: [build] + types: [completed] + workflow_dispatch: + +jobs: + image: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v5 + with: + pull: true + push: true + tags: ghcr.io/${{ github.repository }}:latest \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6bb71d38 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# 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. + +FROM golang:1.21-alpine AS build +RUN apk add --no-cache gcc musl-dev +COPY go.mod /src/ +COPY go.sum /src/ +WORKDIR /src +RUN go mod download +COPY migrations /src/migrations +RUN go generate ./migrations +COPY . /src +RUN go vet ./... +RUN go test ./... -failfast -vet off +RUN go build ./cmd/tootik + +FROM alpine +RUN apk add --no-cache ca-certificates openssl +COPY --from=build /src/tootik / +COPY --from=build /src/LICENSE / +RUN adduser -D tootik +USER tootik +WORKDIR /tmp +ENTRYPOINT ["/tootik"] From 5c400cd8b2cd7b85ab64e987d6e7c1a1e629a608 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sun, 24 Sep 2023 13:31:04 +0300 Subject: [PATCH 07/14] fix breakage of local follows --- fed/follow.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/fed/follow.go b/fed/follow.go index d45f2c94..e6d9da59 100644 --- a/fed/follow.go +++ b/fed/follow.go @@ -24,6 +24,7 @@ import ( "fmt" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" + "strings" ) func Follow(ctx context.Context, follower *ap.Actor, followed string, db *sql.DB) error { @@ -47,12 +48,15 @@ func Follow(ctx context.Context, follower *ap.Actor, followed string, db *sql.DB return fmt.Errorf("Failed to marshal follow: %w", err) } + isLocal := strings.HasPrefix(followed, fmt.Sprintf("https://%s/", cfg.Domain)) + if _, err := db.ExecContext( ctx, - `INSERT INTO follows (id, follower, followed) VALUES(?,?,?)`, + `INSERT INTO follows (id, follower, followed, accepted) VALUES(?,?,?,?)`, followID, follower.ID, followed, + isLocal, // local follows don't need to be accepted ); err != nil { return fmt.Errorf("Failed to insert follow: %w", err) } From aa5729f3d6605ec4068af4e5a17121a78be007cb Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sun, 24 Sep 2023 14:33:13 +0300 Subject: [PATCH 08/14] add first round of tests --- README.md | 2 + test/dm_test.go | 55 +++++++++++++++++++ test/say_test.go | 33 ++++++++++++ test/server.go | 99 ++++++++++++++++++++++++++++++++++ test/users_test.go | 124 +++++++++++++++++++++++++++++++++++++++++++ test/whisper_test.go | 33 ++++++++++++ text/gmi/writer.go | 6 +++ 7 files changed, 352 insertions(+) create mode 100644 test/dm_test.go create mode 100644 test/say_test.go create mode 100644 test/server.go create mode 100644 test/users_test.go create mode 100644 test/whisper_test.go diff --git a/README.md b/README.md index 70de08da..0747f0d3 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ or, to build a static executable: * cfg/ contains global configuration parameters. * logger/ contains logging utilities. +* test/ contains tests. + ## Gemini Frontend * /local shows a compact list of local posts; each entry contains a link to /view. diff --git a/test/dm_test.go b/test/dm_test.go new file mode 100644 index 00000000..e75ca986 --- /dev/null +++ b/test/dm_test.go @@ -0,0 +1,55 @@ +/* +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" + "testing" +) + +func TestDM_HappyFlow(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.DB, server.Alice) + fmt.Println(follow) + assert.Regexp(t, "^30 /users/outbox/[0-9a-f]{64}\r\n$", follow) + + dm := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20Alice", sha256.Sum256([]byte(server.Alice.ID))), server.DB, server.Bob) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", dm) + + view := server.Handle(dm[3:len(dm)-2], server.DB, server.Alice) + assert.Contains(t, view, "Hello Alice") +} + +func TestDM_Loopback(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + resp := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20world", sha256.Sum256([]byte(server.Alice.ID))), server.DB, server.Alice) + assert.Regexp(t, "40 [^\r\n]+\r\n", resp) +} + +func TestDM_NotFollowed(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + resp := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20world", sha256.Sum256([]byte(server.Alice.ID))), server.DB, server.Bob) + assert.Regexp(t, "40 [^\r\n]+\r\n", resp) +} diff --git a/test/say_test.go b/test/say_test.go new file mode 100644 index 00000000..cbd06cfe --- /dev/null +++ b/test/say_test.go @@ -0,0 +1,33 @@ +/* +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 ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestSay_HappyFlow(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + redirect := server.Handle("/users/say?Hello%20world", server.DB, server.Alice) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", redirect) + + view := server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) + assert.Contains(t, view, "Hello world") +} diff --git a/test/server.go b/test/server.go new file mode 100644 index 00000000..dd086f65 --- /dev/null +++ b/test/server.go @@ -0,0 +1,99 @@ +/* +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 ( + "sync" + + "bytes" + "context" + "database/sql" + "fmt" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/cfg" + "github.com/dimkr/tootik/fed" + "github.com/dimkr/tootik/front" + "github.com/dimkr/tootik/migrations" + "github.com/dimkr/tootik/text/gmi" + "github.com/dimkr/tootik/user" + _ "github.com/mattn/go-sqlite3" + "log/slog" + "net/url" + "os" +) + +type server struct { + DB *sql.DB + dbPath string + Alice *ap.Actor + Bob *ap.Actor + Carol *ap.Actor +} + +func (s *server) Shutdown() { + s.DB.Close() + os.Remove(s.dbPath) +} + +func newTestServer() *server { + f, err := os.CreateTemp("", "tootik-*.sqlite3") + if err != nil { + panic(err) + } + f.Close() + + path := f.Name() + + db, err := sql.Open("sqlite3", path+"?_journal_mode=WAL") + if err != nil { + panic(err) + } + + if err := migrations.Run(context.Background(), slog.Default(), db); err != nil { + panic(err) + } + + alice, err := user.Create(context.Background(), db, fmt.Sprintf("https://%s/user/alice", cfg.Domain), "alice", "a") + if err != nil { + panic(err) + } + + bob, err := user.Create(context.Background(), db, fmt.Sprintf("https://%s/user/bob", cfg.Domain), "bob", "b") + if err != nil { + panic(err) + } + + carol, err := user.Create(context.Background(), db, fmt.Sprintf("https://%s/user/carol", cfg.Domain), "carol", "c") + if err != nil { + panic(err) + } + + return &server{dbPath: path, DB: db, Alice: alice, Bob: bob, Carol: carol} +} + +func (s *server) Handle(request string, db *sql.DB, user *ap.Actor) string { + u, err := url.Parse(request) + if err != nil { + panic(err) + } + + var buf bytes.Buffer + var wg sync.WaitGroup + front.Handle(context.Background(), slog.Default(), gmi.Wrap(&buf), u, user, db, fed.NewResolver(nil), &wg) + + return string(buf.Bytes()) +} diff --git a/test/users_test.go b/test/users_test.go new file mode 100644 index 00000000..dde54a76 --- /dev/null +++ b/test/users_test.go @@ -0,0 +1,124 @@ +/* +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" + "testing" +) + +func TestUsers_NoFollows(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + resp := server.Handle("/users", server.DB, server.Bob) + assert.Contains(t, resp, "Nothing to see! Are you following anyone?") +} + +func TestUsers_NewPublicPost(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.DB, server.Alice) + assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), follow) + + users := server.Handle("/users", server.DB, server.Alice) + assert.Contains(t, users, "Nothing to see! Are you following anyone?") + assert.NotContains(t, users, "1 post") + + say := server.Handle("/users/say?Hello%20world", server.DB, server.Bob) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", say) + + users = server.Handle("/users", server.DB, server.Alice) + assert.NotContains(t, users, "Nothing to see! Are you following anyone?") + assert.Contains(t, users, "1 post") + + today := server.Handle("/users/inbox/today", server.DB, server.Alice) + assert.Contains(t, today, "Hello world") + + users = server.Handle("/users", server.DB, server.Carol) + assert.Contains(t, users, "Nothing to see! Are you following anyone?") + assert.NotContains(t, users, "1 post") + + local := server.Handle("/users/local", server.DB, server.Carol) + assert.Contains(t, local, "Hello world") +} + +func TestUsers_NewPostToFollowers(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + redirect := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.DB, server.Alice) + assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), redirect) + + users := server.Handle("/users", server.DB, server.Alice) + assert.Contains(t, users, "Nothing to see! Are you following anyone?") + assert.NotContains(t, users, "1 post") + + whisper := server.Handle("/users/whisper?Hello%20world", server.DB, server.Bob) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", whisper) + + users = server.Handle("/users", server.DB, server.Alice) + assert.NotContains(t, users, "Nothing to see! Are you following anyone?") + assert.Contains(t, users, "1 post") + + today := server.Handle("/users/inbox/today", server.DB, server.Alice) + assert.Contains(t, today, "Hello world") + + users = server.Handle("/users", server.DB, server.Carol) + assert.Contains(t, users, "Nothing to see! Are you following anyone?") + assert.NotContains(t, users, "1 post") + + local := server.Handle("/users/local", server.DB, server.Carol) + assert.Contains(t, local, "Hello world") +} + +func TestUsers_NewDM(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + redirect := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.DB, server.Alice) + assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), redirect) + + users := server.Handle("/users", server.DB, server.Alice) + assert.Contains(t, users, "Nothing to see! Are you following anyone?") + assert.NotContains(t, users, "1 post") + + today := server.Handle("/users/inbox/today", server.DB, server.Alice) + assert.Contains(t, today, "No posts.") + assert.NotContains(t, today, "Hello Alice") + + redirect = server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20Alice", sha256.Sum256([]byte(server.Alice.ID))), server.DB, server.Bob) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", redirect) + + users = server.Handle("/users", server.DB, server.Alice) + assert.NotContains(t, users, "Nothing to see! Are you following anyone?") + assert.Contains(t, users, "1 post") + + today = server.Handle("/users/inbox/today", server.DB, server.Alice) + assert.NotContains(t, today, "No posts.") + assert.Contains(t, today, "Hello Alice") + + users = server.Handle("/users", server.DB, server.Carol) + assert.Contains(t, users, "Nothing to see! Are you following anyone?") + assert.NotContains(t, users, "1 post") + + local := server.Handle("/users/local", server.DB, server.Carol) + assert.NotContains(t, local, "Hello Alice") +} diff --git a/test/whisper_test.go b/test/whisper_test.go new file mode 100644 index 00000000..5d44cb39 --- /dev/null +++ b/test/whisper_test.go @@ -0,0 +1,33 @@ +/* +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 ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestWhisper_HappyFlow(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + redirect := server.Handle("/users/whisper?Hello%20world", server.DB, server.Alice) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", redirect) + + view := server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) + assert.Contains(t, view, "Hello world") +} diff --git a/text/gmi/writer.go b/text/gmi/writer.go index f970d15b..5ea830eb 100644 --- a/text/gmi/writer.go +++ b/text/gmi/writer.go @@ -32,12 +32,18 @@ func Wrap(w io.Writer) text.Writer { func (w *writer) Status(code int, meta string) { fmt.Fprintf(w, "%d %s\r\n", code, meta) + if code == 30 { + w.Base = text.Base{Writer: io.Discard} + } } func (w *writer) Statusf(code int, format string, a ...any) { fmt.Fprintf(w, "%d ", code) fmt.Fprintf(w, format, a...) w.Write([]byte("\r\n")) + if code == 30 { + w.Base = text.Base{Writer: io.Discard} + } } func (w *writer) OK() { From ba2c2c3e7ff7ea8d9df8fe8ac8c23f0610170c6f Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sun, 24 Sep 2023 14:58:39 +0300 Subject: [PATCH 09/14] oops --- test/dm_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/test/dm_test.go b/test/dm_test.go index e75ca986..26373b9c 100644 --- a/test/dm_test.go +++ b/test/dm_test.go @@ -28,7 +28,6 @@ func TestDM_HappyFlow(t *testing.T) { defer server.Shutdown() follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.DB, server.Alice) - fmt.Println(follow) assert.Regexp(t, "^30 /users/outbox/[0-9a-f]{64}\r\n$", follow) dm := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20Alice", sha256.Sum256([]byte(server.Alice.ID))), server.DB, server.Bob) From b6668b8817647dbd54bb14c4a9ce91ed4d1758c4 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sun, 24 Sep 2023 15:02:41 +0300 Subject: [PATCH 10/14] add throttling test --- test/say_test.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/say_test.go b/test/say_test.go index cbd06cfe..b734768a 100644 --- a/test/say_test.go +++ b/test/say_test.go @@ -31,3 +31,23 @@ func TestSay_HappyFlow(t *testing.T) { view := server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) assert.Contains(t, view, "Hello world") } + +func TestSay_Throttling(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + redirect := server.Handle("/users/say?Hello%20world", server.DB, server.Alice) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", redirect) + + view := server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) + assert.Contains(t, view, "Hello world") + + redirect = server.Handle("/users/say?Hello%20again,%20world", server.DB, server.Alice) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", redirect) + + view = server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) + assert.Contains(t, view, "Hello again, world") + + redirect = server.Handle("/users/say?Hello%20once%20more,%20world", server.DB, server.Alice) + assert.Equal(t, "40 Please wait before posting again\r\n", redirect) +} From 386096efcbb920f01d20e18ec97e5789cb39aa2e Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sun, 24 Sep 2023 16:02:11 +0300 Subject: [PATCH 11/14] start throttling after one post, not two --- front/post.go | 2 +- test/say_test.go | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/front/post.go b/front/post.go index 72c23da1..14b579c3 100644 --- a/front/post.go +++ b/front/post.go @@ -59,7 +59,7 @@ func post(w text.Writer, r *request, inReplyTo *ap.Object, to ap.Audience, cc ap if today.Valid && last.Valid { t := time.Unix(last.Int64, 0) - interval := time.Duration(today.Int64/2) * time.Minute + interval := max(1, time.Duration(today.Int64/2)) * time.Minute if now.Sub(t) < interval { r.Log.Warn("User is posting too frequently", "last", t, "can", t.Add(interval)) w.Status(40, "Please wait before posting again") diff --git a/test/say_test.go b/test/say_test.go index b734768a..6dc8c13a 100644 --- a/test/say_test.go +++ b/test/say_test.go @@ -42,12 +42,6 @@ func TestSay_Throttling(t *testing.T) { view := server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) assert.Contains(t, view, "Hello world") - redirect = server.Handle("/users/say?Hello%20again,%20world", server.DB, server.Alice) - assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", redirect) - - view = server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) - assert.Contains(t, view, "Hello again, world") - redirect = server.Handle("/users/say?Hello%20once%20more,%20world", server.DB, server.Alice) assert.Equal(t, "40 Please wait before posting again\r\n", redirect) } From 9e026006971a6aaf8278d9cd18254649c324d7e4 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sun, 24 Sep 2023 16:03:08 +0300 Subject: [PATCH 12/14] fix breakage of tests inside build container --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6bb71d38..57f77863 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ # limitations under the License. FROM golang:1.21-alpine AS build -RUN apk add --no-cache gcc musl-dev +RUN apk add --no-cache gcc musl-dev openssl COPY go.mod /src/ COPY go.sum /src/ WORKDIR /src From f524374ed7af8426670d621fce61069355a60ae7 Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sun, 24 Sep 2023 16:53:48 +0300 Subject: [PATCH 13/14] add reply tests --- test/dm_test.go | 10 +- test/reply_test.go | 218 +++++++++++++++++++++++++++++++++++++++++++ test/say_test.go | 35 +++++-- test/server.go | 16 +++- test/users_test.go | 52 +++++------ test/whisper_test.go | 39 +++++++- 6 files changed, 323 insertions(+), 47 deletions(-) create mode 100644 test/reply_test.go diff --git a/test/dm_test.go b/test/dm_test.go index 26373b9c..727e3808 100644 --- a/test/dm_test.go +++ b/test/dm_test.go @@ -27,13 +27,13 @@ func TestDM_HappyFlow(t *testing.T) { server := newTestServer() defer server.Shutdown() - follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.DB, server.Alice) + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.Alice) assert.Regexp(t, "^30 /users/outbox/[0-9a-f]{64}\r\n$", follow) - dm := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20Alice", sha256.Sum256([]byte(server.Alice.ID))), server.DB, server.Bob) + dm := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20Alice", sha256.Sum256([]byte(server.Alice.ID))), server.Bob) assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", dm) - view := server.Handle(dm[3:len(dm)-2], server.DB, server.Alice) + view := server.Handle(dm[3:len(dm)-2], server.Alice) assert.Contains(t, view, "Hello Alice") } @@ -41,7 +41,7 @@ func TestDM_Loopback(t *testing.T) { server := newTestServer() defer server.Shutdown() - resp := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20world", sha256.Sum256([]byte(server.Alice.ID))), server.DB, server.Alice) + resp := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20world", sha256.Sum256([]byte(server.Alice.ID))), server.Alice) assert.Regexp(t, "40 [^\r\n]+\r\n", resp) } @@ -49,6 +49,6 @@ func TestDM_NotFollowed(t *testing.T) { server := newTestServer() defer server.Shutdown() - resp := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20world", sha256.Sum256([]byte(server.Alice.ID))), server.DB, server.Bob) + resp := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20world", sha256.Sum256([]byte(server.Alice.ID))), server.Bob) assert.Regexp(t, "40 [^\r\n]+\r\n", resp) } diff --git a/test/reply_test.go b/test/reply_test.go new file mode 100644 index 00000000..7e24e671 --- /dev/null +++ b/test/reply_test.go @@ -0,0 +1,218 @@ +/* +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" + "testing" +) + +func TestReply_AuthorNotFollowed(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + say := server.Handle("/users/say?Hello%20world", server.Bob) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", say) + + hash := say[15 : len(say)-2] + + view := server.Handle("/users/view/"+hash, server.Bob) + assert.Contains(t, view, "Hello world") + assert.NotContains(t, view, "Welcome Bob") + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Alice) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", reply) + + view = server.Handle("/users/view/"+hash, server.Alice) + assert.Contains(t, view, "Hello world") + assert.Contains(t, view, "Welcome Bob") + + users := server.Handle("/users/inbox/today", server.Bob) + assert.Contains(t, users, "Welcome Bob") + + local := server.Handle("/local", nil) + assert.Contains(t, local, "Hello world") + assert.Contains(t, local, "Welcome Bob") +} + +func TestReply_AuthorFollowed(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.Alice) + assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), follow) + + say := server.Handle("/users/say?Hello%20world", server.Bob) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", say) + + hash := say[15 : len(say)-2] + + view := server.Handle("/users/view/"+hash, server.Bob) + assert.Contains(t, view, "Hello world") + assert.NotContains(t, view, "Welcome Bob") + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Alice) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", reply) + + view = server.Handle("/users/view/"+hash, server.Alice) + assert.Contains(t, view, "Hello world") + assert.Contains(t, view, "Welcome Bob") + + users := server.Handle("/users/inbox/today", server.Bob) + assert.Contains(t, users, "Welcome Bob") + + local := server.Handle("/local", nil) + assert.Contains(t, local, "Hello world") + assert.Contains(t, local, "Welcome Bob") +} + +func TestReply_PostToFollowers(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.Alice) + assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), follow) + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", whisper) + + hash := whisper[15 : len(whisper)-2] + + view := server.Handle("/users/view/"+hash, server.Bob) + assert.Contains(t, view, "Hello world") + assert.NotContains(t, view, "Welcome Bob") + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Alice) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", reply) + + view = server.Handle("/users/view/"+hash, server.Alice) + assert.Contains(t, view, "Hello world") + assert.Contains(t, view, "Welcome Bob") + + users := server.Handle("/users/inbox/today", server.Bob) + assert.Contains(t, users, "Welcome Bob") + + local := server.Handle("/local", nil) + assert.Contains(t, local, "Hello world") + assert.Contains(t, local, "Welcome Bob") +} + +func TestReply_ReplyToPublicPostByFollowedUser(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.Alice) + assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), follow) + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", whisper) + + hash := whisper[15 : len(whisper)-2] + + view := server.Handle("/users/view/"+hash, server.Bob) + assert.Contains(t, view, "Hello world") + assert.NotContains(t, view, "Welcome Bob") + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Carol) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", reply) + + view = server.Handle("/users/view/"+hash, server.Alice) + assert.Contains(t, view, "Hello world") + assert.Contains(t, view, "Welcome Bob") + + users := server.Handle("/users/inbox/today", server.Alice) + assert.Contains(t, users, "Hello world") + assert.NotContains(t, users, "Welcome Bob") + + local := server.Handle("/local", nil) + assert.Contains(t, local, "Hello world") + assert.Contains(t, local, "Welcome Bob") +} + +func TestReply_ReplyToPublicPostByNotFollowedUser(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", whisper) + + hash := whisper[15 : len(whisper)-2] + + view := server.Handle("/users/view/"+hash, server.Bob) + assert.Contains(t, view, "Hello world") + assert.NotContains(t, view, "Welcome Bob") + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Welcome%%20Bob", hash), server.Carol) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", reply) + + view = server.Handle("/users/view/"+hash, server.Alice) + assert.Contains(t, view, "Hello world") + assert.Contains(t, view, "Welcome Bob") + + users := server.Handle("/users/inbox/today", server.Alice) + assert.NotContains(t, users, "Hello world") + assert.NotContains(t, users, "Welcome Bob") + + local := server.Handle("/local", nil) + assert.Contains(t, local, "Hello world") + assert.Contains(t, local, "Welcome Bob") +} + +func TestReply_DM(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.Alice) + assert.Regexp(t, "^30 /users/outbox/[0-9a-f]{64}\r\n$", follow) + + dm := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20Alice", sha256.Sum256([]byte(server.Alice.ID))), server.Bob) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", dm) + + today := server.Handle("/users/inbox/today", server.Alice) + assert.Contains(t, today, "Hello Alice") + assert.NotContains(t, today, "Hello Bob") + + today = server.Handle("/users/inbox/today", server.Bob) + assert.NotContains(t, today, "Hello Alice") + assert.NotContains(t, today, "Hello Bob") + + hash := dm[15 : len(dm)-2] + + view := server.Handle("/users/view/"+hash, server.Alice) + assert.Contains(t, view, "Hello Alice") + + reply := server.Handle(fmt.Sprintf("/users/reply/%s?Hello%%20Bob", hash), server.Alice) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", reply) + + today = server.Handle("/users/inbox/today", server.Alice) + assert.Contains(t, today, "Hello Alice") + assert.NotContains(t, today, "Hello Bob") + + today = server.Handle("/users/inbox/today", server.Bob) + assert.NotContains(t, today, "Hello Alice") + assert.Contains(t, today, "Hello Bob") +} + +func TestReply_NoSuchPost(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + reply := server.Handle("/users/reply/87428fc522803d31065e7bce3cf03fe475096631e5e07bbd7a0fde60c4cf25c7?Welcome%%20Bob", server.Alice) + assert.Equal(t, "40 Post does not exist\r\n", reply) +} diff --git a/test/say_test.go b/test/say_test.go index 6dc8c13a..0763a02d 100644 --- a/test/say_test.go +++ b/test/say_test.go @@ -17,6 +17,8 @@ limitations under the License. package test import ( + "crypto/sha256" + "fmt" "github.com/stretchr/testify/assert" "testing" ) @@ -25,23 +27,40 @@ func TestSay_HappyFlow(t *testing.T) { server := newTestServer() defer server.Shutdown() - redirect := server.Handle("/users/say?Hello%20world", server.DB, server.Alice) - assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", redirect) + say := server.Handle("/users/say?Hello%20world", server.Alice) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", say) - view := server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) + view := server.Handle(say[3:len(say)-2], server.Bob) assert.Contains(t, view, "Hello world") + + outbox := server.Handle(fmt.Sprintf("/users/outbox/%x", sha256.Sum256([]byte(server.Alice.ID))), server.Bob) + assert.Contains(t, outbox, "Hello world") + + local := server.Handle("/local", server.Carol) + assert.Contains(t, local, "Hello world") } func TestSay_Throttling(t *testing.T) { server := newTestServer() defer server.Shutdown() - redirect := server.Handle("/users/say?Hello%20world", server.DB, server.Alice) - assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", redirect) + say := server.Handle("/users/say?Hello%20world", server.Alice) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", say) - view := server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) + view := server.Handle(say[3:len(say)-2], server.Bob) assert.Contains(t, view, "Hello world") - redirect = server.Handle("/users/say?Hello%20once%20more,%20world", server.DB, server.Alice) - assert.Equal(t, "40 Please wait before posting again\r\n", redirect) + outbox := server.Handle(fmt.Sprintf("/users/outbox/%x", sha256.Sum256([]byte(server.Alice.ID))), server.Alice) + assert.Contains(t, outbox, "Hello world") + + say = server.Handle("/users/say?Hello%20once%20more,%20world", server.Alice) + assert.Equal(t, "40 Please wait before posting again\r\n", say) + + outbox = server.Handle(fmt.Sprintf("/users/outbox/%x", sha256.Sum256([]byte(server.Alice.ID))), server.Bob) + assert.Contains(t, outbox, "Hello world") + assert.NotContains(t, outbox, "Hello once more, world") + + local := server.Handle("/local", server.Carol) + assert.Contains(t, local, "Hello world") + assert.NotContains(t, local, "Hello once more, world") } diff --git a/test/server.go b/test/server.go index dd086f65..892aa740 100644 --- a/test/server.go +++ b/test/server.go @@ -37,15 +37,16 @@ import ( ) type server struct { - DB *sql.DB + db *sql.DB dbPath string Alice *ap.Actor Bob *ap.Actor Carol *ap.Actor + Nobody *ap.Actor } func (s *server) Shutdown() { - s.DB.Close() + s.db.Close() os.Remove(s.dbPath) } @@ -82,10 +83,15 @@ func newTestServer() *server { panic(err) } - return &server{dbPath: path, DB: db, Alice: alice, Bob: bob, Carol: carol} + nobody, err := user.CreateNobody(context.Background(), db) + if err != nil { + panic(err) + } + + return &server{dbPath: path, db: db, Alice: alice, Bob: bob, Carol: carol, Nobody: nobody} } -func (s *server) Handle(request string, db *sql.DB, user *ap.Actor) string { +func (s *server) Handle(request string, user *ap.Actor) string { u, err := url.Parse(request) if err != nil { panic(err) @@ -93,7 +99,7 @@ func (s *server) Handle(request string, db *sql.DB, user *ap.Actor) string { var buf bytes.Buffer var wg sync.WaitGroup - front.Handle(context.Background(), slog.Default(), gmi.Wrap(&buf), u, user, db, fed.NewResolver(nil), &wg) + front.Handle(context.Background(), slog.Default(), gmi.Wrap(&buf), u, user, s.db, fed.NewResolver(nil), &wg) return string(buf.Bytes()) } diff --git a/test/users_test.go b/test/users_test.go index dde54a76..ae80ba76 100644 --- a/test/users_test.go +++ b/test/users_test.go @@ -27,7 +27,7 @@ func TestUsers_NoFollows(t *testing.T) { server := newTestServer() defer server.Shutdown() - resp := server.Handle("/users", server.DB, server.Bob) + resp := server.Handle("/users", server.Bob) assert.Contains(t, resp, "Nothing to see! Are you following anyone?") } @@ -35,28 +35,28 @@ func TestUsers_NewPublicPost(t *testing.T) { server := newTestServer() defer server.Shutdown() - follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.DB, server.Alice) + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.Alice) assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), follow) - users := server.Handle("/users", server.DB, server.Alice) + users := server.Handle("/users", server.Alice) assert.Contains(t, users, "Nothing to see! Are you following anyone?") assert.NotContains(t, users, "1 post") - say := server.Handle("/users/say?Hello%20world", server.DB, server.Bob) + say := server.Handle("/users/say?Hello%20world", server.Bob) assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", say) - users = server.Handle("/users", server.DB, server.Alice) + users = server.Handle("/users", server.Alice) assert.NotContains(t, users, "Nothing to see! Are you following anyone?") assert.Contains(t, users, "1 post") - today := server.Handle("/users/inbox/today", server.DB, server.Alice) + today := server.Handle("/users/inbox/today", server.Alice) assert.Contains(t, today, "Hello world") - users = server.Handle("/users", server.DB, server.Carol) + users = server.Handle("/users", server.Carol) assert.Contains(t, users, "Nothing to see! Are you following anyone?") assert.NotContains(t, users, "1 post") - local := server.Handle("/users/local", server.DB, server.Carol) + local := server.Handle("/users/local", server.Carol) assert.Contains(t, local, "Hello world") } @@ -64,28 +64,28 @@ func TestUsers_NewPostToFollowers(t *testing.T) { server := newTestServer() defer server.Shutdown() - redirect := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.DB, server.Alice) - assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), redirect) + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.Alice) + assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), follow) - users := server.Handle("/users", server.DB, server.Alice) + users := server.Handle("/users", server.Alice) assert.Contains(t, users, "Nothing to see! Are you following anyone?") assert.NotContains(t, users, "1 post") - whisper := server.Handle("/users/whisper?Hello%20world", server.DB, server.Bob) + whisper := server.Handle("/users/whisper?Hello%20world", server.Bob) assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", whisper) - users = server.Handle("/users", server.DB, server.Alice) + users = server.Handle("/users", server.Alice) assert.NotContains(t, users, "Nothing to see! Are you following anyone?") assert.Contains(t, users, "1 post") - today := server.Handle("/users/inbox/today", server.DB, server.Alice) + today := server.Handle("/users/inbox/today", server.Alice) assert.Contains(t, today, "Hello world") - users = server.Handle("/users", server.DB, server.Carol) + users = server.Handle("/users", server.Carol) assert.Contains(t, users, "Nothing to see! Are you following anyone?") assert.NotContains(t, users, "1 post") - local := server.Handle("/users/local", server.DB, server.Carol) + local := server.Handle("/users/local", server.Carol) assert.Contains(t, local, "Hello world") } @@ -93,32 +93,32 @@ func TestUsers_NewDM(t *testing.T) { server := newTestServer() defer server.Shutdown() - redirect := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.DB, server.Alice) - assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), redirect) + follow := server.Handle(fmt.Sprintf("/users/follow/%x", sha256.Sum256([]byte(server.Bob.ID))), server.Alice) + assert.Equal(t, fmt.Sprintf("30 /users/outbox/%x\r\n", sha256.Sum256([]byte(server.Bob.ID))), follow) - users := server.Handle("/users", server.DB, server.Alice) + users := server.Handle("/users", server.Alice) assert.Contains(t, users, "Nothing to see! Are you following anyone?") assert.NotContains(t, users, "1 post") - today := server.Handle("/users/inbox/today", server.DB, server.Alice) + today := server.Handle("/users/inbox/today", server.Alice) assert.Contains(t, today, "No posts.") assert.NotContains(t, today, "Hello Alice") - redirect = server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20Alice", sha256.Sum256([]byte(server.Alice.ID))), server.DB, server.Bob) - assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", redirect) + dm := server.Handle(fmt.Sprintf("/users/dm/%x?Hello%%20Alice", sha256.Sum256([]byte(server.Alice.ID))), server.Bob) + assert.Regexp(t, "30 /users/view/[0-9a-f]{64}", dm) - users = server.Handle("/users", server.DB, server.Alice) + users = server.Handle("/users", server.Alice) assert.NotContains(t, users, "Nothing to see! Are you following anyone?") assert.Contains(t, users, "1 post") - today = server.Handle("/users/inbox/today", server.DB, server.Alice) + today = server.Handle("/users/inbox/today", server.Alice) assert.NotContains(t, today, "No posts.") assert.Contains(t, today, "Hello Alice") - users = server.Handle("/users", server.DB, server.Carol) + users = server.Handle("/users", server.Carol) assert.Contains(t, users, "Nothing to see! Are you following anyone?") assert.NotContains(t, users, "1 post") - local := server.Handle("/users/local", server.DB, server.Carol) + local := server.Handle("/users/local", server.Carol) assert.NotContains(t, local, "Hello Alice") } diff --git a/test/whisper_test.go b/test/whisper_test.go index 5d44cb39..1e30d966 100644 --- a/test/whisper_test.go +++ b/test/whisper_test.go @@ -17,6 +17,8 @@ limitations under the License. package test import ( + "crypto/sha256" + "fmt" "github.com/stretchr/testify/assert" "testing" ) @@ -25,9 +27,40 @@ func TestWhisper_HappyFlow(t *testing.T) { server := newTestServer() defer server.Shutdown() - redirect := server.Handle("/users/whisper?Hello%20world", server.DB, server.Alice) - assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", redirect) + whisper := server.Handle("/users/whisper?Hello%20world", server.Alice) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", whisper) - view := server.Handle(redirect[3:len(redirect)-2], server.DB, server.Bob) + view := server.Handle(whisper[3:len(whisper)-2], server.Bob) assert.Contains(t, view, "Hello world") + + outbox := server.Handle(fmt.Sprintf("/users/outbox/%x", sha256.Sum256([]byte(server.Alice.ID))), server.Bob) + assert.Contains(t, outbox, "Hello world") + + local := server.Handle("/local", server.Carol) + assert.Contains(t, local, "Hello world") +} + +func TestWhisper_Throttling(t *testing.T) { + server := newTestServer() + defer server.Shutdown() + + whisper := server.Handle("/users/whisper?Hello%20world", server.Alice) + assert.Regexp(t, "^30 /users/view/[0-9a-f]{64}\r\n$", whisper) + + view := server.Handle(whisper[3:len(whisper)-2], server.Bob) + assert.Contains(t, view, "Hello world") + + outbox := server.Handle(fmt.Sprintf("/users/outbox/%x", sha256.Sum256([]byte(server.Alice.ID))), server.Alice) + assert.Contains(t, outbox, "Hello world") + + whisper = server.Handle("/users/whisper?Hello%20once%20more,%20world", server.Alice) + assert.Equal(t, "40 Please wait before posting again\r\n", whisper) + + outbox = server.Handle(fmt.Sprintf("/users/outbox/%x", sha256.Sum256([]byte(server.Alice.ID))), server.Bob) + assert.Contains(t, outbox, "Hello world") + assert.NotContains(t, outbox, "Hello once more, world") + + local := server.Handle("/local", server.Carol) + assert.Contains(t, local, "Hello world") + assert.NotContains(t, local, "Hello once more, world") } From 026e970585ae8472866b513a68c4cc7bf91a859c Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Tue, 26 Sep 2023 09:40:13 +0300 Subject: [PATCH 14/14] drop shared cache and fix post visibility tests --- cmd/tootik/main.go | 7 ++- front/cache.go | 10 ++-- front/delete.go | 5 -- front/dm.go | 5 -- front/edit.go | 5 -- front/follow.go | 5 -- front/follows.go | 5 -- front/gemini/gemini.go | 8 +-- front/gopher/gopher.go | 8 +-- front/handler.go | 128 +++++++++++++++++++++++++++++++++++++++++ front/handlers.go | 67 --------------------- front/hashtag.go | 7 --- front/hashtags.go | 7 --- front/inbox.go | 7 --- front/oops.go | 6 -- front/outbox.go | 6 -- front/public.go | 12 ---- front/register.go | 4 -- front/reply.go | 5 -- front/resolve.go | 5 -- front/robots.go | 5 -- front/say.go | 5 -- front/search.go | 6 -- front/stats.go | 8 +-- front/unfollow.go | 5 -- front/users.go | 5 -- front/view.go | 6 -- front/whisper.go | 5 -- test/reply_test.go | 16 +++--- test/server.go | 25 +++++--- test/users_test.go | 2 +- test/whisper_test.go | 4 +- 32 files changed, 174 insertions(+), 230 deletions(-) create mode 100644 front/handler.go delete mode 100644 front/handlers.go diff --git a/cmd/tootik/main.go b/cmd/tootik/main.go index 236aa7e9..7e1d1621 100644 --- a/cmd/tootik/main.go +++ b/cmd/tootik/main.go @@ -28,6 +28,7 @@ import ( "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/fed" + "github.com/dimkr/tootik/front" "github.com/dimkr/tootik/front/finger" "github.com/dimkr/tootik/front/gemini" "github.com/dimkr/tootik/front/gopher" @@ -141,9 +142,11 @@ func main() { wg.Done() }() + handler := front.NewHandler() + wg.Add(1) go func() { - if err := gemini.ListenAndServe(ctx, log, db, resolver, *gemAddr, *gemCert, *gemKey); err != nil { + if err := gemini.ListenAndServe(ctx, log, db, handler, resolver, *gemAddr, *gemCert, *gemKey); err != nil { log.Error("Gemini listener has failed", "error", err) } cancel() @@ -152,7 +155,7 @@ func main() { wg.Add(1) go func() { - if err := gopher.ListenAndServe(ctx, log, db, resolver, *gopherAddr); err != nil { + if err := gopher.ListenAndServe(ctx, log, handler, db, resolver, *gopherAddr); err != nil { log.Error("Gopher listener has failed", "error", err) } cancel() diff --git a/front/cache.go b/front/cache.go index 1dc41563..f6ece05b 100644 --- a/front/cache.go +++ b/front/cache.go @@ -31,9 +31,7 @@ type cacheEntry struct { Created time.Time } -var cache sync.Map - -func callAndCache(r *request, w text.Writer, f func(text.Writer, *request), key string, now time.Time) []byte { +func callAndCache(r *request, w text.Writer, f func(text.Writer, *request), key string, now time.Time, cache *sync.Map) []byte { var buf bytes.Buffer w2 := w.Clone(&buf) @@ -54,7 +52,7 @@ func callAndCache(r *request, w text.Writer, f func(text.Writer, *request), key return raw } -func withCache(f func(text.Writer, *request), d time.Duration) func(text.Writer, *request) { +func withCache(f func(text.Writer, *request), d time.Duration, cache *sync.Map) func(text.Writer, *request) { return func(w text.Writer, r *request) { key := r.URL.String() now := time.Now() @@ -62,7 +60,7 @@ func withCache(f func(text.Writer, *request), d time.Duration) func(text.Writer, entry, cached := cache.Load(key) if !cached { r.Log.Info("Generating first response", "key", key) - w.Write(callAndCache(r, w, f, key, now)) + w.Write(callAndCache(r, w, f, key, now, cache)) return } @@ -79,7 +77,7 @@ func withCache(f func(text.Writer, *request), d time.Duration) func(text.Writer, r.WaitGroup.Add(1) go func() { r.Log.Info("Generating new response", "key", key) - update <- callAndCache(r, w, f, key, now) + update <- callAndCache(r, w, f, key, now, cache) r.WaitGroup.Done() }() diff --git a/front/delete.go b/front/delete.go index 7a7eaeea..b84bc141 100644 --- a/front/delete.go +++ b/front/delete.go @@ -25,13 +25,8 @@ import ( "github.com/dimkr/tootik/fed" "github.com/dimkr/tootik/text" "path/filepath" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/users/delete/[0-9a-f]{64}`)] = delete -} - func delete(w text.Writer, r *request) { if r.User == nil { w.Redirect("/users") diff --git a/front/dm.go b/front/dm.go index 6f29ed3f..3de7e1eb 100644 --- a/front/dm.go +++ b/front/dm.go @@ -23,13 +23,8 @@ import ( "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/text" "path/filepath" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/users/dm/[0-9a-f]{64}`)] = dm -} - func dm(w text.Writer, r *request) { hash := filepath.Base(r.URL.Path) diff --git a/front/edit.go b/front/edit.go index bfc8c770..ca44e57d 100644 --- a/front/edit.go +++ b/front/edit.go @@ -28,14 +28,9 @@ import ( "math" "net/url" "path/filepath" - "regexp" "time" ) -func init() { - handlers[regexp.MustCompile(`^/users/edit/[0-9a-f]{64}`)] = edit -} - func edit(w text.Writer, r *request) { if r.User == nil { w.Redirect("/users") diff --git a/front/follow.go b/front/follow.go index bff0a6c5..f18b6a11 100644 --- a/front/follow.go +++ b/front/follow.go @@ -23,15 +23,10 @@ import ( "github.com/dimkr/tootik/fed" "github.com/dimkr/tootik/text" "path/filepath" - "regexp" ) const maxFollowsPerUser = 150 -func init() { - handlers[regexp.MustCompile(`^/users/follow/[0-9a-f]{64}$`)] = withUserMenu(follow) -} - func follow(w text.Writer, r *request) { if r.User == nil { w.Redirect("/users") diff --git a/front/follows.go b/front/follows.go index 515e9477..b3ac3b44 100644 --- a/front/follows.go +++ b/front/follows.go @@ -23,7 +23,6 @@ import ( "fmt" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/text" - "regexp" "time" ) @@ -33,10 +32,6 @@ type followedUserActivity struct { Count sql.NullInt64 } -func init() { - handlers[regexp.MustCompile(`^/users/follows$`)] = withUserMenu(follows) -} - func follows(w text.Writer, r *request) { if r.User == nil { w.Redirect("/users") diff --git a/front/gemini/gemini.go b/front/gemini/gemini.go index 3eef179f..84799665 100644 --- a/front/gemini/gemini.go +++ b/front/gemini/gemini.go @@ -67,7 +67,7 @@ func getUser(ctx context.Context, db *sql.DB, conn net.Conn, tlsConn *tls.Conn, return &actor, nil } -func handle(ctx context.Context, conn net.Conn, db *sql.DB, resolver *fed.Resolver, wg *sync.WaitGroup, log *slog.Logger) { +func handle(ctx context.Context, handler front.Handler, conn net.Conn, db *sql.DB, resolver *fed.Resolver, wg *sync.WaitGroup, log *slog.Logger) { if err := conn.SetDeadline(time.Now().Add(reqTimeout)); err != nil { log.Warn("Failed to set deadline", "error", err) return @@ -133,10 +133,10 @@ func handle(ctx context.Context, conn net.Conn, db *sql.DB, resolver *fed.Resolv return } - front.Handle(ctx, log, w, reqUrl, user, db, resolver, wg) + handler.Handle(ctx, log, w, reqUrl, user, db, resolver, wg) } -func ListenAndServe(ctx context.Context, log *slog.Logger, db *sql.DB, resolver *fed.Resolver, addr, certPath, keyPath string) error { +func ListenAndServe(ctx context.Context, log *slog.Logger, db *sql.DB, handler front.Handler, resolver *fed.Resolver, addr, certPath, keyPath string) error { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { return err @@ -187,7 +187,7 @@ func ListenAndServe(ctx context.Context, log *slog.Logger, db *sql.DB, resolver wg.Add(1) go func() { - handle(requestCtx, conn, db, resolver, &wg, log) + handle(requestCtx, handler, conn, db, resolver, &wg, log) conn.Close() timer.Stop() cancelRequest() diff --git a/front/gopher/gopher.go b/front/gopher/gopher.go index 3810c258..eff4280e 100644 --- a/front/gopher/gopher.go +++ b/front/gopher/gopher.go @@ -31,7 +31,7 @@ import ( const reqTimeout = time.Second * 30 -func handle(ctx context.Context, log *slog.Logger, conn net.Conn, db *sql.DB, resolver *fed.Resolver, wg *sync.WaitGroup) { +func handle(ctx context.Context, log *slog.Logger, conn net.Conn, handler front.Handler, db *sql.DB, resolver *fed.Resolver, wg *sync.WaitGroup) { if err := conn.SetDeadline(time.Now().Add(reqTimeout)); err != nil { log.Warn("Failed to set deadline", "error", err) return @@ -74,10 +74,10 @@ func handle(ctx context.Context, log *slog.Logger, conn net.Conn, db *sql.DB, re w := gmap.Wrap(conn) - front.Handle(ctx, log.With(slog.Group("request", "path", reqUrl.Path)), w, reqUrl, nil, db, resolver, wg) + handler.Handle(ctx, log.With(slog.Group("request", "path", reqUrl.Path)), w, reqUrl, nil, db, resolver, wg) } -func ListenAndServe(ctx context.Context, log *slog.Logger, db *sql.DB, resolver *fed.Resolver, addr string) error { +func ListenAndServe(ctx context.Context, log *slog.Logger, handler front.Handler, db *sql.DB, resolver *fed.Resolver, addr string) error { l, err := net.Listen("tcp", addr) if err != nil { return err @@ -118,7 +118,7 @@ func ListenAndServe(ctx context.Context, log *slog.Logger, db *sql.DB, resolver wg.Add(1) go func() { - handle(requestCtx, log, conn, db, resolver, &wg) + handle(requestCtx, log, conn, handler, db, resolver, &wg) conn.Write([]byte(".\r\n")) conn.Close() timer.Stop() diff --git a/front/handler.go b/front/handler.go new file mode 100644 index 00000000..ea339877 --- /dev/null +++ b/front/handler.go @@ -0,0 +1,128 @@ +/* +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 ( + "context" + "database/sql" + "errors" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/fed" + "github.com/dimkr/tootik/text" + "log/slog" + "net/url" + "regexp" + "sync" + "time" +) + +type Handler map[*regexp.Regexp]func(text.Writer, *request) + +var ErrNotRegistered = errors.New("User is not registered") + +func NewHandler() Handler { + h := Handler{} + var cache sync.Map + + h[regexp.MustCompile(`^/$`)] = withUserMenu(home) + + h[regexp.MustCompile(`^/users$`)] = withUserMenu(users) + h[regexp.MustCompile(`^/users/register$`)] = register + + h[regexp.MustCompile(`^/users/inbox/[0-9]{4}-[0-9]{2}-[0-9]{2}$`)] = withUserMenu(byDate) + h[regexp.MustCompile(`^/users/inbox/today$`)] = withUserMenu(today) + h[regexp.MustCompile(`^/users/inbox/yesterday$`)] = withUserMenu(yesterday) + + h[regexp.MustCompile(`^/local$`)] = withCache(withUserMenu(local), time.Minute*15, &cache) + h[regexp.MustCompile(`^/users/local$`)] = withCache(withUserMenu(local), time.Minute*15, &cache) + + h[regexp.MustCompile(`^/federated$`)] = withCache(withUserMenu(federated), time.Minute*10, &cache) + h[regexp.MustCompile(`^/users/federated$`)] = withCache(withUserMenu(federated), time.Minute*10, &cache) + + h[regexp.MustCompile(`^/outbox/[0-9a-f]{64}$`)] = withUserMenu(outbox) + h[regexp.MustCompile(`^/users/outbox/[0-9a-f]{64}$`)] = withUserMenu(outbox) + + h[regexp.MustCompile(`^/view/[0-9a-f]{64}$`)] = withUserMenu(view) + h[regexp.MustCompile(`^/users/view/[0-9a-f]{64}$`)] = withUserMenu(view) + + h[regexp.MustCompile(`^/users/whisper$`)] = whisper + h[regexp.MustCompile(`^/users/say$`)] = say + h[regexp.MustCompile(`^/users/dm/[0-9a-f]{64}`)] = dm + + h[regexp.MustCompile(`^/users/reply/[0-9a-f]{64}`)] = reply + + h[regexp.MustCompile(`^/users/edit/[0-9a-f]{64}`)] = edit + h[regexp.MustCompile(`^/users/delete/[0-9a-f]{64}`)] = delete + + h[regexp.MustCompile(`^/users/resolve$`)] = withUserMenu(resolve) + + h[regexp.MustCompile(`^/users/follow/[0-9a-f]{64}$`)] = withUserMenu(follow) + h[regexp.MustCompile(`^/users/unfollow/[0-9a-f]{64}$`)] = withUserMenu(unfollow) + + h[regexp.MustCompile(`^/users/follows$`)] = withUserMenu(follows) + + h[regexp.MustCompile(`^/hashtag/[a-zA-Z0-9]+$`)] = withCache(withUserMenu(hashtag), time.Minute*5, &cache) + h[regexp.MustCompile(`^/users/hashtag/[a-zA-Z0-9]+$`)] = withCache(withUserMenu(hashtag), time.Minute*5, &cache) + + h[regexp.MustCompile(`^/hashtags$`)] = withCache(withUserMenu(hashtags), time.Minute*30, &cache) + h[regexp.MustCompile(`^/users/hashtags$`)] = withCache(withUserMenu(hashtags), time.Minute*30, &cache) + + h[regexp.MustCompile(`^/search$`)] = withUserMenu(search) + h[regexp.MustCompile(`^/users/search$`)] = withUserMenu(search) + + h[regexp.MustCompile(`^/stats$`)] = withCache(withUserMenu(stats), time.Minute*5, &cache) + h[regexp.MustCompile(`^/users/stats$`)] = withCache(withUserMenu(stats), time.Minute*5, &cache) + + h[regexp.MustCompile(`^/oops`)] = withUserMenu(oops) + h[regexp.MustCompile(`^/users/oops`)] = withUserMenu(oops) + + h[regexp.MustCompile(`^/robots.txt$`)] = robots + + return h +} + +func (h Handler) Handle(ctx context.Context, log *slog.Logger, w text.Writer, reqUrl *url.URL, user *ap.Actor, db *sql.DB, resolver *fed.Resolver, wg *sync.WaitGroup) { + for re, handler := range h { + if re.MatchString(reqUrl.Path) { + var l *slog.Logger + if user == nil { + l = log.With(slog.Group("request", "path", reqUrl.Path)) + } else { + l = log.With(slog.Group("request", "path", reqUrl.Path, "user", user.ID)) + } + + handler(w, &request{ + Context: ctx, + URL: reqUrl, + User: user, + DB: db, + Resolver: resolver, + WaitGroup: wg, + Log: l, + }) + return + } + } + + log.Warn("Received an invalid request", "path", reqUrl.Path) + + if user == nil { + w.Redirect("/oops") + } else { + w.Redirect("/users/oops") + } +} diff --git a/front/handlers.go b/front/handlers.go deleted file mode 100644 index a4f0cb9a..00000000 --- a/front/handlers.go +++ /dev/null @@ -1,67 +0,0 @@ -/* -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 ( - "context" - "database/sql" - "errors" - "github.com/dimkr/tootik/ap" - "github.com/dimkr/tootik/fed" - "github.com/dimkr/tootik/text" - "log/slog" - "net/url" - "regexp" - "sync" -) - -var ( - handlers = map[*regexp.Regexp]func(text.Writer, *request){} - ErrNotRegistered = errors.New("User is not registered") -) - -func Handle(ctx context.Context, log *slog.Logger, w text.Writer, reqUrl *url.URL, user *ap.Actor, db *sql.DB, resolver *fed.Resolver, wg *sync.WaitGroup) { - for re, handler := range handlers { - if re.MatchString(reqUrl.Path) { - var l *slog.Logger - if user == nil { - l = log.With(slog.Group("request", "path", reqUrl.Path)) - } else { - l = log.With(slog.Group("request", "path", reqUrl.Path, "user", user.ID)) - } - - handler(w, &request{ - Context: ctx, - URL: reqUrl, - User: user, - DB: db, - Resolver: resolver, - WaitGroup: wg, - Log: l, - }) - return - } - } - - log.Warn("Received an invalid request", "path", reqUrl.Path) - - if user == nil { - w.Redirect("/oops") - } else { - w.Redirect("/users/oops") - } -} diff --git a/front/hashtag.go b/front/hashtag.go index 30da54d8..38fa2810 100644 --- a/front/hashtag.go +++ b/front/hashtag.go @@ -21,15 +21,8 @@ import ( "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/text" "path/filepath" - "regexp" - "time" ) -func init() { - handlers[regexp.MustCompile(`^/hashtag/[a-zA-Z0-9]+$`)] = withCache(withUserMenu(hashtag), time.Minute*5) - handlers[regexp.MustCompile(`^/users/hashtag/[a-zA-Z0-9]+$`)] = withCache(withUserMenu(hashtag), time.Minute*5) -} - func hashtag(w text.Writer, r *request) { offset, err := getOffset(r.URL) if err != nil { diff --git a/front/hashtags.go b/front/hashtags.go index 74346837..7a47759d 100644 --- a/front/hashtags.go +++ b/front/hashtags.go @@ -18,15 +18,8 @@ package front import ( "github.com/dimkr/tootik/text" - "regexp" - "time" ) -func init() { - handlers[regexp.MustCompile(`^/hashtags$`)] = withCache(withUserMenu(hashtags), time.Minute*30) - handlers[regexp.MustCompile(`^/users/hashtags$`)] = withCache(withUserMenu(hashtags), time.Minute*30) -} - func hashtags(w text.Writer, r *request) { rows, err := r.Query(`select hashtag from (select hashtag, max(inserted)/86400 as last, count(distinct author) as users, count(*) as posts from (select hashtags.hashtag, notes.author, notes.inserted from hashtags join notes on notes.id = hashtags.note where inserted > unixepoch()-60*60*24*7) group by hashtag) where users > 1 order by users desc, posts desc, last desc limit 100`) if err != nil { diff --git a/front/inbox.go b/front/inbox.go index c8af85f6..b3a9a08b 100644 --- a/front/inbox.go +++ b/front/inbox.go @@ -19,19 +19,12 @@ package front import ( "fmt" "path/filepath" - "regexp" "time" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/text" ) -func init() { - handlers[regexp.MustCompile("^/users/inbox/[0-9]{4}-[0-9]{2}-[0-9]{2}$")] = withUserMenu(byDate) - handlers[regexp.MustCompile("^/users/inbox/today$")] = withUserMenu(today) - handlers[regexp.MustCompile("^/users/inbox/yesterday$")] = withUserMenu(yesterday) -} - func dailyPosts(w text.Writer, r *request, day time.Time) { if r.User == nil { w.Status(61, "Peer certificate is required") diff --git a/front/oops.go b/front/oops.go index 2477ddd4..53fc4bc5 100644 --- a/front/oops.go +++ b/front/oops.go @@ -18,14 +18,8 @@ package front import ( "github.com/dimkr/tootik/text" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/users/oops`)] = withUserMenu(oops) - handlers[regexp.MustCompile(`^/oops`)] = withUserMenu(oops) -} - func oops(w text.Writer, r *request) { w.OK() w.Title("🦖🦖🦖") diff --git a/front/outbox.go b/front/outbox.go index c9a071ee..3f11e3d3 100644 --- a/front/outbox.go +++ b/front/outbox.go @@ -26,14 +26,8 @@ import ( "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/text" "path/filepath" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/users/outbox/[0-9a-f]{64}$`)] = withUserMenu(outbox) - handlers[regexp.MustCompile(`^/outbox/[0-9a-f]{64}$`)] = withUserMenu(outbox) -} - func outbox(w text.Writer, r *request) { hash := filepath.Base(r.URL.Path) diff --git a/front/public.go b/front/public.go index 74717ddb..39ab5431 100644 --- a/front/public.go +++ b/front/public.go @@ -21,22 +21,10 @@ import ( "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/text" - "regexp" - "time" ) const maxOffset = postsPerPage * 30 -func init() { - handlers[regexp.MustCompile(`^/local$`)] = withCache(withUserMenu(local), time.Minute*15) - handlers[regexp.MustCompile(`^/users/local$`)] = withCache(withUserMenu(local), time.Minute*15) - - handlers[regexp.MustCompile(`^/federated$`)] = withCache(withUserMenu(federated), time.Minute*10) - handlers[regexp.MustCompile(`^/users/federated$`)] = withCache(withUserMenu(federated), time.Minute*10) - - handlers[regexp.MustCompile(`^/$`)] = withUserMenu(home) -} - func local(w text.Writer, r *request) { offset, err := getOffset(r.URL) if err != nil { diff --git a/front/register.go b/front/register.go index b16f1f2c..fb2751bd 100644 --- a/front/register.go +++ b/front/register.go @@ -29,10 +29,6 @@ import ( var userNameRegex = regexp.MustCompile(`^[a-zA-Z0-9-_]{4,32}$`) -func init() { - handlers[regexp.MustCompile(`^/users/register$`)] = register -} - func register(w text.Writer, r *request) { if r.User != nil { r.Log.Warn("Registered user cannot register again") diff --git a/front/reply.go b/front/reply.go index c28297fb..3c329455 100644 --- a/front/reply.go +++ b/front/reply.go @@ -23,13 +23,8 @@ import ( "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/text" "path/filepath" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/users/reply/[0-9a-f]{64}`)] = reply -} - func reply(w text.Writer, r *request) { hash := filepath.Base(r.URL.Path) diff --git a/front/resolve.go b/front/resolve.go index 9a8a716e..0a6c764c 100644 --- a/front/resolve.go +++ b/front/resolve.go @@ -22,14 +22,9 @@ import ( "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/text" "net/url" - "regexp" "strings" ) -func init() { - handlers[regexp.MustCompile(`^/users/resolve$`)] = withUserMenu(resolve) -} - func resolve(w text.Writer, r *request) { if r.URL.RawQuery == "" { w.Status(10, "User name (name or name@domain)") diff --git a/front/robots.go b/front/robots.go index cf762255..bbeb23b6 100644 --- a/front/robots.go +++ b/front/robots.go @@ -18,13 +18,8 @@ package front import ( "github.com/dimkr/tootik/text" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/robots.txt$`)] = robots -} - func robots(w text.Writer, r *request) { w.Status(20, "text/plain") w.Text("User-agent: *") diff --git a/front/say.go b/front/say.go index 3630d593..86e02474 100644 --- a/front/say.go +++ b/front/say.go @@ -19,13 +19,8 @@ package front import ( "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/text" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/users/say$`)] = say -} - func say(w text.Writer, r *request) { to := ap.Audience{} cc := ap.Audience{} diff --git a/front/search.go b/front/search.go index 68915c72..bb37aa20 100644 --- a/front/search.go +++ b/front/search.go @@ -19,14 +19,8 @@ package front import ( "github.com/dimkr/tootik/text" "net/url" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/search$`)] = withUserMenu(search) - handlers[regexp.MustCompile(`^/users/search$`)] = withUserMenu(search) -} - func search(w text.Writer, r *request) { if r.URL.RawQuery == "" { w.Status(10, "Hashtag") diff --git a/front/stats.go b/front/stats.go index 9d6a5288..c02fb1a8 100644 --- a/front/stats.go +++ b/front/stats.go @@ -19,18 +19,12 @@ package front import ( "fmt" "github.com/dimkr/tootik/cfg" - "github.com/dimkr/tootik/graph" "github.com/dimkr/tootik/fed" + "github.com/dimkr/tootik/graph" "github.com/dimkr/tootik/text" - "regexp" "time" ) -func init() { - handlers[regexp.MustCompile(`^/stats$`)] = withCache(withUserMenu(stats), time.Minute*5) - handlers[regexp.MustCompile(`^/users/stats$`)] = withCache(withUserMenu(stats), time.Minute*5) -} - func getGraph(r *request, query string, keys []string, values []int64) string { rows, err := r.Query(query) if err != nil { diff --git a/front/unfollow.go b/front/unfollow.go index 632a9ff8..c7f9d39e 100644 --- a/front/unfollow.go +++ b/front/unfollow.go @@ -23,13 +23,8 @@ import ( "github.com/dimkr/tootik/fed" "github.com/dimkr/tootik/text" "path/filepath" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/users/unfollow/[0-9a-f]{64}$`)] = withUserMenu(unfollow) -} - func unfollow(w text.Writer, r *request) { if r.User == nil { w.Redirect("/users") diff --git a/front/users.go b/front/users.go index 3dac978e..427a7971 100644 --- a/front/users.go +++ b/front/users.go @@ -17,17 +17,12 @@ limitations under the License. package front import ( - "regexp" "time" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/text" ) -func init() { - handlers[regexp.MustCompile("^/users$")] = withUserMenu(users) -} - func users(w text.Writer, r *request) { if r.User == nil { w.Status(61, "Peer certificate is required") diff --git a/front/view.go b/front/view.go index 93396161..b4c3ee79 100644 --- a/front/view.go +++ b/front/view.go @@ -26,14 +26,8 @@ import ( "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/text" "path/filepath" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/users/view/[0-9a-f]{64}$`)] = withUserMenu(view) - handlers[regexp.MustCompile(`^/view/[0-9a-f]{64}$`)] = withUserMenu(view) -} - func view(w text.Writer, r *request) { hash := filepath.Base(r.URL.Path) diff --git a/front/whisper.go b/front/whisper.go index 0a59049e..25999474 100644 --- a/front/whisper.go +++ b/front/whisper.go @@ -19,13 +19,8 @@ package front import ( "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/text" - "regexp" ) -func init() { - handlers[regexp.MustCompile(`^/users/whisper$`)] = whisper -} - func whisper(w text.Writer, r *request) { to := ap.Audience{} cc := ap.Audience{} diff --git a/test/reply_test.go b/test/reply_test.go index 7e24e671..64ddb999 100644 --- a/test/reply_test.go +++ b/test/reply_test.go @@ -105,12 +105,12 @@ func TestReply_PostToFollowers(t *testing.T) { assert.Contains(t, view, "Hello world") assert.Contains(t, view, "Welcome Bob") - users := server.Handle("/users/inbox/today", server.Bob) - assert.Contains(t, users, "Welcome Bob") + today := server.Handle("/users/inbox/today", server.Bob) + assert.Contains(t, today, "Welcome Bob") local := server.Handle("/local", nil) - assert.Contains(t, local, "Hello world") - assert.Contains(t, local, "Welcome Bob") + assert.NotContains(t, local, "Hello world") + assert.NotContains(t, local, "Welcome Bob") } func TestReply_ReplyToPublicPostByFollowedUser(t *testing.T) { @@ -141,8 +141,8 @@ func TestReply_ReplyToPublicPostByFollowedUser(t *testing.T) { assert.NotContains(t, users, "Welcome Bob") local := server.Handle("/local", nil) - assert.Contains(t, local, "Hello world") - assert.Contains(t, local, "Welcome Bob") + assert.NotContains(t, local, "Hello world") + assert.NotContains(t, local, "Welcome Bob") } func TestReply_ReplyToPublicPostByNotFollowedUser(t *testing.T) { @@ -170,8 +170,8 @@ func TestReply_ReplyToPublicPostByNotFollowedUser(t *testing.T) { assert.NotContains(t, users, "Welcome Bob") local := server.Handle("/local", nil) - assert.Contains(t, local, "Hello world") - assert.Contains(t, local, "Welcome Bob") + assert.NotContains(t, local, "Hello world") + assert.NotContains(t, local, "Welcome Bob") } func TestReply_DM(t *testing.T) { diff --git a/test/server.go b/test/server.go index 892aa740..3a9f8552 100644 --- a/test/server.go +++ b/test/server.go @@ -37,12 +37,13 @@ import ( ) type server struct { - db *sql.DB - dbPath string - Alice *ap.Actor - Bob *ap.Actor - Carol *ap.Actor - Nobody *ap.Actor + db *sql.DB + dbPath string + handler front.Handler + Alice *ap.Actor + Bob *ap.Actor + Carol *ap.Actor + Nobody *ap.Actor } func (s *server) Shutdown() { @@ -88,7 +89,15 @@ func newTestServer() *server { panic(err) } - return &server{dbPath: path, db: db, Alice: alice, Bob: bob, Carol: carol, Nobody: nobody} + return &server{ + dbPath: path, + db: db, + handler: front.NewHandler(), + Alice: alice, + Bob: bob, + Carol: carol, + Nobody: nobody, + } } func (s *server) Handle(request string, user *ap.Actor) string { @@ -99,7 +108,7 @@ func (s *server) Handle(request string, user *ap.Actor) string { var buf bytes.Buffer var wg sync.WaitGroup - front.Handle(context.Background(), slog.Default(), gmi.Wrap(&buf), u, user, s.db, fed.NewResolver(nil), &wg) + s.handler.Handle(context.Background(), slog.Default(), gmi.Wrap(&buf), u, user, s.db, fed.NewResolver(nil), &wg) return string(buf.Bytes()) } diff --git a/test/users_test.go b/test/users_test.go index ae80ba76..9e35a52b 100644 --- a/test/users_test.go +++ b/test/users_test.go @@ -86,7 +86,7 @@ func TestUsers_NewPostToFollowers(t *testing.T) { assert.NotContains(t, users, "1 post") local := server.Handle("/users/local", server.Carol) - assert.Contains(t, local, "Hello world") + assert.NotContains(t, local, "Hello world") } func TestUsers_NewDM(t *testing.T) { diff --git a/test/whisper_test.go b/test/whisper_test.go index 1e30d966..6fa2748a 100644 --- a/test/whisper_test.go +++ b/test/whisper_test.go @@ -37,7 +37,7 @@ func TestWhisper_HappyFlow(t *testing.T) { assert.Contains(t, outbox, "Hello world") local := server.Handle("/local", server.Carol) - assert.Contains(t, local, "Hello world") + assert.NotContains(t, local, "Hello world") } func TestWhisper_Throttling(t *testing.T) { @@ -61,6 +61,6 @@ func TestWhisper_Throttling(t *testing.T) { assert.NotContains(t, outbox, "Hello once more, world") local := server.Handle("/local", server.Carol) - assert.Contains(t, local, "Hello world") + assert.NotContains(t, local, "Hello world") assert.NotContains(t, local, "Hello once more, world") }