diff --git a/migrations/0010_status_edits.sql b/migrations/0010_status_edits.sql new file mode 100644 index 0000000..953b72d --- /dev/null +++ b/migrations/0010_status_edits.sql @@ -0,0 +1,4 @@ +-- Store editable source text and edit timestamps for local statuses. + +ALTER TABLE statuses ADD COLUMN source_text TEXT NOT NULL DEFAULT ''; +ALTER TABLE statuses ADD COLUMN edited_at TEXT; diff --git a/readme.md b/readme.md index 30a86ac..2d9711a 100644 --- a/readme.md +++ b/readme.md @@ -94,7 +94,7 @@ npm run deploy 嘟文: - `POST /api/v1/statuses`(支持 `media_ids`、`spoiler_text`、`sensitive`、`in_reply_to_id`、`visibility`、`language`、`poll[...]`、`scheduled_at`,自动解析 `@user`/`@user@host` 提及和 `#hashtag`,投递 Create 给 followers 与 mention) -- `GET /api/v1/statuses/:id`、`DELETE /api/v1/statuses/:id`(联邦 Delete 出站) +- `GET /api/v1/statuses/:id`、`GET /api/v1/statuses/:id/source`、`PUT / PATCH /api/v1/statuses/:id`(联邦 Update Note 出站)、`DELETE /api/v1/statuses/:id`(联邦 Delete 出站) - `GET /api/v1/statuses/:id/context` - `POST /api/v1/statuses/:id/favourite`、`/unfavourite`(联邦 Like / Undo Like) - `POST /api/v1/statuses/:id/reblog`、`/unreblog`(联邦 Announce / Undo Announce) @@ -154,6 +154,7 @@ npm run deploy - `migrations/0007_polls_lists_push_scheduled.sql` — poll / list / push subscription / scheduled statuses - `migrations/0008_outgoing_deliveries.sql` — 出站 ActivityPub 投递队列 / 重试状态 - `migrations/0009_markers.sql` — Mastodon 读位 markers(home / notifications) +- `migrations/0010_status_edits.sql` — 本地嘟文原文 source_text 与 edited_at,支持客户端编辑 ## 重要限制 diff --git a/src/activitypub.ts b/src/activitypub.ts index afe7e34..2dee023 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -624,6 +624,26 @@ export async function createActivity(env: Env, user: User, status: Status, extra }; } +export async function updateNoteActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Promise { + const audience = statusAudience(env, user, status); + const to = extra.to ?? audience.to; + const cc = extra.cc ?? audience.cc; + const [attachments, tag] = await Promise.all([ + loadStatusAttachments(env, status.id), + loadStatusTags(env, status.id) + ]); + return { + "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], + id: activityUrl(env, id()), + type: "Update", + actor: actorUrl(env, user), + published: status.edited_at ?? new Date().toISOString(), + to, + cc, + object: noteObject(env, user, status, { to, cc, attachments, tag }) + }; +} + export function attachmentObject(env: Env, media: Media): Json { return { type: media.mime_type.startsWith("image/") ? "Image" : "Document", @@ -747,6 +767,7 @@ export function noteObject(env: Env, user: User, status: Status, opts: { to?: st attributedTo: actorUrl(env, user), content: status.content, published: status.created_at, + updated: status.edited_at ?? null, url: statusUrl(env, user, status.id), to: opts.to ?? audience.to, cc: opts.cc ?? audience.cc, diff --git a/src/index.ts b/src/index.ts index a3121c1..5ff214f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ import { getPushSubscription, getRelationships, getScheduledStatus, + getStatusSource, getStatusEndpoint, hashtagInfo, hashtagTimeline, @@ -72,6 +73,7 @@ import { token, trendsTags, updateMarkers, + updateStatusEndpoint, unbookmarkStatus, unfavouriteStatus, unfollowAccount, @@ -159,7 +161,9 @@ async function route(request: Request, env: Env): Promise { if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/reject$/))) return rejectFollowRequest(request, env, decodeURIComponent(m[1])); if (method === "POST" && path === "/api/v1/statuses") return createStatus(request, env); + if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/source$/))) return getStatusSource(request, env, decodeURIComponent(m[1])); if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return getStatusEndpoint(request, env, decodeURIComponent(m[1])); + if ((method === "PUT" || method === "PATCH") && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return updateStatusEndpoint(request, env, decodeURIComponent(m[1])); if (method === "DELETE" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return deleteStatusEndpoint(request, env, decodeURIComponent(m[1])); if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/context$/))) return statusContext(env, decodeURIComponent(m[1]), request); if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/favourite$/))) return favouriteStatus(request, env, decodeURIComponent(m[1])); diff --git a/src/mastodon.ts b/src/mastodon.ts index bff5da5..7fc8e3f 100644 --- a/src/mastodon.ts +++ b/src/mastodon.ts @@ -7,6 +7,7 @@ import { followActivity, likeActivity, undoActivity, + updateNoteActivity, updatePersonActivity } from "./activitypub"; import { hashPassword, verifyPassword } from "./crypto"; @@ -136,6 +137,15 @@ type StatusCreateInput = { pollHideTotals: boolean; }; +type StatusEditInput = { + statusText: string; + summary: string; + sensitive: boolean; + visibility: StatusVisibility; + language: string; + mediaIds: string[] | null; +}; + function parseRedirectUris(value: string): string[] { return value.split(/\s+/).map((item) => item.trim()).filter(Boolean); } @@ -613,7 +623,7 @@ async function publishStatus(env: Env, user: User, input: StatusCreateInput): Pr const renderedContent = htmlContent(input.statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags); await env.DB.prepare( - "INSERT INTO statuses (id, user_id, content, summary, sensitive, language, visibility, in_reply_to_id, activity_id, object_id, created_at, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + "INSERT INTO statuses (id, user_id, content, summary, sensitive, language, visibility, in_reply_to_id, activity_id, object_id, created_at, url, source_text, edited_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)" ) .bind( statusId, @@ -627,7 +637,8 @@ async function publishStatus(env: Env, user: User, input: StatusCreateInput): Pr activityId, objectId, now, - statusUrl(env, user, statusId) + statusUrl(env, user, statusId), + input.statusText ) .run(); @@ -745,6 +756,29 @@ function parseStatusCreateInput(body: ParsedBody): StatusCreateInput { }; } +function parseStatusEditInput(body: ParsedBody, existing: Status): StatusEditInput { + const existingText = statusSourceText(existing); + const statusText = Object.prototype.hasOwnProperty.call(body, "status") + ? bodyString(body, "status").trim() + : existingText; + if (!statusText) throw new HttpError(422, "status can't be blank"); + if (statusText.length > MAX_STATUS_CHARS) throw new HttpError(422, "status too long"); + + const visibility = bodyString(body, "visibility", existing.visibility); + if (!isStatusVisibility(visibility)) throw new HttpError(422, "invalid_visibility"); + + return { + statusText, + summary: bodyString(body, "spoiler_text", existing.summary), + sensitive: Object.prototype.hasOwnProperty.call(body, "sensitive") + ? bodyString(body, "sensitive") === "true" + : Boolean(existing.sensitive), + visibility, + language: bodyString(body, "language", existing.language || "en"), + mediaIds: Object.prototype.hasOwnProperty.call(body, "media_ids") ? bodyArray(body, "media_ids") : null + }; +} + function parsePollExpiresIn(value: string): number { const seconds = Number(value); if (!Number.isFinite(seconds)) throw new HttpError(422, "invalid_poll_expiration"); @@ -895,6 +929,106 @@ export async function getStatusEndpoint(request: Request, env: Env, statusId: st return json(await statusJson(env, status, user, request)); } +export async function getStatusSource(request: Request, env: Env, statusId: string): Promise { + const user = await requireUser(request, env); + const status = await getStatus(env, statusId); + if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404); + return json({ + id: status.id, + text: statusSourceText(status), + spoiler_text: status.summary + }); +} + +export async function updateStatusEndpoint(request: Request, env: Env, statusId: string): Promise { + const user = await requireUser(request, env); + const status = await getStatus(env, statusId); + if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404); + + const body = await readBody(request); + const input = parseStatusEditInput(body, status); + const previousMentions = await listMentionsForStatus(env, status.id); + const mentionsAcct = extractMentions(input.statusText); + const hashtags = extractHashtags(input.statusText); + + const resolvedMentions: { acct: string; actorId: string; url: string }[] = []; + for (const acct of mentionsAcct) { + const resolved = await resolveAcct(env, acct); + if (resolved) resolvedMentions.push(resolved); + } + const renderedContent = htmlContent(input.statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags); + const editedAt = new Date().toISOString(); + + await env.DB.prepare( + `UPDATE statuses + SET content = ?, summary = ?, sensitive = ?, language = ?, visibility = ?, url = ?, source_text = ?, edited_at = ? + WHERE id = ? AND user_id = ?` + ).bind( + renderedContent, + input.summary, + input.sensitive ? 1 : 0, + input.language, + input.visibility, + statusUrl(env, user, status.id), + input.statusText, + editedAt, + status.id, + user.id + ).run(); + + if (input.mediaIds !== null) { + await env.DB.prepare("UPDATE media SET status_id = NULL WHERE status_id = ? AND user_id = ?").bind(status.id, user.id).run(); + for (const mediaId of input.mediaIds) { + await env.DB.prepare("UPDATE media SET status_id = ? WHERE id = ? AND user_id = ?").bind(status.id, mediaId, user.id).run(); + } + } + + await env.DB.prepare("DELETE FROM mentions WHERE status_id = ?").bind(status.id).run(); + for (const mention of resolvedMentions) { + await env.DB.prepare("INSERT OR IGNORE INTO mentions (status_id, actor, acct, url) VALUES (?, ?, ?, ?)") + .bind(status.id, mention.actorId, mention.acct, mention.url).run(); + } + await env.DB.prepare("DELETE FROM hashtags WHERE status_id = ?").bind(status.id).run(); + for (const tag of hashtags) { + await env.DB.prepare("INSERT OR IGNORE INTO hashtags (status_id, tag) VALUES (?, ?)").bind(status.id, tag).run(); + } + + const updated = await getStatus(env, status.id); + if (!updated) throw new HttpError(500, "status_not_found"); + + if (updated.visibility === "public" || updated.visibility === "unlisted" || updated.visibility === "private" || updated.visibility === "direct") { + const inboxes = new Set(); + if (updated.visibility !== "direct") { + for (const inbox of await gatherFollowerInboxes(env, user.id)) inboxes.add(inbox); + } + const remoteActors = new Set([ + ...previousMentions.map((mention) => mention.actor), + ...resolvedMentions.map((mention) => mention.actorId) + ].filter((actorId) => !actorId.startsWith(baseUrl(env)))); + for (const actorId of remoteActors) { + const cache = await resolveRemoteActor(env, actorId); + if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox); + } + + const mentionActors = resolvedMentions.map((mention) => mention.actorId); + const to = updated.visibility === "public" + ? ["https://www.w3.org/ns/activitystreams#Public"] + : updated.visibility === "unlisted" + ? [`${actorUrl(env, user)}/followers`] + : updated.visibility === "private" + ? [`${actorUrl(env, user)}/followers`, ...mentionActors] + : mentionActors; + const cc = updated.visibility === "public" + ? [`${actorUrl(env, user)}/followers`, ...mentionActors] + : updated.visibility === "unlisted" + ? ["https://www.w3.org/ns/activitystreams#Public", ...mentionActors] + : []; + await deliverToInboxes(env, user, inboxes, await updateNoteActivity(env, user, updated, { to, cc })); + } + + return json(await statusJson(env, updated, user, request)); +} + export async function getPoll(request: Request, env: Env, pollId: string): Promise { const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first(); if (!poll) return json({ error: "Record not found" }, 404); @@ -1868,6 +2002,28 @@ function parseCachedJson(value: string): T[] { } } +function statusSourceText(status: Status): string { + return status.source_text || htmlToPlainText(status.content); +} + +function htmlToPlainText(value: string): string { + return decodeHtmlEntities(value + .replace(//gi, "\n") + .replace(/<\/p>\s*]*>/gi, "\n\n") + .replace(/<[^>]*>/g, "") + .trim()); +} + +function decodeHtmlEntities(value: string): string { + return value + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + async function statusJson( env: Env, status: Status, @@ -1898,7 +2054,7 @@ function statusRecord(env: Env, status: Status, user: User, context: StatusSeria content: status.content, text: status.content, created_at: status.created_at, - edited_at: null, + edited_at: status.edited_at, visibility: status.visibility, language: status.language, sensitive: Boolean(status.sensitive), diff --git a/src/types.ts b/src/types.ts index e9dd3b5..320363d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -46,6 +46,8 @@ export type Status = { object_id: string; created_at: string; url: string; + source_text: string; + edited_at: string | null; }; export type DeletedStatus = { diff --git a/wrangler.jsonc b/wrangler.jsonc index ae9ffad..7401705 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -17,21 +17,21 @@ }, "d1_databases": [ { - "binding": "DB", + "binding": "DB", //请勿改动 "database_name": "toot_db", - "database_id": "8e042858-bf5f-4d7a-ad84-3e002b0b2f42" + "database_id": "8e042858-bf5f-4d7a-ad84-3e002b0b2f42"//改成自己的 } ], "r2_buckets": [ { - "binding": "MEDIA", + "binding": "MEDIA",//请勿改动 "bucket_name": "toot-media" } ], "kv_namespaces": [ { - "binding": "KV", - "id": "0e14d63f7d624358ab6507ef1bac9017" + "binding": "KV",//请勿改动 + "id": "0e14d63f7d624358ab6507ef1bac9017"//改成自己的 } ] }