From f0a6b695a62d104fb07d995d8eb20267e59f2a6c Mon Sep 17 00:00:00 2001 From: Sven Sauleau Date: Tue, 31 Jan 2023 17:02:42 +0000 Subject: [PATCH] MOW-126: basic local status deletion This change deletes the Note from local object and Actor's outbox tables. --- backend/test/mastodon/statuses.spec.ts | 47 ++++++++++++++++--- functions/api/v1/statuses/[id].ts | 64 ++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/backend/test/mastodon/statuses.spec.ts b/backend/test/mastodon/statuses.spec.ts index 0bf88ce..235aea6 100644 --- a/backend/test/mastodon/statuses.spec.ts +++ b/backend/test/mastodon/statuses.spec.ts @@ -2,10 +2,9 @@ import { strict as assert } from 'node:assert/strict' import { createReply } from 'wildebeest/backend/test/shared.utils' import { createStatus, getMentions } from 'wildebeest/backend/src/mastodon/status' import { createPublicNote, type Note } from 'wildebeest/backend/src/activitypub/objects/note' -import { getObjectByMastodonId } from 'wildebeest/backend/src/activitypub/objects' import { createImage } from 'wildebeest/backend/src/activitypub/objects/image' import * as statuses from 'wildebeest/functions/api/v1/statuses' -import * as statuses_get from 'wildebeest/functions/api/v1/statuses/[id]' +import * as statuses_id from 'wildebeest/functions/api/v1/statuses/[id]' import * as statuses_favourite from 'wildebeest/functions/api/v1/statuses/[id]/favourite' import * as statuses_reblog from 'wildebeest/functions/api/v1/statuses/[id]/reblog' import * as statuses_context from 'wildebeest/functions/api/v1/statuses/[id]/context' @@ -17,7 +16,7 @@ import * as activities from 'wildebeest/backend/src/activitypub/activities' import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow' import { MessageType } from 'wildebeest/backend/src/types/queue' import { MastodonStatus } from 'wildebeest/backend/src/types' -import { mastodonIdSymbol } from 'wildebeest/backend/src/activitypub/objects' +import { mastodonIdSymbol, getObjectByMastodonId } from 'wildebeest/backend/src/activitypub/objects' const userKEK = 'test_kek4' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -537,7 +536,7 @@ describe('Mastodon APIs', () => { await insertLike(db, actor2, note) await insertLike(db, actor3, note) - const res = await statuses_get.handleRequest(db, note[mastodonIdSymbol]!, domain) + const res = await statuses_id.handleRequestGet(db, note[mastodonIdSymbol]!, domain) assert.equal(res.status, 200) const data = await res.json() @@ -553,7 +552,7 @@ describe('Mastodon APIs', () => { const mediaAttachments = [await createImage(domain, db, actor, properties)] const note = await createPublicNote(domain, db, 'my first status', actor, mediaAttachments) - const res = await statuses_get.handleRequest(db, note[mastodonIdSymbol]!, domain) + const res = await statuses_id.handleRequestGet(db, note[mastodonIdSymbol]!, domain) assert.equal(res.status, 200) const data = await res.json() @@ -592,7 +591,7 @@ describe('Mastodon APIs', () => { await insertReblog(db, actor2, note) await insertReblog(db, actor3, note) - const res = await statuses_get.handleRequest(db, note[mastodonIdSymbol]!, domain) + const res = await statuses_id.handleRequestGet(db, note[mastodonIdSymbol]!, domain) assert.equal(res.status, 200) const data = await res.json() @@ -810,5 +809,41 @@ describe('Mastodon APIs', () => { const data = await res.json<{ error: string }>() assert(data.error.includes('Limit exceeded')) }) + + test('delete non-existing status', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const mastodonId = 'abcd' + const res = await statuses_id.handleRequestDelete(db, mastodonId, actor, domain) + assert.equal(res.status, 404) + }) + + test('delete status from a different actor', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') + const note = await createPublicNote(domain, db, 'note from actor2', actor2) + + const res = await statuses_id.handleRequestDelete(db, note[mastodonIdSymbol]!, actor, domain) + assert.equal(res.status, 404) + }) + + test('delete status', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const note = await createPublicNote(domain, db, 'note from actor', actor) + + const res = await statuses_id.handleRequestDelete(db, note[mastodonIdSymbol]!, actor, domain) + assert.equal(res.status, 200) + + { + const { count } = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first() + assert.equal(count, 0) + } + { + const { count } = await db.prepare(`SELECT count(*) as count FROM objects`).first() + assert.equal(count, 0) + } + }) }) }) diff --git a/functions/api/v1/statuses/[id].ts b/functions/api/v1/statuses/[id].ts index 6e61190..d18a2e8 100644 --- a/functions/api/v1/statuses/[id].ts +++ b/functions/api/v1/statuses/[id].ts @@ -1,17 +1,27 @@ // https://docs.joinmastodon.org/methods/statuses/#get +import { type Note } from 'wildebeest/backend/src/activitypub/objects/note' import { cors } from 'wildebeest/backend/src/utils/cors' +import type { Person } from 'wildebeest/backend/src/activitypub/actors' import type { UUID } from 'wildebeest/backend/src/types' import type { ContextData } from 'wildebeest/backend/src/types/context' -import { getMastodonStatusById } from 'wildebeest/backend/src/mastodon/status' +import { getMastodonStatusById, toMastodonStatusFromObject } from 'wildebeest/backend/src/mastodon/status' import type { Env } from 'wildebeest/backend/src/types/env' +import * as errors from 'wildebeest/backend/src/errors' +import { getObjectByMastodonId } from 'wildebeest/backend/src/activitypub/objects' +import { urlToHandle } from 'wildebeest/backend/src/utils/handle' -export const onRequest: PagesFunction = async ({ params, env, request }) => { +export const onRequestGet: PagesFunction = async ({ params, env, request }) => { const domain = new URL(request.url).hostname - return handleRequest(env.DATABASE, params.id as UUID, domain) + return handleRequestGet(env.DATABASE, params.id as UUID, domain) } -export async function handleRequest(db: D1Database, id: UUID, domain: string): Promise { +export const onRequestDelete: PagesFunction = async ({ params, env, request, data }) => { + const domain = new URL(request.url).hostname + return handleRequestDelete(env.DATABASE, params.id as UUID, data.connectedActor, domain) +} + +export async function handleRequestGet(db: D1Database, id: UUID, domain: string): Promise { const status = await getMastodonStatusById(db, id, domain) if (status === null) { return new Response('', { status: 404 }) @@ -23,3 +33,49 @@ export async function handleRequest(db: D1Database, id: UUID, domain: string): P } return new Response(JSON.stringify(status), { headers }) } + +// TODO: eventually use SQLite's `ON DELETE CASCADE` but requires writing the DB +// schema directly into D1, which D1 disallows at the moment. +// Some context at: https://stackoverflow.com/questions/13150075/add-on-delete-cascade-behavior-to-an-sqlite3-table-after-it-has-been-created +async function deleteNote(db: D1Database, note: Note) { + const deleteOutboxObject = db.prepare('DELETE FROM outbox_objects WHERE id=?').bind(note.id.toString()) + const deleteObject = db.prepare('DELETE FROM objects WHERE id=?').bind(note.id.toString()) + + const res = await db.batch([deleteOutboxObject, deleteObject]) + + for (let i = 0, len = res.length; i < len; i++) { + if (!res[i].success) { + throw new Error('SQL error: ' + res[i].error) + } + } +} + +export async function handleRequestDelete( + db: D1Database, + id: UUID, + connectedActor: Person, + domain: string +): Promise { + const obj = (await getObjectByMastodonId(db, id)) as Note + if (obj === null) { + return errors.statusNotFound(id) + } + + const status = await toMastodonStatusFromObject(db, obj, domain) + if (status === null) { + return errors.statusNotFound(id) + } + if (status.account.id !== urlToHandle(connectedActor.id)) { + return errors.statusNotFound(id) + } + + await deleteNote(db, obj) + + // FIXME: deliver a Delete message to the Actor followers and our peers + + const headers = { + ...cors(), + 'content-type': 'application/json; charset=utf-8', + } + return new Response(JSON.stringify(status), { headers }) +}