diff --git a/migrations/0005_actor_local_id.sql b/migrations/0005_actor_local_id.sql new file mode 100644 index 0000000..648c8c1 --- /dev/null +++ b/migrations/0005_actor_local_id.sql @@ -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); diff --git a/readme.md b/readme.md index 2496a7f..29377ed 100644 --- a/readme.md +++ b/readme.md @@ -147,9 +147,13 @@ npm run deploy 这是一个单用户可运行实现,不是完整 Mastodon 服务端: - 只支持单管理员账号自动初始化,不开放注册 -- 远端嘟文缓存只在被本地账号关注的 actor 发出的 `Create(Note)` 时写入,不抓取历史 outbox -- `media_attachments` 已缓存(URL 指向远端原始域名),但 `mentions`、`tags` 在远端缓存嘟文中是空数组 -- 私信(direct visibility)的检索没有按收信人过滤,目前所有客户端都能在公开时间线之外读到自己的嘟文,不应当作私信使用 +- 本地状态的可见性语义并不完整: + - `visibility=private` 目前只会写入本地库,不会按 followers-only 语义完整投递 + - `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)等 ## 参考 diff --git a/src/db.ts b/src/db.ts index bd78403..d4a7794 100644 --- a/src/db.ts +++ b/src/db.ts @@ -153,10 +153,13 @@ export async function upsertActorCache(env: Env, actor: RemoteActor): Promise { + return env.DB.prepare("SELECT * FROM actor_cache WHERE local_id = ?").bind(localId).first(); +} + +export async function ensureActorLocalId(env: Env, cache: ActorCache): Promise { + 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 { await env.DB.prepare("DELETE FROM actor_cache WHERE id = ?").bind(actorId).run(); } diff --git a/src/federation.ts b/src/federation.ts index daa41b7..6fd1265 100644 --- a/src/federation.ts +++ b/src/federation.ts @@ -7,6 +7,7 @@ import { import { actorCacheStale, deleteActorFromCache, + ensureActorLocalId, getActorByKeyId, getActorFromCache, 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 { if (!actorId) return null; 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); - if (!fetched) return cached; + if (!fetched) return cached ? ensureActorLocalId(env, cached) : null; return upsertActorCache(env, fetched); } diff --git a/src/index.ts b/src/index.ts index 655d80b..03dfefa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,8 @@ import { ensureAdminUser } from "./db"; import { HttpError, cors, json, svgResponse } from "./http"; import { accountStatuses, + accountFollowers, + accountFollowing, authorize, authorizeFollowRequest, authorizePage, @@ -115,6 +117,8 @@ async function route(request: Request, env: Env): Promise { 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\/([^/]+)\/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\/([^/]+)\/unfollow$/))) return unfollowAccount(request, env, decodeURIComponent(m[1])); diff --git a/src/mastodon.ts b/src/mastodon.ts index 8974d95..6e7ee94 100644 --- a/src/mastodon.ts +++ b/src/mastodon.ts @@ -21,6 +21,8 @@ import { findOutgoingFollow, findPin, findReblog, + getActorByLocalId, + getActorFromCache, getAdminUser, getAppByClientId, getStatus, @@ -55,6 +57,7 @@ import { } from "./http"; import type { ParsedBody } from "./http"; import type { + ActorCache, CachedStatus, Follow, Media, @@ -416,6 +419,8 @@ function extractFieldsAttributes(body: ParsedBody): { name: string; value: strin export async function getAccount(env: Env, accountId: string): Promise { const local = await getUserByIdOrUsername(env, accountId); 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://")) { const cache = await resolveRemoteActor(env, accountId); if (cache) return json(remoteAccountJson(cache)); @@ -441,20 +446,90 @@ export async function lookupAccount(request: Request, env: Env): Promise { - const user = await getUserByIdOrUsername(env, accountId); - if (!user) return json({ error: "Record not found" }, 404); const url = new URL(request.url); 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(); + 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(); + 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 { + 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]; - 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(); - const items = await serializeStatuses(env, rows.results, request, new Map([[user.id, user]])); - return withPagination(json(items), request, rows.results.map((row) => row.id)); + pagedAppendForTable(where, binds, url, "follows"); + const rows = await env.DB.prepare( + `SELECT * FROM follows WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?` + ).bind(...binds, limit).all(); + + const accounts = await actorIdsToAccounts(env, rows.results.map((row) => row.follower_actor)); + return withPagination(json(accounts), request, rows.results.map((row) => row.id)); +} + +export async function accountFollowing(request: Request, env: Env, accountId: string): Promise { + 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 { + 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[]> { + const accounts = await Promise.all(actorIds.map((actorId) => accountFromActorId(env, actorId))); + return accounts.filter((account): account is Record => Boolean(account)); +} + +async function accountFromActorId(env: Env, actorId: string): Promise | 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 { @@ -1305,11 +1380,11 @@ async function accountJson(env: Env, user: User): Promise { +function remoteAccountJson(cache: ActorCache): Record { const host = (() => { try { return new URL(cache.id).host; } catch { return "remote"; } })(); const username = cache.preferred_username ?? cache.id.split("/").pop() ?? "user"; return { - id: cache.id, + id: cache.local_id ?? cache.id, username, acct: `${username}@${host}`, 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 { const local = await getUserByIdOrUsername(env, key); 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("@")) { const resolved = await resolveAcct(env, key); if (!resolved) return null; @@ -1385,6 +1472,7 @@ async function resolveAccountTarget(env: Env, key: string): Promise (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 { if (ids.length === 0) return response; const url = new URL(request.url); diff --git a/src/types.ts b/src/types.ts index 8a15a5d..dbd5a57 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,6 +170,7 @@ export type OAuthToken = { export type ActorCache = { id: string; + local_id: string | null; inbox: string; shared_inbox: string | null; preferred_username: string | null;