fix 编辑嘟文
This commit is contained in:
@@ -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;
|
||||
@@ -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,支持客户端编辑
|
||||
|
||||
## 重要限制
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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,
|
||||
|
||||
@@ -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<Response> {
|
||||
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]));
|
||||
|
||||
+159
-3
@@ -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<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> {
|
||||
const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first<Poll>();
|
||||
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(/ /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),
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+5
-5
@@ -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"//改成自己的
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user