Files
Toot-Worker/src/mastodon.ts
T
2026-05-14 14:19:23 +08:00

1759 lines
73 KiB
TypeScript

import {
actorDocument,
announceActivity,
createActivity,
deleteActivity,
followActivity,
likeActivity,
undoActivity,
updatePersonActivity
} from "./activitypub";
import { hashPassword, verifyPassword } from "./crypto";
import {
addBookmark,
addPin,
countFollowers,
countFollowing,
countStatuses,
deleteOAuthToken,
findBookmark,
findFavourite,
findOutgoingFollow,
findPin,
findReblog,
getActorByLocalId,
getActorFromCache,
getAdminUser,
getAppByClientId,
getStatus,
getUserById,
getUserByIdOrUsername,
getUserByUsername,
insertOAuthToken,
listCachedStatusAttachments,
listProfileFields,
recordNotification,
removeBookmark,
removePin,
replaceProfileFields,
setUserAvatarKey,
setUserHeaderKey,
takeOAuthCode
} from "./db";
import {
deliverToInboxes,
gatherFollowerInboxes,
resolveDeliveryInboxes,
resolveRemoteActor
} from "./federation";
import {
bodyArray,
bodyString,
cors,
HttpError,
html,
json,
readBody
} from "./http";
import type { ParsedBody } from "./http";
import type {
ActorCache,
CachedStatus,
Follow,
Media,
Mention,
Notification,
Session,
Status,
User
} from "./types";
import {
actorUrl,
activityUrl,
baseUrl,
clampLimit,
escapeHtml,
hostFromBaseUrl,
htmlContent,
id,
isLocalActor,
mediaUrl,
normalizeArray,
objectUrl,
safeFileName,
tokenString
} from "./util";
const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90;
const MAX_STATUS_CHARS = 5000;
const MAX_MEDIA_ATTACHMENTS = 4;
const MAX_MEDIA_BYTES = 10 * 1024 * 1024;
const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"];
function parseRedirectUris(value: string): string[] {
return value.split(/\s+/).map((item) => item.trim()).filter(Boolean);
}
function selectRedirectUri(app: { redirect_uri: string }, requested: string | null | undefined): string | null {
const allowed = parseRedirectUris(app.redirect_uri);
const fallback = allowed[0] ?? "urn:ietf:wg:oauth:2.0:oob";
const candidate = (requested ?? "").trim() || fallback;
return allowed.includes(candidate) ? candidate : null;
}
export async function instance(env: Env): Promise<Response> {
const userCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM users").first<{ count: number }>();
const statusCount = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses").first<{ count: number }>();
const admin = await getAdminUser(env);
return json({
uri: hostFromBaseUrl(env),
title: env.INSTANCE_NAME,
short_description: "A single-user ActivityPub server on Cloudflare Workers.",
description: "A single-user ActivityPub server on Cloudflare Workers.",
email: "",
version: "4.2.0-compatible (toot-worker)",
urls: { streaming_api: `wss://${hostFromBaseUrl(env)}` },
stats: { user_count: userCount?.count ?? 0, status_count: statusCount?.count ?? 0, domain_count: 0 },
languages: ["en"],
registrations: false,
approval_required: false,
invites_enabled: false,
configuration: {
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 },
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 },
polls: { max_options: 4, max_characters_per_option: 50, min_expiration: 300, max_expiration: 2629746 }
},
contact_account: await accountJson(env, admin),
rules: []
});
}
export async function instanceV2(env: Env): Promise<Response> {
const admin = await getAdminUser(env);
return json({
domain: hostFromBaseUrl(env),
title: env.INSTANCE_NAME,
version: "4.2.0-compatible (toot-worker)",
source_url: "https://example.com",
description: "A single-user ActivityPub server on Cloudflare Workers.",
usage: { users: { active_month: 1 } },
thumbnail: { url: `${baseUrl(env)}/header.png` },
languages: ["en"],
configuration: {
urls: { streaming: `wss://${hostFromBaseUrl(env)}` },
accounts: { max_featured_tags: 0 },
statuses: { max_characters: MAX_STATUS_CHARS, max_media_attachments: MAX_MEDIA_ATTACHMENTS, characters_reserved_per_url: 23 },
media_attachments: { supported_mime_types: SUPPORTED_MIME, image_size_limit: MAX_MEDIA_BYTES, image_matrix_limit: 16777216 }
},
registrations: { enabled: false, approval_required: false, message: null },
contact: { email: "", account: await accountJson(env, admin) },
rules: []
});
}
export async function createApp(request: Request, env: Env): Promise<Response> {
const body = await readBody(request);
const now = new Date().toISOString();
const redirectUri = bodyString(body, "redirect_uris", bodyString(body, "redirect_uri", "urn:ietf:wg:oauth:2.0:oob"));
const app = {
id: id(),
client_id: tokenString(32),
client_secret: tokenString(48),
name: bodyString(body, "client_name", bodyString(body, "name", "Mastodon App")),
redirect_uri: redirectUri,
scopes: bodyString(body, "scopes", "read write follow"),
website: bodyString(body, "website", "") || null,
created_at: now
};
await env.DB.prepare(
"INSERT INTO oauth_apps (id, client_id, client_secret, name, redirect_uri, scopes, website, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(app.id, app.client_id, app.client_secret, app.name, app.redirect_uri, app.scopes, app.website, app.created_at)
.run();
return json({
id: app.id,
name: app.name,
website: app.website,
redirect_uri: app.redirect_uri,
client_id: app.client_id,
client_secret: app.client_secret,
vapid_key: ""
});
}
export async function verifyAppCredentials(request: Request, env: Env): Promise<Response> {
const auth = request.headers.get("authorization") ?? "";
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token) throw new HttpError(401, "The access token is invalid");
const session = await env.KV.get<Session>(`token:${token}`, "json");
if (!session) throw new HttpError(401, "The access token is invalid");
const app = await env.DB.prepare("SELECT * FROM oauth_apps WHERE id = ?").bind(session.appId).first<{ name: string; website: string | null }>();
return json({ name: app?.name ?? "Mastodon App", website: app?.website ?? null, vapid_key: "" });
}
export async function authorizePage(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const clientId = url.searchParams.get("client_id");
const app = clientId ? await getAppByClientId(env, clientId) : null;
if (!app) return html("Unknown OAuth application", 400);
const redirectUri = selectRedirectUri(app, url.searchParams.get("redirect_uri"));
if (!redirectUri) return html("Invalid redirect URI", 400);
return html(`<!doctype html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Authorize</title></head>
<body style="font-family:system-ui;margin:2rem;max-width:34rem">
<h1>${escapeHtml(env.INSTANCE_NAME)}</h1>
<p>Authorize ${escapeHtml(app.name)} to access your account.</p>
<form method="post" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${escapeHtml(clientId!)}">
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirectUri)}">
<input type="hidden" name="scope" value="${escapeHtml(url.searchParams.get("scope") ?? app.scopes)}">
<input type="hidden" name="state" value="${escapeHtml(url.searchParams.get("state") ?? "")}">
<label>Username <input name="username" autocomplete="username" value="${escapeHtml(env.ADMIN_USERNAME)}"></label><br><br>
<label>Password <input name="password" type="password" autocomplete="current-password"></label><br><br>
<button>Authorize</button>
</form></body></html>`);
}
export async function authorize(request: Request, env: Env): Promise<Response> {
const body = await readBody(request);
const app = await getAppByClientId(env, bodyString(body, "client_id"));
if (!app) return json({ error: "invalid_client" }, 400);
const redirectUri = selectRedirectUri(app, bodyString(body, "redirect_uri"));
if (!redirectUri) return json({ error: "invalid_request" }, 400);
const user = await getUserByUsername(env, bodyString(body, "username"));
if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) {
return html("Invalid username or password", 401);
}
const code = tokenString(32);
const scope = bodyString(body, "scope", app.scopes);
await env.DB.prepare("INSERT INTO oauth_codes (code, app_id, user_id, redirect_uri, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
.bind(code, app.id, user.id, redirectUri, scope, Math.floor(Date.now() / 1000) + 600)
.run();
if (redirectUri === "urn:ietf:wg:oauth:2.0:oob") return html(`<p>Authorization code:</p><code>${code}</code>`);
const url = new URL(redirectUri);
url.searchParams.set("code", code);
const state = bodyString(body, "state");
if (state) url.searchParams.set("state", state);
return Response.redirect(url.toString(), 302);
}
export async function token(request: Request, env: Env): Promise<Response> {
const body = await readBody(request);
const app = await getAppByClientId(env, bodyString(body, "client_id"));
if (!app || app.client_secret !== bodyString(body, "client_secret")) return json({ error: "invalid_client" }, 401);
const grantType = bodyString(body, "grant_type", "authorization_code");
let userId = "";
let scopes = app.scopes;
if (grantType === "password") {
const user = await getUserByUsername(env, bodyString(body, "username"));
if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) return json({ error: "invalid_grant" }, 400);
userId = user.id;
scopes = bodyString(body, "scope", app.scopes);
} else if (grantType === "client_credentials") {
scopes = bodyString(body, "scope", "read");
} else {
const row = await takeOAuthCode(env, bodyString(body, "code"));
if (!row || row.app_id !== app.id) return json({ error: "invalid_grant" }, 400);
const redirectUri = bodyString(body, "redirect_uri", row.redirect_uri);
if (redirectUri !== row.redirect_uri || !parseRedirectUris(app.redirect_uri).includes(row.redirect_uri)) {
return json({ error: "invalid_grant" }, 400);
}
userId = row.user_id;
scopes = row.scopes;
}
const accessToken = tokenString(48);
await env.KV.put(`token:${accessToken}`, JSON.stringify({ userId, appId: app.id, scopes } satisfies Session), { expirationTtl: TOKEN_TTL_SECONDS });
if (userId) await insertOAuthToken(env, accessToken, userId, app.id, scopes);
return json({ access_token: accessToken, token_type: "Bearer", scope: scopes, created_at: Math.floor(Date.now() / 1000) });
}
export async function revoke(request: Request, env: Env): Promise<Response> {
const body = await readBody(request);
const tokenValue = bodyString(body, "token");
if (tokenValue) {
await env.KV.delete(`token:${tokenValue}`);
await deleteOAuthToken(env, tokenValue);
}
return json({});
}
export async function verifyCredentials(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const account = await accountJson(env, user) as Record<string, unknown>;
const fields = await listProfileFields(env, user.id);
account.source = {
privacy: "public",
sensitive: false,
language: "en",
note: user.note,
fields: fields.map((field) => ({ name: field.name, value: field.value }))
};
return json(account);
}
export async function updateCredentials(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
let form: FormData | null = null;
let body: ParsedBody = {};
if (contentType.includes("multipart/form-data") || contentType.startsWith("application/x-www-form-urlencoded")) {
form = await request.formData();
body = parsedBodyFromForm(form);
} else {
body = await readBody(request);
}
const displayName = bodyString(body, "display_name", user.display_name);
const note = bodyString(body, "note", user.note);
await env.DB.prepare("UPDATE users SET display_name = ?, note = ? WHERE id = ?").bind(displayName, note, user.id).run();
const password = bodyString(body, "password");
if (password) {
const hash = await hashPassword(password);
await env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?").bind(hash, user.id).run();
}
const fields = extractFieldsAttributes(body);
if (fields !== null) {
await replaceProfileFields(env, user.id, fields);
}
if (form) {
const avatar = form.get("avatar");
if (avatar instanceof File && avatar.size > 0) {
if (avatar.size > MAX_MEDIA_BYTES) return json({ error: "avatar too large" }, 413);
const key = await storeProfileAsset(env, user.id, "avatar", avatar);
await setUserAvatarKey(env, user.id, key);
}
const header = form.get("header");
if (header instanceof File && header.size > 0) {
if (header.size > MAX_MEDIA_BYTES) return json({ error: "header too large" }, 413);
const key = await storeProfileAsset(env, user.id, "header", header);
await setUserHeaderKey(env, user.id, key);
}
}
const refreshed = await getUserById(env, user.id);
if (!refreshed) throw new HttpError(500, "user_missing");
const followerInboxes = await gatherFollowerInboxes(env, user.id);
if (followerInboxes.length > 0) {
await deliverToInboxes(env, refreshed, followerInboxes, updatePersonActivity(env, refreshed, await actorDocument(env, refreshed)));
}
return json(await accountJson(env, refreshed));
}
async function storeProfileAsset(env: Env, userId: string, kind: "avatar" | "header", file: File): Promise<string> {
const ext = mimeExtension(file.type) ?? safeFileName(file.name).split(".").pop() ?? "bin";
const key = `${userId}/${kind}-${id()}.${ext}`;
await env.MEDIA.put(key, file.stream(), { httpMetadata: { contentType: file.type || "application/octet-stream" } });
return key;
}
function mimeExtension(mime: string): string | null {
switch (mime) {
case "image/jpeg": case "image/jpg": return "jpg";
case "image/png": return "png";
case "image/gif": return "gif";
case "image/webp": return "webp";
case "image/avif": return "avif";
default: return null;
}
}
function parsedBodyFromForm(form: FormData): ParsedBody {
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;
}
function extractFieldsAttributes(body: ParsedBody): { name: string; value: string }[] | null {
const flat = body["fields_attributes"];
if (Array.isArray(flat)) {
return flat.map((entry) => {
if (typeof entry === "string") {
try {
const parsed = JSON.parse(entry) as { name?: string; value?: string };
return { name: String(parsed.name ?? ""), value: String(parsed.value ?? "") };
} catch {
return { name: entry, value: "" };
}
}
const obj = entry as unknown as { name?: string; value?: string };
return { name: String(obj.name ?? ""), value: String(obj.value ?? "") };
});
}
const indexed: { name: string; value: string }[] = [];
let touched = false;
for (const key of Object.keys(body)) {
const match = key.match(/^fields_attributes\[(\d+)\]\[(name|value)\]$/);
if (!match) continue;
touched = true;
const idx = Number(match[1]);
indexed[idx] = indexed[idx] ?? { name: "", value: "" };
const v = body[key];
indexed[idx][match[2] as "name" | "value"] = typeof v === "string" ? v : Array.isArray(v) ? String(v[0] ?? "") : "";
}
if (!touched) return null;
return indexed.filter(Boolean);
}
export async function getAccount(env: Env, accountId: string): Promise<Response> {
const local = await getUserByIdOrUsername(env, accountId);
if (local) return json(await accountJson(env, local));
const byLocalId = await getActorByLocalId(env, accountId);
if (byLocalId) return json(remoteAccountJson(byLocalId));
if (accountId.startsWith("http://") || accountId.startsWith("https://")) {
const cache = await resolveRemoteActor(env, accountId);
if (cache) return json(remoteAccountJson(cache));
}
return json({ error: "Record not found" }, 404);
}
export async function lookupAccount(request: Request, env: Env): Promise<Response> {
const acct = (new URL(request.url).searchParams.get("acct") ?? "").trim();
if (!acct) return json({ error: "acct parameter is required" }, 422);
const resolved = await resolveAcct(env, acct);
if (!resolved) return json({ error: "Record not found" }, 404);
if (resolved.actorId.startsWith(baseUrl(env))) {
const match = resolved.actorId.match(/\/users\/([^/?#]+)$/);
const user = match ? await getUserByUsername(env, match[1]) : null;
if (!user) return json({ error: "Record not found" }, 404);
return json(await accountJson(env, user));
}
const cache = await resolveRemoteActor(env, resolved.actorId);
if (!cache) return json({ error: "Record not found" }, 404);
return json(remoteAccountJson(cache));
}
export async function accountStatuses(request: Request, env: Env, accountId: string): Promise<Response> {
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
const user = await getUserByIdOrUsername(env, accountId);
if (user) {
const excludeReplies = url.searchParams.get("exclude_replies") === "true";
const where: string[] = ["user_id = ?"];
const binds: unknown[] = [user.id];
if (excludeReplies) where.push("in_reply_to_id IS NULL");
pagedAppend(where, binds, url);
const sql = `SELECT * FROM statuses WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`;
binds.push(limit);
const rows = await env.DB.prepare(sql).bind(...binds).all<Status>();
const items = await serializeStatuses(env, rows.results, request, new Map([[user.id, user]]));
return withPagination(json(items), request, rows.results.map((row) => row.id));
}
const remote = await getActorByLocalId(env, accountId)
?? (accountId.startsWith("http://") || accountId.startsWith("https://") ? await resolveRemoteActor(env, accountId) : null);
if (remote) {
const rows = await env.DB.prepare(
"SELECT * FROM cached_statuses WHERE actor = ? ORDER BY published DESC LIMIT ?"
).bind(remote.id, limit).all<CachedStatus>();
const items = await Promise.all(rows.results.map((row) => cachedStatusToMastodon(env, row)));
return json(items);
}
return json({ error: "Record not found" }, 404);
}
export async function accountFollowers(request: Request, env: Env, accountId: string): Promise<Response> {
const user = await getUserByIdOrUsername(env, accountId);
if (!user) return remoteAccountListFallback(env, accountId);
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 80);
const where: string[] = ["local_user_id = ?", "accepted = 1"];
const binds: unknown[] = [user.id];
pagedAppendForTable(where, binds, url, "follows");
const rows = await env.DB.prepare(
`SELECT * FROM follows WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`
).bind(...binds, limit).all<Follow>();
const accounts = await actorIdsToAccounts(env, rows.results.map((row) => row.follower_actor));
return withPagination(json(accounts), request, rows.results.map((row) => row.id));
}
export async function accountFollowing(request: Request, env: Env, accountId: string): Promise<Response> {
const user = await getUserByIdOrUsername(env, accountId);
if (!user) return remoteAccountListFallback(env, accountId);
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 80);
const where: string[] = ["local_user_id = ?", "accepted = 1"];
const binds: unknown[] = [user.id];
pagedAppendForTable(where, binds, url, "outgoing_follows");
const rows = await env.DB.prepare(
`SELECT * FROM outgoing_follows WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`
).bind(...binds, limit).all<{ id: string; target_actor: string }>();
const accounts = await actorIdsToAccounts(env, rows.results.map((row) => row.target_actor));
return withPagination(json(accounts), request, rows.results.map((row) => row.id));
}
async function remoteAccountListFallback(env: Env, accountId: string): Promise<Response> {
const remote = await getActorByLocalId(env, accountId)
?? (accountId.startsWith("http://") || accountId.startsWith("https://") ? await resolveRemoteActor(env, accountId) : null);
if (remote) return json([]);
return json({ error: "Record not found" }, 404);
}
async function actorIdsToAccounts(env: Env, actorIds: string[]): Promise<Record<string, unknown>[]> {
const accounts = await Promise.all(actorIds.map((actorId) => accountFromActorId(env, actorId)));
return accounts.filter((account): account is Record<string, unknown> => Boolean(account));
}
async function accountFromActorId(env: Env, actorId: string): Promise<Record<string, unknown> | null> {
if (actorId.startsWith(baseUrl(env))) {
const match = actorId.match(/\/users\/([^/?#]+)$/);
const user = match ? await getUserByUsername(env, match[1]) : null;
return user ? accountJson(env, user) : null;
}
const cache = await resolveRemoteActor(env, actorId) ?? await getActorFromCache(env, actorId);
return cache ? remoteAccountJson(cache) : null;
}
export async function createStatus(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const body = await readBody(request);
const statusText = bodyString(body, "status").trim();
if (!statusText) return json({ error: "status can't be blank" }, 422);
if (statusText.length > MAX_STATUS_CHARS) return json({ error: "status too long" }, 422);
const summary = bodyString(body, "spoiler_text");
const sensitive = bodyString(body, "sensitive") === "true";
const visibility = bodyString(body, "visibility", "public");
const inReplyTo = bodyString(body, "in_reply_to_id");
const language = bodyString(body, "language", "en");
const mediaIds = bodyArray(body, "media_ids");
if (mediaIds.length > MAX_MEDIA_ATTACHMENTS) return json({ error: "too_many_attachments" }, 422);
const now = new Date().toISOString();
const statusId = id();
const objectId = objectUrl(env, statusId);
const activityId = activityUrl(env, statusId);
const mentionsAcct = extractMentions(statusText);
const hashtags = extractHashtags(statusText);
const resolvedMentions: { acct: string; actorId: string; url: string }[] = [];
for (const acct of mentionsAcct) {
const resolved = await resolveAcct(env, acct);
if (resolved) resolvedMentions.push(resolved);
}
const renderedContent = htmlContent(statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags);
await env.DB.prepare(
"INSERT INTO statuses (id, user_id, content, summary, sensitive, language, visibility, in_reply_to_id, activity_id, object_id, created_at, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(
statusId,
user.id,
renderedContent,
summary,
sensitive ? 1 : 0,
language,
visibility,
inReplyTo || null,
activityId,
objectId,
now,
objectId
)
.run();
for (const mediaId of mediaIds) {
await env.DB.prepare("UPDATE media SET status_id = ? WHERE id = ? AND user_id = ?").bind(statusId, mediaId, user.id).run();
}
for (const mention of resolvedMentions) {
await env.DB.prepare("INSERT OR IGNORE INTO mentions (status_id, actor, acct, url) VALUES (?, ?, ?, ?)")
.bind(statusId, mention.actorId, mention.acct, mention.url).run();
}
for (const tag of hashtags) {
await env.DB.prepare("INSERT OR IGNORE INTO hashtags (status_id, tag) VALUES (?, ?)").bind(statusId, tag).run();
}
let replyParent: Status | null = null;
if (inReplyTo) {
replyParent = await getStatus(env, inReplyTo);
if (replyParent) {
const parentUser = await getUserById(env, replyParent.user_id);
if (parentUser && parentUser.id !== user.id) {
await recordNotification(env, parentUser.id, "mention", actorUrl(env, user), statusId);
}
}
}
for (const mention of resolvedMentions) {
if (mention.actorId.startsWith(baseUrl(env))) {
const mentionedUser = await getUserByUsername(env, mention.acct.split("@")[0]);
if (mentionedUser && mentionedUser.id !== user.id) {
await recordNotification(env, mentionedUser.id, "mention", actorUrl(env, user), statusId);
}
}
}
const status = await getStatus(env, statusId);
if (!status) throw new HttpError(500, "status_not_found");
if (visibility === "public" || visibility === "unlisted") {
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
for (const mention of resolvedMentions) {
if (!mention.actorId.startsWith(baseUrl(env))) {
const cache = await resolveRemoteActor(env, mention.actorId);
if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox);
}
}
const to = visibility === "public" ? ["https://www.w3.org/ns/activitystreams#Public"] : [`${actorUrl(env, user)}/followers`];
const cc = visibility === "public" ? [`${actorUrl(env, user)}/followers`, ...resolvedMentions.map((m) => m.actorId)] : ["https://www.w3.org/ns/activitystreams#Public", ...resolvedMentions.map((m) => m.actorId)];
const activity = createActivity(env, user, status, { to, cc });
await deliverToInboxes(env, user, inboxes, activity);
} else if (visibility === "direct") {
const inboxes = new Set<string>();
for (const mention of resolvedMentions) {
if (!mention.actorId.startsWith(baseUrl(env))) {
const cache = await resolveRemoteActor(env, mention.actorId);
if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox);
}
}
const activity = createActivity(env, user, status, { to: resolvedMentions.map((m) => m.actorId), cc: [] });
await deliverToInboxes(env, user, inboxes, activity);
}
return json(await statusJson(env, status, user, request));
}
export async function getStatusEndpoint(request: Request, env: Env, statusId: string): Promise<Response> {
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
const user = await getUserById(env, status.user_id);
if (!user) return json({ error: "Record not found" }, 404);
return json(await statusJson(env, status, user, request));
}
export async function deleteStatusEndpoint(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404);
const serialized = await statusJson(env, status, user, request);
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
const mentions = await listMentionsForStatus(env, status.id);
for (const mention of mentions) {
if (!mention.actor.startsWith(baseUrl(env))) {
const cache = await resolveRemoteActor(env, mention.actor);
if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox);
}
}
const media = await env.DB.prepare("SELECT r2_key FROM media WHERE status_id = ?").bind(status.id).all<{ r2_key: string }>();
const deleteResults = await Promise.allSettled(media.results.map((item) => env.MEDIA.delete(item.r2_key)));
for (const result of deleteResults) {
if (result.status === "rejected") console.warn("media-delete-failed", status.id, String(result.reason));
}
await env.DB.prepare(
"INSERT INTO deleted_statuses (id, user_id, object_id, url, deleted_at) VALUES (?, ?, ?, ?, ?)"
).bind(status.id, user.id, status.object_id, status.url, new Date().toISOString()).run();
await env.DB.prepare("DELETE FROM statuses WHERE id = ?").bind(status.id).run();
await env.DB.prepare("DELETE FROM media WHERE status_id = ?").bind(status.id).run();
await env.DB.prepare("DELETE FROM mentions WHERE status_id = ?").bind(status.id).run();
await env.DB.prepare("DELETE FROM hashtags WHERE status_id = ?").bind(status.id).run();
await env.DB.prepare("DELETE FROM favourites WHERE status_id = ?").bind(status.id).run();
await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ?").bind(status.id).run();
await env.DB.prepare("DELETE FROM notifications WHERE status_id = ?").bind(status.id).run();
await deliverToInboxes(env, user, inboxes, deleteActivity(env, user, status));
return json(serialized);
}
export async function statusContext(env: Env, statusId: string, request: Request): Promise<Response> {
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
const ancestors: Status[] = [];
let cursor = status.in_reply_to_id;
while (cursor) {
const parent = await getStatus(env, cursor);
if (!parent) break;
ancestors.unshift(parent);
cursor = parent.in_reply_to_id;
}
const descRows = await env.DB.prepare("SELECT * FROM statuses WHERE in_reply_to_id = ? ORDER BY created_at ASC LIMIT 40").bind(statusId).all<Status>();
const serialized = await serializeStatuses(env, [...ancestors, ...descRows.results], request);
const byId = new Map(serialized.map((item) => [String(item.id), item]));
return json({
ancestors: ancestors.map((item) => byId.get(item.id)).filter(Boolean),
descendants: descRows.results.map((item) => byId.get(item.id)).filter(Boolean)
});
}
export async function favouriteStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
const actor = actorUrl(env, user);
const existing = await findFavourite(env, status.id, actor);
if (!existing) {
const activityId = activityUrl(env, id());
await env.DB.prepare(
"INSERT INTO favourites (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)"
).bind(id(), status.id, actor, activityId, new Date().toISOString()).run();
const owner = await getUserById(env, status.user_id);
if (owner && owner.id !== user.id) {
await recordNotification(env, owner.id, "favourite", actor, status.id);
}
if (!isLocalActor(env, status.object_id)) {
const inboxes = await resolveDeliveryInboxes(env, [status.object_id]);
await deliverToInboxes(env, user, inboxes, likeActivity(env, user, status.object_id, activityId));
}
}
const owner = await getUserById(env, status.user_id);
if (!owner) throw new HttpError(500, "owner_missing");
return json(await statusJson(env, status, owner, request));
}
export async function unfavouriteStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
const actor = actorUrl(env, user);
const existing = await findFavourite(env, status.id, actor);
if (existing) {
await env.DB.prepare("DELETE FROM favourites WHERE id = ?").bind(existing.id).run();
if (!isLocalActor(env, status.object_id)) {
const inboxes = await resolveDeliveryInboxes(env, [status.object_id]);
await deliverToInboxes(env, user, inboxes, undoActivity(env, user, likeActivity(env, user, status.object_id, existing.activity_id)));
}
}
const owner = await getUserById(env, status.user_id);
if (!owner) throw new HttpError(500, "owner_missing");
return json(await statusJson(env, status, owner, request));
}
export async function reblogStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
const actor = actorUrl(env, user);
const existing = await findReblog(env, status.id, actor);
if (!existing) {
const activityId = activityUrl(env, id());
await env.DB.prepare(
"INSERT INTO reblogs (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)"
).bind(id(), status.id, actor, activityId, new Date().toISOString()).run();
const owner = await getUserById(env, status.user_id);
if (owner && owner.id !== user.id) {
await recordNotification(env, owner.id, "reblog", actor, status.id);
}
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
if (!isLocalActor(env, status.object_id)) {
for (const inbox of await resolveDeliveryInboxes(env, [status.object_id])) inboxes.add(inbox);
}
await deliverToInboxes(env, user, inboxes, announceActivity(env, user, status.object_id, activityId));
}
const owner = await getUserById(env, status.user_id);
if (!owner) throw new HttpError(500, "owner_missing");
return json(await statusJson(env, status, owner, request));
}
export async function unreblogStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
const actor = actorUrl(env, user);
const existing = await findReblog(env, status.id, actor);
if (existing) {
await env.DB.prepare("DELETE FROM reblogs WHERE id = ?").bind(existing.id).run();
const inboxes = new Set<string>(await gatherFollowerInboxes(env, user.id));
if (!isLocalActor(env, status.object_id)) {
for (const inbox of await resolveDeliveryInboxes(env, [status.object_id])) inboxes.add(inbox);
}
await deliverToInboxes(env, user, inboxes, undoActivity(env, user, announceActivity(env, user, status.object_id, existing.activity_id)));
}
const owner = await getUserById(env, status.user_id);
if (!owner) throw new HttpError(500, "owner_missing");
return json(await statusJson(env, status, owner, request));
}
export async function bookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
await addBookmark(env, user.id, status.id);
const owner = await getUserById(env, status.user_id);
if (!owner) throw new HttpError(500, "owner_missing");
return json(await statusJson(env, status, owner, request));
}
export async function unbookmarkStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
if (!status) return json({ error: "Record not found" }, 404);
await removeBookmark(env, user.id, status.id);
const owner = await getUserById(env, status.user_id);
if (!owner) throw new HttpError(500, "owner_missing");
return json(await statusJson(env, status, owner, request));
}
export async function pinStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404);
await addPin(env, user.id, status.id);
return json(await statusJson(env, status, user, request));
}
export async function unpinStatus(request: Request, env: Env, statusId: string): Promise<Response> {
const user = await requireUser(request, env);
const status = await getStatus(env, statusId);
if (!status || status.user_id !== user.id) return json({ error: "Record not found" }, 404);
await removePin(env, user.id, status.id);
return json(await statusJson(env, status, user, request));
}
export async function bookmarksList(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
const rows = await env.DB.prepare(
`SELECT s.* FROM statuses s INNER JOIN bookmarks b ON b.status_id = s.id
WHERE b.user_id = ? ORDER BY b.created_at DESC LIMIT ?`
).bind(user.id, limit).all<Status>();
const items = await serializeStatuses(env, rows.results, request);
return withPagination(json(items), request, rows.results.map((s) => s.id));
}
export async function favouritesList(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
const actor = actorUrl(env, user);
const rows = await env.DB.prepare(
`SELECT s.* FROM statuses s INNER JOIN favourites f ON f.status_id = s.id
WHERE f.actor = ? ORDER BY f.created_at DESC LIMIT ?`
).bind(actor, limit).all<Status>();
const items = await serializeStatuses(env, rows.results, request);
return withPagination(json(items), request, rows.results.map((s) => s.id));
}
export async function publicTimeline(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
const where: string[] = ["visibility = 'public'"];
const binds: unknown[] = [];
pagedAppend(where, binds, url);
const sql = `SELECT * FROM statuses WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`;
binds.push(limit);
const rows = await env.DB.prepare(sql).bind(...binds).all<Status>();
const items = await serializeStatuses(env, rows.results, request);
return withPagination(json(items), request, rows.results.map((s) => s.id));
}
export async function homeTimeline(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
const localRows = await env.DB.prepare(
"SELECT * FROM statuses WHERE user_id = ? ORDER BY created_at DESC LIMIT ?"
).bind(user.id, limit).all<Status>();
const cachedRows = await env.DB.prepare(
`SELECT cs.* FROM cached_statuses cs
INNER JOIN outgoing_follows of ON of.target_actor = cs.actor
WHERE of.local_user_id = ? AND of.accepted = 1
ORDER BY cs.published DESC LIMIT ?`
).bind(user.id, limit).all<CachedStatus>();
const localItems = await serializeStatuses(env, localRows.results, request);
const cachedItems = await Promise.all(cachedRows.results.map((row) => cachedStatusToMastodon(env, row)));
const merged = [...localItems, ...cachedItems].sort((a, b) => {
const at = String(a.created_at ?? "");
const bt = String(b.created_at ?? "");
return bt.localeCompare(at);
}).slice(0, limit);
return json(merged);
}
export async function hashtagTimeline(request: Request, env: Env, tag: string): Promise<Response> {
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 20, 40);
const where: string[] = ["s.visibility = 'public'", "h.tag = ?"];
const binds: unknown[] = [tag.toLowerCase()];
const maxId = url.searchParams.get("max_id");
if (maxId) { where.push("s.created_at < (SELECT created_at FROM statuses WHERE id = ?)"); binds.push(maxId); }
const sinceId = url.searchParams.get("since_id");
if (sinceId) { where.push("s.created_at > (SELECT created_at FROM statuses WHERE id = ?)"); binds.push(sinceId); }
const minId = url.searchParams.get("min_id");
if (minId) { where.push("s.created_at > (SELECT created_at FROM statuses WHERE id = ?)"); binds.push(minId); }
const sql = `SELECT s.* FROM statuses s INNER JOIN hashtags h ON h.status_id = s.id WHERE ${where.join(" AND ")} ORDER BY s.created_at DESC LIMIT ?`;
binds.push(limit);
const rows = await env.DB.prepare(sql).bind(...binds).all<Status>();
const items = await serializeStatuses(env, rows.results, request);
return withPagination(json(items), request, rows.results.map((s) => s.id));
}
export async function hashtagInfo(env: Env, tag: string): Promise<Response> {
const normalized = tag.toLowerCase();
const row = await env.DB.prepare("SELECT COUNT(DISTINCT status_id) AS count FROM hashtags WHERE tag = ?").bind(normalized).first<{ count: number }>();
return json({
name: normalized,
url: `${baseUrl(env)}/tags/${encodeURIComponent(normalized)}`,
history: [],
following: false,
statuses_count: row?.count ?? 0
});
}
export async function uploadMedia(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const form = await request.formData();
const file = form.get("file");
if (!(file instanceof File)) return json({ error: "file is required" }, 422);
if (file.size > MAX_MEDIA_BYTES) return json({ error: "file too large" }, 413);
const mediaId = id();
const key = `${user.id}/${mediaId}/${safeFileName(file.name || "upload")}`;
await env.MEDIA.put(key, file.stream(), { httpMetadata: { contentType: file.type || "application/octet-stream" } });
await env.DB.prepare(
"INSERT INTO media (id, user_id, status_id, r2_key, mime_type, description, size, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)
.bind(mediaId, user.id, null, key, file.type || "application/octet-stream", form.get("description")?.toString() ?? null, file.size, new Date().toISOString())
.run();
const media = await env.DB.prepare("SELECT * FROM media WHERE id = ?").bind(mediaId).first<Media>();
return json(mediaJson(env, media!), 200);
}
export async function updateMedia(request: Request, env: Env, mediaId: string): Promise<Response> {
const user = await requireUser(request, env);
const body = await readBody(request);
const description = bodyString(body, "description", "");
await env.DB.prepare("UPDATE media SET description = ? WHERE id = ? AND user_id = ?").bind(description, mediaId, user.id).run();
const media = await env.DB.prepare("SELECT * FROM media WHERE id = ?").bind(mediaId).first<Media>();
if (!media) return json({ error: "Record not found" }, 404);
return json(mediaJson(env, media));
}
export async function serveMedia(env: Env, key: string): Promise<Response> {
const object = await env.MEDIA.get(decodeURIComponent(key));
if (!object) return new Response("Not found", { status: 404 });
return cors(new Response(object.body, {
headers: {
"content-type": object.httpMetadata?.contentType ?? "application/octet-stream",
"cache-control": "public, max-age=86400"
}
}));
}
export async function notificationsList(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const url = new URL(request.url);
const limit = clampLimit(url.searchParams.get("limit"), 15, 80);
const types = normalizeArray(url.searchParams.getAll("types[]"));
const excludeTypes = normalizeArray(url.searchParams.getAll("exclude_types[]"));
const where: string[] = ["user_id = ?"];
const binds: unknown[] = [user.id];
if (types.length > 0) {
where.push(`type IN (${types.map(() => "?").join(",")})`);
binds.push(...types);
}
if (excludeTypes.length > 0) {
where.push(`type NOT IN (${excludeTypes.map(() => "?").join(",")})`);
binds.push(...excludeTypes);
}
const maxId = url.searchParams.get("max_id");
if (maxId) {
where.push("created_at < (SELECT created_at FROM notifications WHERE id = ?)");
binds.push(maxId);
}
const sinceId = url.searchParams.get("since_id");
if (sinceId) {
where.push("created_at > (SELECT created_at FROM notifications WHERE id = ?)");
binds.push(sinceId);
}
const sql = `SELECT * FROM notifications WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`;
binds.push(limit);
const rows = await env.DB.prepare(sql).bind(...binds).all<Notification>();
const out = await serializeNotifications(env, rows.results, request);
return withPagination(json(out), request, rows.results.map((n) => n.id));
}
export async function notificationClear(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
await env.DB.prepare("DELETE FROM notifications WHERE user_id = ?").bind(user.id).run();
return json({});
}
export async function notificationDismiss(request: Request, env: Env, notificationId: string): Promise<Response> {
const user = await requireUser(request, env);
await env.DB.prepare("DELETE FROM notifications WHERE id = ? AND user_id = ?").bind(notificationId, user.id).run();
return json({});
}
export async function getRelationships(request: Request, env: Env): Promise<Response> {
const user = await requireUser(request, env);
const url = new URL(request.url);
const ids = url.searchParams.getAll("id[]").concat(url.searchParams.getAll("id"));
const out = [];
for (const target of ids) {
out.push(await relationshipFor(env, user, target));
}
return json(out);
}
export async function followAccount(request: Request, env: Env, accountId: string): Promise<Response> {
const user = await requireUser(request, env);
const target = await resolveAccountTarget(env, accountId);
if (!target) return json({ error: "Record not found" }, 404);
if (target.kind === "local") {
await env.DB.prepare(
"INSERT OR REPLACE INTO outgoing_follows (id, local_user_id, target_actor, target_inbox, activity_id, accepted, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)"
).bind(id(), user.id, target.actorId, `${target.actorId}/inbox`, "", new Date().toISOString()).run();
await env.DB.prepare(
"INSERT OR REPLACE INTO follows (id, follower_actor, local_user_id, inbox, accepted, created_at) VALUES (?, ?, ?, ?, 1, ?)"
).bind(id(), actorUrl(env, user), target.userId, `${actorUrl(env, user)}/inbox`, new Date().toISOString()).run();
} else {
const activityId = activityUrl(env, id());
const cache = await resolveRemoteActor(env, target.actorId);
if (!cache) return json({ error: "remote_actor_unreachable" }, 502);
await env.DB.prepare(
"INSERT OR REPLACE INTO outgoing_follows (id, local_user_id, target_actor, target_inbox, activity_id, accepted, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)"
).bind(id(), user.id, cache.id, cache.inbox, activityId, new Date().toISOString()).run();
await deliverToInboxes(env, user, [cache.inbox], followActivity(env, user, cache.id, activityId));
}
return json(await relationshipFor(env, user, accountId));
}
export async function unfollowAccount(request: Request, env: Env, accountId: string): Promise<Response> {
const user = await requireUser(request, env);
const target = await resolveAccountTarget(env, accountId);
if (!target) return json({ error: "Record not found" }, 404);
const existing = await findOutgoingFollow(env, user.id, target.actorId);
await env.DB.prepare("DELETE FROM outgoing_follows WHERE local_user_id = ? AND target_actor = ?").bind(user.id, target.actorId).run();
if (target.kind === "local") {
await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ? AND local_user_id = ?").bind(actorUrl(env, user), target.userId).run();
} else if (existing) {
const cache = await resolveRemoteActor(env, target.actorId);
if (cache) {
await deliverToInboxes(env, user, [cache.inbox], undoActivity(env, user, followActivity(env, user, target.actorId, existing.activity_id)));
}
}
return json(await relationshipFor(env, user, accountId));
}
export async function followRequestsList(request: Request, env: Env): Promise<Response> {
await requireUser(request, env);
return json([]);
}
export async function authorizeFollowRequest(request: Request, env: Env, _accountId: string): Promise<Response> {
await requireUser(request, env);
return json({ id: _accountId, following: true, requested: false });
}
export async function rejectFollowRequest(request: Request, env: Env, _accountId: string): Promise<Response> {
await requireUser(request, env);
return json({ id: _accountId, following: false, requested: false });
}
export async function search(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const q = (url.searchParams.get("q") ?? "").trim();
const type = url.searchParams.get("type");
const accounts: unknown[] = [];
const statuses: unknown[] = [];
const hashtags: unknown[] = [];
if (!q) return json({ accounts, statuses, hashtags });
if (!type || type === "accounts") {
if (q.startsWith("@") || q.includes("@")) {
const acct = q.replace(/^@/, "");
const resolved = await resolveAcct(env, acct);
if (resolved) {
if (resolved.actorId.startsWith(baseUrl(env))) {
const local = await getUserByUsername(env, resolved.acct.split("@")[0]);
if (local) accounts.push(await accountJson(env, local));
} else {
const cache = await resolveRemoteActor(env, resolved.actorId);
if (cache) accounts.push(remoteAccountJson(cache));
}
}
} else {
const rows = await env.DB.prepare("SELECT * FROM users WHERE username LIKE ? LIMIT 20").bind(`%${q}%`).all<User>();
for (const row of rows.results) accounts.push(await accountJson(env, row));
}
}
if (!type || type === "statuses") {
const rows = await env.DB.prepare("SELECT * FROM statuses WHERE content LIKE ? ORDER BY created_at DESC LIMIT 20").bind(`%${escapeHtml(q)}%`).all<Status>();
statuses.push(...await serializeStatuses(env, rows.results, request));
}
if (!type || type === "hashtags") {
const tag = q.replace(/^#/, "");
const rows = await env.DB.prepare("SELECT tag, COUNT(*) AS count FROM hashtags WHERE tag LIKE ? GROUP BY tag LIMIT 20").bind(`%${tag}%`).all<{ tag: string; count: number }>();
for (const row of rows.results) hashtags.push({ name: row.tag, url: `${baseUrl(env)}/tags/${encodeURIComponent(row.tag)}`, history: [] });
}
return json({ accounts, statuses, hashtags });
}
export async function customEmojis(env: Env): Promise<Response> {
void env;
return json([]);
}
export async function filtersV1(_request: Request, env: Env): Promise<Response> {
void env;
return json([]);
}
export async function trendsTags(env: Env): Promise<Response> {
void env;
return json([]);
}
export async function pushSubscription(): Promise<Response> {
return json({ error: "push subscriptions not supported" }, 422);
}
export async function markersList(request: Request, env: Env): Promise<Response> {
void request; void env;
return json({});
}
type StatusSerializationContext = {
usersById: Map<string, User>;
accountByUserId: Map<string, Record<string, unknown>>;
mediaByStatusId: Map<string, Media[]>;
mentionsByStatusId: Map<string, Mention[]>;
hashtagsByStatusId: Map<string, string[]>;
favouriteCountByStatusId: Map<string, number>;
favouritedStatusIds: Set<string>;
reblogCountByStatusId: Map<string, number>;
rebloggedStatusIds: Set<string>;
replyCountByStatusId: Map<string, number>;
bookmarkedStatusIds: Set<string>;
pinnedStatusIds: Set<string>;
};
async function cachedStatusToMastodon(env: Env, row: CachedStatus): Promise<Record<string, unknown>> {
const cache = await resolveRemoteActor(env, row.actor);
const account = cache ? remoteAccountJson(cache) : { id: row.actor, acct: row.actor, username: row.actor };
const attachments = await listCachedStatusAttachments(env, row.id);
return {
id: row.object_id,
uri: row.object_id,
url: row.url,
account,
in_reply_to_id: null,
in_reply_to_account_id: null,
content: row.content,
text: row.content,
created_at: row.published,
edited_at: null,
visibility: "public",
language: row.language,
sensitive: Boolean(row.sensitive),
spoiler_text: row.summary,
media_attachments: attachments.map((att) => ({
id: `${row.id}:${att.position}`,
type: att.mime_type.startsWith("image/") ? "image"
: att.mime_type.startsWith("video/") ? "video"
: att.mime_type.startsWith("audio/") ? "audio" : "unknown",
url: att.url,
preview_url: att.preview_url ?? att.url,
remote_url: att.url,
text_url: null,
meta: {},
description: att.description,
blurhash: null
})),
mentions: [],
tags: [],
emojis: [],
reblogs_count: 0,
favourites_count: 0,
replies_count: 0,
reblog: null,
application: null,
favourited: false,
reblogged: false,
muted: false,
bookmarked: false,
pinned: false,
card: null,
poll: null
};
}
async function statusJson(
env: Env,
status: Status,
user: User,
request: Request,
context?: StatusSerializationContext
): Promise<Record<string, unknown>> {
const resolvedContext = context ?? await buildStatusSerializationContext(env, [status], request, new Map([[user.id, user]]));
return statusRecord(env, status, user, resolvedContext);
}
function statusRecord(env: Env, status: Status, user: User, context: StatusSerializationContext): Record<string, unknown> {
const media = context.mediaByStatusId.get(status.id) ?? [];
const mentions = context.mentionsByStatusId.get(status.id) ?? [];
const tags = context.hashtagsByStatusId.get(status.id) ?? [];
return {
id: status.id,
uri: status.object_id,
url: status.url,
account: context.accountByUserId.get(user.id) ?? {
id: user.id,
username: user.username,
acct: user.username,
display_name: user.display_name
},
in_reply_to_id: status.in_reply_to_id,
in_reply_to_account_id: null,
content: status.content,
text: status.content,
created_at: status.created_at,
edited_at: null,
visibility: status.visibility,
language: status.language,
sensitive: Boolean(status.sensitive),
spoiler_text: status.summary,
media_attachments: media.map((item) => mediaJson(env, item)),
mentions: mentions.map((mention) => ({
id: mention.actor,
username: mention.acct.split("@")[0],
acct: mention.acct,
url: mention.url
})),
tags: tags.map((tag) => ({ name: tag, url: `${baseUrl(env)}/tags/${encodeURIComponent(tag)}` })),
emojis: [],
reblogs_count: context.reblogCountByStatusId.get(status.id) ?? 0,
favourites_count: context.favouriteCountByStatusId.get(status.id) ?? 0,
replies_count: context.replyCountByStatusId.get(status.id) ?? 0,
reblog: null,
application: { name: "Toot Worker", website: null },
favourited: context.favouritedStatusIds.has(status.id),
reblogged: context.rebloggedStatusIds.has(status.id),
muted: false,
bookmarked: context.bookmarkedStatusIds.has(status.id),
pinned: context.pinnedStatusIds.has(status.id),
card: null,
poll: null
};
}
async function serializeStatuses(
env: Env,
statuses: Status[],
request: Request,
usersById?: Map<string, User>
): Promise<Record<string, unknown>[]> {
if (statuses.length === 0) return [];
const context = await buildStatusSerializationContext(env, statuses, request, usersById);
return statuses.flatMap((status) => {
const user = context.usersById.get(status.user_id);
return user ? [statusRecord(env, status, user, context)] : [];
});
}
async function buildStatusSerializationContext(
env: Env,
statuses: Status[],
request: Request,
initialUsersById: Map<string, User> = new Map()
): Promise<StatusSerializationContext> {
const statusIds = uniqueStrings(statuses.map((status) => status.id));
const usersById = new Map(initialUsersById);
const missingUserIds = uniqueStrings(statuses.map((status) => status.user_id).filter((userId) => !usersById.has(userId)));
if (missingUserIds.length > 0) {
for (const user of await loadUsersByIds(env, missingUserIds)) usersById.set(user.id, user);
}
const viewer = await viewerActor(request, env);
const viewerId = await viewerUserId(request, env);
const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId, bookmarkedStatusIds, pinnedStatusIds] = await Promise.all([
loadMediaByStatusIds(env, statusIds),
loadMentionsByStatusIds(env, statusIds),
loadHashtagsByStatusIds(env, statusIds),
loadStatusInteractionSummary(env, "favourites", statusIds, viewer),
loadStatusInteractionSummary(env, "reblogs", statusIds, viewer),
loadReplyCountByStatusIds(env, statusIds),
viewerId ? loadBookmarkedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set<string>()),
viewerId ? loadPinnedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set<string>())
]);
const accountByUserId = new Map<string, Record<string, unknown>>();
for (const user of usersById.values()) {
accountByUserId.set(user.id, await accountJson(env, user));
}
return {
usersById,
accountByUserId,
mediaByStatusId,
mentionsByStatusId,
hashtagsByStatusId,
favouriteCountByStatusId: favouriteSummary.countByStatusId,
favouritedStatusIds: favouriteSummary.viewerMatchedStatusIds,
reblogCountByStatusId: reblogSummary.countByStatusId,
rebloggedStatusIds: reblogSummary.viewerMatchedStatusIds,
replyCountByStatusId,
bookmarkedStatusIds,
pinnedStatusIds
};
}
async function accountJson(env: Env, user: User): Promise<Record<string, unknown>> {
const [followersCount, followingCount, statusesCount, fields] = await Promise.all([
countFollowers(env, user.id),
countFollowing(env, user.id),
countStatuses(env, user.id),
listProfileFields(env, user.id)
]);
const acct = `${user.username}`;
const avatar = user.avatar_r2_key ? mediaUrl(env, user.avatar_r2_key) : `${baseUrl(env)}/avatar.png`;
const header = user.header_r2_key ? mediaUrl(env, user.header_r2_key) : `${baseUrl(env)}/header.png`;
return {
id: user.id,
username: user.username,
acct,
display_name: user.display_name,
locked: false,
bot: false,
discoverable: true,
group: false,
created_at: user.created_at,
note: user.note,
url: actorUrl(env, user),
avatar,
avatar_static: avatar,
header,
header_static: header,
followers_count: followersCount,
following_count: followingCount,
statuses_count: statusesCount,
last_status_at: null,
emojis: [],
fields: fields.map((field) => ({ name: field.name, value: field.value, verified_at: null }))
};
}
function remoteAccountJson(cache: ActorCache): Record<string, unknown> {
const host = (() => { try { return new URL(cache.id).host; } catch { return "remote"; } })();
const username = cache.preferred_username ?? cache.id.split("/").pop() ?? "user";
return {
id: cache.local_id ?? cache.id,
username,
acct: `${username}@${host}`,
display_name: cache.name ?? username,
locked: false,
bot: false,
discoverable: true,
group: false,
created_at: cache.fetched_at,
note: cache.summary ?? "",
url: cache.id,
avatar: cache.icon_url ?? "",
avatar_static: cache.icon_url ?? "",
header: "",
header_static: "",
followers_count: 0,
following_count: 0,
statuses_count: 0,
last_status_at: null,
emojis: [],
fields: []
};
}
function mediaJson(env: Env, media: Media): Record<string, unknown> {
const url = mediaUrl(env, media.r2_key);
return {
id: media.id,
type: media.mime_type.startsWith("image/") ? "image" : media.mime_type.startsWith("video/") ? "video" : "unknown",
url,
preview_url: url,
remote_url: null,
text_url: null,
meta: {},
description: media.description,
blurhash: null
};
}
async function relationshipFor(env: Env, user: User, target: string): Promise<Record<string, unknown>> {
const resolved = await resolveAccountTarget(env, target);
const actorId = resolved?.actorId ?? target;
const outgoing = await findOutgoingFollow(env, user.id, actorId);
const incoming = await env.DB.prepare("SELECT * FROM follows WHERE follower_actor = ? AND local_user_id = ?").bind(actorId, user.id).first<Follow>();
return {
id: target,
following: Boolean(outgoing && outgoing.accepted),
showing_reblogs: true,
notifying: false,
languages: null,
followed_by: Boolean(incoming),
blocking: false,
blocked_by: false,
muting: false,
muting_notifications: false,
requested: Boolean(outgoing && !outgoing.accepted),
domain_blocking: false,
endorsed: false,
note: ""
};
}
type AccountTarget = { kind: "local"; userId: string; actorId: string } | { kind: "remote"; actorId: string };
async function resolveAccountTarget(env: Env, key: string): Promise<AccountTarget | null> {
const local = await getUserByIdOrUsername(env, key);
if (local) return { kind: "local", userId: local.id, actorId: actorUrl(env, local) };
const byLocalId = await getActorByLocalId(env, key);
if (byLocalId) return { kind: "remote", actorId: byLocalId.id };
if (key.startsWith("http://") || key.startsWith("https://")) {
if (key.startsWith(baseUrl(env))) {
const match = key.match(/\/users\/([^/?#]+)$/);
const u = match ? await getUserByUsername(env, match[1]) : null;
if (u) return { kind: "local", userId: u.id, actorId: actorUrl(env, u) };
}
await resolveRemoteActor(env, key);
return { kind: "remote", actorId: key };
}
if (key.includes("@")) {
const resolved = await resolveAcct(env, key);
if (!resolved) return null;
if (resolved.actorId.startsWith(baseUrl(env))) {
const match = resolved.actorId.match(/\/users\/([^/?#]+)$/);
const localUser = match ? await getUserByUsername(env, match[1]) : null;
if (localUser) return { kind: "local", userId: localUser.id, actorId: resolved.actorId };
}
await resolveRemoteActor(env, resolved.actorId);
return { kind: "remote", actorId: resolved.actorId };
}
return null;
}
async function resolveAcct(env: Env, acct: string): Promise<{ acct: string; actorId: string; url: string } | null> {
const trimmed = acct.replace(/^@/, "");
const [name, host] = trimmed.split("@");
if (!name) return null;
const targetHost = host ?? hostFromBaseUrl(env);
if (targetHost.toLowerCase() === hostFromBaseUrl(env).toLowerCase()) {
const user = await getUserByUsername(env, name);
if (!user) return null;
return { acct: name, actorId: actorUrl(env, user), url: actorUrl(env, user) };
}
try {
const wf = await fetch(`https://${targetHost}/.well-known/webfinger?resource=acct:${name}@${targetHost}`, {
headers: { accept: "application/jrd+json, application/json" }
});
if (!wf.ok) return null;
const doc = await wf.json() as { links?: { rel: string; type?: string; href: string }[] };
const self = doc.links?.find((link) => link.rel === "self" && (link.type ?? "").includes("activity+json"));
if (!self?.href) return null;
return { acct: `${name}@${targetHost}`, actorId: self.href, url: self.href };
} catch {
return null;
}
}
async function listMentionsForStatus(env: Env, statusId: string): Promise<Mention[]> {
const rows = await env.DB.prepare("SELECT * FROM mentions WHERE status_id = ?").bind(statusId).all<Mention>();
return rows.results;
}
async function listHashtagsForStatus(env: Env, statusId: string): Promise<string[]> {
const rows = await env.DB.prepare("SELECT tag FROM hashtags WHERE status_id = ?").bind(statusId).all<{ tag: string }>();
return rows.results.map((row) => row.tag);
}
function extractMentions(text: string): string[] {
const re = /@([A-Za-z0-9._-]+(?:@[A-Za-z0-9.-]+\.[A-Za-z]{2,})?)/g;
const out = new Set<string>();
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) out.add(match[1]);
return [...out];
}
function extractHashtags(text: string): string[] {
const re = /(?:^|\s)#([\p{L}\p{N}_]{1,64})/gu;
const out = new Set<string>();
let match: RegExpExecArray | null;
while ((match = re.exec(text)) !== null) out.add(match[1].toLowerCase());
return [...out];
}
function uniqueStrings(values: Array<string | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => Boolean(value)))];
}
function placeholders(count: number): string {
return Array.from({ length: count }, () => "?").join(",");
}
async function loadUsersByIds(env: Env, userIds: string[]): Promise<User[]> {
if (userIds.length === 0) return [];
const rows = await env.DB.prepare(`SELECT * FROM users WHERE id IN (${placeholders(userIds.length)})`).bind(...userIds).all<User>();
return rows.results;
}
async function loadStatusesByIds(env: Env, statusIds: string[]): Promise<Status[]> {
if (statusIds.length === 0) return [];
const rows = await env.DB.prepare(`SELECT * FROM statuses WHERE id IN (${placeholders(statusIds.length)})`).bind(...statusIds).all<Status>();
return rows.results;
}
async function loadMediaByStatusIds(env: Env, statusIds: string[]): Promise<Map<string, Media[]>> {
const grouped = new Map<string, Media[]>();
if (statusIds.length === 0) return grouped;
const rows = await env.DB.prepare(
`SELECT * FROM media WHERE status_id IN (${placeholders(statusIds.length)}) ORDER BY created_at ASC`
).bind(...statusIds).all<Media>();
for (const row of rows.results) {
if (!row.status_id) continue;
const bucket = grouped.get(row.status_id);
if (bucket) bucket.push(row);
else grouped.set(row.status_id, [row]);
}
return grouped;
}
async function loadMentionsByStatusIds(env: Env, statusIds: string[]): Promise<Map<string, Mention[]>> {
const grouped = new Map<string, Mention[]>();
if (statusIds.length === 0) return grouped;
const rows = await env.DB.prepare(
`SELECT * FROM mentions WHERE status_id IN (${placeholders(statusIds.length)})`
).bind(...statusIds).all<Mention>();
for (const row of rows.results) {
const bucket = grouped.get(row.status_id);
if (bucket) bucket.push(row);
else grouped.set(row.status_id, [row]);
}
return grouped;
}
async function loadHashtagsByStatusIds(env: Env, statusIds: string[]): Promise<Map<string, string[]>> {
const grouped = new Map<string, string[]>();
if (statusIds.length === 0) return grouped;
const rows = await env.DB.prepare(
`SELECT status_id, tag FROM hashtags WHERE status_id IN (${placeholders(statusIds.length)})`
).bind(...statusIds).all<{ status_id: string; tag: string }>();
for (const row of rows.results) {
const bucket = grouped.get(row.status_id);
if (bucket) bucket.push(row.tag);
else grouped.set(row.status_id, [row.tag]);
}
return grouped;
}
async function loadStatusInteractionSummary(
env: Env,
table: "favourites" | "reblogs",
statusIds: string[],
viewer: string | null
): Promise<{ countByStatusId: Map<string, number>; viewerMatchedStatusIds: Set<string> }> {
const countByStatusId = new Map<string, number>();
const viewerMatchedStatusIds = new Set<string>();
if (statusIds.length === 0) return { countByStatusId, viewerMatchedStatusIds };
const viewerSql = viewer ? ", MAX(CASE WHEN actor = ? THEN 1 ELSE 0 END) AS viewer_match" : "";
const sql = `SELECT status_id, COUNT(*) AS count${viewerSql} FROM ${table} WHERE status_id IN (${placeholders(statusIds.length)}) GROUP BY status_id`;
const binds = viewer ? [viewer, ...statusIds] : statusIds;
const rows = await env.DB.prepare(sql).bind(...binds).all<{ status_id: string; count: number; viewer_match?: number }>();
for (const row of rows.results) {
countByStatusId.set(row.status_id, row.count);
if (row.viewer_match) viewerMatchedStatusIds.add(row.status_id);
}
return { countByStatusId, viewerMatchedStatusIds };
}
async function loadReplyCountByStatusIds(env: Env, statusIds: string[]): Promise<Map<string, number>> {
const counts = new Map<string, number>();
if (statusIds.length === 0) return counts;
const rows = await env.DB.prepare(
`SELECT in_reply_to_id AS status_id, COUNT(*) AS count FROM statuses WHERE in_reply_to_id IN (${placeholders(statusIds.length)}) GROUP BY in_reply_to_id`
).bind(...statusIds).all<{ status_id: string; count: number }>();
for (const row of rows.results) counts.set(row.status_id, row.count);
return counts;
}
async function serializeNotifications(env: Env, notifications: Notification[], request: Request): Promise<Record<string, unknown>[]> {
if (notifications.length === 0) return [];
const statuses = await loadStatusesByIds(env, uniqueStrings(notifications.map((notification) => notification.status_id)));
const serializedStatuses = await serializeStatuses(env, statuses, request);
const serializedStatusById = new Map(serializedStatuses.map((item) => [String(item.id), item]));
const remoteActorIds = uniqueStrings(
notifications.map((notification) => notification.actor).filter((actorId) => !actorId.startsWith(baseUrl(env)))
);
const remoteAccounts = new Map<string, Record<string, unknown>>();
const remoteResults = await Promise.all(remoteActorIds.map(async (actorId) => [actorId, await resolveRemoteActor(env, actorId)] as const));
for (const [actorId, actorCache] of remoteResults) {
remoteAccounts.set(actorId, actorCache ? remoteAccountJson(actorCache) : { id: actorId, acct: actorId, username: actorId });
}
const localAccounts = new Map<string, Record<string, unknown>>();
const out: Record<string, unknown>[] = [];
for (const notification of notifications) {
let account = localAccounts.get(notification.actor) ?? remoteAccounts.get(notification.actor);
if (!account) {
const match = notification.actor.match(/\/users\/([^/?#]+)$/);
const localUser = match ? await getUserByUsername(env, match[1]) : null;
account = localUser ? await accountJson(env, localUser) : { id: notification.actor, acct: notification.actor };
localAccounts.set(notification.actor, account);
}
out.push({
id: notification.id,
type: notification.type,
created_at: notification.created_at,
account,
status: notification.status_id ? serializedStatusById.get(notification.status_id) ?? null : null
});
}
return out;
}
function pagedAppend(where: string[], binds: unknown[], url: URL): void {
const maxId = url.searchParams.get("max_id");
if (maxId) {
where.push("created_at < (SELECT created_at FROM statuses WHERE id = ?)");
binds.push(maxId);
}
const sinceId = url.searchParams.get("since_id");
if (sinceId) {
where.push("created_at > (SELECT created_at FROM statuses WHERE id = ?)");
binds.push(sinceId);
}
const minId = url.searchParams.get("min_id");
if (minId) {
where.push("created_at > (SELECT created_at FROM statuses WHERE id = ?)");
binds.push(minId);
}
}
function pagedAppendForTable(where: string[], binds: unknown[], url: URL, table: "follows" | "outgoing_follows"): void {
const maxId = url.searchParams.get("max_id");
if (maxId) {
where.push(`created_at < (SELECT created_at FROM ${table} WHERE id = ?)`);
binds.push(maxId);
}
const sinceId = url.searchParams.get("since_id");
if (sinceId) {
where.push(`created_at > (SELECT created_at FROM ${table} WHERE id = ?)`);
binds.push(sinceId);
}
const minId = url.searchParams.get("min_id");
if (minId) {
where.push(`created_at > (SELECT created_at FROM ${table} WHERE id = ?)`);
binds.push(minId);
}
}
function withPagination(response: Response, request: Request, ids: string[]): Response {
if (ids.length === 0) return response;
const url = new URL(request.url);
const nextUrl = new URL(url);
nextUrl.searchParams.set("max_id", ids[ids.length - 1]);
const prevUrl = new URL(url);
prevUrl.searchParams.set("since_id", ids[0]);
const link = `<${nextUrl}>; rel="next", <${prevUrl}>; rel="prev"`;
const headers = new Headers(response.headers);
headers.set("link", link);
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
async function viewerActor(request: Request, env: Env): Promise<string | null> {
const auth = request.headers.get("authorization") ?? "";
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token) return null;
const session = await env.KV.get<Session>(`token:${token}`, "json");
if (!session) return null;
const user = await getUserById(env, session.userId);
return user ? actorUrl(env, user) : null;
}
async function viewerUserId(request: Request, env: Env): Promise<string | null> {
const auth = request.headers.get("authorization") ?? "";
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token) return null;
const session = await env.KV.get<Session>(`token:${token}`, "json");
return session?.userId ?? null;
}
async function loadBookmarkedStatusIds(env: Env, userId: string, statusIds: string[]): Promise<Set<string>> {
if (statusIds.length === 0) return new Set();
const placeholders = statusIds.map(() => "?").join(",");
const rows = await env.DB.prepare(
`SELECT status_id FROM bookmarks WHERE user_id = ? AND status_id IN (${placeholders})`
).bind(userId, ...statusIds).all<{ status_id: string }>();
return new Set(rows.results.map((row) => row.status_id));
}
async function loadPinnedStatusIds(env: Env, userId: string, statusIds: string[]): Promise<Set<string>> {
if (statusIds.length === 0) return new Set();
const placeholders = statusIds.map(() => "?").join(",");
const rows = await env.DB.prepare(
`SELECT status_id FROM pinned_statuses WHERE user_id = ? AND status_id IN (${placeholders})`
).bind(userId, ...statusIds).all<{ status_id: string }>();
return new Set(rows.results.map((row) => row.status_id));
}
async function requireUser(request: Request, env: Env): Promise<User> {
const auth = request.headers.get("authorization") ?? "";
const token = auth.match(/^Bearer\s+(.+)$/i)?.[1];
if (!token) throw new HttpError(401, "The access token is invalid");
const session = await env.KV.get<Session>(`token:${token}`, "json");
if (!session) throw new HttpError(401, "The access token is invalid");
const user = await getUserById(env, session.userId);
if (!user) throw new HttpError(401, "The access token is invalid");
return user;
}
export { requireUser };