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 };
+126
View File
@@ -0,0 +1,126 @@
import { base64, base64Decode, base64Url, base64UrlDecode, bytesToArrayBuffer, concatBytes, encoder } from "./util";
const PBKDF2_ITERATIONS = 100_000;
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS);
return `pbkdf2$${PBKDF2_ITERATIONS}$${base64Url(salt)}$${base64Url(hash)}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
if (stored.startsWith("pbkdf2$")) {
const [, itersText, saltPart, hashPart] = stored.split("$");
const iters = Number(itersText);
if (!Number.isFinite(iters) || iters <= 0) return false;
const salt = base64UrlDecode(saltPart);
const hash = await pbkdf2(password, salt, iters);
return constantTimeEqual(base64Url(hash), hashPart);
}
const [saltPart, hashPart] = stored.split(".");
if (!saltPart || !hashPart) return false;
const salt = base64UrlDecode(saltPart);
const digest = await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(concatBytes(salt, encoder.encode(password))));
return constantTimeEqual(base64Url(new Uint8Array(digest)), hashPart);
}
export function passwordNeedsUpgrade(stored: string): boolean {
return !stored.startsWith("pbkdf2$");
}
async function pbkdf2(password: string, salt: Uint8Array, iters: number): Promise<Uint8Array> {
const key = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt: bytesToArrayBuffer(salt), iterations: iters }, key, 32 * 8);
return new Uint8Array(bits);
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export async function digestBase64(value: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
return base64(new Uint8Array(digest));
}
export async function signString(input: string, jwk: JsonWebKey): Promise<string> {
const key = await crypto.subtle.importKey(
"jwk",
jwk,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, encoder.encode(input));
return base64(new Uint8Array(signature));
}
export async function verifyWithPem(pem: string, data: string, signatureBase64: string): Promise<boolean> {
const key = await importSpkiPem(pem);
return await crypto.subtle.verify(
{ name: "RSASSA-PKCS1-v1_5" },
key,
bytesToArrayBuffer(base64Decode(signatureBase64)),
encoder.encode(data)
);
}
export async function exportSpkiPem(jwk: JsonWebKey): Promise<string> {
const key = await crypto.subtle.importKey(
"jwk",
jwk,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
true,
["verify"]
);
const spki = await crypto.subtle.exportKey("spki", key);
return toPem("PUBLIC KEY", spki);
}
export async function importSpkiPem(pem: string): Promise<CryptoKey> {
return await crypto.subtle.importKey(
"spki",
pemToArrayBuffer(pem),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
true,
["verify"]
);
}
function toPem(label: string, key: ArrayBuffer): string {
const b64 = base64(new Uint8Array(key));
const lines = b64.match(/.{1,64}/g)?.join("\n") ?? b64;
return `-----BEGIN ${label}-----\n${lines}\n-----END ${label}-----`;
}
function pemToArrayBuffer(pem: string): ArrayBuffer {
const b64 = pem.replace(/-----BEGIN [^-]+-----/g, "").replace(/-----END [^-]+-----/g, "").replace(/\s+/g, "");
return bytesToArrayBuffer(base64Decode(b64));
}
export type ParsedSignature = {
keyId: string;
algorithm: string | null;
headers: string[];
signature: string;
};
export function parseSignatureHeader(value: string): ParsedSignature | null {
const fields = new Map<string, string>();
const re = /(\w+)="([^"]*)"/g;
let match: RegExpExecArray | null;
while ((match = re.exec(value)) !== null) fields.set(match[1].toLowerCase(), match[2]);
const keyId = fields.get("keyid");
const headers = fields.get("headers");
const signature = fields.get("signature");
if (!keyId || !signature) return null;
return {
keyId,
algorithm: fields.get("algorithm") ?? null,
headers: (headers ?? "(created)").split(/\s+/).map((item) => item.toLowerCase()).filter(Boolean),
signature
};
}
+196
View File
@@ -0,0 +1,196 @@
import { exportSpkiPem, hashPassword } from "./crypto";
import type {
ActorCache,
Favourite,
Follow,
Media,
Notification,
OAuthApp,
OAuthCode,
OutgoingFollow,
Reblog,
RemoteActor,
Status,
User
} from "./types";
import { ACTOR_CACHE_TTL_MS } from "./types";
import { id } from "./util";
export async function ensureAdminUser(env: Env): Promise<void> {
const existing = await env.DB.prepare("SELECT id FROM users WHERE username = ?").bind(env.ADMIN_USERNAME).first<{ id: string }>();
if (existing) return;
const keyPair = await crypto.subtle.generateKey(
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
true,
["sign", "verify"]
) as CryptoKeyPair;
const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
const publicKey = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
const now = new Date().toISOString();
await env.DB.prepare(
"INSERT OR IGNORE INTO users (id, username, display_name, note, password_hash, private_key_jwk, public_key_jwk, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(id(), env.ADMIN_USERNAME, env.ADMIN_USERNAME, "", await hashPassword(env.ADMIN_PASSWORD), JSON.stringify(privateKey), JSON.stringify(publicKey), now)
.run();
}
export async function getUserById(env: Env, userId: string): Promise<User | null> {
return env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(userId).first<User>();
}
export async function getUserByUsername(env: Env, username: string): Promise<User | null> {
return env.DB.prepare("SELECT * FROM users WHERE username = ?").bind(username).first<User>();
}
export async function getUserByIdOrUsername(env: Env, key: string): Promise<User | null> {
return env.DB.prepare("SELECT * FROM users WHERE id = ? OR username = ?").bind(key, key).first<User>();
}
export async function getAdminUser(env: Env): Promise<User> {
const user = await getUserByUsername(env, env.ADMIN_USERNAME);
if (!user) throw new Error("admin_user_missing");
return user;
}
export async function getAppByClientId(env: Env, clientId: string): Promise<OAuthApp | null> {
return env.DB.prepare("SELECT * FROM oauth_apps WHERE client_id = ?").bind(clientId).first<OAuthApp>();
}
export async function takeOAuthCode(env: Env, code: string): Promise<OAuthCode | null> {
const row = await env.DB.prepare("SELECT * FROM oauth_codes WHERE code = ?").bind(code).first<OAuthCode>();
if (!row) return null;
await env.DB.prepare("DELETE FROM oauth_codes WHERE code = ?").bind(code).run();
if (row.expires_at < Math.floor(Date.now() / 1000)) return null;
return row;
}
export async function getStatus(env: Env, statusId: string): Promise<Status | null> {
return env.DB.prepare("SELECT * FROM statuses WHERE id = ?").bind(statusId).first<Status>();
}
export async function getStatusByObjectId(env: Env, objectId: string): Promise<Status | null> {
return env.DB.prepare("SELECT * FROM statuses WHERE object_id = ?").bind(objectId).first<Status>();
}
export async function listMediaForStatus(env: Env, statusId: string): Promise<Media[]> {
const rows = await env.DB.prepare("SELECT * FROM media WHERE status_id = ? ORDER BY created_at ASC").bind(statusId).all<Media>();
return rows.results;
}
export async function listFollowers(env: Env, userId: string): Promise<Follow[]> {
const rows = await env.DB.prepare("SELECT * FROM follows WHERE local_user_id = ? AND accepted = 1").bind(userId).all<Follow>();
return rows.results;
}
export async function countFollowers(env: Env, userId: string): Promise<number> {
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM follows WHERE local_user_id = ? AND accepted = 1").bind(userId).first<{ count: number }>();
return row?.count ?? 0;
}
export async function countFollowing(env: Env, userId: string): Promise<number> {
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM outgoing_follows WHERE local_user_id = ? AND accepted = 1").bind(userId).first<{ count: number }>();
return row?.count ?? 0;
}
export async function countStatuses(env: Env, userId: string): Promise<number> {
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses WHERE user_id = ?").bind(userId).first<{ count: number }>();
return row?.count ?? 0;
}
export async function listFavouritesForStatus(env: Env, statusId: string): Promise<Favourite[]> {
const rows = await env.DB.prepare("SELECT * FROM favourites WHERE status_id = ?").bind(statusId).all<Favourite>();
return rows.results;
}
export async function listReblogsForStatus(env: Env, statusId: string): Promise<Reblog[]> {
const rows = await env.DB.prepare("SELECT * FROM reblogs WHERE status_id = ?").bind(statusId).all<Reblog>();
return rows.results;
}
export async function findFavourite(env: Env, statusId: string, actor: string): Promise<Favourite | null> {
return env.DB.prepare("SELECT * FROM favourites WHERE status_id = ? AND actor = ?").bind(statusId, actor).first<Favourite>();
}
export async function findReblog(env: Env, statusId: string, actor: string): Promise<Reblog | null> {
return env.DB.prepare("SELECT * FROM reblogs WHERE status_id = ? AND actor = ?").bind(statusId, actor).first<Reblog>();
}
export async function findOutgoingFollow(env: Env, userId: string, target: string): Promise<OutgoingFollow | null> {
return env.DB.prepare("SELECT * FROM outgoing_follows WHERE local_user_id = ? AND target_actor = ?").bind(userId, target).first<OutgoingFollow>();
}
export async function recordNotification(env: Env, userId: string, type: string, actor: string, statusId: string | null): Promise<Notification | null> {
if (actor === userId) return null;
const notificationId = id();
const now = new Date().toISOString();
try {
await env.DB.prepare(
"INSERT INTO notifications (id, user_id, type, actor, status_id, read, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
)
.bind(notificationId, userId, type, actor, statusId, now)
.run();
} catch {
return null;
}
return env.DB.prepare("SELECT * FROM notifications WHERE id = ?").bind(notificationId).first<Notification>();
}
export async function getActorFromCache(env: Env, actorId: string): Promise<ActorCache | null> {
return env.DB.prepare("SELECT * FROM actor_cache WHERE id = ?").bind(actorId).first<ActorCache>();
}
export async function getActorByKeyId(env: Env, keyId: string): Promise<ActorCache | null> {
return env.DB.prepare("SELECT * FROM actor_cache WHERE public_key_id = ?").bind(keyId).first<ActorCache>();
}
export async function upsertActorCache(env: Env, actor: RemoteActor): Promise<ActorCache | null> {
if (!actor.id) return null;
const inbox = actor.inbox ?? actor.id;
const sharedInbox = actor.endpoints?.sharedInbox ?? null;
const iconUrl = typeof actor.icon === "string" ? actor.icon : actor.icon?.url ?? null;
const now = new Date().toISOString();
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
inbox = excluded.inbox,
shared_inbox = excluded.shared_inbox,
preferred_username = excluded.preferred_username,
name = excluded.name,
summary = excluded.summary,
icon_url = excluded.icon_url,
public_key_id = excluded.public_key_id,
public_key_pem = excluded.public_key_pem,
fetched_at = excluded.fetched_at`
)
.bind(
actor.id,
inbox,
sharedInbox,
actor.preferredUsername ?? null,
actor.name ?? null,
actor.summary ?? null,
iconUrl,
actor.publicKey?.id ?? null,
actor.publicKey?.publicKeyPem ?? null,
now
)
.run();
return getActorFromCache(env, actor.id);
}
export async function deleteActorFromCache(env: Env, actorId: string): Promise<void> {
await env.DB.prepare("DELETE FROM actor_cache WHERE id = ?").bind(actorId).run();
}
export function actorCacheStale(cache: ActorCache): boolean {
const fetched = Date.parse(cache.fetched_at);
if (!Number.isFinite(fetched)) return true;
return Date.now() - fetched > ACTOR_CACHE_TTL_MS;
}
export async function exportUserPublicKeyPem(user: User): Promise<string> {
return exportSpkiPem(JSON.parse(user.public_key_jwk) as JsonWebKey);
}
+260
View File
@@ -0,0 +1,260 @@
import {
digestBase64,
parseSignatureHeader,
signString,
verifyWithPem
} from "./crypto";
import {
actorCacheStale,
deleteActorFromCache,
getActorByKeyId,
getActorFromCache,
recordNotification,
upsertActorCache
} from "./db";
import type { ActorCache, Json, 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\"";
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 cached;
const fetched = await fetchRemoteActor(actorId);
if (!fetched) return cached;
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> {
const unique = new Set<string>();
for (const inbox of inboxes) {
if (inbox) unique.add(inbox);
}
await Promise.allSettled([...unique].map((inbox) => sendSignedActivity(env, user, inbox, activity)));
}
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 decodeRemoteSignatureBase64(value: string): Promise<Uint8Array> {
return base64Decode(value);
}
export function activitySignableData(input: string): Uint8Array {
return encoder.encode(input);
}
+90
View File
@@ -0,0 +1,90 @@
export class HttpError extends Error {
constructor(readonly status: number, message: string) {
super(message);
}
}
export function json(data: unknown, status = 200, headers: HeadersInit = {}): Response {
return cors(new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json; charset=utf-8", ...headers }
}));
}
export function activityJson(data: unknown, status = 200): Response {
return cors(new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/activity+json; charset=utf-8" }
}));
}
export function html(body: string, status = 200): Response {
return cors(new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } }));
}
export function svgResponse(svg: string): Response {
return cors(new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "public, max-age=3600" } }));
}
export function cors(response: Response): Response {
const headers = new Headers(response.headers);
headers.set("access-control-allow-origin", "*");
headers.set("access-control-allow-methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
headers.set("access-control-allow-headers", "authorization,content-type,accept,digest,signature,date,idempotency-key");
headers.set("access-control-expose-headers", "link,x-ratelimit-remaining,x-ratelimit-reset");
headers.set("x-content-type-options", "nosniff");
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
export type ParsedBody = Record<string, string | string[] | File>;
export async function readBody(request: Request): Promise<ParsedBody> {
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
if (contentType.includes("application/json")) {
try {
const value = (await request.json()) as Record<string, unknown>;
const out: ParsedBody = {};
for (const [key, raw] of Object.entries(value)) {
if (Array.isArray(raw)) out[key] = raw.map(String);
else if (raw !== null && raw !== undefined) out[key] = String(raw);
}
return out;
} catch {
throw new HttpError(400, "invalid_json");
}
}
const form = await request.formData();
const data: ParsedBody = {};
for (const [key, value] of form) {
const cleanKey = key.endsWith("[]") ? key.slice(0, -2) : key;
const normalized = value instanceof File ? value : String(value);
const existing = data[cleanKey];
if (existing === undefined) {
data[cleanKey] = key.endsWith("[]") ? [normalized as string] : normalized;
} else if (Array.isArray(existing)) {
existing.push(normalized as string);
} else {
data[cleanKey] = [existing as string, normalized as string];
}
}
return data;
}
export function bodyString(body: ParsedBody, key: string, fallback = ""): string {
const value = body[key];
if (typeof value === "string") return value;
if (Array.isArray(value) && value.length > 0) return value[0];
return fallback;
}
export function bodyArray(body: ParsedBody, key: string): string[] {
const value = body[key];
if (Array.isArray(value)) return value;
if (typeof value === "string" && value) return [value];
return [];
}
export function bodyFile(body: ParsedBody, key: string): File | null {
const value = body[key];
return value instanceof File ? value : null;
}
+157
View File
@@ -0,0 +1,157 @@
import {
AVATAR_SVG,
HEADER_SVG,
activityObject,
actor,
followersCollection,
followingCollection,
hostMeta,
inboxHandler,
nodeInfo,
nodeInfoLinks,
outbox,
webFinger
} from "./activitypub";
import { ensureAdminUser } from "./db";
import { HttpError, cors, json, svgResponse } from "./http";
import {
accountStatuses,
authorize,
authorizeFollowRequest,
authorizePage,
bookmarkStatus,
createApp,
createStatus,
customEmojis,
deleteStatusEndpoint,
favouriteStatus,
filtersV1,
followAccount,
followRequestsList,
getAccount,
getRelationships,
getStatusEndpoint,
homeTimeline,
instance,
instanceV2,
markersList,
notificationClear,
notificationDismiss,
notificationsList,
publicTimeline,
pushSubscription,
reblogStatus,
rejectFollowRequest,
revoke,
search,
serveMedia,
statusContext,
token,
trendsTags,
unfavouriteStatus,
unfollowAccount,
unreblogStatus,
updateCredentials,
updateMedia,
uploadMedia,
verifyAppCredentials,
verifyCredentials
} from "./mastodon";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
await ensureAdminUser(env);
return await route(request, env);
} catch (error) {
if (error instanceof HttpError) return json({ error: error.message }, error.status);
console.error("unhandled", error);
return json({ error: "internal_server_error" }, 500);
}
}
};
async function route(request: Request, env: Env): Promise<Response> {
if (request.method === "OPTIONS") return cors(new Response(null, { status: 204 }));
const url = new URL(request.url);
const path = url.pathname.replace(/\/+$/, "") || "/";
const method = request.method.toUpperCase();
if (method === "GET" && path === "/") return nodeInfo(env);
if (method === "GET" && path === "/avatar.png") return svgResponse(AVATAR_SVG);
if (method === "GET" && path === "/header.png") return svgResponse(HEADER_SVG);
if (method === "GET" && path === "/.well-known/webfinger") return webFinger(request, env);
if (method === "GET" && path === "/.well-known/nodeinfo") return nodeInfoLinks(env);
if (method === "GET" && path === "/.well-known/host-meta") return hostMeta(env);
if (method === "GET" && path === "/nodeinfo/2.0") return nodeInfo(env);
if (method === "GET" && path === "/api/v1/instance") return instance(env);
if (method === "GET" && path === "/api/v2/instance") return instanceV2(env);
if (method === "POST" && path === "/api/v1/apps") return createApp(request, env);
if (method === "GET" && path === "/api/v1/apps/verify_credentials") return verifyAppCredentials(request, env);
if (method === "GET" && path === "/oauth/authorize") return authorizePage(request, env);
if (method === "POST" && path === "/oauth/authorize") return authorize(request, env);
if (method === "POST" && path === "/oauth/token") return token(request, env);
if (method === "POST" && path === "/oauth/revoke") return revoke(request, env);
if (method === "GET" && path === "/api/v1/accounts/verify_credentials") return verifyCredentials(request, env);
if ((method === "PATCH" || method === "POST") && path === "/api/v1/accounts/update_credentials") return updateCredentials(request, env);
if (method === "GET" && path === "/api/v1/accounts/relationships") return getRelationships(request, env);
if (method === "GET" && path === "/api/v1/accounts/search") return search(request, env);
let m: RegExpMatchArray | null;
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 === "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 === "GET" && path === "/api/v1/follow_requests") return followRequestsList(request, env);
if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/authorize$/))) return authorizeFollowRequest(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/follow_requests\/([^/]+)\/reject$/))) return rejectFollowRequest(request, env, decodeURIComponent(m[1]));
if (method === "POST" && path === "/api/v1/statuses") return createStatus(request, env);
if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return getStatusEndpoint(request, env, decodeURIComponent(m[1]));
if (method === "DELETE" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)$/))) return deleteStatusEndpoint(request, env, decodeURIComponent(m[1]));
if (method === "GET" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/context$/))) return statusContext(env, decodeURIComponent(m[1]), request);
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/favourite$/))) return favouriteStatus(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unfavourite$/))) return unfavouriteStatus(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/reblog$/))) return reblogStatus(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unreblog$/))) return unreblogStatus(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/bookmark$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unbookmark$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/pin$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/api\/v1\/statuses\/([^/]+)\/unpin$/))) return bookmarkStatus(request, env, decodeURIComponent(m[1]));
if (method === "GET" && path === "/api/v1/timelines/public") return publicTimeline(request, env);
if (method === "GET" && path === "/api/v1/timelines/home") return homeTimeline(request, env);
if (method === "POST" && (path === "/api/v1/media" || path === "/api/v2/media")) return uploadMedia(request, env);
if (method === "PUT" && (m = path.match(/^\/api\/v1\/media\/([^/]+)$/))) return updateMedia(request, env, decodeURIComponent(m[1]));
if (method === "GET" && path === "/api/v1/notifications") return notificationsList(request, env);
if (method === "POST" && path === "/api/v1/notifications/clear") return notificationClear(request, env);
if (method === "POST" && (m = path.match(/^\/api\/v1\/notifications\/([^/]+)\/dismiss$/))) return notificationDismiss(request, env, decodeURIComponent(m[1]));
if (method === "GET" && (path === "/api/v2/search" || path === "/api/v1/search")) return search(request, env);
if (method === "GET" && path === "/api/v1/custom_emojis") return customEmojis(env);
if (method === "GET" && path === "/api/v1/filters") return filtersV1(request, env);
if (method === "GET" && path === "/api/v1/trends/tags") return trendsTags(env);
if (method === "GET" && path === "/api/v1/markers") return markersList(request, env);
if (method === "POST" && path === "/api/v1/push/subscription") return pushSubscription();
if (method === "GET" && (m = path.match(/^\/media\/(.+)$/))) return serveMedia(env, m[1]);
if (method === "GET" && (m = path.match(/^\/users\/([^/]+)$/))) return actor(env, decodeURIComponent(m[1]));
if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/outbox$/))) return outbox(request, env, decodeURIComponent(m[1]));
if (method === "POST" && (m = path.match(/^\/users\/([^/]+)\/inbox$/))) return inboxHandler(request, env, decodeURIComponent(m[1]));
if (method === "POST" && path === "/inbox") return inboxHandler(request, env, null);
if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/followers$/))) return followersCollection(env, decodeURIComponent(m[1]));
if (method === "GET" && (m = path.match(/^\/users\/([^/]+)\/following$/))) return followingCollection(env, decodeURIComponent(m[1]));
if (method === "GET" && (m = path.match(/^\/objects\/([^/]+)$/))) return activityObject(env, decodeURIComponent(m[1]));
return json({ error: "not_found" }, 404);
}
+1321
View File
File diff suppressed because it is too large Load Diff
+167
View File
@@ -0,0 +1,167 @@
export type Json = Record<string, unknown>;
export type User = {
id: string;
username: string;
display_name: string;
note: string;
password_hash: string;
private_key_jwk: string;
public_key_jwk: string;
created_at: string;
};
export type OAuthApp = {
id: string;
client_id: string;
client_secret: string;
name: string;
redirect_uri: string;
scopes: string;
website: string | null;
created_at: string;
};
export type OAuthCode = {
code: string;
app_id: string;
user_id: string;
redirect_uri: string;
scopes: string;
expires_at: number;
};
export type Status = {
id: string;
user_id: string;
content: string;
summary: string;
sensitive: number;
language: string;
visibility: string;
in_reply_to_id: string | null;
activity_id: string;
object_id: string;
created_at: string;
url: string;
};
export type DeletedStatus = {
id: string;
user_id: string;
object_id: string;
url: string;
deleted_at: string;
};
export type Media = {
id: string;
user_id: string;
status_id: string | null;
r2_key: string;
mime_type: string;
description: string | null;
size: number;
created_at: string;
};
export type Follow = {
id: string;
follower_actor: string;
local_user_id: string;
inbox: string;
accepted: number;
created_at: string;
};
export type OutgoingFollow = {
id: string;
local_user_id: string;
target_actor: string;
target_inbox: string;
activity_id: string;
accepted: number;
created_at: string;
};
export type Notification = {
id: string;
user_id: string;
type: string;
actor: string;
status_id: string | null;
read: number;
created_at: string;
};
export type Favourite = {
id: string;
status_id: string;
actor: string;
activity_id: string;
created_at: string;
};
export type Reblog = {
id: string;
status_id: string;
actor: string;
activity_id: string;
created_at: string;
};
export type Mention = {
status_id: string;
actor: string;
acct: string;
url: string;
};
export type Hashtag = {
status_id: string;
tag: string;
};
export type ActorCache = {
id: string;
inbox: string;
shared_inbox: string | null;
preferred_username: string | null;
name: string | null;
summary: string | null;
icon_url: string | null;
public_key_id: string | null;
public_key_pem: string | null;
fetched_at: string;
};
export type RemoteActor = {
id: string;
type?: string;
inbox?: string;
endpoints?: { sharedInbox?: string };
preferredUsername?: string;
name?: string;
summary?: string;
icon?: { url?: string } | string;
publicKey?: {
id?: string;
owner?: string;
publicKeyPem?: string;
};
};
export type Session = {
userId: string;
appId: string;
scopes: string;
};
export const ACTIVITY_CONTEXT = "https://www.w3.org/ns/activitystreams";
export const SECURITY_CONTEXT = "https://w3id.org/security/v1";
export const PUBLIC_COLLECTION = "https://www.w3.org/ns/activitystreams#Public";
export const ACTOR_CACHE_TTL_MS = 1000 * 60 * 60 * 24;
export const SIGNATURE_MAX_SKEW_MS = 1000 * 60 * 60 * 12;
export const AVATAR_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160"><rect width="160" height="160" fill="#d8e1e8"/><circle cx="80" cy="56" r="28" fill="#6b7c8f"/><path d="M30 136c10-28 34-42 50-42s40 14 50 42" fill="#6b7c8f"/></svg>`;
export const HEADER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1500 500"><defs><linearGradient id="g" x1="0" x2="1"><stop stop-color="#d8e1e8"/><stop offset="1" stop-color="#8aa0b6"/></linearGradient></defs><rect width="1500" height="500" fill="url(#g)"/><circle cx="220" cy="120" r="90" fill="#f7fbff" fill-opacity=".35"/><circle cx="1280" cy="380" r="120" fill="#f7fbff" fill-opacity=".2"/></svg>`;
+123
View File
@@ -0,0 +1,123 @@
import type { User } from "./types";
export const encoder = new TextEncoder();
export const decoder = new TextDecoder();
export function id(): string {
return crypto.randomUUID();
}
export function tokenString(bytes: number): string {
return base64Url(crypto.getRandomValues(new Uint8Array(bytes)));
}
export function concatBytes(...parts: Uint8Array[]): Uint8Array {
let total = 0;
for (const part of parts) total += part.length;
const out = new Uint8Array(total);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
export function base64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
export function base64Decode(value: string): Uint8Array {
const binary = atob(value);
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
}
export function base64Url(bytes: Uint8Array): string {
return base64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export function base64UrlDecode(value: string): Uint8Array {
const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (value.length % 4)) % 4);
return base64Decode(padded);
}
export function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!);
}
export function safeFileName(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 120) || "upload";
}
export function htmlContent(text: string, mentions: { acct: string; url: string }[] = [], hashtags: string[] = []): string {
let escaped = escapeHtml(text);
for (const mention of mentions) {
const at = escapeHtml(`@${mention.acct}`);
const url = escapeHtml(mention.url);
const localName = mention.acct.split("@")[0];
const span = `<span class="h-card"><a href="${url}" class="u-url mention">@<span>${escapeHtml(localName)}</span></a></span>`;
escaped = escaped.replaceAll(at, span);
}
for (const tag of hashtags) {
const pattern = new RegExp(`#${escapeHtml(tag)}\\b`, "g");
escaped = escaped.replace(pattern, `<a href="#" class="mention hashtag" rel="tag">#<span>${escapeHtml(tag)}</span></a>`);
}
return `<p>${escaped.replace(/\n{2,}/g, "</p><p>").replace(/\n/g, "<br>")}</p>`;
}
export function normalizeArray(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String);
if (typeof value === "string" && value) return [value];
return [];
}
export function clampLimit(value: unknown, fallback: number, max: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(Math.floor(parsed), max);
}
export function baseUrl(env: Env): string {
return env.PUBLIC_BASE_URL.replace(/\/+$/, "");
}
export function hostFromBaseUrl(env: Env): string {
return new URL(baseUrl(env)).host;
}
export function actorUrl(env: Env, user: User): string {
return `${baseUrl(env)}/users/${user.username}`;
}
export function objectUrl(env: Env, statusId: string): string {
return `${baseUrl(env)}/objects/${statusId}`;
}
export function activityUrl(env: Env, activityId: string): string {
return `${baseUrl(env)}/activities/${activityId}`;
}
export function isLocalActor(env: Env, actorId: string): boolean {
try {
return new URL(actorId).host === hostFromBaseUrl(env);
} catch {
return false;
}
}
export function parseAcctFromActor(env: Env, actorId: string): string {
try {
const url = new URL(actorId);
const name = url.pathname.split("/").filter(Boolean).pop() ?? actorId;
if (url.host === hostFromBaseUrl(env)) return name;
return `${name}@${url.host}`;
} catch {
return actorId;
}
}