From bd3088bfc900922520f9bb984dc29ba0225b592c Mon Sep 17 00:00:00 2001 From: Dima Krasner Date: Sun, 17 Sep 2023 13:14:35 +0300 Subject: [PATCH] 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