diff --git a/fed/apgateway.go b/fed/apgateway.go index ccf3f88a..2260d53e 100644 --- a/fed/apgateway.go +++ b/fed/apgateway.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -239,7 +239,7 @@ func (l *Listener) handleAPGatewayGet(w http.ResponseWriter, r *http.Request) { union all select json(notes.object) as raw from notes join persons on notes.author = persons.id - where notes.cid = $1 and notes.public = 1 and persons.ed25519privkey is not null + where notes.cid = $1 and notes.deleted = 0 and notes.public = 1 and persons.ed25519privkey is not null union all select json(outbox.activity) as raw from outbox join persons on outbox.activity->>'$.actor' = persons.id diff --git a/fed/post.go b/fed/post.go index 35c0762a..405dba28 100644 --- a/fed/post.go +++ b/fed/post.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -40,7 +40,7 @@ func (l *Listener) handlePost(w http.ResponseWriter, r *http.Request) { slog.Info("Fetching post", "post", postID) var note string - if err := l.DB.QueryRowContext(r.Context(), `select json(object) from notes where id = ? and public = 1`, postID).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { + if err := l.DB.QueryRowContext(r.Context(), `select json(object) from notes where id = ? and public = 1 and deleted = 0`, postID).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { w.WriteHeader(http.StatusNotFound) return } else if err != nil { diff --git a/fed/resolve.go b/fed/resolve.go index 5bde7968..d90659b1 100644 --- a/fed/resolve.go +++ b/fed/resolve.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -158,7 +158,7 @@ func deleteActor(ctx context.Context, db *sql.DB, id string) { slog.Warn("Failed to delete shares by actor", "id", id, "error", err) } - if _, err := db.ExecContext(ctx, `delete from notes where author = ?`, id); err != nil { + if _, err := db.ExecContext(ctx, `update notes set object = jsonb_set(jsonb_remove(object, '$.name', '$.summary', '$.tag', '$.attachment'), '$.content', '[deleted]'), deleted = 1 where author = ?`, id); err != nil { slog.Warn("Failed to delete notes by actor", "id", id, "error", err) } @@ -170,7 +170,7 @@ func deleteActor(ctx context.Context, db *sql.DB, id string) { slog.Warn("Failed to delete keys for actor", "id", id, "error", err) } - if _, err := db.ExecContext(ctx, `delete from persons where id = ?`, id); err != nil { + if _, err := db.ExecContext(ctx, `update persons set deleted = 1 where id = ?`, id); err != nil { slog.Warn("Failed to delete actor", "id", id, "error", err) } } diff --git a/fed/resolve_test.go b/fed/resolve_test.go index 4a41a6fa..9d6f6ebf 100644 --- a/fed/resolve_test.go +++ b/fed/resolve_test.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -3728,7 +3728,7 @@ func TestResolve_FederatedActorOldCacheActorDeleted(t *testing.T) { assert.Empty(client.Data) var ok int - assert.NoError(db.QueryRow(`select not exists (select 1 from notes where author = 'https://0.0.0.0/user/dan') and not exists (select 1 from persons where id = 'https://0.0.0.0/user/dan')`).Scan(&ok)) + assert.NoError(db.QueryRow(`select not exists (select 1 from notes where author = 'https://0.0.0.0/user/dan' and deleted = 0) and not exists (select 1 from persons where id = 'https://0.0.0.0/user/dan' and deleted = 0)`).Scan(&ok)) assert.Equal(1, ok) } @@ -3893,7 +3893,7 @@ func TestResolve_FederatedActorFirstTimeDeleted(t *testing.T) { assert.Empty(client.Data) var ok int - assert.NoError(db.QueryRow(`select exists (select 1 from notes where author = 'https://0.0.0.0/user/dan') and not exists (select 1 from persons where id = 'https://0.0.0.0/user/dan')`).Scan(&ok)) + assert.NoError(db.QueryRow(`select exists (select 1 from notes where author = 'https://0.0.0.0/user/dan' and deleted = 0) and not exists (select 1 from persons where id = 'https://0.0.0.0/user/dan' and deleted = 0)`).Scan(&ok)) assert.Equal(1, ok) } diff --git a/front/bookmark.go b/front/bookmark.go index 7b0e555f..95d18630 100644 --- a/front/bookmark.go +++ b/front/bookmark.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -46,6 +46,7 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { select 1 from notes where notes.id = $1 and + notes.deleted = 0 and ( notes.author = $2 or notes.public = 1 or diff --git a/front/communities.go b/front/communities.go index a2fa3f81..33730199 100644 --- a/front/communities.go +++ b/front/communities.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -42,7 +42,8 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { persons.id = notes.author where persons.host = $1 and - persons.actor->>'$.type' = 'Group' + persons.actor->>'$.type' = 'Group' and + notes.deleted = 0 ) u group by u.id diff --git a/front/delete.go b/front/delete.go index 5b3dd744..f9b42b67 100644 --- a/front/delete.go +++ b/front/delete.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -34,7 +34,7 @@ func (h *Handler) delete(w text.Writer, r *Request, args ...string) { postID := "https://" + args[1] var note ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = ? and author in (select id from persons where cid = ?)`, postID, ap.Canonical(r.User.ID)).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = ? and deleted = 0 and author in (select id from persons where cid = ?)`, postID, ap.Canonical(r.User.ID)).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { r.Log.Warn("Attempted to delete a non-existing post", "post", postID, "error", err) w.Error() return diff --git a/front/edit.go b/front/edit.go index 8df7a3e3..1219558c 100644 --- a/front/edit.go +++ b/front/edit.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -35,7 +35,7 @@ func (h *Handler) doEdit(w text.Writer, r *Request, args []string, readInput inp postID := "https://" + args[1] var note ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = ? and author in (select id from persons where cid = ?)`, postID, ap.Canonical(r.User.ID)).Scan(¬e); errors.Is(err, sql.ErrNoRows) { + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = ? and deleted = 0 and author in (select id from persons where cid = ?)`, postID, ap.Canonical(r.User.ID)).Scan(¬e); errors.Is(err, sql.ErrNoRows) { r.Log.Warn("Attempted to edit non-existing post", "post", postID, "error", err) w.Error() return @@ -77,7 +77,7 @@ func (h *Handler) doEdit(w text.Writer, r *Request, args []string, readInput inp } var parent ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where cid = ?`, ap.Canonical(note.InReplyTo)).Scan(&parent); errors.Is(err, sql.ErrNoRows) { + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where cid = ? and deleted = 0`, ap.Canonical(note.InReplyTo)).Scan(&parent); errors.Is(err, sql.ErrNoRows) { r.Log.Warn("Parent post does not exist", "parent", note.InReplyTo) } else if err != nil { r.Log.Warn("Failed to fetch parent post", "parent", note.InReplyTo, "error", err) diff --git a/front/fts.go b/front/fts.go index 6a4ce01b..c9a21ada 100644 --- a/front/fts.go +++ b/front/fts.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -69,6 +69,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { groups.actor->>'$.type' = 'Group' and exists (select 1 from shares where shares.by = groups.id and shares.note = notes.id) where notes.public = 1 and + notes.deleted = 0 and notesfts.content match $1 order by rank desc limit $2 @@ -90,6 +91,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { notes.id = notesfts.id where notes.public = 1 and + notes.deleted = 0 and notesfts.content match $1 union all select notes.id, notes.object, notes.author, notes.inserted, rank, 1 as aud from @@ -112,7 +114,8 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { where follows.follower = $2 and follows.accepted = 1 and - notesfts.content match $1 + notesfts.content match $1 and + notes.deleted = 0 union all select notes.id, notes.object, notes.author, notes.inserted, rank, 0 as aud from notesfts @@ -124,7 +127,8 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { $2 in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'$.to') where value = $2)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = $2)) - ) + ) and + notes.deleted = 0 ) u join persons authors on authors.id = u.author and coalesce(authors.actor->>'$.discoverable', 1) diff --git a/front/hashtag.go b/front/hashtag.go index ff104e42..c3d98736 100644 --- a/front/hashtag.go +++ b/front/hashtag.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -32,7 +32,7 @@ func (h *Handler) hashtag(w text.Writer, r *Request, args ...string) { func(offset int) (*sql.Rows, error) { return h.DB.QueryContext( r.Context, - `select json(notes.object), json(persons.actor), null, notes.inserted from notes join hashtags on notes.id = hashtags.note left join (select object->>'$.inReplyTo' as id, count(*) as count from notes where inserted >= unixepoch() - 7*24*60*60 group by object->>'$.inReplyTo') replies on notes.id = replies.id left join persons on notes.author = persons.id where notes.public = 1 and hashtags.hashtag = $1 order by replies.count desc, notes.inserted/(24*60*60) desc, notes.inserted desc limit $2 offset $3`, + `select json(notes.object), json(persons.actor), null, notes.inserted from notes join hashtags on notes.id = hashtags.note left join (select object->>'$.inReplyTo' as id, count(*) as count from notes where deleted = 0 and inserted >= unixepoch() - 7*24*60*60 group by object->>'$.inReplyTo') replies on notes.id = replies.id left join persons on notes.author = persons.id where notes.public = 1 and notes.deleted = 0 and hashtags.hashtag = $1 order by replies.count desc, notes.inserted/(24*60*60) desc, notes.inserted desc limit $2 offset $3`, tag, h.Config.PostsPerPage, offset, diff --git a/front/hashtags.go b/front/hashtags.go index 0651b4b8..ce5abf01 100644 --- a/front/hashtags.go +++ b/front/hashtags.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -70,7 +70,8 @@ func (h *Handler) hashtags(w text.Writer, r *Request, args ...string) { where follows.accepted = 1 and follows.follower like ? and - notes.inserted > unixepoch()-60*60*24*7 + notes.inserted > unixepoch()-60*60*24*7 and + notes.deleted = 0 ) group by hashtag diff --git a/front/local.go b/front/local.go index 5cb79adc..acee58b7 100644 --- a/front/local.go +++ b/front/local.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -36,7 +36,7 @@ func (h *Handler) local(w text.Writer, r *Request, args ...string) { select notes.object, persons.actor, null as sharer, notes.inserted from persons join notes on notes.author = persons.id - where notes.public = 1 and persons.host = $1 + where notes.public = 1 and notes.deleted = 0 and persons.host = $1 union all select notes.object, persons.actor, sharers.actor as sharer, shares.inserted from persons sharers join shares @@ -45,7 +45,7 @@ func (h *Handler) local(w text.Writer, r *Request, args ...string) { on notes.id = shares.note join persons on persons.id = notes.author - where notes.public = 1 and sharers.host = $1 + where notes.public = 1 and notes.deleted = 0 and sharers.host = $1 ) order by inserted desc limit $2 diff --git a/front/outbox.go b/front/outbox.go index d3d4ba1b..311e2f44 100644 --- a/front/outbox.go +++ b/front/outbox.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -55,7 +55,8 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { actorID := "https://" + args[1] var actor ap.Actor - if err := h.DB.QueryRowContext(r.Context, `select json(actor) from persons where id = ?`, actorID).Scan(&actor); err != nil && errors.Is(err, sql.ErrNoRows) { + var deleted int + if err := h.DB.QueryRowContext(r.Context, `select json(actor), deleted from persons where id = ?`, actorID).Scan(&actor, &deleted); errors.Is(err, sql.ErrNoRows) { r.Log.Info("Person was not found", "actor", actorID) w.Status(40, "User not found") return @@ -79,13 +80,13 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // unauthenticated users can only see public posts in a group rows, err = h.DB.QueryContext( r.Context, - `select json(u.object), json(authors.actor), null, max(u.inserted, coalesce(max(replies.inserted), 0)) from ( + `select json(u.object), json(authors.actor), null, max(u.inserted, coalesce(max(replies.inserted) filter (where replies.deleted = 0), 0)) from ( select notes.id, notes.object, notes.author, shares.inserted from shares join notes on notes.id = shares.note - where shares.by = $1 and notes.public = 1 and notes.object->>'$.inReplyTo' is null + where shares.by = $1 and notes.public = 1 and notes.deleted = 0 and notes.object->>'$.inReplyTo' is null union all select notes.id, notes.object, notes.author, notes.inserted from notes - where notes.author = $1 and notes.public = 1 and notes.object->>'$.inReplyTo' is null + where notes.author = $1 and notes.public = 1 and notes.deleted = 0 and notes.object->>'$.inReplyTo' is null ) u join persons authors on authors.id = u.author left join notes replies on replies.object->>'$.inReplyTo' = u.id @@ -99,7 +100,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { // users can see public posts in a group and non-public posts if they follow the group rows, err = h.DB.QueryContext( r.Context, - `select json(u.object), json(authors.actor), null, max(u.inserted, coalesce(max(replies.inserted), 0)) from ( + `select json(u.object), json(authors.actor), null, max(u.inserted, coalesce(max(replies.inserted) filter (where replies.deleted = 0), 0)) from ( select notes.id, notes.object, notes.author, shares.inserted from shares join notes on notes.id = shares.note where @@ -108,6 +109,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { notes.public = 1 or exists (select 1 from follows where follower = $2 and followed = $1 and accepted = 1) ) and + notes.deleted = 0 and notes.object->>'$.inReplyTo' is null union all select notes.id, notes.object, notes.author, notes.inserted from notes @@ -117,6 +119,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { notes.public = 1 or exists (select 1 from follows where follower = $2 and followed = $1 and accepted = 1) ) and + notes.deleted = 0 and notes.object->>'$.inReplyTo' is null ) u join persons authors on authors.id = u.author @@ -135,14 +138,14 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { `select json(object), json(actor), json(sharer), max(inserted) from ( select notes.id, persons.actor, notes.object, notes.inserted, null as sharer from notes join persons on persons.id = $1 - where notes.author = $1 and notes.public = 1 + where notes.author = $1 and notes.public = 1 and notes.deleted = 0 union all select notes.id, authors.actor, notes.object, shares.inserted, sharers.actor as by from shares join notes on notes.id = shares.note join persons authors on authors.id = notes.author join persons sharers on sharers.id = $1 - where shares.by = $1 and notes.public = 1 + where shares.by = $1 and notes.public = 1 and notes.deleted = 0 ) group by id order by max(inserted) desc limit $2 offset $3`, @@ -157,13 +160,13 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { `select json(object), json(actor), json(sharer), max(inserted) from ( select notes.id, persons.actor, notes.object, notes.inserted, null as sharer from notes join persons on persons.id = notes.author - where notes.author = $1 + where notes.author = $1 and notes.deleted = 0 union all select notes.id, authors.actor, notes.object, shares.inserted, sharers.actor as by from shares join notes on notes.id = shares.note join persons authors on authors.id = notes.author join persons sharers on sharers.id = $1 - where shares.by = $1 + where shares.by = $1 and notes.deleted = 0 ) group by id order by max(inserted) desc limit $2 offset $3`, @@ -178,11 +181,11 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { `select json(object), json(actor), json(sharer), max(inserted) from ( select notes.id, persons.actor, notes.object, notes.inserted, null as sharer from notes join persons on persons.id = $1 - where notes.author = $1 and notes.public = 1 + where notes.author = $1 and notes.public = 1 and notes.deleted = 0 union select notes.id, persons.actor, notes.object, notes.inserted, null as sharer from notes join persons on persons.id = $1 - where ( + where notes.deleted = 0 and ( notes.author = $1 and ( $2 in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'$.to') where value = $2)) or @@ -197,6 +200,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = persons.actor->>'$.followers')) join persons authors on authors.id = $1 where notes.public = 0 and + notes.deleted = 0 and notes.author = $1 and persons.id = $1 and exists (select 1 from follows where follower = $2 and followed = $1 and accepted = 1) @@ -206,7 +210,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { join notes on notes.id = shares.note join persons authors on authors.id = notes.author join persons sharers on sharers.id = $1 - where shares.by = $1 and notes.public = 1 + where shares.by = $1 and notes.public = 1 and notes.deleted = 0 ) group by id order by max(inserted) desc limit $3 offset $4`, @@ -242,6 +246,11 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { w.Title(displayName) } + if deleted == 1 { + w.Text("[Deleted]") + w.Empty() + } + if offset == 0 && len(actor.Icon) > 0 && actor.Icon[0].URL != "" { w.Link(actor.Icon[0].URL, "Avatar") } else if offset == 0 { diff --git a/front/print.go b/front/print.go index 49ed9025..80aab06b 100644 --- a/front/print.go +++ b/front/print.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -250,12 +250,12 @@ func (h *Handler) printCompactNote(w text.Writer, r *Request, note *ap.Object, a contentLines, links, hashtags, mentionedUsers := h.getNoteContent(note, true) var replies int - if err := h.DB.QueryRowContext(r.Context, `select count(*) from notes where object->>'$.inReplyTo' = ?`, note.ID).Scan(&replies); err != nil { + if err := h.DB.QueryRowContext(r.Context, `select count(*) from notes where object->>'$.inReplyTo' = ? and deleted = 0`, note.ID).Scan(&replies); err != nil { r.Log.Warn("Failed to count replies", "id", note.ID, "error", err) } var quotes int - if err := h.DB.QueryRowContext(r.Context, `select count(*) from notes where object->>'$.quote' = ?`, note.ID).Scan("es); err != nil { + if err := h.DB.QueryRowContext(r.Context, `select count(*) from notes where object->>'$.quote' = ? and deleted = 0`, note.ID).Scan("es); err != nil { r.Log.Warn("Failed to count quotes", "id", note.ID, "error", err) } diff --git a/front/reply.go b/front/reply.go index 99c65494..819d83a7 100644 --- a/front/reply.go +++ b/front/reply.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -40,6 +40,7 @@ func (h *Handler) replyOrQuote(w text.Writer, r *Request, args []string, quote b join persons on persons.id = notes.author where notes.id = $1 and + notes.deleted = 0 and ( notes.public = 1 or notes.author = $2 or diff --git a/front/share.go b/front/share.go index 7ec148f3..c897de39 100644 --- a/front/share.go +++ b/front/share.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -51,7 +51,7 @@ func (h *Handler) share(w text.Writer, r *Request, args ...string) { postID := "https://" + args[1] var note ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = $1 and public = 1 and author != $2 and not exists (select 1 from shares where note = notes.id and by = $2)`, postID, r.User.ID).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = $1 and deleted = 0 and public = 1 and author != $2 and not exists (select 1 from shares where note = notes.id and by = $2)`, postID, r.User.ID).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { r.Log.Warn("Attempted to share non-existing post", "post", postID, "error", err) w.Error() return diff --git a/front/view.go b/front/view.go index d8bec6de..6310a931 100644 --- a/front/view.go +++ b/front/view.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -527,7 +527,8 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { left join persons on persons.id = replies.author where notes.id = $1 and - replies.public = 1 + replies.public = 1 and + replies.deleted = 0 order by replies.inserted desc limit $2 offset $3 `, postID, @@ -543,6 +544,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { left join persons on persons.id = replies.author where notes.id = $1 and + replies.deleted = 0 and ( replies.public = 1 or replies.author = $2 or diff --git a/inbox/feed.go b/inbox/feed.go index d6bafa91..ca5c0873 100644 --- a/inbox/feed.go +++ b/inbox/feed.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -64,6 +64,7 @@ func (u FeedUpdater) Run(ctx context.Context) error { follows.follower like $1 and follows.accepted = 1 and notes.inserted >= $2 and + notes.deleted = 0 and not exists (select 1 from feed where feed.follower = follows.follower and feed.note->>'$.id' = notes.id and feed.sharer is null) union select myposts.author as follower, notes.object as note, authors.actor as author, null as sharer, notes.inserted from @@ -79,6 +80,7 @@ func (u FeedUpdater) Run(ctx context.Context) error { where notes.author != myposts.author and notes.inserted >= $2 and + notes.deleted = 0 and myposts.author like $1 and not exists (select 1 from feed where feed.follower = myposts.author and feed.note->>'$.id' = notes.id and feed.sharer is null) union all @@ -102,6 +104,7 @@ func (u FeedUpdater) Run(ctx context.Context) error { sharers.id = follows.followed where notes.public = 1 and + notes.deleted = 0 and shares.inserted >= $2 and follows.follower like $1 and follows.accepted = 1 and diff --git a/inbox/inbox.go b/inbox/inbox.go index 6b92af45..158bb13d 100644 --- a/inbox/inbox.go +++ b/inbox/inbox.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -59,11 +59,11 @@ func (inbox *Inbox) processCreateActivity(ctx context.Context, tx *sql.Tx, sende } var audience sql.NullString - if err := tx.QueryRowContext(ctx, `select object->>'$.audience' from notes where id = ?`, post.ID).Scan(&audience); err != nil && !errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select object->>'$.audience' from notes where id = ? and deleted = 0`, post.ID).Scan(&audience); err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("failed to check if %s is a duplicate: %w", post.ID, err) } else if err == nil { if sender.ID == post.Audience && !audience.Valid { - if _, err := tx.ExecContext(ctx, `update notes set object = jsonb_set(jsonb_remove(object, '$.proof', '$.signature'), '$.audience', ?) where id = ? and object->>'$.audience' is null`, post.Audience, post.ID); err != nil { + if _, err := tx.ExecContext(ctx, `update notes set object = jsonb_set(jsonb_remove(object, '$.proof', '$.signature'), '$.audience', ?) where id = ? and deleted = 0 and object->>'$.audience' is null`, post.Audience, post.ID); err != nil { return fmt.Errorf("failed to set %s audience to %s: %w", post.ID, audience.String, err) } @@ -150,12 +150,12 @@ func (inbox *Inbox) ProcessActivity(ctx context.Context, tx *sql.Tx, sender *ap. slog.Info("Received delete request", "activity", activity, "deleted", deleted) if deleted == activity.Actor { - if _, err := tx.ExecContext(ctx, `delete from persons where id = ?`, deleted); err != nil { + if _, err := tx.ExecContext(ctx, `update persons set deleted = 1 where id = ?`, deleted); err != nil { return fmt.Errorf("failed to delete person %s: %w", deleted, err) } } else { var note ap.Object - if err := tx.QueryRowContext(ctx, `select json(object) from notes where id = ?`, deleted).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select json(object) from notes where id = ? and deleted = 0`, deleted).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { slog.Debug("Received delete request for non-existing post", "activity", activity, "deleted", deleted) return nil } else if err != nil { @@ -169,10 +169,7 @@ func (inbox *Inbox) ProcessActivity(ctx context.Context, tx *sql.Tx, sender *ap. if _, err := tx.ExecContext(ctx, `delete from notesfts where id = ?`, deleted); err != nil { return fmt.Errorf("cannot delete %s: %w", deleted, err) } - if _, err := tx.ExecContext(ctx, `delete from notes where id = ?`, deleted); err != nil { - return fmt.Errorf("cannot delete %s: %w", deleted, err) - } - if _, err := tx.ExecContext(ctx, `delete from shares where note = ?`, deleted); err != nil { + if _, err := tx.ExecContext(ctx, `update notes set object = jsonb_set(jsonb_remove(object, '$.name', '$.summary', '$.tag', '$.attachment'), '$.content', '[deleted]'), deleted = 1 where id = ?`, deleted); err != nil { return fmt.Errorf("cannot delete %s: %w", deleted, err) } if _, err := tx.ExecContext(ctx, `delete from feed where note->>'$.id' = ?`, deleted); err != nil { @@ -390,7 +387,7 @@ func (inbox *Inbox) ProcessActivity(ctx context.Context, tx *sql.Tx, sender *ap. var oldPost ap.Object var lastChange int64 - if err := tx.QueryRowContext(ctx, `select max(inserted, updated), json(object) from notes where id = ? and author in (select id from persons where cid = ?)`, post.ID, ap.Canonical(post.AttributedTo)).Scan(&lastChange, &oldPost); err != nil && errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select max(inserted, updated), json(object) from notes where id = ? and deleted = 0 and author in (select id from persons where cid = ?)`, post.ID, ap.Canonical(post.AttributedTo)).Scan(&lastChange, &oldPost); err != nil && errors.Is(err, sql.ErrNoRows) { slog.Debug("Received Update for non-existing post", "activity", activity) return inbox.processCreateActivity(ctx, tx, sender, activity, rawActivity, post, shared) } else if err != nil { @@ -418,7 +415,7 @@ func (inbox *Inbox) ProcessActivity(ctx context.Context, tx *sql.Tx, sender *ap. if _, err := tx.ExecContext( ctx, - `update notes set object = jsonb(?), updated = unixepoch() where id = ?`, + `update notes set object = jsonb(?), updated = unixepoch() where id = ? and deleted = 0`, post, post.ID, ); err != nil { diff --git a/migrations/058_deleted.go b/migrations/058_deleted.go new file mode 100644 index 00000000..19f69c5a --- /dev/null +++ b/migrations/058_deleted.go @@ -0,0 +1,18 @@ +package migrations + +import ( + "context" + "database/sql" +) + +func deleted(ctx context.Context, domain string, tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, `ALTER TABLE notes ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `ALTER TABLE persons ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0`); err != nil { + return err + } + + return nil +} diff --git a/outbox/deleter.go b/outbox/deleter.go index 10b05702..5662564f 100644 --- a/outbox/deleter.go +++ b/outbox/deleter.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -87,6 +87,7 @@ func (d *Deleter) deletePosts(ctx context.Context) (bool, error) { where persons.ttl is not null and notes.inserted <= unixepoch() - (persons.ttl * 24 * 60 * 60) and + notes.deleted = 0 and not exists (select 1 from bookmarks where bookmarks.by = persons.id and bookmarks.note = notes.id) order by notes.inserted limit ? diff --git a/outbox/poller.go b/outbox/poller.go index 14f787a1..6fe5bcf6 100644 --- a/outbox/poller.go +++ b/outbox/poller.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -39,7 +39,7 @@ type pollResult struct { } func (p *Poller) Run(ctx context.Context) error { - rows, err := p.DB.QueryContext(ctx, `select poll, option, count(*) from (select polls.id as poll, votes.object->>'$.name' as option, votes.author as voter from notes polls left join notes votes on votes.object->>'$.inReplyTo' = polls.id where polls.object->>'$.type' = 'Question' and polls.id like $1 and polls.object->>'$.closed' is null and (votes.object->>'$.name' is not null or votes.id is null) group by poll, option, voter) group by poll, option`, fmt.Sprintf("https://%s/%%", p.Domain)) + rows, err := p.DB.QueryContext(ctx, `select poll, option, count(case when voter is not null then 1 end) from (select polls.id as poll, votes.object->>'$.name' as option, votes.author as voter from notes polls left join notes votes on votes.object->>'$.inReplyTo' = polls.id and votes.deleted = 0 where polls.object->>'$.type' = 'Question' and polls.id like $1 and polls.deleted = 0 and polls.object->>'$.closed' is null and (votes.object->>'$.name' is not null or votes.id is null) group by poll, option, voter) group by poll, option`, fmt.Sprintf("https://%s/%%", p.Domain)) if err != nil { return err } @@ -69,7 +69,7 @@ func (p *Poller) Run(ctx context.Context) error { var obj ap.Object var author ap.Actor var ed25519PrivKey []byte - if err := p.DB.QueryRowContext(ctx, "select json(notes.object), json(persons.actor), persons.ed25519privkey from notes join persons on persons.id = notes.author where notes.id = ?", pollID).Scan(&obj, &author, &ed25519PrivKey); err != nil { + if err := p.DB.QueryRowContext(ctx, "select json(notes.object), json(persons.actor), persons.ed25519privkey from notes join persons on persons.id = notes.author where notes.id = ? and notes.deleted = 0", pollID).Scan(&obj, &author, &ed25519PrivKey); err != nil { slog.Warn("Failed to fetch poll", "poll", pollID, "error", err) continue } diff --git a/test/delete_test.go b/test/delete_test.go index acceaeeb..bef03fa7 100644 --- a/test/delete_test.go +++ b/test/delete_test.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -42,7 +42,7 @@ func TestDelete_HappyFlow(t *testing.T) { assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Alice.ID, "https://")), delete) view = server.Handle("/users/view/"+id, server.Alice) - assert.Equal(view, "40 Post not found\r\n") + assert.NotContains(view, "Hello world") } func TestDelete_NotAuthor(t *testing.T) { @@ -113,17 +113,23 @@ func TestDelete_WithReply(t *testing.T) { replyID := reply[15 : len(reply)-2] + view := server.Handle("/users/view/"+replyID, server.Alice) + assert.Contains(view, "Hello world") + assert.Contains(view, "Welcome Alice") + delete := server.Handle("/users/delete/"+replyID, server.Bob) assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), delete) - view := server.Handle("/users/view/"+replyID, server.Alice) - assert.Equal(view, "40 Post not found\r\n") + view = server.Handle("/users/view/"+replyID, server.Alice) + assert.Contains(view, "Hello world") + assert.NotContains(view, "Welcome Alice") delete = server.Handle("/users/delete/"+postID, server.Alice) assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Alice.ID, "https://")), delete) view = server.Handle("/users/view/"+postID, server.Alice) - assert.Equal(view, "40 Post not found\r\n") + assert.NotContains(view, "Hello world") + assert.NotContains(view, "Welcome Alice") } func TestDelete_WithReplyPostDeletedFirst(t *testing.T) { @@ -142,15 +148,21 @@ func TestDelete_WithReplyPostDeletedFirst(t *testing.T) { replyID := reply[15 : len(reply)-2] + view := server.Handle("/users/view/"+postID, server.Alice) + assert.Contains(view, "Hello world") + assert.Contains(view, "Welcome Alice") + delete := server.Handle("/users/delete/"+postID, server.Alice) assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Alice.ID, "https://")), delete) - view := server.Handle("/users/view/"+postID, server.Alice) - assert.Equal(view, "40 Post not found\r\n") + view = server.Handle("/users/view/"+postID, server.Alice) + assert.NotContains(view, "Hello world") + assert.Contains(view, "Welcome Alice") delete = server.Handle("/users/delete/"+replyID, server.Bob) assert.Equal(fmt.Sprintf("30 /users/outbox/%s\r\n", strings.TrimPrefix(server.Bob.ID, "https://")), delete) view = server.Handle("/users/view/"+replyID, server.Alice) - assert.Equal(view, "40 Post not found\r\n") + assert.NotContains(view, "Hello world") + assert.NotContains(view, "Welcome Alice") }