fix 编辑嘟文

This commit is contained in:
浪子
2026-05-16 10:13:22 +08:00
parent d39940cd59
commit ad6a8b0dcf
7 changed files with 197 additions and 9 deletions
+4
View File
@@ -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;
+2 -1
View File
@@ -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) - `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` - `GET /api/v1/statuses/:id/context`
- `POST /api/v1/statuses/:id/favourite``/unfavourite`(联邦 Like / Undo Like) - `POST /api/v1/statuses/:id/favourite``/unfavourite`(联邦 Like / Undo Like)
- `POST /api/v1/statuses/:id/reblog``/unreblog`(联邦 Announce / Undo Announce) - `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/0007_polls_lists_push_scheduled.sql` — poll / list / push subscription / scheduled statuses
- `migrations/0008_outgoing_deliveries.sql` — 出站 ActivityPub 投递队列 / 重试状态 - `migrations/0008_outgoing_deliveries.sql` — 出站 ActivityPub 投递队列 / 重试状态
- `migrations/0009_markers.sql` — Mastodon 读位 markers(home / notifications) - `migrations/0009_markers.sql` — Mastodon 读位 markers(home / notifications)
- `migrations/0010_status_edits.sql` — 本地嘟文原文 source_text 与 edited_at,支持客户端编辑
## 重要限制 ## 重要限制
+21
View File
@@ -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<Json> {
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 { export function attachmentObject(env: Env, media: Media): Json {
return { return {
type: media.mime_type.startsWith("image/") ? "Image" : "Document", 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), attributedTo: actorUrl(env, user),
content: status.content, content: status.content,
published: status.created_at, published: status.created_at,
updated: status.edited_at ?? null,
url: statusUrl(env, user, status.id), url: statusUrl(env, user, status.id),
to: opts.to ?? audience.to, to: opts.to ?? audience.to,
cc: opts.cc ?? audience.cc, cc: opts.cc ?? audience.cc,
+4
View File
@@ -44,6 +44,7 @@ import {
getPushSubscription, getPushSubscription,
getRelationships, getRelationships,
getScheduledStatus, getScheduledStatus,
getStatusSource,
getStatusEndpoint, getStatusEndpoint,
hashtagInfo, hashtagInfo,
hashtagTimeline, hashtagTimeline,
@@ -72,6 +73,7 @@ import {
token, token,
trendsTags, trendsTags,
updateMarkers, updateMarkers,
updateStatusEndpoint,
unbookmarkStatus, unbookmarkStatus,
unfavouriteStatus, unfavouriteStatus,
unfollowAccount, unfollowAccount,
@@ -159,7 +161,9 @@ async function route(request: Request, env: Env): Promise<Response> {
if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/reject$/))) return rejectFollowRequest(request, env, decodeURIComponent(m[1])); 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 === "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 === "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 === "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 === "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])); if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/favourite$/))) return favouriteStatus(request, env, decodeURIComponent(m[1]));
+159 -3
View File
@@ -7,6 +7,7 @@ import {
followActivity, followActivity,
likeActivity, likeActivity,
undoActivity, undoActivity,
updateNoteActivity,
updatePersonActivity updatePersonActivity
} from "./activitypub"; } from "./activitypub";
import { hashPassword, verifyPassword } from "./crypto"; import { hashPassword, verifyPassword } from "./crypto";
@@ -136,6 +137,15 @@ type StatusCreateInput = {
pollHideTotals: boolean; pollHideTotals: boolean;
}; };
type StatusEditInput = {
statusText: string;
summary: string;
sensitive: boolean;
visibility: StatusVisibility;
language: string;
mediaIds: string[] | null;
};
function parseRedirectUris(value: string): string[] { function parseRedirectUris(value: string): string[] {
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean); 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); const renderedContent = htmlContent(input.statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags);
await env.DB.prepare( 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( .bind(
statusId, statusId,
@@ -627,7 +637,8 @@ async function publishStatus(env: Env, user: User, input: StatusCreateInput): Pr
activityId, activityId,
objectId, objectId,
now, now,
statusUrl(env, user, statusId) statusUrl(env, user, statusId),
input.statusText
) )
.run(); .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 { function parsePollExpiresIn(value: string): number {
const seconds = Number(value); const seconds = Number(value);
if (!Number.isFinite(seconds)) throw new HttpError(422, "invalid_poll_expiration"); 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)); return json(await statusJson(env, status, user, request));
} }
export async function getStatusSource(request: Request, env: Env, statusId: string): Promise<Response> {
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<Response> {
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<string>();
if (updated.visibility !== "direct") {
for (const inbox of await gatherFollowerInboxes(env, user.id)) inboxes.add(inbox);
}
const remoteActors = new Set<string>([
...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<Response> { export async function getPoll(request: Request, env: Env, pollId: string): Promise<Response> {
const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first<Poll>(); const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first<Poll>();
if (!poll) return json({ error: "Record not found" }, 404); if (!poll) return json({ error: "Record not found" }, 404);
@@ -1868,6 +2002,28 @@ function parseCachedJson<T>(value: string): T[] {
} }
} }
function statusSourceText(status: Status): string {
return status.source_text || htmlToPlainText(status.content);
}
function htmlToPlainText(value: string): string {
return decodeHtmlEntities(value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>\s*<p[^>]*>/gi, "\n\n")
.replace(/<[^>]*>/g, "")
.trim());
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
async function statusJson( async function statusJson(
env: Env, env: Env,
status: Status, status: Status,
@@ -1898,7 +2054,7 @@ function statusRecord(env: Env, status: Status, user: User, context: StatusSeria
content: status.content, content: status.content,
text: status.content, text: status.content,
created_at: status.created_at, created_at: status.created_at,
edited_at: null, edited_at: status.edited_at,
visibility: status.visibility, visibility: status.visibility,
language: status.language, language: status.language,
sensitive: Boolean(status.sensitive), sensitive: Boolean(status.sensitive),
+2
View File
@@ -46,6 +46,8 @@ export type Status = {
object_id: string; object_id: string;
created_at: string; created_at: string;
url: string; url: string;
source_text: string;
edited_at: string | null;
}; };
export type DeletedStatus = { export type DeletedStatus = {
+5 -5
View File
@@ -17,21 +17,21 @@
}, },
"d1_databases": [ "d1_databases": [
{ {
"binding": "DB", "binding": "DB", //
"database_name": "toot_db", "database_name": "toot_db",
"database_id": "8e042858-bf5f-4d7a-ad84-3e002b0b2f42" "database_id": "8e042858-bf5f-4d7a-ad84-3e002b0b2f42"//
} }
], ],
"r2_buckets": [ "r2_buckets": [
{ {
"binding": "MEDIA", "binding": "MEDIA",//
"bucket_name": "toot-media" "bucket_name": "toot-media"
} }
], ],
"kv_namespaces": [ "kv_namespaces": [
{ {
"binding": "KV", "binding": "KV",//
"id": "0e14d63f7d624358ab6507ef1bac9017" "id": "0e14d63f7d624358ab6507ef1bac9017"//
} }
] ]
} }