import { actorDocument, announceActivity, cacheRemoteNote, createActivity, deleteActivity, followActivity, likeActivity, undoActivity, updateNoteActivity, updatePersonActivity } from "./activitypub"; import { hashPassword, verifyPassword } from "./crypto"; import { addBookmark, addPin, countFollowers, countFollowing, countStatuses, deleteOAuthToken, findBookmark, findFavourite, findOutgoingFollow, findPin, findReblog, getActorByLocalId, getActorFromCache, getAdminUser, getAppByClientId, getOAuthToken, getCachedStatusByObjectId, getStatus, getUserById, getUserByIdOrUsername, getUserByUsername, insertOAuthToken, listCachedStatusAttachments, listMarkers, listProfileFields, recordNotification, removeBookmark, removePin, replaceProfileFields, saveMarker, setUserAvatarKey, setUserHeaderKey, takeOAuthCode, touchOAuthToken } from "./db"; import { deliverToInboxes, gatherFollowerInboxes, objectAsJson, resolveDeliveryInboxes, resolveRemoteActor } from "./federation"; import { bodyArray, bodyString, cors, HttpError, html, json, readBody } from "./http"; import type { ParsedBody } from "./http"; import type { ActorCache, AccountList, CachedStatus, CachedStatusMention, CachedStatusTag, Follow, Json, Media, Mention, Marker, Notification, Poll, PollOption, PushSubscription, ScheduledStatus, Session, Status, User } from "./types"; import { actorUrl, activityUrl, baseUrl, clampLimit, escapeHtml, hostFromBaseUrl, htmlContent, id, isLocalActor, mediaUrl, normalizeArray, objectUrl, profileUrl, safeFileName, statusUrl, tokenString } from "./util"; const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 90; const MAX_STATUS_CHARS = 5000; const MAX_MEDIA_ATTACHMENTS = 20; const MAX_MEDIA_BYTES = 10 * 1024 * 1024; const AUTH_RATE_LIMIT_WINDOW_SECONDS = 15 * 60; const AUTH_RATE_LIMIT_LOCK_SECONDS = 15 * 60; const AUTH_RATE_LIMIT_MAX_IP_FAILURES = 20; const AUTH_RATE_LIMIT_MAX_USERNAME_FAILURES = 8; const SUPPORTED_MIME = ["image/jpeg", "image/png", "image/gif", "image/webp"]; const VALID_STATUS_VISIBILITIES = new Set(["public", "unlisted", "private", "direct"]); const MAX_POLL_OPTIONS = 4; const MAX_POLL_OPTION_CHARS = 50; const MIN_POLL_EXPIRATION_SECONDS = 300; const MAX_POLL_EXPIRATION_SECONDS = 2629746; const SCHEDULED_STATUS_MIN_DELAY_SECONDS = 300; const ACTIVITY_JSON_ACCEPT = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\", application/json"; type StatusVisibility = "public" | "unlisted" | "private" | "direct"; type StatusViewer = { user: User | null; actor: string | null; followsByOwnerId: Map; remoteFollowsByActorId: Map; }; type StatusCreateInput = { statusText: string; summary: string; sensitive: boolean; visibility: StatusVisibility; inReplyTo: string; language: string; mediaIds: string[]; pollOptions: string[]; pollExpiresIn: number | null; pollMultiple: boolean; pollHideTotals: boolean; }; type StatusEditInput = { statusText: string; summary: string; sensitive: boolean; visibility: StatusVisibility; language: string; mediaIds: string[] | null; }; 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 { 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: MAX_POLL_OPTIONS, max_characters_per_option: MAX_POLL_OPTION_CHARS, min_expiration: MIN_POLL_EXPIRATION_SECONDS, max_expiration: MAX_POLL_EXPIRATION_SECONDS } }, contact_account: await accountJson(env, admin), rules: [] }); } export async function instanceV2(env: Env): Promise { 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 }, polls: { max_options: MAX_POLL_OPTIONS, max_characters_per_option: MAX_POLL_OPTION_CHARS, min_expiration: MIN_POLL_EXPIRATION_SECONDS, max_expiration: MAX_POLL_EXPIRATION_SECONDS } }, 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 { 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 { 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 loadSession(env, token); if (!session) throw new HttpError(401, "The access token is invalid"); requireScopes(session, ["read"]); 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 { 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(` Authorize

${escapeHtml(env.INSTANCE_NAME)}

Authorize ${escapeHtml(app.name)} to access your account.





`); } export async function authorize(request: Request, env: Env): Promise { 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 username = bodyString(body, "username"); await assertAuthNotRateLimited(request, env, username); const user = await getUserByUsername(env, username); if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) { await recordAuthFailure(request, env, username); return html("Invalid username or password", 401); } await clearAuthFailures(request, env, username); const code = tokenString(32); const scope = requestedScopesWithinApp(bodyString(body, "scope", app.scopes), 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(`

Authorization 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 { 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 username = bodyString(body, "username"); await assertAuthNotRateLimited(request, env, username); const user = await getUserByUsername(env, username); if (!user || !(await verifyPassword(bodyString(body, "password"), user.password_hash))) { await recordAuthFailure(request, env, username); return json({ error: "invalid_grant" }, 400); } await clearAuthFailures(request, env, username); userId = user.id; scopes = requestedScopesWithinApp(bodyString(body, "scope", app.scopes), app.scopes); } else if (grantType === "client_credentials") { scopes = requestedScopesWithinApp(bodyString(body, "scope", "read"), app.scopes); } 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 { const body = await readBody(request); const tokenValue = bodyString(body, "token"); if (tokenValue) { await env.KV.delete(`token:${tokenValue}`); await deleteOAuthToken(env, tokenValue); } return json({}); } type AuthFailureRecord = { count: number; firstFailureAt: number; lockedUntil?: number; }; async function assertAuthNotRateLimited(request: Request, env: Env, username: string): Promise { const keys = authRateLimitKeys(request, username); const now = Math.floor(Date.now() / 1000); const records = await Promise.all(keys.map(({ key }) => env.KV.get(key, "json"))); const lockedUntil = records.reduce((latest, record) => Math.max(latest, record?.lockedUntil ?? 0), 0); if (lockedUntil > now) { const retryAfter = String(Math.max(1, lockedUntil - now)); throw new HttpError(429, "rate_limited", { "retry-after": retryAfter }); } } async function recordAuthFailure(request: Request, env: Env, username: string): Promise { const now = Math.floor(Date.now() / 1000); await Promise.all(authRateLimitKeys(request, username).map(async ({ key, limit }) => { const existing = await env.KV.get(key, "json"); const inWindow = existing && now - existing.firstFailureAt < AUTH_RATE_LIMIT_WINDOW_SECONDS; const next: AuthFailureRecord = { count: inWindow ? existing.count + 1 : 1, firstFailureAt: inWindow ? existing.firstFailureAt : now }; if (next.count >= limit) next.lockedUntil = now + AUTH_RATE_LIMIT_LOCK_SECONDS; const ttl = Math.max(AUTH_RATE_LIMIT_WINDOW_SECONDS, AUTH_RATE_LIMIT_LOCK_SECONDS); await env.KV.put(key, JSON.stringify(next), { expirationTtl: ttl }); })); } async function clearAuthFailures(request: Request, env: Env, username: string): Promise { await Promise.all(authRateLimitKeys(request, username).map(({ key }) => env.KV.delete(key))); } function authRateLimitKeys(request: Request, username: string): { key: string; limit: number }[] { const ip = request.headers.get("cf-connecting-ip") ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; const normalizedUsername = username.trim().toLowerCase() || "empty"; return [ { key: `auth_fail:ip:${ip}`, limit: AUTH_RATE_LIMIT_MAX_IP_FAILURES }, { key: `auth_fail:user:${normalizedUsername}`, limit: AUTH_RATE_LIMIT_MAX_USERNAME_FAILURES } ]; } export async function verifyCredentials(request: Request, env: Env): Promise { const user = await requireUser(request, env); const account = await accountJson(env, user) as Record; 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 { 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 { if (!isSupportedImageMime(file.type)) throw new HttpError(415, "unsupported media type"); 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 isSupportedImageMime(mime: string): boolean { return SUPPORTED_MIME.includes(mime) || mime === "image/jpg"; } 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 { 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 { 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 { 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]; const viewer = await loadStatusViewer(request, env); const visibilityClause = await visibleStatusWhereForOwner(env, user.id, viewer); if (visibilityClause) where.push(visibilityClause); 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(); 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 viewer = await loadStatusViewer(request, env); const fetchLimit = Math.min(limit * 4, 160); const rows = await env.DB.prepare( "SELECT * FROM cached_statuses WHERE actor = ? ORDER BY published DESC LIMIT ?" ).bind(remote.id, fetchLimit).all(); const visibleRows = await filterCachedStatusesForViewer(env, rows.results, viewer); const items = await Promise.all(visibleRows.slice(0, limit).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 { 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(); 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 { 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 { 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[]> { const accounts = await Promise.all(actorIds.map((actorId) => accountFromActorId(env, actorId))); return accounts.filter((account): account is Record => Boolean(account)); } async function accountFromActorId(env: Env, actorId: string): Promise | 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 { const user = await requireUser(request, env); const body = await readBody(request); const input = parseStatusCreateInput(body); const scheduledAt = bodyString(body, "scheduled_at"); if (scheduledAt) return scheduleStatus(env, user, input, scheduledAt); const status = await publishStatus(env, user, input); return json(await statusJson(env, status, user, request)); } async function publishStatus(env: Env, user: User, input: StatusCreateInput): Promise { const now = new Date().toISOString(); const statusId = id(); const objectId = objectUrl(env, statusId); const activityId = activityUrl(env, statusId); const mentionsAcct = extractMentions(input.statusText); const hashtags = extractHashtags(input.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(input.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, source_text, edited_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)" ) .bind( statusId, user.id, renderedContent, input.summary, input.sensitive ? 1 : 0, input.language, input.visibility, input.inReplyTo || null, activityId, objectId, now, statusUrl(env, user, statusId), input.statusText ) .run(); for (const mediaId of input.mediaIds) { await env.DB.prepare("UPDATE media SET status_id = ? WHERE id = ? AND user_id = ?").bind(statusId, mediaId, user.id).run(); } if (input.pollOptions.length > 0) { const pollId = id(); const expiresAt = input.pollExpiresIn ? new Date(Date.now() + input.pollExpiresIn * 1000).toISOString() : null; await env.DB.prepare( "INSERT INTO polls (id, status_id, user_id, expires_at, multiple, hide_totals, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)" ).bind(pollId, statusId, user.id, expiresAt, input.pollMultiple ? 1 : 0, input.pollHideTotals ? 1 : 0, now).run(); for (let i = 0; i < input.pollOptions.length; i++) { await env.DB.prepare("INSERT INTO poll_options (poll_id, position, title) VALUES (?, ?, ?)") .bind(pollId, i, input.pollOptions[i]).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 (input.inReplyTo) { replyParent = await getStatus(env, input.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 (input.visibility === "public" || input.visibility === "unlisted" || input.visibility === "private") { const inboxes = new Set(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 mentionActors = resolvedMentions.map((m) => m.actorId); const to = input.visibility === "public" ? ["https://www.w3.org/ns/activitystreams#Public"] : input.visibility === "unlisted" ? [`${actorUrl(env, user)}/followers`] : [`${actorUrl(env, user)}/followers`, ...mentionActors]; const cc = input.visibility === "public" ? [`${actorUrl(env, user)}/followers`, ...mentionActors] : input.visibility === "unlisted" ? ["https://www.w3.org/ns/activitystreams#Public", ...mentionActors] : []; const activity = await createActivity(env, user, status, { to, cc }); await deliverToInboxes(env, user, inboxes, activity); } else if (input.visibility === "direct") { const inboxes = new Set(); 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 = await createActivity(env, user, status, { to: resolvedMentions.map((m) => m.actorId), cc: [] }); await deliverToInboxes(env, user, inboxes, activity); } return status; } function parseStatusCreateInput(body: ParsedBody): StatusCreateInput { const statusText = bodyString(body, "status").trim(); if (!statusText) throw new HttpError(422, "status can't be blank"); if (statusText.length > MAX_STATUS_CHARS) throw new HttpError(422, "status too long"); const visibility = bodyString(body, "visibility", "public"); if (!isStatusVisibility(visibility)) throw new HttpError(422, "invalid_visibility"); const pollOptions = bodyArray(body, "poll[options]").map((option) => option.trim()).filter(Boolean); if (pollOptions.length === 1) throw new HttpError(422, "poll needs at least two options"); if (pollOptions.length > MAX_POLL_OPTIONS) throw new HttpError(422, "too_many_poll_options"); if (pollOptions.some((option) => option.length > MAX_POLL_OPTION_CHARS)) throw new HttpError(422, "poll_option_too_long"); const pollExpiresIn = pollOptions.length > 0 ? parsePollExpiresIn(bodyString(body, "poll[expires_in]", String(MIN_POLL_EXPIRATION_SECONDS))) : null; const mediaIds = bodyArray(body, "media_ids"); if (mediaIds.length > MAX_MEDIA_ATTACHMENTS) throw new HttpError(422, "too_many_media_attachments"); return { statusText, summary: bodyString(body, "spoiler_text"), sensitive: bodyString(body, "sensitive") === "true", visibility, inReplyTo: bodyString(body, "in_reply_to_id"), language: bodyString(body, "language", "en"), mediaIds, pollOptions, pollExpiresIn, pollMultiple: bodyString(body, "poll[multiple]") === "true", pollHideTotals: bodyString(body, "poll[hide_totals]") === "true" }; } function parseStatusEditInput(body: ParsedBody, existing: Status): StatusEditInput { const existingText = statusSourceText(existing); const statusText = Object.prototype.hasOwnProperty.call(body, "status") ? bodyString(body, "status").trim() : existingText; if (!statusText) throw new HttpError(422, "status can't be blank"); if (statusText.length > MAX_STATUS_CHARS) throw new HttpError(422, "status too long"); const visibility = bodyString(body, "visibility", existing.visibility); if (!isStatusVisibility(visibility)) throw new HttpError(422, "invalid_visibility"); const mediaIds = Object.prototype.hasOwnProperty.call(body, "media_ids") ? bodyArray(body, "media_ids") : null; if (mediaIds && mediaIds.length > MAX_MEDIA_ATTACHMENTS) throw new HttpError(422, "too_many_media_attachments"); return { statusText, summary: bodyString(body, "spoiler_text", existing.summary), sensitive: Object.prototype.hasOwnProperty.call(body, "sensitive") ? bodyString(body, "sensitive") === "true" : Boolean(existing.sensitive), visibility, language: bodyString(body, "language", existing.language || "en"), mediaIds }; } function parsePollExpiresIn(value: string): number { const seconds = Number(value); if (!Number.isFinite(seconds)) throw new HttpError(422, "invalid_poll_expiration"); const wholeSeconds = Math.floor(seconds); if (wholeSeconds < MIN_POLL_EXPIRATION_SECONDS) throw new HttpError(422, "poll_expiration_too_short"); if (wholeSeconds > MAX_POLL_EXPIRATION_SECONDS) throw new HttpError(422, "poll_expiration_too_long"); return wholeSeconds; } async function scheduleStatus(env: Env, user: User, input: StatusCreateInput, scheduledAtValue: string): Promise { const scheduledAt = new Date(scheduledAtValue); if (!Number.isFinite(scheduledAt.getTime())) return json({ error: "invalid_scheduled_at" }, 422); if (scheduledAt.getTime() < Date.now() + SCHEDULED_STATUS_MIN_DELAY_SECONDS * 1000) { return json({ error: "scheduled_at_too_soon" }, 422); } const now = new Date().toISOString(); const row: ScheduledStatus = { id: id(), user_id: user.id, params_json: JSON.stringify(input), media_ids_json: JSON.stringify(input.mediaIds), scheduled_at: scheduledAt.toISOString(), created_at: now }; await env.DB.prepare( "INSERT INTO scheduled_statuses (id, user_id, params_json, media_ids_json, scheduled_at, created_at) VALUES (?, ?, ?, ?, ?, ?)" ).bind(row.id, row.user_id, row.params_json, row.media_ids_json, row.scheduled_at, row.created_at).run(); return json(await scheduledStatusJson(env, row)); } export async function listScheduledStatuses(request: Request, env: Env): Promise { const user = await requireUser(request, env); const url = new URL(request.url); const limit = clampLimit(url.searchParams.get("limit"), 20, 80); const rows = await env.DB.prepare( "SELECT * FROM scheduled_statuses WHERE user_id = ? ORDER BY scheduled_at ASC LIMIT ?" ).bind(user.id, limit).all(); return json(await Promise.all(rows.results.map((row) => scheduledStatusJson(env, row)))); } export async function getScheduledStatus(request: Request, env: Env, scheduledId: string): Promise { const user = await requireUser(request, env); const row = await env.DB.prepare("SELECT * FROM scheduled_statuses WHERE id = ? AND user_id = ?").bind(scheduledId, user.id).first(); if (!row) return json({ error: "Record not found" }, 404); return json(await scheduledStatusJson(env, row)); } export async function updateScheduledStatus(request: Request, env: Env, scheduledId: string): Promise { const user = await requireUser(request, env); const row = await env.DB.prepare("SELECT * FROM scheduled_statuses WHERE id = ? AND user_id = ?").bind(scheduledId, user.id).first(); if (!row) return json({ error: "Record not found" }, 404); const body = await readBody(request); const scheduledAtValue = bodyString(body, "scheduled_at"); if (!scheduledAtValue) return json({ error: "scheduled_at is required" }, 422); const scheduledAt = new Date(scheduledAtValue); if (!Number.isFinite(scheduledAt.getTime())) return json({ error: "invalid_scheduled_at" }, 422); if (scheduledAt.getTime() < Date.now() + SCHEDULED_STATUS_MIN_DELAY_SECONDS * 1000) { return json({ error: "scheduled_at_too_soon" }, 422); } await env.DB.prepare("UPDATE scheduled_statuses SET scheduled_at = ? WHERE id = ? AND user_id = ?") .bind(scheduledAt.toISOString(), scheduledId, user.id).run(); const updated = await env.DB.prepare("SELECT * FROM scheduled_statuses WHERE id = ?").bind(scheduledId).first(); return json(await scheduledStatusJson(env, updated!)); } export async function deleteScheduledStatus(request: Request, env: Env, scheduledId: string): Promise { const user = await requireUser(request, env); const row = await env.DB.prepare("SELECT * FROM scheduled_statuses WHERE id = ? AND user_id = ?").bind(scheduledId, user.id).first(); if (!row) return json({ error: "Record not found" }, 404); await env.DB.prepare("DELETE FROM scheduled_statuses WHERE id = ? AND user_id = ?").bind(scheduledId, user.id).run(); return json({}); } export async function publishDueScheduledStatuses(env: Env): Promise { const rows = await env.DB.prepare( "SELECT * FROM scheduled_statuses WHERE scheduled_at <= ? ORDER BY scheduled_at ASC LIMIT 10" ).bind(new Date().toISOString()).all(); for (const row of rows.results) { const user = await getUserById(env, row.user_id); if (!user) { await env.DB.prepare("DELETE FROM scheduled_statuses WHERE id = ?").bind(row.id).run(); continue; } try { await publishStatus(env, user, parseScheduledStatusInput(row.params_json)); await env.DB.prepare("DELETE FROM scheduled_statuses WHERE id = ?").bind(row.id).run(); } catch (error) { console.warn("scheduled-status-publish-failed", row.id, String(error)); } } } async function scheduledStatusJson(env: Env, row: ScheduledStatus): Promise> { const input = parseScheduledStatusInput(row.params_json); const mediaIds = parseCachedJson(row.media_ids_json); const media = mediaIds.length > 0 ? (await env.DB.prepare(`SELECT * FROM media WHERE id IN (${placeholders(mediaIds.length)})`).bind(...mediaIds).all()).results : []; return { id: row.id, scheduled_at: row.scheduled_at, params: { text: input.statusText, media_ids: input.mediaIds, sensitive: input.sensitive, spoiler_text: input.summary, visibility: input.visibility, scheduled_at: row.scheduled_at, poll: input.pollOptions.length > 0 ? { options: input.pollOptions, expires_in: input.pollExpiresIn, multiple: input.pollMultiple, hide_totals: input.pollHideTotals } : null, idempotency: null, in_reply_to_id: input.inReplyTo || null, application_id: null }, media_attachments: media.map((item) => mediaJson(env, item)) }; } function parseScheduledStatusInput(value: string): StatusCreateInput { const parsed = JSON.parse(value) as StatusCreateInput; if (!isStatusVisibility(parsed.visibility)) throw new Error("invalid_scheduled_visibility"); return { statusText: String(parsed.statusText ?? ""), summary: String(parsed.summary ?? ""), sensitive: Boolean(parsed.sensitive), visibility: parsed.visibility, inReplyTo: String(parsed.inReplyTo ?? ""), language: String(parsed.language ?? "en"), mediaIds: Array.isArray(parsed.mediaIds) ? parsed.mediaIds.map(String) : [], pollOptions: Array.isArray(parsed.pollOptions) ? parsed.pollOptions.map(String) : [], pollExpiresIn: typeof parsed.pollExpiresIn === "number" ? parsed.pollExpiresIn : null, pollMultiple: Boolean(parsed.pollMultiple), pollHideTotals: Boolean(parsed.pollHideTotals) }; } export async function getStatusEndpoint(request: Request, env: Env, statusId: string): Promise { const status = await getStatus(env, statusId); if (!status) return json({ error: "Record not found" }, 404); const viewer = await loadStatusViewer(request, env); if (!await canViewerViewStatus(env, status, viewer)) 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 getStatusSource(request: Request, env: Env, statusId: string): Promise { 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); return json({ id: status.id, text: statusSourceText(status), spoiler_text: status.summary }); } export async function updateStatusEndpoint(request: Request, env: Env, statusId: string): Promise { 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 body = await readBody(request); const input = parseStatusEditInput(body, status); const previousMentions = await listMentionsForStatus(env, status.id); const mentionsAcct = extractMentions(input.statusText); const hashtags = extractHashtags(input.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(input.statusText, resolvedMentions.map(({ acct, url }) => ({ acct, url })), hashtags); const editedAt = new Date().toISOString(); await env.DB.prepare( `UPDATE statuses SET content = ?, summary = ?, sensitive = ?, language = ?, visibility = ?, url = ?, source_text = ?, edited_at = ? WHERE id = ? AND user_id = ?` ).bind( renderedContent, input.summary, input.sensitive ? 1 : 0, input.language, input.visibility, statusUrl(env, user, status.id), input.statusText, editedAt, status.id, user.id ).run(); if (input.mediaIds !== null) { await env.DB.prepare("UPDATE media SET status_id = NULL WHERE status_id = ? AND user_id = ?").bind(status.id, user.id).run(); for (const mediaId of input.mediaIds) { await env.DB.prepare("UPDATE media SET status_id = ? WHERE id = ? AND user_id = ?").bind(status.id, mediaId, user.id).run(); } } await env.DB.prepare("DELETE FROM mentions WHERE status_id = ?").bind(status.id).run(); for (const mention of resolvedMentions) { await env.DB.prepare("INSERT OR IGNORE INTO mentions (status_id, actor, acct, url) VALUES (?, ?, ?, ?)") .bind(status.id, mention.actorId, mention.acct, mention.url).run(); } await env.DB.prepare("DELETE FROM hashtags WHERE status_id = ?").bind(status.id).run(); for (const tag of hashtags) { await env.DB.prepare("INSERT OR IGNORE INTO hashtags (status_id, tag) VALUES (?, ?)").bind(status.id, tag).run(); } const updated = await getStatus(env, status.id); if (!updated) throw new HttpError(500, "status_not_found"); if (updated.visibility === "public" || updated.visibility === "unlisted" || updated.visibility === "private" || updated.visibility === "direct") { const inboxes = new Set(); if (updated.visibility !== "direct") { for (const inbox of await gatherFollowerInboxes(env, user.id)) inboxes.add(inbox); } const remoteActors = new Set([ ...previousMentions.map((mention) => mention.actor), ...resolvedMentions.map((mention) => mention.actorId) ].filter((actorId) => !actorId.startsWith(baseUrl(env)))); for (const actorId of remoteActors) { const cache = await resolveRemoteActor(env, actorId); if (cache) inboxes.add(cache.shared_inbox ?? cache.inbox); } const mentionActors = resolvedMentions.map((mention) => mention.actorId); const to = updated.visibility === "public" ? ["https://www.w3.org/ns/activitystreams#Public"] : updated.visibility === "unlisted" ? [`${actorUrl(env, user)}/followers`] : updated.visibility === "private" ? [`${actorUrl(env, user)}/followers`, ...mentionActors] : mentionActors; const cc = updated.visibility === "public" ? [`${actorUrl(env, user)}/followers`, ...mentionActors] : updated.visibility === "unlisted" ? ["https://www.w3.org/ns/activitystreams#Public", ...mentionActors] : []; await deliverToInboxes(env, user, inboxes, await updateNoteActivity(env, user, updated, { to, cc })); } return json(await statusJson(env, updated, user, request)); } export async function getPoll(request: Request, env: Env, pollId: string): Promise { const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first(); if (!poll) return json({ error: "Record not found" }, 404); const status = await getStatus(env, poll.status_id); if (!status) return json({ error: "Record not found" }, 404); const viewer = await loadStatusViewer(request, env); if (!await canViewerViewStatus(env, status, viewer)) return json({ error: "Record not found" }, 404); return json(await pollJson(env, poll, viewer.actor)); } export async function votePoll(request: Request, env: Env, pollId: string): Promise { const user = await requireUser(request, env); const poll = await env.DB.prepare("SELECT * FROM polls WHERE id = ?").bind(pollId).first(); if (!poll) return json({ error: "Record not found" }, 404); const status = await getStatus(env, poll.status_id); if (!status) return json({ error: "Record not found" }, 404); const viewer = statusViewerForUser(env, user); if (!await canViewerViewStatus(env, status, viewer)) return json({ error: "Record not found" }, 404); if (poll.expires_at && Date.parse(poll.expires_at) <= Date.now()) return json({ error: "poll_expired" }, 422); const body = await readBody(request); const choices = uniqueNumbers(bodyArray(body, "choices").map((choice) => Number(choice))); if (choices.length === 0) return json({ error: "choices can't be blank" }, 422); if (!poll.multiple && choices.length > 1) return json({ error: "poll_is_single_choice" }, 422); const options = await env.DB.prepare("SELECT * FROM poll_options WHERE poll_id = ?").bind(poll.id).all(); const validPositions = new Set(options.results.map((option) => option.position)); if (choices.some((choice) => !validPositions.has(choice))) return json({ error: "invalid_choice" }, 422); const actor = actorUrl(env, user); const existing = await env.DB.prepare("SELECT 1 AS hit FROM poll_votes WHERE poll_id = ? AND voter_actor = ? LIMIT 1").bind(poll.id, actor).first<{ hit: number }>(); if (existing) return json({ error: "already_voted" }, 422); const now = new Date().toISOString(); for (const choice of choices) { await env.DB.prepare("INSERT INTO poll_votes (poll_id, position, voter_actor, created_at) VALUES (?, ?, ?, ?)") .bind(poll.id, choice, actor, now).run(); } return json(await pollJson(env, poll, actor)); } export async function deleteStatusEndpoint(request: Request, env: Env, statusId: string): Promise { 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(); if (status.visibility !== "direct") { for (const inbox of await gatherFollowerInboxes(env, user.id)) inboxes.add(inbox); } 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, statusUrl(env, user, status.id), 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(); const poll = await env.DB.prepare("SELECT id FROM polls WHERE status_id = ?").bind(status.id).first<{ id: string }>(); if (poll) { await env.DB.prepare("DELETE FROM poll_votes WHERE poll_id = ?").bind(poll.id).run(); await env.DB.prepare("DELETE FROM poll_options WHERE poll_id = ?").bind(poll.id).run(); await env.DB.prepare("DELETE FROM polls WHERE id = ?").bind(poll.id).run(); } const mentionActors = mentions.map((mention) => mention.actor); const deleteAudience = status.visibility === "direct" ? { to: mentionActors, cc: [] } : status.visibility === "private" ? { to: [`${actorUrl(env, user)}/followers`, ...mentionActors], cc: [] } : {}; await deliverToInboxes(env, user, inboxes, deleteActivity(env, user, status, deleteAudience)); return json(serialized); } export async function statusContext(env: Env, statusId: string, request: Request): Promise { const status = await getStatus(env, statusId); if (!status) return json({ error: "Record not found" }, 404); const viewer = await loadStatusViewer(request, env); if (!await canViewerViewStatus(env, status, viewer)) 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(); const visibleAncestors = await filterStatusesForViewer(env, ancestors, viewer); const visibleDescendants = await filterStatusesForViewer(env, descRows.results, viewer); const serialized = await serializeStatuses(env, [...visibleAncestors, ...visibleDescendants], request); const byId = new Map(serialized.map((item) => [String(item.id), item])); return json({ ancestors: visibleAncestors.map((item) => byId.get(item.id)).filter(Boolean), descendants: visibleDescendants.map((item) => byId.get(item.id)).filter(Boolean) }); } export async function favouriteStatus(request: Request, env: Env, statusId: string): Promise { const user = await requireUser(request, env); const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); 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 { const user = await requireUser(request, env); const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); 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 { const user = await requireUser(request, env); const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); if (!status) return json({ error: "Record not found" }, 404); if (status.visibility !== "public" && status.visibility !== "unlisted") return json({ error: "status_not_rebloggable" }, 422); 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(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 { const user = await requireUser(request, env); const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); 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(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 { const user = await requireUser(request, env); const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); 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 { const user = await requireUser(request, env); const status = await visibleStatusOrNull(env, statusId, statusViewerForUser(env, user)); 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 { 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 { 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 { 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(); const visibleRows = await filterStatusesForViewer(env, rows.results, statusViewerForUser(env, user)); const items = await serializeStatuses(env, visibleRows, request); return withPagination(json(items), request, visibleRows.map((s) => s.id)); } export async function favouritesList(request: Request, env: Env): Promise { 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(); const visibleRows = await filterStatusesForViewer(env, rows.results, statusViewerForUser(env, user)); const items = await serializeStatuses(env, visibleRows, request); return withPagination(json(items), request, visibleRows.map((s) => s.id)); } export async function publicTimeline(request: Request, env: Env): Promise { 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(); 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 { const user = await requireUser(request, env); const url = new URL(request.url); const limit = clampLimit(url.searchParams.get("limit"), 20, 40); const viewer = statusViewerForUser(env, user); const cachedFetchLimit = Math.min(limit * 4, 160); const localRows = await env.DB.prepare( "SELECT * FROM statuses WHERE user_id = ? ORDER BY created_at DESC LIMIT ?" ).bind(user.id, limit).all(); 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, cachedFetchLimit).all(); const localItems = await serializeStatuses(env, localRows.results, request); const visibleCachedRows = await filterCachedStatusesForViewer(env, cachedRows.results, viewer); const cachedItems = await Promise.all(visibleCachedRows.slice(0, limit).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 { 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(); 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 { 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 { 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); if (!isSupportedImageMime(file.type)) return json({ error: "unsupported media type" }, 415); 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(); return json(mediaJson(env, media!), 200); } export async function updateMedia(request: Request, env: Env, mediaId: string): Promise { 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(); if (!media) return json({ error: "Record not found" }, 404); return json(mediaJson(env, media)); } export async function serveMedia(env: Env, key: string): Promise { 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 { 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(); 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 { 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 { 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 { 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 listsList(request: Request, env: Env): Promise { const user = await requireUser(request, env); const rows = await env.DB.prepare("SELECT * FROM lists WHERE user_id = ? ORDER BY created_at DESC").bind(user.id).all(); return json(rows.results.map(listJson)); } export async function createList(request: Request, env: Env): Promise { const user = await requireUser(request, env); const body = await readBody(request); const title = bodyString(body, "title").trim(); if (!title) return json({ error: "title can't be blank" }, 422); const row: AccountList = { id: id(), user_id: user.id, title, replies_policy: bodyString(body, "replies_policy", "list") || "list", exclusive: bodyString(body, "exclusive") === "true" ? 1 : 0, created_at: new Date().toISOString() }; await env.DB.prepare( "INSERT INTO lists (id, user_id, title, replies_policy, exclusive, created_at) VALUES (?, ?, ?, ?, ?, ?)" ).bind(row.id, row.user_id, row.title, row.replies_policy, row.exclusive, row.created_at).run(); return json(listJson(row)); } export async function getList(request: Request, env: Env, listId: string): Promise { const user = await requireUser(request, env); const row = await getOwnedList(env, user.id, listId); if (!row) return json({ error: "Record not found" }, 404); return json(listJson(row)); } export async function updateList(request: Request, env: Env, listId: string): Promise { const user = await requireUser(request, env); const existing = await getOwnedList(env, user.id, listId); if (!existing) return json({ error: "Record not found" }, 404); const body = await readBody(request); const title = bodyString(body, "title", existing.title).trim(); if (!title) return json({ error: "title can't be blank" }, 422); const repliesPolicy = bodyString(body, "replies_policy", existing.replies_policy) || existing.replies_policy; const exclusiveValue = bodyString(body, "exclusive"); const exclusive = exclusiveValue ? (exclusiveValue === "true" ? 1 : 0) : existing.exclusive; await env.DB.prepare("UPDATE lists SET title = ?, replies_policy = ?, exclusive = ? WHERE id = ? AND user_id = ?") .bind(title, repliesPolicy, exclusive, listId, user.id).run(); const updated = await getOwnedList(env, user.id, listId); return json(listJson(updated!)); } export async function deleteList(request: Request, env: Env, listId: string): Promise { const user = await requireUser(request, env); const existing = await getOwnedList(env, user.id, listId); if (!existing) return json({ error: "Record not found" }, 404); await env.DB.prepare("DELETE FROM list_accounts WHERE list_id = ?").bind(listId).run(); await env.DB.prepare("DELETE FROM lists WHERE id = ? AND user_id = ?").bind(listId, user.id).run(); return json({}); } export async function listAccounts(request: Request, env: Env, listId: string): Promise { const user = await requireUser(request, env); const existing = await getOwnedList(env, user.id, listId); if (!existing) return json({ error: "Record not found" }, 404); const rows = await env.DB.prepare("SELECT account_actor FROM list_accounts WHERE list_id = ? ORDER BY created_at DESC").bind(listId).all<{ account_actor: string }>(); return json(await actorIdsToAccounts(env, rows.results.map((row) => row.account_actor))); } export async function addListAccounts(request: Request, env: Env, listId: string): Promise { const user = await requireUser(request, env); const existing = await getOwnedList(env, user.id, listId); if (!existing) return json({ error: "Record not found" }, 404); const body = await readBody(request); const accountIds = bodyArray(body, "account_ids"); if (accountIds.length === 0) return json({ error: "account_ids can't be blank" }, 422); const now = new Date().toISOString(); for (const accountId of accountIds) { const target = await resolveAccountTarget(env, accountId); if (!target) continue; await env.DB.prepare("INSERT OR IGNORE INTO list_accounts (list_id, account_actor, created_at) VALUES (?, ?, ?)") .bind(listId, target.actorId, now).run(); } return json({}); } export async function removeListAccounts(request: Request, env: Env, listId: string): Promise { const user = await requireUser(request, env); const existing = await getOwnedList(env, user.id, listId); if (!existing) return json({ error: "Record not found" }, 404); const body = await readBody(request); for (const accountId of bodyArray(body, "account_ids")) { const target = await resolveAccountTarget(env, accountId); const actorId = target?.actorId ?? accountId; await env.DB.prepare("DELETE FROM list_accounts WHERE list_id = ? AND account_actor = ?").bind(listId, actorId).run(); } return json({}); } export async function listTimeline(request: Request, env: Env, listId: string): Promise { const user = await requireUser(request, env); const existing = await getOwnedList(env, user.id, listId); if (!existing) return json({ error: "Record not found" }, 404); const url = new URL(request.url); const limit = clampLimit(url.searchParams.get("limit"), 20, 40); const accountRows = await env.DB.prepare("SELECT account_actor FROM list_accounts WHERE list_id = ?").bind(listId).all<{ account_actor: string }>(); const actors = accountRows.results.map((row) => row.account_actor); const localUserIds: string[] = []; const remoteActors: string[] = []; for (const actor of actors) { if (actor.startsWith(baseUrl(env))) { const match = actor.match(/\/users\/([^/?#]+)$/); const local = match ? await getUserByUsername(env, match[1]) : null; if (local) localUserIds.push(local.id); } else { remoteActors.push(actor); } } const viewer = statusViewerForUser(env, user); const localRows = localUserIds.length > 0 ? (await env.DB.prepare(`SELECT * FROM statuses WHERE user_id IN (${placeholders(localUserIds.length)}) ORDER BY created_at DESC LIMIT ?`).bind(...localUserIds, limit * 2).all()).results : []; const remoteRows = remoteActors.length > 0 ? (await env.DB.prepare(`SELECT * FROM cached_statuses WHERE actor IN (${placeholders(remoteActors.length)}) ORDER BY published DESC LIMIT ?`).bind(...remoteActors, limit * 2).all()).results : []; const visibleLocalRows = await filterStatusesForViewer(env, localRows, viewer); const visibleRemoteRows = await filterCachedStatusesForViewer(env, remoteRows, viewer); const localItems = await serializeStatuses(env, visibleLocalRows.slice(0, limit), request); const remoteItems = await Promise.all(visibleRemoteRows.slice(0, limit).map((row) => cachedStatusToMastodon(env, row))); return json([...localItems, ...remoteItems].sort((a, b) => String(b.created_at ?? "").localeCompare(String(a.created_at ?? ""))).slice(0, limit)); } export async function followAccount(request: Request, env: Env, accountId: string): Promise { const user = await requireUser(request, env, ["follow"]); 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 { const user = await requireUser(request, env, ["follow"]); 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 { await requireUser(request, env, ["follow"]); return json([]); } export async function authorizeFollowRequest(request: Request, env: Env, _accountId: string): Promise { await requireUser(request, env, ["follow"]); return json({ id: _accountId, following: true, requested: false }); } export async function rejectFollowRequest(request: Request, env: Env, _accountId: string): Promise { await requireUser(request, env, ["follow"]); return json({ id: _accountId, following: false, requested: false }); } export async function search(request: Request, env: Env): Promise { const url = new URL(request.url); const q = (url.searchParams.get("q") ?? "").trim().slice(0, 128); 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 ? ESCAPE '\\' LIMIT 20").bind(likeContains(q)).all(); for (const row of rows.results) accounts.push(await accountJson(env, row)); } } if (!type || type === "statuses") { const viewer = await loadStatusViewer(request, env); const remoteStatus = await resolveRemoteStatusSearch(env, q); if (remoteStatus && await canViewerViewCachedStatus(env, remoteStatus, viewer)) { statuses.push(await cachedStatusToMastodon(env, remoteStatus)); } const rows = await env.DB.prepare("SELECT * FROM statuses WHERE content LIKE ? ESCAPE '\\' ORDER BY created_at DESC LIMIT 100").bind(likeContains(q)).all(); const visibleRows = await filterStatusesForViewer(env, rows.results, viewer); statuses.push(...await serializeStatuses(env, visibleRows.slice(0, 20), 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 ? ESCAPE '\\' GROUP BY tag LIMIT 20").bind(likeContains(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 }); } function likeContains(value: string): string { return `%${value.replace(/[\\%_]/g, (char) => `\\${char}`)}%`; } async function resolveRemoteStatusSearch(env: Env, q: string): Promise { const url = parseSearchUrl(q); if (!url || url.host.toLowerCase() === hostFromBaseUrl(env).toLowerCase()) return null; const cached = await env.DB.prepare("SELECT * FROM cached_statuses WHERE object_id = ? OR url = ? LIMIT 1") .bind(url.toString(), url.toString()).first(); if (cached) return cached; let data: Json; try { const response = await fetch(url.toString(), { headers: { accept: ACTIVITY_JSON_ACCEPT }, signal: AbortSignal.timeout(10_000), cf: { cacheTtl: 60 } }); if (!response.ok) return null; data = await response.json() as Json; } catch { return null; } const resolved = remoteNoteFromFetchedObject(data); if (!resolved) return null; await resolveRemoteActor(env, resolved.actorId); const stored = await cacheRemoteNote(env, resolved.actorId, resolved.note, resolved.activity); return stored ?? getCachedStatusByObjectId(env, String(resolved.note.id)); } function parseSearchUrl(value: string): URL | null { try { const url = new URL(value); return url.protocol === "https:" || url.protocol === "http:" ? url : null; } catch { return null; } } function remoteNoteFromFetchedObject(value: Json): { actorId: string; note: Json; activity: Json } | null { const type = String(value.type ?? ""); if (type === "Note") { const actorId = actorIdFromField(value.attributedTo); return actorId && typeof value.id === "string" ? { actorId, note: value, activity: {} } : null; } const note = objectAsJson(value.object); if (type === "Create" && note && String(note.type ?? "") === "Note" && typeof note.id === "string") { const actorId = actorIdFromField(value.actor) ?? actorIdFromField(note.attributedTo); return actorId ? { actorId, note, activity: value } : null; } if (type === "Announce" && note && String(note.type ?? "") === "Note" && typeof note.id === "string") { const actorId = actorIdFromField(note.attributedTo) ?? actorIdFromField(value.actor); return actorId ? { actorId, note, activity: value } : null; } return null; } function actorIdFromField(value: unknown): string | null { if (typeof value === "string" && value) return value; if (Array.isArray(value)) { for (const item of value) { const actorId = actorIdFromField(item); if (actorId) return actorId; } } const obj = objectAsJson(value); return typeof obj?.id === "string" && obj.id ? obj.id : null; } export async function customEmojis(env: Env): Promise { void env; return json([]); } export async function filtersV1(_request: Request, env: Env): Promise { void env; return json([]); } export async function trendsTags(env: Env): Promise { void env; return json([]); } export async function getPushSubscription(request: Request, env: Env): Promise { const user = await requireUser(request, env); const row = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first(); if (!row) return json({ error: "Record not found" }, 404); return json(pushSubscriptionJson(row)); } export async function createPushSubscription(request: Request, env: Env): Promise { const user = await requireUser(request, env); const body = await readBody(request); const endpoint = bodyString(body, "subscription[endpoint]"); const serverKey = bodyString(body, "subscription[keys][p256dh]"); const auth = bodyString(body, "subscription[keys][auth]"); if (!endpoint || !serverKey || !auth) return json({ error: "subscription is incomplete" }, 422); const now = new Date().toISOString(); const existing = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first(); const idValue = existing?.id ?? id(); const alerts = pushAlertsFromBody(body, existing?.alerts_json); const policy = bodyString(body, "data[policy]", existing?.policy ?? "all") || "all"; await env.DB.prepare( `INSERT INTO push_subscriptions (id, user_id, endpoint, server_key, auth, alerts_json, policy, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET endpoint = excluded.endpoint, server_key = excluded.server_key, auth = excluded.auth, alerts_json = excluded.alerts_json, policy = excluded.policy, updated_at = excluded.updated_at` ).bind(idValue, user.id, endpoint, serverKey, auth, JSON.stringify(alerts), policy, existing?.created_at ?? now, now).run(); const row = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first(); return json(pushSubscriptionJson(row!)); } export async function updatePushSubscription(request: Request, env: Env): Promise { const user = await requireUser(request, env); const row = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first(); if (!row) return json({ error: "Record not found" }, 404); const body = await readBody(request); const alerts = pushAlertsFromBody(body, row.alerts_json); const policy = bodyString(body, "data[policy]", row.policy) || row.policy; await env.DB.prepare("UPDATE push_subscriptions SET alerts_json = ?, policy = ?, updated_at = ? WHERE user_id = ?") .bind(JSON.stringify(alerts), policy, new Date().toISOString(), user.id).run(); const updated = await env.DB.prepare("SELECT * FROM push_subscriptions WHERE user_id = ?").bind(user.id).first(); return json(pushSubscriptionJson(updated!)); } export async function deletePushSubscription(request: Request, env: Env): Promise { const user = await requireUser(request, env); await env.DB.prepare("DELETE FROM push_subscriptions WHERE user_id = ?").bind(user.id).run(); return json({}); } export async function markersList(request: Request, env: Env): Promise { const user = await requireUser(request, env); const url = new URL(request.url); const timelines = uniqueStrings(url.searchParams.getAll("timeline[]").concat(url.searchParams.getAll("timeline"))); if (timelines.length === 0) return json({}); const rows = await listMarkers(env, user.id, timelines); return json(markersJson(rows)); } export async function updateMarkers(request: Request, env: Env): Promise { const user = await requireUser(request, env); const body = await readBody(request); const out: Record = {}; for (const timeline of ["home", "notifications"]) { const lastReadId = bodyString(body, `${timeline}[last_read_id]`).trim(); if (!lastReadId) continue; const result = await saveMarker(env, user.id, timeline, lastReadId); if (result.conflict) throw new HttpError(409, "Conflict during update, please try again"); if (result.marker) out[timeline] = markerJson(result.marker); } return json(out); } function markersJson(rows: Marker[]): Record { const out: Record = {}; for (const row of rows) out[row.timeline] = markerJson(row); return out; } function markerJson(row: Marker): Record { return { last_read_id: row.last_read_id, version: row.version, updated_at: row.updated_at }; } function pushSubscriptionJson(row: PushSubscription): Record { return { id: row.id, endpoint: row.endpoint, server_key: row.server_key, alerts: parseObjectJson(row.alerts_json), policy: row.policy }; } function pushAlertsFromBody(body: ParsedBody, fallback?: string): Record { const alerts = parseObjectJson(fallback ?? "{}") as Record; const keys = ["follow", "favourite", "reblog", "mention", "poll", "status", "update", "admin.sign_up", "admin.report"]; for (const key of keys) { const value = bodyString(body, `data[alerts][${key}]`); if (value) alerts[key] = value === "true"; } return alerts; } function parseObjectJson(value: string): Record { try { const parsed = JSON.parse(value) as unknown; return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}; } catch { return {}; } } type StatusSerializationContext = { usersById: Map; accountByUserId: Map>; mediaByStatusId: Map; mentionsByStatusId: Map; hashtagsByStatusId: Map; favouriteCountByStatusId: Map; favouritedStatusIds: Set; reblogCountByStatusId: Map; rebloggedStatusIds: Set; replyCountByStatusId: Map; bookmarkedStatusIds: Set; pinnedStatusIds: Set; pollByStatusId: Map; pollOptionsByPollId: Map; pollVotesByPollId: Map>; pollVotersCountByPollId: Map; pollOwnVotesByPollId: Map; }; async function cachedStatusToMastodon(env: Env, row: CachedStatus): Promise> { 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); const mentions = parseCachedJson(row.mentions_json); const tags = parseCachedJson(row.tags_json); return { id: row.object_id, uri: row.object_id, url: row.url, account, in_reply_to_id: row.in_reply_to, in_reply_to_account_id: null, content: row.content, text: row.content, created_at: row.published, edited_at: null, visibility: row.visibility, 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: mentions.map((mention) => ({ id: mention.actor, username: mention.acct.replace(/^@/, "").split("@")[0], acct: mention.acct.replace(/^@/, ""), url: mention.url })), tags: tags.map((tag) => ({ name: tag.name, url: tag.url })), 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 }; } function parseCachedJson(value: string): T[] { try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed as T[] : []; } catch { return []; } } function statusSourceText(status: Status): string { return status.source_text || htmlToPlainText(status.content); } function htmlToPlainText(value: string): string { return decodeHtmlEntities(value .replace(//gi, "\n") .replace(/<\/p>\s*]*>/gi, "\n\n") .replace(/<[^>]*>/g, "") .trim()); } function decodeHtmlEntities(value: string): string { return value .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'"); } async function statusJson( env: Env, status: Status, user: User, request: Request, context?: StatusSerializationContext ): Promise> { 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 { 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: statusUrl(env, user, status.id), 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: status.edited_at, 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: pollRecord(status.id, context) }; } function pollRecord(statusId: string, context: StatusSerializationContext): Record | null { const poll = context.pollByStatusId.get(statusId); if (!poll) return null; const options = context.pollOptionsByPollId.get(poll.id) ?? []; const voteCounts = context.pollVotesByPollId.get(poll.id) ?? new Map(); const votersCount = context.pollVotersCountByPollId.get(poll.id) ?? 0; const ownVotes = context.pollOwnVotesByPollId.get(poll.id) ?? []; const now = Date.now(); const expiresAt = poll.expires_at ? Date.parse(poll.expires_at) : NaN; const expired = Number.isFinite(expiresAt) ? expiresAt <= now : false; const showTotals = !poll.hide_totals || expired || ownVotes.length > 0; const votesCount = [...voteCounts.values()].reduce((total, count) => total + count, 0); return { id: poll.id, expires_at: poll.expires_at, expired, multiple: Boolean(poll.multiple), votes_count: showTotals ? votesCount : null, voters_count: showTotals ? votersCount : null, voted: ownVotes.length > 0, own_votes: ownVotes, options: options.map((option) => ({ title: option.title, votes_count: showTotals ? voteCounts.get(option.position) ?? 0 : null })), emojis: [] }; } async function pollJson(env: Env, poll: Poll, viewer: string | null): Promise> { const [options, voteRows, votersRow] = await Promise.all([ env.DB.prepare("SELECT * FROM poll_options WHERE poll_id = ? ORDER BY position ASC").bind(poll.id).all(), env.DB.prepare("SELECT position, COUNT(*) AS count FROM poll_votes WHERE poll_id = ? GROUP BY position").bind(poll.id).all<{ position: number; count: number }>(), env.DB.prepare("SELECT COUNT(DISTINCT voter_actor) AS count FROM poll_votes WHERE poll_id = ?").bind(poll.id).first<{ count: number }>() ]); const ownRows = viewer ? (await env.DB.prepare("SELECT position FROM poll_votes WHERE poll_id = ? AND voter_actor = ? ORDER BY position ASC").bind(poll.id, viewer).all<{ position: number }>()).results : []; const context: StatusSerializationContext = { usersById: new Map(), accountByUserId: new Map(), mediaByStatusId: new Map(), mentionsByStatusId: new Map(), hashtagsByStatusId: new Map(), favouriteCountByStatusId: new Map(), favouritedStatusIds: new Set(), reblogCountByStatusId: new Map(), rebloggedStatusIds: new Set(), replyCountByStatusId: new Map(), bookmarkedStatusIds: new Set(), pinnedStatusIds: new Set(), pollByStatusId: new Map([[poll.status_id, poll]]), pollOptionsByPollId: new Map([[poll.id, options.results]]), pollVotesByPollId: new Map([[poll.id, new Map(voteRows.results.map((row) => [row.position, row.count]))]]), pollVotersCountByPollId: new Map([[poll.id, votersRow?.count ?? 0]]), pollOwnVotesByPollId: new Map([[poll.id, ownRows.map((row) => row.position)]]) }; return pollRecord(poll.status_id, context)!; } async function serializeStatuses( env: Env, statuses: Status[], request: Request, usersById?: Map ): Promise[]> { 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 = new Map() ): Promise { 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 viewerUserForContext = await viewerUser(request, env); const viewer = viewerUserForContext ? actorUrl(env, viewerUserForContext) : null; const viewerId = viewerUserForContext?.id ?? null; const [mediaByStatusId, mentionsByStatusId, hashtagsByStatusId, favouriteSummary, reblogSummary, replyCountByStatusId, bookmarkedStatusIds, pinnedStatusIds, pollContext] = 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()), viewerId ? loadPinnedStatusIds(env, viewerId, statusIds) : Promise.resolve(new Set()), loadPollSerializationContext(env, statusIds, viewer) ]); const accountByUserId = new Map>(); 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, pollByStatusId: pollContext.pollByStatusId, pollOptionsByPollId: pollContext.pollOptionsByPollId, pollVotesByPollId: pollContext.pollVotesByPollId, pollVotersCountByPollId: pollContext.pollVotersCountByPollId, pollOwnVotesByPollId: pollContext.pollOwnVotesByPollId }; } async function accountJson(env: Env, user: User): Promise> { 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: profileUrl(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 { 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 { 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> { 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(); 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: "" }; } function listJson(row: AccountList): Record { return { id: row.id, title: row.title, replies_policy: row.replies_policy, exclusive: Boolean(row.exclusive) }; } async function getOwnedList(env: Env, userId: string, listId: string): Promise { return env.DB.prepare("SELECT * FROM lists WHERE id = ? AND user_id = ?").bind(listId, userId).first(); } type AccountTarget = { kind: "local"; userId: string; actorId: string } | { kind: "remote"; actorId: string }; async function resolveAccountTarget(env: Env, key: string): Promise { 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" }, signal: AbortSignal.timeout(10_000) }); 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 { const rows = await env.DB.prepare("SELECT * FROM mentions WHERE status_id = ?").bind(statusId).all(); return rows.results; } async function listHashtagsForStatus(env: Env, statusId: string): Promise { 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(); 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(); let match: RegExpExecArray | null; while ((match = re.exec(text)) !== null) out.add(match[1].toLowerCase()); return [...out]; } function uniqueStrings(values: Array): string[] { return [...new Set(values.filter((value): value is string => Boolean(value)))]; } function uniqueNumbers(values: number[]): number[] { return [...new Set(values.filter((value) => Number.isInteger(value) && value >= 0))]; } function placeholders(count: number): string { return Array.from({ length: count }, () => "?").join(","); } async function loadUsersByIds(env: Env, userIds: string[]): Promise { if (userIds.length === 0) return []; const rows = await env.DB.prepare(`SELECT * FROM users WHERE id IN (${placeholders(userIds.length)})`).bind(...userIds).all(); return rows.results; } async function loadStatusesByIds(env: Env, statusIds: string[]): Promise { if (statusIds.length === 0) return []; const rows = await env.DB.prepare(`SELECT * FROM statuses WHERE id IN (${placeholders(statusIds.length)})`).bind(...statusIds).all(); return rows.results; } async function loadCachedStatusesByObjectIds(env: Env, objectIds: string[]): Promise { if (objectIds.length === 0) return []; const rows = await env.DB.prepare(`SELECT * FROM cached_statuses WHERE object_id IN (${placeholders(objectIds.length)})`).bind(...objectIds).all(); return rows.results; } async function loadMediaByStatusIds(env: Env, statusIds: string[]): Promise> { const grouped = new Map(); 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(); 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> { const grouped = new Map(); 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(); 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> { const grouped = new Map(); 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; viewerMatchedStatusIds: Set }> { const countByStatusId = new Map(); const viewerMatchedStatusIds = new Set(); 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> { const counts = new Map(); 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 loadPollSerializationContext( env: Env, statusIds: string[], viewer: string | null ): Promise> { const pollByStatusId = new Map(); const pollOptionsByPollId = new Map(); const pollVotesByPollId = new Map>(); const pollVotersCountByPollId = new Map(); const pollOwnVotesByPollId = new Map(); if (statusIds.length === 0) return { pollByStatusId, pollOptionsByPollId, pollVotesByPollId, pollVotersCountByPollId, pollOwnVotesByPollId }; const polls = await env.DB.prepare(`SELECT * FROM polls WHERE status_id IN (${placeholders(statusIds.length)})`).bind(...statusIds).all(); const pollIds = polls.results.map((poll) => poll.id); for (const poll of polls.results) pollByStatusId.set(poll.status_id, poll); if (pollIds.length === 0) return { pollByStatusId, pollOptionsByPollId, pollVotesByPollId, pollVotersCountByPollId, pollOwnVotesByPollId }; const optionRows = await env.DB.prepare( `SELECT * FROM poll_options WHERE poll_id IN (${placeholders(pollIds.length)}) ORDER BY position ASC` ).bind(...pollIds).all(); for (const option of optionRows.results) { const bucket = pollOptionsByPollId.get(option.poll_id); if (bucket) bucket.push(option); else pollOptionsByPollId.set(option.poll_id, [option]); } const voteRows = await env.DB.prepare( `SELECT poll_id, position, COUNT(*) AS count FROM poll_votes WHERE poll_id IN (${placeholders(pollIds.length)}) GROUP BY poll_id, position` ).bind(...pollIds).all<{ poll_id: string; position: number; count: number }>(); for (const row of voteRows.results) { let bucket = pollVotesByPollId.get(row.poll_id); if (!bucket) { bucket = new Map(); pollVotesByPollId.set(row.poll_id, bucket); } bucket.set(row.position, row.count); } const voterRows = await env.DB.prepare( `SELECT poll_id, COUNT(DISTINCT voter_actor) AS count FROM poll_votes WHERE poll_id IN (${placeholders(pollIds.length)}) GROUP BY poll_id` ).bind(...pollIds).all<{ poll_id: string; count: number }>(); for (const row of voterRows.results) pollVotersCountByPollId.set(row.poll_id, row.count); if (viewer) { const ownRows = await env.DB.prepare( `SELECT poll_id, position FROM poll_votes WHERE voter_actor = ? AND poll_id IN (${placeholders(pollIds.length)}) ORDER BY position ASC` ).bind(viewer, ...pollIds).all<{ poll_id: string; position: number }>(); for (const row of ownRows.results) { const bucket = pollOwnVotesByPollId.get(row.poll_id); if (bucket) bucket.push(row.position); else pollOwnVotesByPollId.set(row.poll_id, [row.position]); } } return { pollByStatusId, pollOptionsByPollId, pollVotesByPollId, pollVotersCountByPollId, pollOwnVotesByPollId }; } async function serializeNotifications(env: Env, notifications: Notification[], request: Request): Promise[]> { if (notifications.length === 0) return []; const notificationStatusIds = uniqueStrings(notifications.map((notification) => notification.status_id)); const viewer = await loadStatusViewer(request, env); const statuses = await loadStatusesByIds(env, notificationStatusIds); const localStatusIds = new Set(statuses.map((status) => status.id)); const cachedStatuses = await loadCachedStatusesByObjectIds(env, notificationStatusIds.filter((statusId) => !localStatusIds.has(statusId))); const visibleStatuses = await filterStatusesForViewer(env, statuses, viewer); const visibleCachedStatuses = await filterCachedStatusesForViewer(env, cachedStatuses, viewer); const serializedStatuses = await serializeStatuses(env, visibleStatuses, request); const serializedCachedStatuses = await Promise.all(visibleCachedStatuses.map((row) => cachedStatusToMastodon(env, row))); const serializedStatusById = new Map( [...serializedStatuses, ...serializedCachedStatuses].map((item) => [String(item.id), item]) ); const remoteActorIds = uniqueStrings( notifications.map((notification) => notification.actor).filter((actorId) => !actorId.startsWith(baseUrl(env))) ); const remoteAccounts = new Map>(); 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>(); const out: Record[] = []; 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 }); } function isStatusVisibility(value: string): value is StatusVisibility { return VALID_STATUS_VISIBILITIES.has(value); } async function loadStatusViewer(request: Request, env: Env): Promise { return statusViewerForUser(env, await viewerUser(request, env)); } function statusViewerForUser(env: Env, user: User | null): StatusViewer { return { user, actor: user ? actorUrl(env, user) : null, followsByOwnerId: new Map(), remoteFollowsByActorId: new Map() }; } async function visibleStatusWhereForOwner(env: Env, ownerUserId: string, viewer: StatusViewer, column = "visibility"): Promise { if (viewer.user?.id === ownerUserId) return null; if (await viewerFollowsOwner(env, viewer, ownerUserId)) return `${column} IN ('public', 'unlisted', 'private')`; return `${column} IN ('public', 'unlisted')`; } async function visibleStatusOrNull(env: Env, statusId: string, viewer: StatusViewer): Promise { const status = await getStatus(env, statusId); if (!status) return null; return await canViewerViewStatus(env, status, viewer) ? status : null; } async function filterStatusesForViewer(env: Env, statuses: Status[], viewer: StatusViewer): Promise { const visible: Status[] = []; for (const status of statuses) { if (await canViewerViewStatus(env, status, viewer)) visible.push(status); } return visible; } async function filterCachedStatusesForViewer(env: Env, statuses: CachedStatus[], viewer: StatusViewer): Promise { const visible: CachedStatus[] = []; for (const status of statuses) { if (await canViewerViewCachedStatus(env, status, viewer)) visible.push(status); } return visible; } async function canViewerViewStatus(env: Env, status: Status, viewer: StatusViewer): Promise { if (status.visibility === "public" || status.visibility === "unlisted") return true; if (viewer.user?.id === status.user_id) return true; if (status.visibility === "private") return viewerFollowsOwner(env, viewer, status.user_id); return false; } async function canViewerViewCachedStatus(env: Env, status: CachedStatus, viewer: StatusViewer): Promise { if (status.visibility === "public" || status.visibility === "unlisted") return true; if (!viewer.actor) return false; if (status.visibility === "private") return viewerFollowsRemoteActor(env, viewer, status.actor); return parseCachedJson(status.local_recipients_json).includes(viewer.actor); } async function viewerFollowsOwner(env: Env, viewer: StatusViewer, ownerUserId: string): Promise { if (!viewer.actor) return false; const cached = viewer.followsByOwnerId.get(ownerUserId); if (cached !== undefined) return cached; const row = await env.DB.prepare( "SELECT 1 AS hit FROM follows WHERE local_user_id = ? AND follower_actor = ? AND accepted = 1 LIMIT 1" ).bind(ownerUserId, viewer.actor).first<{ hit: number }>(); const follows = Boolean(row?.hit); viewer.followsByOwnerId.set(ownerUserId, follows); return follows; } async function viewerFollowsRemoteActor(env: Env, viewer: StatusViewer, actorId: string): Promise { if (!viewer.user) return false; const cached = viewer.remoteFollowsByActorId.get(actorId); if (cached !== undefined) return cached; const row = await env.DB.prepare( "SELECT 1 AS hit FROM outgoing_follows WHERE local_user_id = ? AND target_actor = ? AND accepted = 1 LIMIT 1" ).bind(viewer.user.id, actorId).first<{ hit: number }>(); const follows = Boolean(row?.hit); viewer.remoteFollowsByActorId.set(actorId, follows); return follows; } async function viewerUser(request: Request, env: Env): Promise { const auth = request.headers.get("authorization") ?? ""; const token = auth.match(/^Bearer\s+(.+)$/i)?.[1]; if (!token) return null; const session = await loadSession(env, token); if (!session) return null; if (!hasAnyScope(session, ["read"])) return null; return getUserById(env, session.userId); } async function loadBookmarkedStatusIds(env: Env, userId: string, statusIds: string[]): Promise> { 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> { 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, scopes = defaultRequiredScopes(request)): Promise { 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 loadSession(env, token); if (!session) throw new HttpError(401, "The access token is invalid"); requireScopes(session, scopes); const user = await getUserById(env, session.userId); if (!user) throw new HttpError(401, "The access token is invalid"); return user; } async function loadSession(env: Env, token: string): Promise { const session = await env.KV.get(`token:${token}`, "json"); if (session) return session; const row = await getOAuthToken(env, token); if (!row) return null; const createdAt = Date.parse(row.created_at); if (!Number.isFinite(createdAt) || Date.now() - createdAt > TOKEN_TTL_SECONDS * 1000) { await deleteOAuthToken(env, token).catch(() => undefined); return null; } const restored = { userId: row.user_id, appId: row.app_id, scopes: row.scopes } satisfies Session; await Promise.allSettled([ env.KV.put(`token:${token}`, JSON.stringify(restored), { expirationTtl: TOKEN_TTL_SECONDS }), touchOAuthToken(env, token) ]); return restored; } function defaultRequiredScopes(request: Request): string[] { return request.method.toUpperCase() === "GET" ? ["read"] : ["write"]; } function requestedScopesWithinApp(requested: string, appScopes: string): string { const requestedScopes = normalizeScopes(requested || appScopes); const allowed = normalizeScopes(appScopes); if (requestedScopes.length === 0) return appScopes; if (requestedScopes.every((scope) => scopeAllowed(scope, allowed))) return requestedScopes.join(" "); throw new HttpError(400, "invalid_scope"); } function requireScopes(session: Session, required: string[]): void { if (!hasAllScopes(session, required)) throw new HttpError(403, "insufficient_scope"); } function hasAllScopes(session: Session, required: string[]): boolean { return required.every((scope) => hasAnyScope(session, [scope])); } function hasAnyScope(session: Session, required: string[]): boolean { const granted = normalizeScopes(session.scopes); return required.some((scope) => scopeAllowed(scope, granted)); } function scopeAllowed(required: string, granted: string[]): boolean { if (granted.includes(required)) return true; if (!required.includes(":") && granted.some((scope) => scope.startsWith(`${required}:`))) return true; const root = required.split(":")[0]; return granted.includes(root); } function normalizeScopes(scopes: string): string[] { return [...new Set(scopes.split(/\s+/).map((scope) => scope.trim()).filter(Boolean))]; } export { requireUser };