This commit is contained in:
浪子
2026-05-14 23:39:53 +08:00
parent 3fff8632fb
commit e2fcf08e2f
8 changed files with 190 additions and 16 deletions
+22
View File
@@ -0,0 +1,22 @@
-- Queue outbound ActivityPub deliveries so API responses do not wait on
-- remote inbox latency and failed deliveries can be retried.
CREATE TABLE IF NOT EXISTS outgoing_deliveries (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
inbox TEXT NOT NULL,
activity_id TEXT NOT NULL,
activity_json TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TEXT,
locked_until TEXT,
delivered_at TEXT,
failed_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, inbox, activity_id)
);
CREATE INDEX IF NOT EXISTS idx_outgoing_deliveries_due ON outgoing_deliveries(next_attempt_at, locked_until);
CREATE INDEX IF NOT EXISTS idx_outgoing_deliveries_user_time ON outgoing_deliveries(user_id, created_at DESC);
+12 -6
View File
@@ -3,7 +3,7 @@
一个运行在 Cloudflare Workers 上的单用户联邦宇宙发布软件,使用: 一个运行在 Cloudflare Workers 上的单用户联邦宇宙发布软件,使用:
- Workers: HTTP API、ActivityPub 路由、Mastodon API 兼容层 - Workers: HTTP API、ActivityPub 路由、Mastodon API 兼容层
- D1: 用户、OAuth 应用/Token、嘟文、媒体索引、关注、收藏、转发、通知、提及、话题标签、收藏夹、置顶、远端 actor 与远端嘟文缓存 - D1: 用户、OAuth 应用/Token、嘟文、媒体索引、关注、收藏、转发、通知、提及、话题标签、收藏夹、置顶、远端 actor 与远端嘟文缓存、出站联邦投递队列
- R2: 媒体文件 - R2: 媒体文件
- KV: OAuth access token 会话(D1 同步保留,便于管理) - KV: OAuth access token 会话(D1 同步保留,便于管理)
@@ -17,17 +17,20 @@ npm run db:local
npm run dev npm run dev
``` ```
默认管理员账号来自 `wrangler.jsonc`: 默认管理员用户名来自 `wrangler.jsonc``ADMIN_USERNAME`,当前示例值为 `sun`。密码必须通过 Secret 或本地开发变量提供:
- 用户名:`admin` 生产部署:
- 密码:`change-me-before-deploy`
部署前必须改掉 `ADMIN_PASSWORD`,更推荐用 Cloudflare secret 管理密码:
```bash ```bash
wrangler secret put ADMIN_PASSWORD wrangler secret put ADMIN_PASSWORD
``` ```
本地开发可以创建 `.dev.vars`:
```dotenv
ADMIN_PASSWORD=change-me-before-deploy
```
`PUBLIC_BASE_URL` 必须在首次正式部署前改成你的稳定实例域名,例如: `PUBLIC_BASE_URL` 必须在首次正式部署前改成你的稳定实例域名,例如:
```json ```json
@@ -126,6 +129,8 @@ npm run deploy
入站 inbox 处理类型:`Follow` / `Undo(Follow)` / `Accept(Follow)` / `Reject(Follow)` / `Like` / `Undo(Like)` / `Announce` / `Undo(Announce)` / `Delete(Note)`(同时清远端嘟文缓存)/ `Delete(Person)`(同时清缓存与关注关系)/ `Update(Person)` / `Update(Note)` / `Create(Note)`(被关注的远端账号公开 / followers-only Note,或投递给本地用户的 Note 会写入 `cached_statuses` 给 home timeline 和通知使用,同时若 mention/回复本地用户会触发通知)。 入站 inbox 处理类型:`Follow` / `Undo(Follow)` / `Accept(Follow)` / `Reject(Follow)` / `Like` / `Undo(Like)` / `Announce` / `Undo(Announce)` / `Delete(Note)`(同时清远端嘟文缓存)/ `Delete(Person)`(同时清缓存与关注关系)/ `Update(Person)` / `Update(Note)` / `Create(Note)`(被关注的远端账号公开 / followers-only Note,或投递给本地用户的 Note 会写入 `cached_statuses` 给 home timeline 和通知使用,同时若 mention/回复本地用户会触发通知)。
出站 ActivityPub 投递会先写入 D1 `outgoing_deliveries` 队列,再由请求后的 `waitUntil` 和每分钟 Cron 触发器后台签名发送。失败投递会指数退避重试,最多 8 次后标记为失败并保留错误摘要。
## 安全 ## 安全
- 密码哈希使用 **PBKDF2-SHA256 / 100000 iterations**,带每用户随机 16 字节 salt,旧版 `salt.hash` 哈希也能继续验证(便于无缝升级)。 - 密码哈希使用 **PBKDF2-SHA256 / 100000 iterations**,带每用户随机 16 字节 salt,旧版 `salt.hash` 哈希也能继续验证(便于无缝升级)。
@@ -147,6 +152,7 @@ npm run deploy
- `migrations/0003_bookmarks_cache.sql` — 收藏夹(bookmarks)/ 置顶(pinned_statuses)/ 远端嘟文缓存(cached_statuses)/ OAuth Token 持久表 - `migrations/0003_bookmarks_cache.sql` — 收藏夹(bookmarks)/ 置顶(pinned_statuses)/ 远端嘟文缓存(cached_statuses)/ OAuth Token 持久表
- `migrations/0006_cached_status_metadata.sql` — 远端缓存嘟文的可见性 / mentions / tags / 本地收件人元数据 - `migrations/0006_cached_status_metadata.sql` — 远端缓存嘟文的可见性 / mentions / tags / 本地收件人元数据
- `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 投递队列 / 重试状态
## 重要限制 ## 重要限制
-2
View File
@@ -14,7 +14,6 @@ import {
upsertCachedStatus upsertCachedStatus
} from "./db"; } from "./db";
import { import {
deliverToInboxes,
isDuplicateActivity, isDuplicateActivity,
isFollowedByAnyLocalUser, isFollowedByAnyLocalUser,
notifyForLocalStatus, notifyForLocalStatus,
@@ -22,7 +21,6 @@ import {
objectIdString, objectIdString,
parseActivity, parseActivity,
recordRemoteActivity, recordRemoteActivity,
resolveDeliveryInboxes,
resolveRemoteActor, resolveRemoteActor,
sendSignedActivity, sendSignedActivity,
verifyInboundSignature verifyInboundSignature
+70
View File
@@ -5,10 +5,12 @@ import type {
CachedStatusAttachment, CachedStatusAttachment,
Favourite, Favourite,
Follow, Follow,
Json,
Media, Media,
Notification, Notification,
OAuthApp, OAuthApp,
OAuthCode, OAuthCode,
OutgoingDelivery,
OutgoingFollow, OutgoingFollow,
Reblog, Reblog,
RemoteActor, RemoteActor,
@@ -337,3 +339,71 @@ export async function setUserAvatarKey(env: Env, userId: string, key: string | n
export async function setUserHeaderKey(env: Env, userId: string, key: string | null): Promise<void> { export async function setUserHeaderKey(env: Env, userId: string, key: string | null): Promise<void> {
await env.DB.prepare("UPDATE users SET header_r2_key = ? WHERE id = ?").bind(key, userId).run(); await env.DB.prepare("UPDATE users SET header_r2_key = ? WHERE id = ?").bind(key, userId).run();
} }
export async function enqueueOutgoingDeliveries(env: Env, userId: string, inboxes: Iterable<string>, activity: Json): Promise<void> {
const uniqueInboxes = [...new Set([...inboxes].map((inbox) => inbox.trim()).filter(Boolean))];
if (uniqueInboxes.length === 0) return;
const now = new Date().toISOString();
const activityId = typeof activity.id === "string" && activity.id ? activity.id : id();
const activityJson = JSON.stringify(activity);
const statements = uniqueInboxes.map((inbox) => env.DB.prepare(
`INSERT OR IGNORE INTO outgoing_deliveries
(id, user_id, inbox, activity_id, activity_json, attempts, next_attempt_at, locked_until, delivered_at, failed_at, last_error, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, 0, ?, NULL, NULL, NULL, NULL, ?, ?)`
).bind(id(), userId, inbox, activityId, activityJson, now, now, now));
for (let offset = 0; offset < statements.length; offset += 50) {
await env.DB.batch(statements.slice(offset, offset + 50));
}
}
export async function listDueOutgoingDeliveries(env: Env, now: string, limit: number): Promise<OutgoingDelivery[]> {
const rows = await env.DB.prepare(
`SELECT * FROM outgoing_deliveries
WHERE delivered_at IS NULL
AND failed_at IS NULL
AND next_attempt_at IS NOT NULL
AND next_attempt_at <= ?
AND (locked_until IS NULL OR locked_until <= ?)
ORDER BY next_attempt_at ASC, created_at ASC
LIMIT ?`
).bind(now, now, limit).all<OutgoingDelivery>();
return rows.results;
}
export async function claimOutgoingDelivery(env: Env, deliveryId: string, now: string, lockedUntil: string): Promise<boolean> {
const result = await env.DB.prepare(
`UPDATE outgoing_deliveries
SET locked_until = ?, updated_at = ?
WHERE id = ?
AND delivered_at IS NULL
AND failed_at IS NULL
AND next_attempt_at IS NOT NULL
AND next_attempt_at <= ?
AND (locked_until IS NULL OR locked_until <= ?)`
).bind(lockedUntil, now, deliveryId, now, now).run();
return (result.meta.changes ?? 0) > 0;
}
export async function markOutgoingDeliveryDelivered(env: Env, deliveryId: string): Promise<void> {
const now = new Date().toISOString();
await env.DB.prepare(
"UPDATE outgoing_deliveries SET delivered_at = ?, locked_until = NULL, updated_at = ? WHERE id = ?"
).bind(now, now, deliveryId).run();
}
export async function markOutgoingDeliveryFailed(
env: Env,
deliveryId: string,
attempts: number,
nextAttemptAt: string | null,
error: string
): Promise<void> {
const now = new Date().toISOString();
const failedAt = nextAttemptAt ? null : now;
await env.DB.prepare(
`UPDATE outgoing_deliveries
SET attempts = ?, next_attempt_at = ?, locked_until = NULL, failed_at = ?, last_error = ?, updated_at = ?
WHERE id = ?`
).bind(attempts, nextAttemptAt, failedAt, error.slice(0, 500), now, deliveryId).run();
}
+61 -5
View File
@@ -6,18 +6,28 @@ import {
} from "./crypto"; } from "./crypto";
import { import {
actorCacheStale, actorCacheStale,
claimOutgoingDelivery,
deleteActorFromCache, deleteActorFromCache,
enqueueOutgoingDeliveries,
ensureActorLocalId, ensureActorLocalId,
getActorByKeyId, getActorByKeyId,
getActorFromCache, getActorFromCache,
getUserById,
listDueOutgoingDeliveries,
markOutgoingDeliveryDelivered,
markOutgoingDeliveryFailed,
recordNotification, recordNotification,
upsertActorCache upsertActorCache
} from "./db"; } from "./db";
import type { ActorCache, Json, RemoteActor, Status, User } from "./types"; import type { ActorCache, Json, OutgoingDelivery, RemoteActor, Status, User } from "./types";
import { SIGNATURE_MAX_SKEW_MS } from "./types"; import { SIGNATURE_MAX_SKEW_MS } from "./types";
import { actorUrl, base64Decode, encoder, hostFromBaseUrl, parseAcctFromActor } from "./util"; import { actorUrl, base64Decode, encoder, hostFromBaseUrl, parseAcctFromActor } from "./util";
const ACTIVITY_HEADERS = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""; const ACTIVITY_HEADERS = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"";
const DELIVERY_BATCH_SIZE = 20;
const DELIVERY_MAX_ATTEMPTS = 8;
const DELIVERY_LEASE_MS = 60_000;
const DELIVERY_MAX_BACKOFF_SECONDS = 60 * 60;
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;
@@ -153,11 +163,57 @@ export async function sendSignedActivity(env: Env, user: User, inboxUrl: string,
} }
export async function deliverToInboxes(env: Env, user: User, inboxes: Iterable<string>, activity: Json): Promise<void> { export async function deliverToInboxes(env: Env, user: User, inboxes: Iterable<string>, activity: Json): Promise<void> {
const unique = new Set<string>(); await enqueueOutgoingDeliveries(env, user.id, inboxes, activity);
for (const inbox of inboxes) {
if (inbox) unique.add(inbox);
} }
await Promise.allSettled([...unique].map((inbox) => sendSignedActivity(env, user, inbox, activity)));
export async function processOutgoingDeliveries(env: Env): Promise<void> {
const now = new Date().toISOString();
const deliveries = await listDueOutgoingDeliveries(env, now, DELIVERY_BATCH_SIZE);
for (const delivery of deliveries) {
await processOutgoingDelivery(env, delivery);
}
}
async function processOutgoingDelivery(env: Env, delivery: OutgoingDelivery): Promise<void> {
const now = new Date();
const nowIso = now.toISOString();
const lockedUntil = new Date(now.getTime() + DELIVERY_LEASE_MS).toISOString();
const claimed = await claimOutgoingDelivery(env, delivery.id, nowIso, lockedUntil);
if (!claimed) return;
let activity: Json;
try {
activity = JSON.parse(delivery.activity_json) as Json;
} catch {
await markOutgoingDeliveryFailed(env, delivery.id, DELIVERY_MAX_ATTEMPTS, null, "invalid_activity_json");
return;
}
const user = await getUserById(env, delivery.user_id);
if (!user) {
await markOutgoingDeliveryFailed(env, delivery.id, DELIVERY_MAX_ATTEMPTS, null, "delivery_user_missing");
return;
}
const result = await sendSignedActivity(env, user, delivery.inbox, activity).catch((error) => ({
ok: false,
status: 0,
text: String(error)
}));
if (result.ok) {
await markOutgoingDeliveryDelivered(env, delivery.id);
return;
}
const attempts = delivery.attempts + 1;
const nextAttemptAt = attempts >= DELIVERY_MAX_ATTEMPTS ? null : nextDeliveryAttemptAt(attempts);
const error = result.status ? `${result.status} ${result.text}` : result.text;
await markOutgoingDeliveryFailed(env, delivery.id, attempts, nextAttemptAt, error);
}
function nextDeliveryAttemptAt(attempts: number): string {
const delaySeconds = Math.min(DELIVERY_MAX_BACKOFF_SECONDS, 60 * (2 ** Math.max(0, attempts - 1)));
return new Date(Date.now() + delaySeconds * 1000).toISOString();
} }
export async function gatherFollowerInboxes(env: Env, userId: string): Promise<string[]> { export async function gatherFollowerInboxes(env: Env, userId: string): Promise<string[]> {
+6 -3
View File
@@ -86,13 +86,15 @@ import {
verifyAppCredentials, verifyAppCredentials,
verifyCredentials verifyCredentials
} from "./mastodon"; } from "./mastodon";
import { processOutgoingDeliveries } from "./federation";
export default { export default {
async fetch(request: Request, env: Env): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
try { try {
await ensureAdminUser(env); await ensureAdminUser(env);
await publishDueScheduledStatuses(env); const response = await route(request, env);
return await route(request, env); ctx.waitUntil(processOutgoingDeliveries(env));
return response;
} catch (error) { } catch (error) {
if (error instanceof HttpError) return json({ error: error.message }, error.status); if (error instanceof HttpError) return json({ error: error.message }, error.status);
console.error("unhandled", error); console.error("unhandled", error);
@@ -102,6 +104,7 @@ export default {
async scheduled(_event: ScheduledEvent, env: Env): Promise<void> { async scheduled(_event: ScheduledEvent, env: Env): Promise<void> {
await ensureAdminUser(env); await ensureAdminUser(env);
await publishDueScheduledStatuses(env); await publishDueScheduledStatuses(env);
await processOutgoingDeliveries(env);
} }
}; };
+16
View File
@@ -227,6 +227,22 @@ export type ScheduledStatus = {
created_at: string; created_at: string;
}; };
export type OutgoingDelivery = {
id: string;
user_id: string;
inbox: string;
activity_id: string;
activity_json: string;
attempts: number;
next_attempt_at: string | null;
locked_until: string | null;
delivered_at: string | null;
failed_at: string | null;
last_error: string | null;
created_at: string;
updated_at: string;
};
export type OAuthToken = { export type OAuthToken = {
token: string; token: string;
user_id: string; user_id: string;
+3
View File
@@ -12,6 +12,9 @@
"secrets": { "secrets": {
"required": ["ADMIN_PASSWORD"] "required": ["ADMIN_PASSWORD"]
}, },
"triggers": {
"crons": ["* * * * *"]
},
"d1_databases": [ "d1_databases": [
{ {
"binding": "DB", "binding": "DB",