修复关注

This commit is contained in:
浪子
2026-05-14 14:19:23 +08:00
parent 5a9acd60c5
commit 635aad8162
7 changed files with 157 additions and 21 deletions
+5
View File
@@ -0,0 +1,5 @@
-- Remote actors need a slash-free local id so Mastodon API paths like
-- /api/v1/accounts/:id/follow can target them without URL-encoding "/".
ALTER TABLE actor_cache ADD COLUMN local_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_actor_cache_local_id ON actor_cache(local_id);
+7 -3
View File
@@ -147,9 +147,13 @@ npm run deploy
这是一个单用户可运行实现,不是完整 Mastodon 服务端: 这是一个单用户可运行实现,不是完整 Mastodon 服务端:
- 只支持单管理员账号自动初始化,不开放注册 - 只支持单管理员账号自动初始化,不开放注册
- 远端嘟文缓存只在被本地账号关注的 actor 发出的 `Create(Note)` 时写入,不抓取历史 outbox - 本地状态的可见性语义并不完整:
- `media_attachments` 已缓存(URL 指向远端原始域名),但 `mentions``tags` 在远端缓存嘟文中是空数组 - `visibility=private` 目前只会写入本地库,不会按 followers-only 语义完整投递
- 私信(direct visibility)的检索没有按收信人过滤,目前所有客户端都能在公开时间线之外读到自己的嘟文,不应当作私信使用 - `unlisted` / `private` / `direct` 没有做完整的读权限控制,`GET /api/v1/accounts/:id/statuses``GET /api/v1/statuses/:id`、搜索接口以及 ActivityPub outbox 都可能暴露非公开内容,不应当存放敏感或真正私密的信息
- 远端嘟文缓存只在被本地账号关注的 actor 发出的入站 `Create(Note)` 时写入,不抓取历史 outbox
- 远端缓存嘟文只保留正文、CW、语言和附件; `mentions``tags`、互动计数等不会完整恢复,在 home timeline 中统一按 `visibility: public` 返回
- 媒体上传只支持 `image/jpeg``image/png``image/gif``image/webp`,单文件 10MB,单条状态最多 4 个附件; 头像和封面同样只按图片路径处理
- 没有实现接口级限流、反滥用或审核流; `follow_requests` 相关接口仍是 stub
- 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等 - 没有实现轮询(poll)、列表(list)、推送(push)、未来嘟文(scheduled)等
## 参考 ## 参考
+17 -2
View File
@@ -153,10 +153,13 @@ export async function upsertActorCache(env: Env, actor: RemoteActor): Promise<Ac
const sharedInbox = actor.endpoints?.sharedInbox ?? null; const sharedInbox = actor.endpoints?.sharedInbox ?? null;
const iconUrl = typeof actor.icon === "string" ? actor.icon : actor.icon?.url ?? null; const iconUrl = typeof actor.icon === "string" ? actor.icon : actor.icon?.url ?? null;
const now = new Date().toISOString(); const now = new Date().toISOString();
const existing = await getActorFromCache(env, actor.id);
const localId = existing?.local_id ?? id();
await env.DB.prepare( await env.DB.prepare(
`INSERT INTO actor_cache (id, inbox, shared_inbox, preferred_username, name, summary, icon_url, public_key_id, public_key_pem, fetched_at) `INSERT INTO actor_cache (id, local_id, inbox, shared_inbox, preferred_username, name, summary, icon_url, public_key_id, public_key_pem, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
local_id = COALESCE(actor_cache.local_id, excluded.local_id),
inbox = excluded.inbox, inbox = excluded.inbox,
shared_inbox = excluded.shared_inbox, shared_inbox = excluded.shared_inbox,
preferred_username = excluded.preferred_username, preferred_username = excluded.preferred_username,
@@ -169,6 +172,7 @@ export async function upsertActorCache(env: Env, actor: RemoteActor): Promise<Ac
) )
.bind( .bind(
actor.id, actor.id,
localId,
inbox, inbox,
sharedInbox, sharedInbox,
actor.preferredUsername ?? null, actor.preferredUsername ?? null,
@@ -183,6 +187,17 @@ export async function upsertActorCache(env: Env, actor: RemoteActor): Promise<Ac
return getActorFromCache(env, actor.id); return getActorFromCache(env, actor.id);
} }
export async function getActorByLocalId(env: Env, localId: string): Promise<ActorCache | null> {
return env.DB.prepare("SELECT * FROM actor_cache WHERE local_id = ?").bind(localId).first<ActorCache>();
}
export async function ensureActorLocalId(env: Env, cache: ActorCache): Promise<ActorCache> {
if (cache.local_id) return cache;
const localId = id();
await env.DB.prepare("UPDATE actor_cache SET local_id = COALESCE(local_id, ?) WHERE id = ?").bind(localId, cache.id).run();
return await getActorFromCache(env, cache.id) ?? { ...cache, local_id: localId };
}
export async function deleteActorFromCache(env: Env, actorId: string): Promise<void> { export async function deleteActorFromCache(env: Env, actorId: string): Promise<void> {
await env.DB.prepare("DELETE FROM actor_cache WHERE id = ?").bind(actorId).run(); await env.DB.prepare("DELETE FROM actor_cache WHERE id = ?").bind(actorId).run();
} }
+3 -2
View File
@@ -7,6 +7,7 @@ import {
import { import {
actorCacheStale, actorCacheStale,
deleteActorFromCache, deleteActorFromCache,
ensureActorLocalId,
getActorByKeyId, getActorByKeyId,
getActorFromCache, getActorFromCache,
recordNotification, recordNotification,
@@ -21,9 +22,9 @@ const ACTIVITY_HEADERS = "application/activity+json, application/ld+json; profil
export async function resolveRemoteActor(env: Env, actorId: string, opts: { force?: boolean } = {}): Promise<ActorCache | null> { export async function resolveRemoteActor(env: Env, actorId: string, opts: { force?: boolean } = {}): Promise<ActorCache | null> {
if (!actorId) return null; if (!actorId) return null;
const cached = await getActorFromCache(env, actorId); const cached = await getActorFromCache(env, actorId);
if (cached && !opts.force && !actorCacheStale(cached)) return cached; if (cached && !opts.force && !actorCacheStale(cached)) return ensureActorLocalId(env, cached);
const fetched = await fetchRemoteActor(actorId); const fetched = await fetchRemoteActor(actorId);
if (!fetched) return cached; if (!fetched) return cached ? ensureActorLocalId(env, cached) : null;
return upsertActorCache(env, fetched); return upsertActorCache(env, fetched);
} }
+4
View File
@@ -16,6 +16,8 @@ import { ensureAdminUser } from "./db";
import { HttpError, cors, json, svgResponse } from "./http"; import { HttpError, cors, json, svgResponse } from "./http";
import { import {
accountStatuses, accountStatuses,
accountFollowers,
accountFollowing,
authorize, authorize,
authorizeFollowRequest, authorizeFollowRequest,
authorizePage, authorizePage,
@@ -115,6 +117,8 @@ async function route(request: Request, env: Env): Promise<Response> {
if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)$/))) return getAccount(env, decodeURIComponent(m[1])); if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)$/))) return getAccount(env, decodeURIComponent(m[1]));
if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/statuses$/))) return accountStatuses(request, env, decodeURIComponent(m[1])); if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/statuses$/))) return accountStatuses(request, env, decodeURIComponent(m[1]));
if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/followers$/))) return accountFollowers(request, env, decodeURIComponent(m[1]));
if (method === "GET" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/following$/))) return accountFollowing(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/follow$/))) return followAccount(request, env, decodeURIComponent(m[1])); if (method === "POST" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/follow$/))) return followAccount(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/unfollow$/))) return unfollowAccount(request, env, decodeURIComponent(m[1])); if (method === "POST" && (m = path.match(/^\/api\/v1\/accounts\/([^/]+)\/unfollow$/))) return unfollowAccount(request, env, decodeURIComponent(m[1]));
+120 -14
View File
@@ -21,6 +21,8 @@ import {
findOutgoingFollow, findOutgoingFollow,
findPin, findPin,
findReblog, findReblog,
getActorByLocalId,
getActorFromCache,
getAdminUser, getAdminUser,
getAppByClientId, getAppByClientId,
getStatus, getStatus,
@@ -55,6 +57,7 @@ import {
} from "./http"; } from "./http";
import type { ParsedBody } from "./http"; import type { ParsedBody } from "./http";
import type { import type {
ActorCache,
CachedStatus, CachedStatus,
Follow, Follow,
Media, Media,
@@ -416,6 +419,8 @@ function extractFieldsAttributes(body: ParsedBody): { name: string; value: strin
export async function getAccount(env: Env, accountId: string): Promise<Response> { export async function getAccount(env: Env, accountId: string): Promise<Response> {
const local = await getUserByIdOrUsername(env, accountId); const local = await getUserByIdOrUsername(env, accountId);
if (local) return json(await accountJson(env, local)); if (local) return json(await accountJson(env, local));
const byLocalId = await getActorByLocalId(env, accountId);
if (byLocalId) return json(remoteAccountJson(byLocalId));
if (accountId.startsWith("http://") || accountId.startsWith("https://")) { if (accountId.startsWith("http://") || accountId.startsWith("https://")) {
const cache = await resolveRemoteActor(env, accountId); const cache = await resolveRemoteActor(env, accountId);
if (cache) return json(remoteAccountJson(cache)); if (cache) return json(remoteAccountJson(cache));
@@ -441,20 +446,90 @@ export async function lookupAccount(request: Request, env: Env): Promise<Respons
} }
export async function accountStatuses(request: Request, env: Env, accountId: string): Promise<Response> { export async function accountStatuses(request: Request, env: Env, accountId: string): Promise<Response> {
const user = await getUserByIdOrUsername(env, accountId);
if (!user) return json({ error: "Record not found" }, 404);
const url = new URL(request.url); const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 40); const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
const excludeReplies = url.searchParams.get("exclude_replies") === "true";
const where: string[] = ["user_id = ?"]; const user = await getUserByIdOrUsername(env, accountId);
if (user) {
const excludeReplies = url.searchParams.get("exclude_replies") === "true";
const where: string[] = ["user_id = ?"];
const binds: unknown[] = [user.id];
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 ?`;
binds.push(limit);
const rows = await env.DB.prepare(sql).bind(...binds).all<Status>();
const items = await serializeStatuses(env, rows.results, request, new Map([[user.id, user]]));
return withPagination(json(items), request, rows.results.map((row) => row.id));
}
const remote = await getActorByLocalId(env, accountId)
?? (accountId.startsWith("http://") || accountId.startsWith("https://") ? await resolveRemoteActor(env, accountId) : null);
if (remote) {
const rows = await env.DB.prepare(
"SELECT * FROM cached_statuses WHERE actor = ? ORDER BY published DESC LIMIT ?"
).bind(remote.id, limit).all<CachedStatus>();
const items = await Promise.all(rows.results.map((row) => cachedStatusToMastodon(env, row)));
return json(items);
}
return json({ error: "Record not found" }, 404);
}
export async function accountFollowers(request: Request, env: Env, accountId: string): Promise<Response> {
const user = await getUserByIdOrUsername(env, accountId);
if (!user) return remoteAccountListFallback(env, accountId);
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 80);
const where: string[] = ["local_user_id = ?", "accepted = 1"];
const binds: unknown[] = [user.id]; const binds: unknown[] = [user.id];
if (excludeReplies) where.push("in_reply_to_id IS NULL"); pagedAppendForTable(where, binds, url, "follows");
pagedAppend(where, binds, url); const rows = await env.DB.prepare(
const sql = `SELECT * FROM statuses WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`; `SELECT * FROM follows WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`
binds.push(limit); ).bind(...binds, limit).all<Follow>();
const rows = await env.DB.prepare(sql).bind(...binds).all<Status>();
const items = await serializeStatuses(env, rows.results, request, new Map([[user.id, user]])); const accounts = await actorIdsToAccounts(env, rows.results.map((row) => row.follower_actor));
return withPagination(json(items), request, rows.results.map((row) => row.id)); return withPagination(json(accounts), request, rows.results.map((row) => row.id));
}
export async function accountFollowing(request: Request, env: Env, accountId: string): Promise<Response> {
const user = await getUserByIdOrUsername(env, accountId);
if (!user) return remoteAccountListFallback(env, accountId);
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 80);
const where: string[] = ["local_user_id = ?", "accepted = 1"];
const binds: unknown[] = [user.id];
pagedAppendForTable(where, binds, url, "outgoing_follows");
const rows = await env.DB.prepare(
`SELECT * FROM outgoing_follows WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`
).bind(...binds, limit).all<{ id: string; target_actor: string }>();
const accounts = await actorIdsToAccounts(env, rows.results.map((row) => row.target_actor));
return withPagination(json(accounts), request, rows.results.map((row) => row.id));
}
async function remoteAccountListFallback(env: Env, accountId: string): Promise<Response> {
const remote = await getActorByLocalId(env, accountId)
?? (accountId.startsWith("http://") || accountId.startsWith("https://") ? await resolveRemoteActor(env, accountId) : null);
if (remote) return json([]);
return json({ error: "Record not found" }, 404);
}
async function actorIdsToAccounts(env: Env, actorIds: string[]): Promise<Record<string, unknown>[]> {
const accounts = await Promise.all(actorIds.map((actorId) => accountFromActorId(env, actorId)));
return accounts.filter((account): account is Record<string, unknown> => Boolean(account));
}
async function accountFromActorId(env: Env, actorId: string): Promise<Record<string, unknown> | null> {
if (actorId.startsWith(baseUrl(env))) {
const match = actorId.match(/\/users\/([^/?#]+)$/);
const user = match ? await getUserByUsername(env, match[1]) : null;
return user ? accountJson(env, user) : null;
}
const cache = await resolveRemoteActor(env, actorId) ?? await getActorFromCache(env, actorId);
return cache ? remoteAccountJson(cache) : null;
} }
export async function createStatus(request: Request, env: Env): Promise<Response> { export async function createStatus(request: Request, env: Env): Promise<Response> {
@@ -1305,11 +1380,11 @@ async function accountJson(env: Env, user: User): Promise<Record<string, unknown
}; };
} }
function remoteAccountJson(cache: { id: string; preferred_username: string | null; name: string | null; summary: string | null; icon_url: string | null; fetched_at: string }): Record<string, unknown> { function remoteAccountJson(cache: ActorCache): Record<string, unknown> {
const host = (() => { try { return new URL(cache.id).host; } catch { return "remote"; } })(); const host = (() => { try { return new URL(cache.id).host; } catch { return "remote"; } })();
const username = cache.preferred_username ?? cache.id.split("/").pop() ?? "user"; const username = cache.preferred_username ?? cache.id.split("/").pop() ?? "user";
return { return {
id: cache.id, id: cache.local_id ?? cache.id,
username, username,
acct: `${username}@${host}`, acct: `${username}@${host}`,
display_name: cache.name ?? username, display_name: cache.name ?? username,
@@ -1376,7 +1451,19 @@ type AccountTarget = { kind: "local"; userId: string; actorId: string } | { kind
async function resolveAccountTarget(env: Env, key: string): Promise<AccountTarget | null> { async function resolveAccountTarget(env: Env, key: string): Promise<AccountTarget | null> {
const local = await getUserByIdOrUsername(env, key); const local = await getUserByIdOrUsername(env, key);
if (local) return { kind: "local", userId: local.id, actorId: actorUrl(env, local) }; if (local) return { kind: "local", userId: local.id, actorId: actorUrl(env, local) };
if (key.startsWith("http://") || key.startsWith("https://")) return { kind: "remote", actorId: key };
const byLocalId = await getActorByLocalId(env, key);
if (byLocalId) return { kind: "remote", actorId: byLocalId.id };
if (key.startsWith("http://") || key.startsWith("https://")) {
if (key.startsWith(baseUrl(env))) {
const match = key.match(/\/users\/([^/?#]+)$/);
const u = match ? await getUserByUsername(env, match[1]) : null;
if (u) return { kind: "local", userId: u.id, actorId: actorUrl(env, u) };
}
await resolveRemoteActor(env, key);
return { kind: "remote", actorId: key };
}
if (key.includes("@")) { if (key.includes("@")) {
const resolved = await resolveAcct(env, key); const resolved = await resolveAcct(env, key);
if (!resolved) return null; if (!resolved) return null;
@@ -1385,6 +1472,7 @@ async function resolveAccountTarget(env: Env, key: string): Promise<AccountTarge
const localUser = match ? await getUserByUsername(env, match[1]) : null; const localUser = match ? await getUserByUsername(env, match[1]) : null;
if (localUser) return { kind: "local", userId: localUser.id, actorId: resolved.actorId }; if (localUser) return { kind: "local", userId: localUser.id, actorId: resolved.actorId };
} }
await resolveRemoteActor(env, resolved.actorId);
return { kind: "remote", actorId: resolved.actorId }; return { kind: "remote", actorId: resolved.actorId };
} }
return null; return null;
@@ -1589,6 +1677,24 @@ function pagedAppend(where: string[], binds: unknown[], url: URL): void {
} }
} }
function pagedAppendForTable(where: string[], binds: unknown[], url: URL, table: "follows" | "outgoing_follows"): void {
const maxId = url.searchParams.get("max_id");
if (maxId) {
where.push(`created_at < (SELECT created_at FROM ${table} WHERE id = ?)`);
binds.push(maxId);
}
const sinceId = url.searchParams.get("since_id");
if (sinceId) {
where.push(`created_at > (SELECT created_at FROM ${table} WHERE id = ?)`);
binds.push(sinceId);
}
const minId = url.searchParams.get("min_id");
if (minId) {
where.push(`created_at > (SELECT created_at FROM ${table} WHERE id = ?)`);
binds.push(minId);
}
}
function withPagination(response: Response, request: Request, ids: string[]): Response { function withPagination(response: Response, request: Request, ids: string[]): Response {
if (ids.length === 0) return response; if (ids.length === 0) return response;
const url = new URL(request.url); const url = new URL(request.url);
+1
View File
@@ -170,6 +170,7 @@ export type OAuthToken = {
export type ActorCache = { export type ActorCache = {
id: string; id: string;
local_id: string | null;
inbox: string; inbox: string;
shared_inbox: string | null; shared_inbox: string | null;
preferred_username: string | null; preferred_username: string | null;