From 5365a3569f6416d81510dd5e8df30c646e767819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=AA=E5=AD=90?= Date: Thu, 14 May 2026 15:14:10 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=84=E4=BB=B6=E4=B8=8A=E4=BC=A0=E9=99=90?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- readme.md | 9 +-- src/activitypub.ts | 38 ++++++++--- src/mastodon.ts | 164 ++++++++++++++++++++++++++++++++++----------- 3 files changed, 161 insertions(+), 50 deletions(-) diff --git a/readme.md b/readme.md index 29377ed..9f9a3ab 100644 --- a/readme.md +++ b/readme.md @@ -147,12 +147,13 @@ npm run deploy 这是一个单用户可运行实现,不是完整 Mastodon 服务端: - 只支持单管理员账号自动初始化,不开放注册 -- 本地状态的可见性语义并不完整: - - `visibility=private` 目前只会写入本地库,不会按 followers-only 语义完整投递 - - `unlisted` / `private` / `direct` 没有做完整的读权限控制,`GET /api/v1/accounts/:id/statuses`、`GET /api/v1/statuses/:id`、搜索接口以及 ActivityPub outbox 都可能暴露非公开内容,不应当存放敏感或真正私密的信息 +- 本地状态的可见性已做基础控制: + - `public` / `unlisted` 可公开读取; ActivityPub outbox 只暴露这两类状态 + - `private` 会按 followers-only 投递,本地读取限作者和本地关注者 + - `direct` 仍没有完整受众表,本地读取保守限制为作者可见,不应当作为完整私信系统使用 - 远端嘟文缓存只在被本地账号关注的 actor 发出的入站 `Create(Note)` 时写入,不抓取历史 outbox - 远端缓存嘟文只保留正文、CW、语言和附件; `mentions`、`tags`、互动计数等不会完整恢复,在 home timeline 中统一按 `visibility: public` 返回 -- 媒体上传只支持 `image/jpeg`、`image/png`、`image/gif`、`image/webp`,单文件 10MB,单条状态最多 4 个附件; 头像和封面同样只按图片路径处理 +- 媒体上传只支持 `image/jpeg`、`image/png`、`image/gif`、`image/webp`,单文件 10MB,单条状态的附件数量不做服务端限制; 头像和封面同样只按图片路径处理 - 没有实现接口级限流、反滥用或审核流; `follow_requests` 相关接口仍是 stub - 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等 diff --git a/src/activitypub.ts b/src/activitypub.ts index 1a6880d..2e10bfa 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -157,7 +157,9 @@ export async function outbox(request: Request, env: Env, username: string): Prom if (!user) return json({ error: "not_found" }, 404); const url = new URL(request.url); const wantsPage = url.searchParams.has("page"); - const totalRow = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses WHERE user_id = ?").bind(user.id).first<{ count: number }>(); + const totalRow = await env.DB.prepare( + "SELECT COUNT(*) AS count FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted')" + ).bind(user.id).first<{ count: number }>(); const totalItems = totalRow?.count ?? 0; const base = `${actorUrl(env, user)}/outbox`; @@ -172,7 +174,9 @@ export async function outbox(request: Request, env: Env, username: string): Prom } const limit = clampLimit(url.searchParams.get("limit"), 20, 40); - const rows = await env.DB.prepare("SELECT * FROM statuses WHERE user_id = ? ORDER BY created_at DESC LIMIT ?").bind(user.id, limit).all(); + const rows = await env.DB.prepare( + "SELECT * FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted') ORDER BY created_at DESC LIMIT ?" + ).bind(user.id, limit).all(); const items = rows.results.map((status) => createActivity(env, user, status)); return activityJson({ "@context": ACTIVITY_CONTEXT, @@ -211,6 +215,7 @@ export async function followingCollection(env: Env, username: string): Promise { const status = await getStatus(env, objectId); if (status) { + if (status.visibility !== "public" && status.visibility !== "unlisted") return json({ error: "not_found" }, 404); const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(status.user_id).first(); if (!user) return json({ error: "not_found" }, 404); return activityJson(noteObject(env, user, status)); @@ -513,8 +518,9 @@ async function localUserFromTarget(env: Env, actorId: string | null): Promise; +}; function parseRedirectUris(value: string): string[] { return value.split(/\s+/).map((item) => item.trim()).filter(Boolean); @@ -120,7 +128,7 @@ export async function instance(env: Env): Promise { approval_required: false, invites_enabled: false, configuration: { - statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 }, + statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, characters_reserved_per_url: 23 }, media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 }, polls: { max_options: 4, max_characters_per_option: 50, min_expiration: 300, max_expiration: 2629746 } }, @@ -143,7 +151,7 @@ export async function instanceV2(env: Env): Promise { configuration: { urls: { streaming: `wss://${hostFromBaseUrl(env)}` }, accounts: { max_featured_tags: 0 }, - statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 }, + statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: REPORTED_MEDIA_ATTACHMENTS_LIMIT, characters_reserved_per_url: 23 }, media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 } }, registrations: { enabled: false, approval_required: false, message: null }, @@ -454,6 +462,9 @@ export async function accountStatuses(request: Request, env: Env, accountId: str const excludeReplies = url.searchParams.get("exclude_replies") === "true"; const where: string[] = ["user_id = ?"]; const binds: unknown[] = [user.id]; + const viewer = await loadStatusViewer(request, env); + const visibilityClause = await visibleStatusWhereForOwner(env, user.id, viewer); + if (visibilityClause) where.push(visibilityClause); if (excludeReplies) where.push("in_reply_to_id IS NULL"); pagedAppend(where, binds, url); const sql = `SELECT * FROM statuses WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`; @@ -542,11 +553,11 @@ export async function createStatus(request: Request, env: Env): Promise MAX_MEDIA_ATTACHMENTS) return json({ error: "too_many_attachments" }, 422); const now = new Date().toISOString(); const statusId = id(); @@ -619,7 +630,7 @@ export async function createStatus(request: Request, env: Env): Promise(await gatherFollowerInboxes(env, user.id)); for (const mention of resolvedMentions) { if (!mention.actorId.startsWith(baseUrl(env))) { @@ -627,8 +638,17 @@ export async function createStatus(request: Request, env: Env): Promise m.actorId)] : ["https://www.w3.org/ns/activitystreams#Public", ...resolvedMentions.map((m) => m.actorId)]; + const mentionActors = resolvedMentions.map((m) => m.actorId); + const to = visibility === "public" + ? ["https://www.w3.org/ns/activitystreams#Public"] + : visibility === "unlisted" + ? [`${actorUrl(env, user)}/followers`] + : [`${actorUrl(env, user)}/followers`, ...mentionActors]; + const cc = visibility === "public" + ? [`${actorUrl(env, user)}/followers`, ...mentionActors] + : visibility === "unlisted" + ? ["https://www.w3.org/ns/activitystreams#Public", ...mentionActors] + : []; const activity = createActivity(env, user, status, { to, cc }); await deliverToInboxes(env, user, inboxes, activity); } else if (visibility === "direct") { @@ -649,6 +669,8 @@ export async function createStatus(request: Request, env: Env): Promise { const status = await getStatus(env, statusId); if (!status) return json({ error: "Record not found" }, 404); + const viewer = await loadStatusViewer(request, env); + if (!await canViewerViewStatus(env, status, viewer)) return json({ error: "Record not found" }, 404); const user = await getUserById(env, status.user_id); if (!user) return json({ error: "Record not found" }, 404); return json(await statusJson(env, status, user, request)); @@ -661,7 +683,10 @@ export async function deleteStatusEndpoint(request: Request, env: Env, statusId: const serialized = await statusJson(env, status, user, request); - const inboxes = new Set(await gatherFollowerInboxes(env, user.id)); + const inboxes = new Set(); + if (status.visibility !== "direct") { + for (const inbox of await gatherFollowerInboxes(env, user.id)) inboxes.add(inbox); + } const mentions = await listMentionsForStatus(env, status.id); for (const mention of mentions) { if (!mention.actor.startsWith(baseUrl(env))) { @@ -686,13 +711,22 @@ export async function deleteStatusEndpoint(request: Request, env: Env, statusId: await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ?").bind(status.id).run(); await env.DB.prepare("DELETE FROM notifications WHERE status_id = ?").bind(status.id).run(); - await deliverToInboxes(env, user, inboxes, deleteActivity(env, user, status)); + const mentionActors = mentions.map((mention) => mention.actor); + const deleteAudience = status.visibility === "direct" + ? { to: mentionActors, cc: [] } + : status.visibility === "private" + ? { to: [`${actorUrl(env, user)}/followers`, ...mentionActors], cc: [] } + : {}; + await deliverToInboxes(env, user, inboxes, deleteActivity(env, user, status, deleteAudience)); return json(serialized); } export async function statusContext(env: Env, statusId: string, request: Request): Promise { const status = await getStatus(env, statusId); if (!status) return json({ error: "Record not found" }, 404); + const viewer = await loadStatusViewer(request, env); + if (!await canViewerViewStatus(env, status, viewer)) return json({ error: "Record not found" }, 404); + const ancestors: Status[] = []; let cursor = status.in_reply_to_id; while (cursor) { @@ -702,17 +736,19 @@ export async function statusContext(env: Env, statusId: string, request: Request cursor = parent.in_reply_to_id; } const descRows = await env.DB.prepare("SELECT * FROM statuses WHERE in_reply_to_id = ? ORDER BY created_at ASC LIMIT 40").bind(statusId).all(); - const serialized = await serializeStatuses(env, [...ancestors, ...descRows.results], request); + const visibleAncestors = await filterStatusesForViewer(env, ancestors, viewer); + const visibleDescendants = await filterStatusesForViewer(env, descRows.results, viewer); + const serialized = await serializeStatuses(env, [...visibleAncestors, ...visibleDescendants], request); const byId = new Map(serialized.map((item) => [String(item.id), item])); return json({ - ancestors: ancestors.map((item) => byId.get(item.id)).filter(Boolean), - descendants: descRows.results.map((item) => byId.get(item.id)).filter(Boolean) + ancestors: visibleAncestors.map((item) => byId.get(item.id)).filter(Boolean), + descendants: visibleDescendants.map((item) => byId.get(item.id)).filter(Boolean) }); } export async function favouriteStatus(request: Request, env: Env, statusId: string): Promise { const user = await requireUser(request, env); - const status = await getStatus(env, statusId); + const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); if (!status) return json({ error: "Record not found" }, 404); const actor = actorUrl(env, user); @@ -739,7 +775,7 @@ export async function favouriteStatus(request: Request, env: Env, statusId: stri export async function unfavouriteStatus(request: Request, env: Env, statusId: string): Promise { const user = await requireUser(request, env); - const status = await getStatus(env, statusId); + const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); if (!status) return json({ error: "Record not found" }, 404); const actor = actorUrl(env, user); @@ -758,8 +794,9 @@ export async function unfavouriteStatus(request: Request, env: Env, statusId: st export async function reblogStatus(request: Request, env: Env, statusId: string): Promise { const user = await requireUser(request, env); - const status = await getStatus(env, statusId); + const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); if (!status) return json({ error: "Record not found" }, 404); + if (status.visibility !== "public" && status.visibility !== "unlisted") return json({ error: "status_not_rebloggable" }, 422); const actor = actorUrl(env, user); const existing = await findReblog(env, status.id, actor); @@ -785,7 +822,7 @@ export async function reblogStatus(request: Request, env: Env, statusId: string) export async function unreblogStatus(request: Request, env: Env, statusId: string): Promise { const user = await requireUser(request, env); - const status = await getStatus(env, statusId); + const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); if (!status) return json({ error: "Record not found" }, 404); const actor = actorUrl(env, user); @@ -805,7 +842,7 @@ export async function unreblogStatus(request: Request, env: Env, statusId: strin export async function bookmarkStatus(request: Request, env: Env, statusId: string): Promise { const user = await requireUser(request, env); - const status = await getStatus(env, statusId); + const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); if (!status) return json({ error: "Record not found" }, 404); await addBookmark(env, user.id, status.id); const owner = await getUserById(env, status.user_id); @@ -815,7 +852,7 @@ export async function bookmarkStatus(request: Request, env: Env, statusId: strin export async function unbookmarkStatus(request: Request, env: Env, statusId: string): Promise { const user = await requireUser(request, env); - const status = await getStatus(env, statusId); + const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); if (!status) return json({ error: "Record not found" }, 404); await removeBookmark(env, user.id, status.id); const owner = await getUserById(env, status.user_id); @@ -847,8 +884,9 @@ export async function bookmarksList(request: Request, env: Env): Promise(); - const items = await serializeStatuses(env, rows.results, request); - return withPagination(json(items), request, rows.results.map((s) => s.id)); + const visibleRows = await filterStatusesForViewer(env, rows.results, statusViewerForUser(env, user)); + const items = await serializeStatuses(env, visibleRows, request); + return withPagination(json(items), request, visibleRows.map((s) => s.id)); } export async function favouritesList(request: Request, env: Env): Promise { @@ -860,8 +898,9 @@ export async function favouritesList(request: Request, env: Env): Promise(); - const items = await serializeStatuses(env, rows.results, request); - return withPagination(json(items), request, rows.results.map((s) => s.id)); + const visibleRows = await filterStatusesForViewer(env, rows.results, statusViewerForUser(env, user)); + const items = await serializeStatuses(env, visibleRows, request); + return withPagination(json(items), request, visibleRows.map((s) => s.id)); } export async function publicTimeline(request: Request, env: Env): Promise { @@ -1123,8 +1162,9 @@ export async function search(request: Request, env: Env): Promise { } if (!type || type === "statuses") { - const rows = await env.DB.prepare("SELECT * FROM statuses WHERE content LIKE ? ORDER BY created_at DESC LIMIT 20").bind(`%${escapeHtml(q)}%`).all(); - statuses.push(...await serializeStatuses(env, rows.results, request)); + const rows = await env.DB.prepare("SELECT * FROM statuses WHERE content LIKE ? ORDER BY created_at DESC LIMIT 100").bind(`%${escapeHtml(q)}%`).all(); + const visibleRows = await filterStatusesForViewer(env, rows.results, await loadStatusViewer(request, env)); + statuses.push(...await serializeStatuses(env, visibleRows.slice(0, 20), request)); } if (!type || type === "hashtags") { @@ -1311,8 +1351,9 @@ async function buildStatusSerializationContext( for (const user of await loadUsersByIds(env, missingUserIds)) usersById.set(user.id, user); } - const viewer = await viewerActor(request, env); - const viewerId = await viewerUserId(request, env); + const viewerUserForContext = await viewerUser(request, env); + const viewer = viewerUserForContext ? actorUrl(env, viewerUserForContext) : null; + const viewerId = viewerUserForContext?.id ?? null; const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId, bookmarkedStatusIds, pinnedStatusIds] = await Promise.all([ loadMediaByStatusIds(env, statusIds), loadMentionsByStatusIds(env, statusIds), @@ -1626,7 +1667,8 @@ async function serializeNotifications(env: Env, notifications: Notification[], r if (notifications.length === 0) return []; const statuses = await loadStatusesByIds(env, uniqueStrings(notifications.map((notification) => notification.status_id))); - const serializedStatuses = await serializeStatuses(env, statuses, request); + const visibleStatuses = await filterStatusesForViewer(env, statuses, await loadStatusViewer(request, env)); + const serializedStatuses = await serializeStatuses(env, visibleStatuses, request); const serializedStatusById = new Map(serializedStatuses.map((item) => [String(item.id), item])); const remoteActorIds = uniqueStrings( @@ -1708,22 +1750,68 @@ function withPagination(response: Response, request: Request, ids: string[]): Re return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); } -async function viewerActor(request: Request, env: Env): Promise { +function isStatusVisibility(value: string): value is StatusVisibility { + return VALID_STATUS_VISIBILITIES.has(value); +} + +async function loadStatusViewer(request: Request, env: Env): Promise { + return statusViewerForUser(env, await viewerUser(request, env)); +} + +function statusViewerForUser(env: Env, user: User | null): StatusViewer { + return { + user, + actor: user ? actorUrl(env, user) : null, + followsByOwnerId: new Map() + }; +} + +async function visibleStatusWhereForOwner(env: Env, ownerUserId: string, viewer: StatusViewer, column = "visibility"): Promise { + if (viewer.user?.id === ownerUserId) return null; + if (await viewerFollowsOwner(env, viewer, ownerUserId)) return `${column} IN ('public', 'unlisted', 'private')`; + return `${column} IN ('public', 'unlisted')`; +} + +async function visibleStatusOrNull(env: Env, statusId: string, viewer: StatusViewer): Promise { + const status = await getStatus(env, statusId); + if (!status) return null; + return await canViewerViewStatus(env, status, viewer) ? status : null; +} + +async function filterStatusesForViewer(env: Env, statuses: Status[], viewer: StatusViewer): Promise { + const visible: Status[] = []; + for (const status of statuses) { + if (await canViewerViewStatus(env, status, viewer)) visible.push(status); + } + return visible; +} + +async function canViewerViewStatus(env: Env, status: Status, viewer: StatusViewer): Promise { + if (status.visibility === "public" || status.visibility === "unlisted") return true; + if (viewer.user?.id === status.user_id) return true; + if (status.visibility === "private") return viewerFollowsOwner(env, viewer, status.user_id); + return false; +} + +async function viewerFollowsOwner(env: Env, viewer: StatusViewer, ownerUserId: string): Promise { + if (!viewer.actor) return false; + const cached = viewer.followsByOwnerId.get(ownerUserId); + if (cached !== undefined) return cached; + const row = await env.DB.prepare( + "SELECT 1 AS hit FROM follows WHERE local_user_id = ? AND follower_actor = ? AND accepted = 1 LIMIT 1" + ).bind(ownerUserId, viewer.actor).first<{ hit: number }>(); + const follows = Boolean(row?.hit); + viewer.followsByOwnerId.set(ownerUserId, follows); + return follows; +} + +async function viewerUser(request: Request, env: Env): Promise { const auth = request.headers.get("authorization") ?? ""; const token = auth.match(/^Bearer\s+(.+)$/i)?.[1]; if (!token) return null; const session = await env.KV.get(`token:${token}`, "json"); if (!session) return null; - const user = await getUserById(env, session.userId); - return user ? actorUrl(env, user) : null; -} - -async function viewerUserId(request: Request, env: Env): Promise { - const auth = request.headers.get("authorization") ?? ""; - const token = auth.match(/^Bearer\s+(.+)$/i)?.[1]; - if (!token) return null; - const session = await env.KV.get(`token:${token}`, "json"); - return session?.userId ?? null; + return getUserById(env, session.userId); } async function loadBookmarkedStatusIds(env: Env, userId: string, statusIds: string[]): Promise> {