Initial toot-worker implementation

This commit is contained in:
浪子
2026-05-14 09:59:58 +08:00
commit 01880d39a0
19 changed files with 4952 additions and 0 deletions
+532
View File
@@ -0,0 +1,532 @@
import {
deleteActorFromCache,
exportUserPublicKeyPem,
findFavourite,
findReblog,
getStatus,
getStatusByObjectId,
getUserByUsername,
recordNotification,
upsertActorCache
} from "./db";
import {
deliverToInboxes,
isDuplicateActivity,
notifyForLocalStatus,
objectAsJson,
objectIdString,
parseActivity,
recordRemoteActivity,
resolveDeliveryInboxes,
resolveRemoteActor,
sendSignedActivity,
verifyInboundSignature
} from "./federation";
import { activityJson, json } from "./http";
import {
ACTIVITY_CONTEXT,
AVATAR_SVG,
HEADER_SVG,
PUBLIC_COLLECTION,
SECURITY_CONTEXT
} from "./types";
import type { ActorCache, Json, RemoteActor, Status, User } from "./types";
import {
actorUrl,
activityUrl,
baseUrl,
clampLimit,
hostFromBaseUrl,
id,
objectUrl
} from "./util";
export async function webFinger(request: Request, env: Env): Promise<Response> {
const resource = new URL(request.url).searchParams.get("resource") ?? "";
const match = resource.match(/^acct:([^@]+)@(.+)$/);
if (!match) return json({ error: "not_found" }, 404);
if (match[2].toLowerCase() !== hostFromBaseUrl(env).toLowerCase()) return json({ error: "not_found" }, 404);
const user = await getUserByUsername(env, match[1]);
if (!user) return json({ error: "not_found" }, 404);
return json({
subject: `acct:${user.username}@${hostFromBaseUrl(env)}`,
aliases: [actorUrl(env, user)],
links: [
{ rel: "self", type: "application/activity+json", href: actorUrl(env, user) },
{ rel: "http://webfinger.net/rel/profile-page", type: "text/html", href: actorUrl(env, user) }
]
}, 200, { "content-type": "application/jrd+json; charset=utf-8" });
}
export async function hostMeta(env: Env): Promise<Response> {
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
<Link rel="lrdd" template="${baseUrl(env)}/.well-known/webfinger?resource={uri}"/>
</XRD>`;
return new Response(xml, { headers: { "content-type": "application/xrd+xml; charset=utf-8" } });
}
export function nodeInfoLinks(env: Env): Response {
return json({ links: [{ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", href: `${baseUrl(env)}/nodeinfo/2.0` }] });
}
export async function nodeInfo(env: Env): Promise<Response> {
const users = await env.DB.prepare("SELECT COUNT(*) AS count FROM users").first<{ count: number }>();
const posts = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses").first<{ count: number }>();
return json({
version: "2.0",
software: { name: "toot-worker", version: "0.3.0" },
protocols: ["activitypub"],
services: { inbound: [], outbound: [] },
usage: { users: { total: users?.count ?? 0 }, localPosts: posts?.count ?? 0 },
openRegistrations: false,
metadata: { nodeName: env.INSTANCE_NAME, singleUserMode: true }
});
}
export async function actor(env: Env, username: string): Promise<Response> {
const user = await getUserByUsername(env, username);
if (!user) return json({ error: "not_found" }, 404);
return activityJson(await actorDocument(env, user));
}
export async function actorDocument(env: Env, user: User): Promise<Json> {
const url = actorUrl(env, user);
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT, { manuallyApprovesFollowers: "as:manuallyApprovesFollowers" }],
id: url,
type: "Person",
preferredUsername: user.username,
name: user.display_name,
summary: user.note,
url,
inbox: `${url}/inbox`,
outbox: `${url}/outbox`,
followers: `${url}/followers`,
following: `${url}/following`,
endpoints: { sharedInbox: `${baseUrl(env)}/inbox` },
icon: { type: "Image", mediaType: "image/svg+xml", url: `${baseUrl(env)}/avatar.png` },
image: { type: "Image", mediaType: "image/svg+xml", url: `${baseUrl(env)}/header.png` },
manuallyApprovesFollowers: false,
discoverable: true,
publicKey: {
id: `${url}#main-key`,
owner: url,
publicKeyPem: await exportUserPublicKeyPem(user)
}
};
}
export async function outbox(request: Request, env: Env, username: string): Promise<Response> {
const user = await getUserByUsername(env, username);
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 totalItems = totalRow?.count ?? 0;
const base = `${actorUrl(env, user)}/outbox`;
if (!wantsPage) {
return activityJson({
"@context": ACTIVITY_CONTEXT,
id: base,
type: "OrderedCollection",
totalItems,
first: `${base}?page=true`
});
}
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<Status>();
const items = rows.results.map((status) => createActivity(env, user, status));
return activityJson({
"@context": ACTIVITY_CONTEXT,
id: `${base}?page=true`,
partOf: base,
type: "OrderedCollectionPage",
orderedItems: items,
totalItems
});
}
export async function followersCollection(env: Env, username: string): Promise<Response> {
const user = await getUserByUsername(env, username);
if (!user) return json({ error: "not_found" }, 404);
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>();
return activityJson({
"@context": ACTIVITY_CONTEXT,
id: `${actorUrl(env, user)}/followers`,
type: "Collection",
totalItems: row?.count ?? 0
});
}
export async function followingCollection(env: Env, username: string): Promise<Response> {
const user = await getUserByUsername(env, username);
if (!user) return json({ error: "not_found" }, 404);
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM outgoing_follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>();
return activityJson({
"@context": ACTIVITY_CONTEXT,
id: `${actorUrl(env, user)}/following`,
type: "Collection",
totalItems: row?.count ?? 0
});
}
export async function activityObject(env: Env, objectId: string): Promise<Response> {
const status = await getStatus(env, objectId);
if (status) {
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(status.user_id).first<User>();
if (!user) return json({ error: "not_found" }, 404);
return activityJson(noteObject(env, user, status));
}
const tomb = await env.DB.prepare("SELECT * FROM deleted_statuses WHERE id = ?").bind(objectId).first<{ id: string; deleted_at: string }>();
if (tomb) {
return activityJson({
"@context": ACTIVITY_CONTEXT,
id: `${baseUrl(env)}/objects/${tomb.id}`,
type: "Tombstone",
deleted: tomb.deleted_at
}, 410);
}
return json({ error: "not_found" }, 404);
}
export async function inboxHandler(request: Request, env: Env, username: string | null): Promise<Response> {
const localUser = username ? await getUserByUsername(env, username) : null;
if (username && !localUser) return json({ error: "not_found" }, 404);
const bodyText = await request.text();
const activity = parseActivity(bodyText);
if (!activity || !activity.type) return json({ error: "invalid_activity" }, 400);
if (activity.activityId && await isDuplicateActivity(env, activity.activityId)) {
return new Response(null, { status: 202 });
}
const verified = await verifyInboundSignature(request, bodyText, env);
if (!verified) return json({ error: "invalid_signature" }, 401);
if (activity.actor && verified.actor.id !== activity.actor) {
return json({ error: "actor_signature_mismatch" }, 401);
}
await recordRemoteActivity(env, activity, true);
const ctx: InboxContext = {
env,
activity,
actorId: verified.actor.id,
actorCache: verified.actor,
localUser
};
switch (activity.type) {
case "Follow":
return handleFollow(ctx);
case "Undo":
return handleUndo(ctx);
case "Accept":
return handleAccept(ctx);
case "Reject":
return handleReject(ctx);
case "Like":
return handleLike(ctx);
case "Announce":
return handleAnnounce(ctx);
case "Delete":
return handleDelete(ctx);
case "Update":
return handleUpdate(ctx);
case "Create":
return handleCreate(ctx);
default:
return new Response(null, { status: 202 });
}
}
type InboxContext = {
env: Env;
activity: NonNullable<ReturnType<typeof parseActivity>>;
actorId: string;
actorCache: ActorCache;
localUser: User | null;
};
async function handleFollow(ctx: InboxContext): Promise<Response> {
const { env, activity, actorId, actorCache } = ctx;
const object = objectIdString(activity.body.object);
const localUser = await localUserFromTarget(env, object) ?? ctx.localUser;
if (!localUser) return json({ error: "unknown_local_user" }, 404);
const inbox = actorCache.shared_inbox ?? actorCache.inbox;
await env.DB.prepare(
"INSERT OR REPLACE INTO follows (id, follower_actor, local_user_id, inbox, accepted, created_at) VALUES (?, ?, ?, ?, 1, ?)"
).bind(id(), actorId, localUser.id, inbox, new Date().toISOString()).run();
await recordNotification(env, localUser.id, "follow", actorId, null);
await sendSignedActivity(env, localUser, actorCache.inbox, {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: activityUrl(env, id()),
type: "Accept",
actor: actorUrl(env, localUser),
object: activity.body
});
return new Response(null, { status: 202 });
}
async function handleUndo(ctx: InboxContext): Promise<Response> {
const { env, activity, actorId } = ctx;
const inner = objectAsJson(activity.body.object);
if (!inner) return new Response(null, { status: 202 });
const innerType = String(inner.type ?? "");
const localUser = ctx.localUser ?? await localUserFromTarget(env, objectIdString(inner.object));
if (!localUser) return new Response(null, { status: 202 });
if (innerType === "Follow") {
await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ? AND local_user_id = ?").bind(actorId, localUser.id).run();
} else if (innerType === "Like") {
const target = objectIdString(inner.object);
if (target) {
const status = await getStatusByObjectId(env, target);
if (status) await env.DB.prepare("DELETE FROM favourites WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run();
}
} else if (innerType === "Announce") {
const target = objectIdString(inner.object);
if (target) {
const status = await getStatusByObjectId(env, target);
if (status) await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run();
}
}
return new Response(null, { status: 202 });
}
async function handleAccept(ctx: InboxContext): Promise<Response> {
const { env, activity, actorId } = ctx;
const inner = objectAsJson(activity.body.object);
if (!inner || String(inner.type ?? "") !== "Follow") return new Response(null, { status: 202 });
const innerActor = typeof inner.actor === "string" ? inner.actor : String((inner.actor as Json | undefined)?.id ?? "");
const localUser = await localUserFromTarget(env, innerActor);
if (!localUser) return new Response(null, { status: 202 });
await env.DB.prepare(
"UPDATE outgoing_follows SET accepted = 1 WHERE local_user_id = ? AND target_actor = ?"
).bind(localUser.id, actorId).run();
return new Response(null, { status: 202 });
}
async function handleReject(ctx: InboxContext): Promise<Response> {
const { env, actorId } = ctx;
await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run();
return new Response(null, { status: 202 });
}
async function handleLike(ctx: InboxContext): Promise<Response> {
const { env, activity, actorId } = ctx;
const target = objectIdString(activity.body.object);
if (!target) return new Response(null, { status: 202 });
const status = await getStatusByObjectId(env, target);
if (!status) return new Response(null, { status: 202 });
const existing = await findFavourite(env, status.id, actorId);
if (existing) return new Response(null, { status: 202 });
await env.DB.prepare(
"INSERT INTO favourites (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)"
).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run();
await notifyForLocalStatus(env, status, "favourite", actorId);
return new Response(null, { status: 202 });
}
async function handleAnnounce(ctx: InboxContext): Promise<Response> {
const { env, activity, actorId } = ctx;
const target = objectIdString(activity.body.object);
if (!target) return new Response(null, { status: 202 });
const status = await getStatusByObjectId(env, target);
if (!status) return new Response(null, { status: 202 });
const existing = await findReblog(env, status.id, actorId);
if (existing) return new Response(null, { status: 202 });
await env.DB.prepare(
"INSERT INTO reblogs (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)"
).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run();
await notifyForLocalStatus(env, status, "reblog", actorId);
return new Response(null, { status: 202 });
}
async function handleDelete(ctx: InboxContext): Promise<Response> {
const { env, activity, actorId } = ctx;
const target = objectIdString(activity.body.object);
if (!target) return new Response(null, { status: 202 });
if (target === actorId) {
await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ?").bind(actorId).run();
await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run();
await env.DB.prepare("DELETE FROM favourites WHERE actor = ?").bind(actorId).run();
await env.DB.prepare("DELETE FROM reblogs WHERE actor = ?").bind(actorId).run();
await env.DB.prepare("DELETE FROM notifications WHERE actor = ?").bind(actorId).run();
await deleteActorFromCache(env, actorId);
return new Response(null, { status: 202 });
}
await env.DB.prepare("DELETE FROM favourites WHERE actor = ? AND activity_id LIKE ?").bind(actorId, `%${target}%`).run();
return new Response(null, { status: 202 });
}
async function handleUpdate(ctx: InboxContext): Promise<Response> {
const { env, activity, actorId } = ctx;
const obj = objectAsJson(activity.body.object);
if (!obj) return new Response(null, { status: 202 });
if (String(obj.type ?? "") === "Person" && obj.id === actorId) {
await upsertActorCache(env, obj as unknown as RemoteActor);
}
return new Response(null, { status: 202 });
}
async function handleCreate(ctx: InboxContext): Promise<Response> {
const { env, activity, actorId } = ctx;
const obj = objectAsJson(activity.body.object);
if (!obj) return new Response(null, { status: 202 });
const recipients = collectRecipients(activity.body, obj);
const localActorIds = new Set<string>();
for (const target of recipients) {
if (!target.startsWith(baseUrl(env))) continue;
const m = target.match(/\/users\/([^/?#]+)$/);
if (m) localActorIds.add(m[1]);
}
for (const username of localActorIds) {
const localUser = await getUserByUsername(env, username);
if (!localUser) continue;
const inReplyTo = typeof obj.inReplyTo === "string" ? obj.inReplyTo : null;
let statusId: string | null = null;
if (inReplyTo) {
const parent = await getStatusByObjectId(env, inReplyTo);
if (parent) statusId = parent.id;
}
await recordNotification(env, localUser.id, "mention", actorId, statusId);
}
return new Response(null, { status: 202 });
}
function collectRecipients(activity: Json, object: Json): string[] {
const fields: unknown[] = [activity.to, activity.cc, activity.bto, activity.bcc, object.to, object.cc];
const out = new Set<string>();
for (const field of fields) {
if (Array.isArray(field)) {
for (const value of field) {
if (typeof value === "string") out.add(value);
}
} else if (typeof field === "string") {
out.add(field);
}
}
return [...out];
}
async function localUserFromTarget(env: Env, actorId: string | null): Promise<User | null> {
if (!actorId) return null;
if (!actorId.startsWith(baseUrl(env))) return null;
const match = actorId.match(/\/users\/([^/?#]+)$/);
if (!match) return null;
return getUserByUsername(env, match[1]);
}
export function createActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Json {
const to = extra.to ?? [PUBLIC_COLLECTION];
const cc = extra.cc ?? [`${actorUrl(env, user)}/followers`];
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: status.activity_id,
type: "Create",
actor: actorUrl(env, user),
published: status.created_at,
to,
cc,
object: noteObject(env, user, status, { to, cc })
};
}
export function deleteActivity(env: Env, user: User, status: Status): Json {
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: activityUrl(env, id()),
type: "Delete",
actor: actorUrl(env, user),
to: [PUBLIC_COLLECTION],
object: {
id: status.object_id,
type: "Tombstone"
}
};
}
export function followActivity(env: Env, user: User, target: string, activityId: string): Json {
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: activityId,
type: "Follow",
actor: actorUrl(env, user),
object: target
};
}
export function undoActivity(env: Env, user: User, inner: Json): Json {
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: activityUrl(env, id()),
type: "Undo",
actor: actorUrl(env, user),
object: inner
};
}
export function likeActivity(env: Env, user: User, target: string, activityId: string): Json {
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: activityId,
type: "Like",
actor: actorUrl(env, user),
object: target
};
}
export function announceActivity(env: Env, user: User, target: string, activityId: string): Json {
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: activityId,
type: "Announce",
actor: actorUrl(env, user),
published: new Date().toISOString(),
to: [PUBLIC_COLLECTION],
cc: [`${actorUrl(env, user)}/followers`],
object: target
};
}
export function updatePersonActivity(env: Env, user: User, doc: Json): Json {
return {
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
id: activityUrl(env, id()),
type: "Update",
actor: actorUrl(env, user),
to: [PUBLIC_COLLECTION],
object: doc
};
}
export function noteObject(env: Env, user: User, status: Status, opts: { to?: string[]; cc?: string[]; attachments?: Json[]; tag?: Json[] } = {}): Json {
return {
id: status.object_id,
type: "Note",
summary: status.summary || null,
sensitive: Boolean(status.sensitive),
inReplyTo: status.in_reply_to_id ? objectUrl(env, status.in_reply_to_id) : null,
attributedTo: actorUrl(env, user),
content: status.content,
published: status.created_at,
url: status.url,
to: opts.to ?? [PUBLIC_COLLECTION],
cc: opts.cc ?? [`${actorUrl(env, user)}/followers`],
attachment: opts.attachments ?? [],
tag: opts.tag ?? []
};
}
export { AVATAR_SVG, HEADER_SVG };