Skip to content

Commit

Permalink
add basic support for post editing
Browse files Browse the repository at this point in the history
  • Loading branch information
dimkr committed Sep 17, 2023
1 parent 1e30654 commit bd3088b
Show file tree
Hide file tree
Showing 6 changed files with 218 additions and 1 deletion.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions fed/edit.go
Original file line number Diff line number Diff line change
@@ -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
}
107 changes: 107 additions & 0 deletions front/edit.go
Original file line number Diff line number Diff line change
@@ -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(&noteString); 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), &note); 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, &note, 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)
}
7 changes: 7 additions & 0 deletions front/print.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
14 changes: 14 additions & 0 deletions migrations/005_edits.go
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 1 addition & 1 deletion migrations/add.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit bd3088b

Please sign in to comment.