Files
Toot-Worker/src/federation.ts
T
浪子 e2fcf08e2f fix
2026-05-14 23:39:53 +08:00

323 lines
11 KiB
TypeScript

import {
digestBase64,
parseSignatureHeader,
signString,
verifyWithPem
} from "./crypto";
import {
actorCacheStale,
claimOutgoingDelivery,
deleteActorFromCache,
enqueueOutgoingDeliveries,
ensureActorLocalId,
getActorByKeyId,
getActorFromCache,
getUserById,
listDueOutgoingDeliveries,
markOutgoingDeliveryDelivered,
markOutgoingDeliveryFailed,
recordNotification,
upsertActorCache
} from "./db";
import type { ActorCache, Json, OutgoingDelivery, RemoteActor, Status, User } from "./types";
import { SIGNATURE_MAX_SKEW_MS } from "./types";
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 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> {
if (!actorId) return null;
const cached = await getActorFromCache(env, actorId);
if (cached && !opts.force && !actorCacheStale(cached)) return ensureActorLocalId(env, cached);
const fetched = await fetchRemoteActor(actorId);
if (!fetched) return cached ? ensureActorLocalId(env, cached) : null;
return upsertActorCache(env, fetched);
}
export async function fetchRemoteActor(actorId: string): Promise<RemoteActor | null> {
try {
const response = await fetch(actorId, {
headers: { accept: ACTIVITY_HEADERS },
cf: { cacheTtl: 60 }
});
if (!response.ok) return null;
const data = await response.json() as RemoteActor;
if (!data || !data.id) return null;
return data;
} catch {
return null;
}
}
export async function discoverActorByKeyId(env: Env, keyId: string): Promise<ActorCache | null> {
const cached = await getActorByKeyId(env, keyId);
if (cached && !actorCacheStale(cached)) return cached;
const actorIdGuess = keyId.split("#")[0];
const refreshed = await resolveRemoteActor(env, actorIdGuess, { force: true });
if (refreshed?.public_key_id === keyId) return refreshed;
return cached;
}
export type VerifiedSignature = {
actor: ActorCache;
keyId: string;
};
export async function verifyInboundSignature(request: Request, body: string, env: Env): Promise<VerifiedSignature | null> {
const sigHeader = request.headers.get("signature");
if (!sigHeader) return null;
const parsed = parseSignatureHeader(sigHeader);
if (!parsed || !parsed.headers.includes("(request-target)")) return null;
const dateHeader = request.headers.get("date");
if (dateHeader) {
const stamp = Date.parse(dateHeader);
if (!Number.isFinite(stamp)) return null;
if (Math.abs(Date.now() - stamp) > SIGNATURE_MAX_SKEW_MS) return null;
} else if (parsed.headers.includes("date")) {
return null;
}
const digestHeader = request.headers.get("digest");
if (digestHeader) {
const expected = `SHA-256=${await digestBase64(body)}`;
if (digestHeader !== expected) return null;
} else if (request.method.toUpperCase() === "POST" && body) {
return null;
}
const url = new URL(request.url);
const hostHeader = request.headers.get("host");
if (parsed.headers.includes("host") && hostHeader && hostHeader.toLowerCase() !== url.host.toLowerCase()) {
return null;
}
const lines: string[] = [];
for (const headerName of parsed.headers) {
if (headerName === "(request-target)") {
lines.push(`(request-target): ${request.method.toLowerCase()} ${url.pathname}${url.search}`);
continue;
}
const value = request.headers.get(headerName);
if (value === null) return null;
lines.push(`${headerName}: ${value}`);
}
const actor = await discoverActorByKeyId(env, parsed.keyId);
if (!actor || !actor.public_key_pem) return null;
try {
const ok = await verifyWithPem(actor.public_key_pem, lines.join("\n"), parsed.signature);
if (!ok) return null;
} catch {
return null;
}
return { actor, keyId: parsed.keyId };
}
export async function sendSignedActivity(env: Env, user: User, inboxUrl: string, activity: Json): Promise<{ ok: boolean; status: number; text: string }> {
const target = new URL(inboxUrl);
const body = JSON.stringify(activity);
const digest = `SHA-256=${await digestBase64(body)}`;
const date = new Date().toUTCString();
const host = target.host;
const path = `${target.pathname}${target.search}`;
const signingString = [
`(request-target): post ${path}`,
`host: ${host}`,
`date: ${date}`,
`digest: ${digest}`
].join("\n");
const signature = await signString(signingString, JSON.parse(user.private_key_jwk) as JsonWebKey);
const headerValue = [
`keyId="${actorUrl(env, user)}#main-key"`,
`algorithm="rsa-sha256"`,
`headers="(request-target) host date digest"`,
`signature="${signature}"`
].join(",");
try {
const response = await fetch(inboxUrl, {
method: "POST",
headers: {
accept: "application/activity+json",
"content-type": "application/activity+json",
date,
digest,
signature: headerValue,
"user-agent": `toot-worker (+https://${hostFromBaseUrl(env)})`
},
body
});
const text = response.ok ? "" : await response.text().catch(() => "");
if (!response.ok) console.warn("signed-delivery", inboxUrl, response.status, text.slice(0, 200));
return { ok: response.ok, status: response.status, text };
} catch (error) {
console.warn("signed-delivery-error", inboxUrl, String(error));
return { ok: false, status: 0, text: String(error) };
}
}
export async function deliverToInboxes(env: Env, user: User, inboxes: Iterable<string>, activity: Json): Promise<void> {
await enqueueOutgoingDeliveries(env, user.id, inboxes, 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[]> {
const rows = await env.DB.prepare(
"SELECT inbox FROM follows WHERE local_user_id = ? AND accepted = 1"
).bind(userId).all<{ inbox: string }>();
const inboxes = new Set<string>();
for (const row of rows.results) {
if (row.inbox) {
const actor = await getActorFromCache(env, row.inbox);
const shared = (await getActorByKeyId(env, row.inbox))?.shared_inbox;
inboxes.add(actor?.shared_inbox ?? shared ?? row.inbox);
}
}
return [...inboxes];
}
export async function resolveDeliveryInboxes(env: Env, actorIds: Iterable<string>): Promise<string[]> {
const inboxes = new Set<string>();
for (const actorId of actorIds) {
const actor = await resolveRemoteActor(env, actorId);
if (!actor) continue;
inboxes.add(actor.shared_inbox ?? actor.inbox);
}
return [...inboxes];
}
export type InboundActivity = {
body: Json;
bodyText: string;
activityId: string;
type: string;
actor: string;
};
export function parseActivity(bodyText: string): InboundActivity | null {
try {
const body = JSON.parse(bodyText) as Json;
return {
body,
bodyText,
activityId: String(body.id ?? ""),
type: String(body.type ?? ""),
actor: typeof body.actor === "string" ? body.actor : String((body.actor as Json | undefined)?.id ?? "")
};
} catch {
return null;
}
}
export function activityObjectField(activity: InboundActivity, field: string): unknown {
const object = activity.body[field];
return object;
}
export function objectAsJson(value: unknown): Json | null {
return value && typeof value === "object" ? value as Json : null;
}
export function objectIdString(value: unknown): string | null {
if (typeof value === "string") return value;
const obj = objectAsJson(value);
if (obj && typeof obj.id === "string") return obj.id;
return null;
}
export async function recordRemoteActivity(env: Env, activity: InboundActivity, verified: boolean): Promise<void> {
await env.DB.prepare(
"INSERT OR IGNORE INTO remote_activities (id, actor, type, payload, received_at) VALUES (?, ?, ?, ?, ?)"
)
.bind(
activity.activityId || crypto.randomUUID(),
activity.actor,
activity.type,
JSON.stringify({ ...activity.body, signature_verified: verified }),
new Date().toISOString()
)
.run();
}
export async function isDuplicateActivity(env: Env, activityId: string): Promise<boolean> {
if (!activityId) return false;
const row = await env.DB.prepare("SELECT id FROM remote_activities WHERE id = ?").bind(activityId).first<{ id: string }>();
return Boolean(row);
}
export async function notifyForLocalStatus(env: Env, status: Status, type: string, actor: string): Promise<void> {
await recordNotification(env, status.user_id, type, actor, status.id);
}
export function mentionAcct(env: Env, actorId: string): string {
return parseAcctFromActor(env, actorId);
}
export async function isFollowedByAnyLocalUser(env: Env, actorId: string): Promise<boolean> {
const row = await env.DB.prepare("SELECT 1 AS hit FROM outgoing_follows WHERE target_actor = ? AND accepted = 1 LIMIT 1").bind(actorId).first<{ hit: number }>();
return Boolean(row);
}
export async function decodeRemoteSignatureBase64(value: string): Promise<Uint8Array> {
return base64Decode(value);
}
export function activitySignableData(input: string): Uint8Array {
return encoder.encode(input);
}