import { deleteActorFromCache, deleteCachedStatus, exportUserPublicKeyPem, findFavourite, findReblog, getCachedStatusByObjectId, getStatus, getStatusByObjectId, getUserByUsername, listMediaForStatus, listProfileFields, recordNotification, upsertActorCache, upsertCachedStatus } from "./db"; import { isDuplicateActivity, isFollowedByAnyLocalUser, notifyForLocalStatus, objectAsJson, objectIdString, parseActivity, recordRemoteActivity, resolveRemoteActor, sendSignedActivity, verifyInboundSignature } from "./federation"; import { activityJson, json } from "./http"; import { ACTIVITY_CONTEXT, AVATAR_SVG, HEADER_SVG, PUBLIC_COLLECTION, SECURITY_CONTEXT } from "./types"; import type { ActorCache, CachedStatus, CachedStatusMention, CachedStatusTag, Json, Media, RemoteActor, Status, User } from "./types"; import { actorUrl, activityUrl, baseUrl, clampLimit, hostFromBaseUrl, id, mediaUrl, objectUrl, profileUrl, statusUrl } from "./util"; export async function webFinger(request: Request, env: Env): Promise { const resource = new URL(request.url).searchParams.get("resource") ?? ""; const match = resource.match(/^acct:([^@]+)@(.+)$/); if (!match) return json({ error: "not_found" }, 404); if (match[2].toLowerCase() !== hostFromBaseUrl(env).toLowerCase()) return json({ error: "not_found" }, 404); const user = await getUserByUsername(env, match[1]); if (!user) return json({ error: "not_found" }, 404); return json({ subject: `acct:${user.username}@${hostFromBaseUrl(env)}`, aliases: [actorUrl(env, user), profileUrl(env, user)], links: [ { rel: "self", type: "application/activity+json", href: actorUrl(env, user) }, { rel: "http://webfinger.net/rel/profile-page", type: "text/html", href: profileUrl(env, user) } ] }, 200, { "content-type": "application/jrd+json; charset=utf-8" }); } export async function hostMeta(env: Env): Promise { const xml = ` `; return new Response(xml, { headers: { "content-type": "application/xrd+xml; charset=utf-8" } }); } export function nodeInfoLinks(env: Env): Response { return json({ links: [{ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", href: `${baseUrl(env)}/nodeinfo/2.0` }] }); } export async function nodeInfo(env: Env): Promise { const users = await env.DB.prepare("SELECT COUNT(*) AS count FROM users").first<{ count: number }>(); const posts = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses").first<{ count: number }>(); return json({ version: "2.0", software: { name: "toot-worker", version: "0.3.0" }, protocols: ["activitypub"], services: { inbound: [], outbound: [] }, usage: { users: { total: users?.count ?? 0 }, localPosts: posts?.count ?? 0 }, openRegistrations: false, metadata: { nodeName: env.INSTANCE_NAME, singleUserMode: true } }); } export async function actor(env: Env, username: string): Promise { const user = await getUserByUsername(env, username); if (!user) return json({ error: "not_found" }, 404); return activityJson(await actorDocument(env, user)); } export async function actorDocument(env: Env, user: User): Promise { const url = actorUrl(env, user); const profile = profileUrl(env, user); const fields = await listProfileFields(env, user.id); const avatarUrl = user.avatar_r2_key ? mediaUrl(env, user.avatar_r2_key) : `${baseUrl(env)}/avatar.png`; const headerUrl = user.header_r2_key ? mediaUrl(env, user.header_r2_key) : `${baseUrl(env)}/header.png`; const avatarMime = guessImageMime(user.avatar_r2_key); const headerMime = guessImageMime(user.header_r2_key); return { "@context": [ ACTIVITY_CONTEXT, SECURITY_CONTEXT, { manuallyApprovesFollowers: "as:manuallyApprovesFollowers", schema: "http://schema.org#", PropertyValue: "schema:PropertyValue", value: "schema:value" } ], id: url, type: "Person", preferredUsername: user.username, name: user.display_name, summary: user.note, url: profile, inbox: `${url}/inbox`, outbox: `${url}/outbox`, followers: `${url}/followers`, following: `${url}/following`, endpoints: { sharedInbox: `${baseUrl(env)}/inbox` }, icon: { type: "Image", mediaType: avatarMime, url: avatarUrl }, image: { type: "Image", mediaType: headerMime, url: headerUrl }, manuallyApprovesFollowers: false, discoverable: true, attachment: fields.map((field) => ({ type: "PropertyValue", name: field.name, value: field.value })), publicKey: { id: `${url}#main-key`, owner: url, publicKeyPem: await exportUserPublicKeyPem(user) } }; } function guessImageMime(r2Key: string | null): string { if (!r2Key) return "image/svg+xml"; const ext = r2Key.split(".").pop()?.toLowerCase(); switch (ext) { case "jpg": case "jpeg": return "image/jpeg"; case "png": return "image/png"; case "gif": return "image/gif"; case "webp": return "image/webp"; case "avif": return "image/avif"; default: return "image/jpeg"; } } export async function outbox(request: Request, env: Env, username: string): Promise { const user = await getUserByUsername(env, username); if (!user) return json({ error: "not_found" }, 404); const url = new URL(request.url); const wantsPage = url.searchParams.has("page"); const totalRow = await env.DB.prepare( "SELECT COUNT(*) AS count FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted')" ).bind(user.id).first<{ count: number }>(); const totalItems = totalRow?.count ?? 0; const base = `${actorUrl(env, user)}/outbox`; if (!wantsPage) { return activityJson({ "@context": ACTIVITY_CONTEXT, id: base, type: "OrderedCollection", totalItems, first: `${base}?page=true` }); } const limit = clampLimit(url.searchParams.get("limit"), 20, 40); const rows = await env.DB.prepare( "SELECT * FROM statuses WHERE user_id = ? AND visibility IN ('public', 'unlisted') ORDER BY created_at DESC LIMIT ?" ).bind(user.id, limit).all(); const items = await Promise.all(rows.results.map((status) => createActivity(env, user, status))); return activityJson({ "@context": ACTIVITY_CONTEXT, id: `${base}?page=true`, partOf: base, type: "OrderedCollectionPage", orderedItems: items, totalItems }); } export async function followersCollection(env: Env, username: string): Promise { const user = await getUserByUsername(env, username); if (!user) return json({ error: "not_found" }, 404); const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>(); return activityJson({ "@context": ACTIVITY_CONTEXT, id: `${actorUrl(env, user)}/followers`, type: "Collection", totalItems: row?.count ?? 0 }); } export async function followingCollection(env: Env, username: string): Promise { const user = await getUserByUsername(env, username); if (!user) return json({ error: "not_found" }, 404); const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM outgoing_follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>(); return activityJson({ "@context": ACTIVITY_CONTEXT, id: `${actorUrl(env, user)}/following`, type: "Collection", totalItems: row?.count ?? 0 }); } export async function activityObject(env: Env, objectId: string): Promise { const status = await getStatus(env, objectId); if (status) { if (status.visibility !== "public" && status.visibility !== "unlisted") return json({ error: "not_found" }, 404); const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(status.user_id).first(); if (!user) return json({ error: "not_found" }, 404); const [attachments, tag] = await Promise.all([ loadStatusAttachments(env, status.id), loadStatusTags(env, status.id) ]); return activityJson(noteObject(env, user, status, { attachments, tag })); } const tomb = await env.DB.prepare("SELECT * FROM deleted_statuses WHERE id = ?").bind(objectId).first<{ id: string; deleted_at: string }>(); if (tomb) { return activityJson({ "@context": ACTIVITY_CONTEXT, id: `${baseUrl(env)}/objects/${tomb.id}`, type: "Tombstone", deleted: tomb.deleted_at }, 410); } return json({ error: "not_found" }, 404); } export async function inboxHandler(request: Request, env: Env, username: string | null): Promise { const localUser = username ? await getUserByUsername(env, username) : null; if (username && !localUser) return json({ error: "not_found" }, 404); const bodyText = await request.text(); const activity = parseActivity(bodyText); if (!activity || !activity.type) return json({ error: "invalid_activity" }, 400); if (activity.activityId && await isDuplicateActivity(env, activity.activityId)) { return new Response(null, { status: 202 }); } const verified = await verifyInboundSignature(request, bodyText, env); if (!verified) return json({ error: "invalid_signature" }, 401); if (activity.actor && verified.actor.id !== activity.actor) { return json({ error: "actor_signature_mismatch" }, 401); } await recordRemoteActivity(env, activity, true); const ctx: InboxContext = { env, activity, actorId: verified.actor.id, actorCache: verified.actor, localUser }; switch (activity.type) { case "Follow": return handleFollow(ctx); case "Undo": return handleUndo(ctx); case "Accept": return handleAccept(ctx); case "Reject": return handleReject(ctx); case "Like": return handleLike(ctx); case "Announce": return handleAnnounce(ctx); case "Delete": return handleDelete(ctx); case "Update": return handleUpdate(ctx); case "Create": return handleCreate(ctx); default: return new Response(null, { status: 202 }); } } type InboxContext = { env: Env; activity: NonNullable>; actorId: string; actorCache: ActorCache; localUser: User | null; }; async function handleFollow(ctx: InboxContext): Promise { const { env, activity, actorId, actorCache } = ctx; const object = objectIdString(activity.body.object); const localUser = await localUserFromTarget(env, object) ?? ctx.localUser; if (!localUser) return json({ error: "unknown_local_user" }, 404); const inbox = actorCache.shared_inbox ?? actorCache.inbox; await env.DB.prepare( "INSERT OR REPLACE INTO follows (id, follower_actor, local_user_id, inbox, accepted, created_at) VALUES (?, ?, ?, ?, 1, ?)" ).bind(id(), actorId, localUser.id, inbox, new Date().toISOString()).run(); await recordNotification(env, localUser.id, "follow", actorId, null); await sendSignedActivity(env, localUser, actorCache.inbox, { "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], id: activityUrl(env, id()), type: "Accept", actor: actorUrl(env, localUser), object: activity.body }); return new Response(null, { status: 202 }); } async function handleUndo(ctx: InboxContext): Promise { const { env, activity, actorId } = ctx; const inner = objectAsJson(activity.body.object); if (!inner) return new Response(null, { status: 202 }); const innerType = String(inner.type ?? ""); const localUser = ctx.localUser ?? await localUserFromTarget(env, objectIdString(inner.object)); if (!localUser) return new Response(null, { status: 202 }); if (innerType === "Follow") { await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ? AND local_user_id = ?").bind(actorId, localUser.id).run(); } else if (innerType === "Like") { const target = objectIdString(inner.object); if (target) { const status = await getStatusByObjectId(env, target); if (status) await env.DB.prepare("DELETE FROM favourites WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run(); } } else if (innerType === "Announce") { const target = objectIdString(inner.object); if (target) { const status = await getStatusByObjectId(env, target); if (status) await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run(); } } return new Response(null, { status: 202 }); } async function handleAccept(ctx: InboxContext): Promise { const { env, activity, actorId } = ctx; const inner = objectAsJson(activity.body.object); if (!inner || String(inner.type ?? "") !== "Follow") return new Response(null, { status: 202 }); const innerActor = typeof inner.actor === "string" ? inner.actor : String((inner.actor as Json | undefined)?.id ?? ""); const localUser = await localUserFromTarget(env, innerActor); if (!localUser) return new Response(null, { status: 202 }); await env.DB.prepare( "UPDATE outgoing_follows SET accepted = 1 WHERE local_user_id = ? AND target_actor = ?" ).bind(localUser.id, actorId).run(); return new Response(null, { status: 202 }); } async function handleReject(ctx: InboxContext): Promise { const { env, actorId } = ctx; await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run(); return new Response(null, { status: 202 }); } async function handleLike(ctx: InboxContext): Promise { const { env, activity, actorId } = ctx; const target = objectIdString(activity.body.object); if (!target) return new Response(null, { status: 202 }); const status = await getStatusByObjectId(env, target); if (!status) return new Response(null, { status: 202 }); const existing = await findFavourite(env, status.id, actorId); if (existing) return new Response(null, { status: 202 }); await env.DB.prepare( "INSERT INTO favourites (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)" ).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run(); await notifyForLocalStatus(env, status, "favourite", actorId); return new Response(null, { status: 202 }); } async function handleAnnounce(ctx: InboxContext): Promise { const { env, activity, actorId } = ctx; const target = objectIdString(activity.body.object); if (!target) return new Response(null, { status: 202 }); const status = await getStatusByObjectId(env, target); if (!status) return new Response(null, { status: 202 }); const existing = await findReblog(env, status.id, actorId); if (existing) return new Response(null, { status: 202 }); await env.DB.prepare( "INSERT INTO reblogs (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)" ).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run(); await notifyForLocalStatus(env, status, "reblog", actorId); return new Response(null, { status: 202 }); } async function handleDelete(ctx: InboxContext): Promise { const { env, activity, actorId } = ctx; const target = objectIdString(activity.body.object); if (!target) return new Response(null, { status: 202 }); if (target === actorId) { await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ?").bind(actorId).run(); await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run(); await env.DB.prepare("DELETE FROM favourites WHERE actor = ?").bind(actorId).run(); await env.DB.prepare("DELETE FROM reblogs WHERE actor = ?").bind(actorId).run(); await env.DB.prepare("DELETE FROM notifications WHERE actor = ?").bind(actorId).run(); await env.DB.prepare("DELETE FROM cached_statuses WHERE actor = ?").bind(actorId).run(); await deleteActorFromCache(env, actorId); return new Response(null, { status: 202 }); } await deleteCachedStatus(env, target); return new Response(null, { status: 202 }); } async function handleUpdate(ctx: InboxContext): Promise { const { env, activity, actorId } = ctx; const obj = objectAsJson(activity.body.object); if (!obj) return new Response(null, { status: 202 }); if (String(obj.type ?? "") === "Person" && obj.id === actorId) { await upsertActorCache(env, obj as unknown as RemoteActor); return new Response(null, { status: 202 }); } if (String(obj.type ?? "") === "Note" && typeof obj.id === "string") { const existing = await getCachedStatusByObjectId(env, obj.id); if (existing && existing.actor === actorId) { await cacheRemoteNote(env, actorId, obj, activity.body, existing); } } return new Response(null, { status: 202 }); } async function handleCreate(ctx: InboxContext): Promise { const { env, activity, actorId } = ctx; const obj = objectAsJson(activity.body.object); if (!obj || String(obj.type ?? "") !== "Note") return new Response(null, { status: 202 }); const recipients = collectRecipients(activity.body, obj); const localActorIds = new Set(); for (const target of recipients) { if (!target.startsWith(baseUrl(env))) continue; const m = target.match(/\/users\/([^/?#]+)$/); if (m) localActorIds.add(m[1]); } const isPublic = recipients.includes(PUBLIC_COLLECTION); const isFollowersOnly = recipients.some((recipient) => isFollowersCollection(actorId, recipient)); const followed = await isFollowedByAnyLocalUser(env, actorId); if ((followed && (isPublic || isFollowersOnly)) || localActorIds.size > 0) { await cacheRemoteNote(env, actorId, obj, activity.body); } for (const username of localActorIds) { const localUser = await getUserByUsername(env, username); if (!localUser) continue; await recordNotification(env, localUser.id, "mention", actorId, typeof obj.id === "string" ? obj.id : null); } return new Response(null, { status: 202 }); } export async function cacheRemoteNote(env: Env, actorId: string, note: Json, activity: Json = {}, fallback?: CachedStatus): Promise { if (typeof note.id !== "string") return null; const cachedId = note.id; const recipients = collectRecipients(activity, note); const mentions = note.tag === undefined ? parseJsonArray(fallback?.mentions_json) : extractRemoteMentions(note); const tags = note.tag === undefined ? parseJsonArray(fallback?.tags_json) : extractRemoteHashtags(note); const localRecipients = recipients.length === 0 ? parseJsonArray(fallback?.local_recipients_json) : collectLocalRecipients(env, recipients); const stored = await upsertCachedStatus(env, { id: cachedId, object_id: cachedId, actor: actorId, content: typeof note.content === "string" ? note.content : "", summary: typeof note.summary === "string" ? note.summary : "", sensitive: note.sensitive ? 1 : 0, language: typeof note.contentMap === "object" && note.contentMap ? Object.keys(note.contentMap as Json)[0] ?? "en" : "en", visibility: inferRemoteVisibility(actorId, activity, note, fallback?.visibility ?? "public"), in_reply_to: typeof note.inReplyTo === "string" ? note.inReplyTo : null, url: typeof note.url === "string" ? note.url : cachedId, published: typeof note.published === "string" ? note.published : new Date().toISOString(), mentions_json: JSON.stringify(mentions), tags_json: JSON.stringify(tags), local_recipients_json: JSON.stringify(localRecipients) }); if (!stored) return null; await env.DB.prepare("DELETE FROM cached_status_attachments WHERE cached_status_id = ?").bind(stored.id).run(); const attachments = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [note.attachment] : []; let position = 0; for (const raw of attachments) { if (!raw || typeof raw !== "object") continue; const att = raw as Json; const url = typeof att.url === "string" ? att.url : (att.url && typeof att.url === "object" && typeof (att.url as Json).href === "string") ? String((att.url as Json).href) : null; if (!url) continue; const mime = typeof att.mediaType === "string" ? att.mediaType : "application/octet-stream"; const description = typeof att.name === "string" ? att.name : typeof att.summary === "string" ? att.summary : null; await env.DB.prepare( "INSERT OR REPLACE INTO cached_status_attachments (cached_status_id, position, url, preview_url, mime_type, description) VALUES (?, ?, ?, ?, ?, ?)" ).bind(stored.id, position, url, null, mime, description).run(); position++; } return stored; } function inferRemoteVisibility(actorId: string, activity: Json, object: Json, fallback: string): string { const to = collectRecipientFields(activity.to, activity.bto, object.to, object.bto); const cc = collectRecipientFields(activity.cc, activity.bcc, object.cc, object.bcc); const recipients = new Set([...to, ...cc]); if (recipients.size === 0) return fallback; if (to.has(PUBLIC_COLLECTION)) return "public"; if (cc.has(PUBLIC_COLLECTION)) return "unlisted"; if ([...recipients].some((recipient) => isFollowersCollection(actorId, recipient))) return "private"; return "direct"; } function isFollowersCollection(actorId: string, recipient: string): boolean { return recipient === `${actorId}/followers` || recipient.endsWith("/followers"); } function collectLocalRecipients(env: Env, recipients: string[]): string[] { return recipients.filter((recipient) => recipient.startsWith(baseUrl(env)) && /\/users\/[^/?#]+$/.test(recipient)); } function extractRemoteMentions(note: Json): CachedStatusMention[] { const mentions: CachedStatusMention[] = []; for (const tag of noteTagObjects(note)) { if (String(tag.type ?? "") !== "Mention") continue; const url = stringValue(tag.href) ?? stringValue(tag.id); if (!url) continue; const acct = mentionAcct(tag, url); mentions.push({ actor: url, acct, url }); } return mentions; } function extractRemoteHashtags(note: Json): CachedStatusTag[] { const tags: CachedStatusTag[] = []; for (const tag of noteTagObjects(note)) { const name = stringValue(tag.name); if (String(tag.type ?? "") !== "Hashtag" && !name?.startsWith("#")) continue; if (!name) continue; tags.push({ name: name.replace(/^#/, "").toLowerCase(), url: stringValue(tag.href) ?? stringValue(tag.id) }); } return tags; } function noteTagObjects(note: Json): Json[] { const tags = Array.isArray(note.tag) ? note.tag : note.tag ? [note.tag] : []; return tags.filter((tag): tag is Json => Boolean(tag) && typeof tag === "object"); } function mentionAcct(tag: Json, url: string): string { const name = stringValue(tag.name)?.replace(/^@/, ""); if (name) return name; try { const parsed = new URL(url); const username = parsed.pathname.split("/").filter(Boolean).pop() ?? parsed.host; return `${username}@${parsed.host}`; } catch { return url; } } function stringValue(value: unknown): string | null { return typeof value === "string" && value ? value : null; } function parseJsonArray(value: string | null | undefined): T[] { if (!value) return []; try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed as T[] : []; } catch { return []; } } function collectRecipients(activity: Json, object: Json): string[] { const fields: unknown[] = [activity.to, activity.cc, activity.bto, activity.bcc, object.to, object.cc, object.bto, object.bcc]; return [...collectRecipientFields(...fields)]; } function collectRecipientFields(...fields: unknown[]): Set { const out = new Set(); for (const field of fields) { if (Array.isArray(field)) { for (const value of field) { if (typeof value === "string") out.add(value); } } else if (typeof field === "string") { out.add(field); } } return out; } async function localUserFromTarget(env: Env, actorId: string | null): Promise { if (!actorId) return null; if (!actorId.startsWith(baseUrl(env))) return null; const match = actorId.match(/\/users\/([^/?#]+)$/); if (!match) return null; return getUserByUsername(env, match[1]); } export async function createActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Promise { const audience = statusAudience(env, user, status); const to = extra.to ?? audience.to; const cc = extra.cc ?? audience.cc; const [attachments, tag] = await Promise.all([ loadStatusAttachments(env, status.id), loadStatusTags(env, status.id) ]); return { "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], id: status.activity_id, type: "Create", actor: actorUrl(env, user), published: status.created_at, to, cc, object: noteObject(env, user, status, { to, cc, attachments, tag }) }; } export async function updateNoteActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Promise { const audience = statusAudience(env, user, status); const to = extra.to ?? audience.to; const cc = extra.cc ?? audience.cc; const [attachments, tag] = await Promise.all([ loadStatusAttachments(env, status.id), loadStatusTags(env, status.id) ]); return { "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], id: activityUrl(env, id()), type: "Update", actor: actorUrl(env, user), published: status.edited_at ?? new Date().toISOString(), to, cc, object: noteObject(env, user, status, { to, cc, attachments, tag }) }; } export function attachmentObject(env: Env, media: Media): Json { return { type: media.mime_type.startsWith("image/") ? "Image" : "Document", mediaType: media.mime_type, url: mediaUrl(env, media.r2_key), name: media.description ?? null }; } export async function loadStatusAttachments(env: Env, statusId: string): Promise { const media = await listMediaForStatus(env, statusId); return media.map((item) => attachmentObject(env, item)); } export async function loadStatusTags(env: Env, statusId: string): Promise { const [mentionRows, hashtagRows] = await Promise.all([ env.DB.prepare("SELECT actor, acct FROM mentions WHERE status_id = ?").bind(statusId).all<{ actor: string; acct: string }>(), env.DB.prepare("SELECT tag FROM hashtags WHERE status_id = ?").bind(statusId).all<{ tag: string }>() ]); const tags: Json[] = []; for (const mention of mentionRows.results) { tags.push({ type: "Mention", href: mention.actor, name: `@${mention.acct}` }); } for (const row of hashtagRows.results) { tags.push({ type: "Hashtag", href: `${baseUrl(env)}/tags/${encodeURIComponent(row.tag)}`, name: `#${row.tag}` }); } return tags; } function statusAudience(env: Env, user: User, status: Status): { to: string[]; cc: string[] } { if (status.visibility === "unlisted") { return { to: [`${actorUrl(env, user)}/followers`], cc: [PUBLIC_COLLECTION] }; } if (status.visibility === "private") { return { to: [`${actorUrl(env, user)}/followers`], cc: [] }; } if (status.visibility === "direct") { return { to: [], cc: [] }; } return { to: [PUBLIC_COLLECTION], cc: [`${actorUrl(env, user)}/followers`] }; } export function deleteActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Json { const audience = statusAudience(env, user, status); return { "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], id: activityUrl(env, id()), type: "Delete", actor: actorUrl(env, user), to: extra.to ?? audience.to, cc: extra.cc ?? audience.cc, object: { id: status.object_id, type: "Tombstone" } }; } export function followActivity(env: Env, user: User, target: string, activityId: string): Json { return { "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], id: activityId, type: "Follow", actor: actorUrl(env, user), object: target }; } export function undoActivity(env: Env, user: User, inner: Json): Json { return { "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], id: activityUrl(env, id()), type: "Undo", actor: actorUrl(env, user), object: inner }; } export function likeActivity(env: Env, user: User, target: string, activityId: string): Json { return { "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], id: activityId, type: "Like", actor: actorUrl(env, user), object: target }; } export function announceActivity(env: Env, user: User, target: string, activityId: string): Json { return { "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], id: activityId, type: "Announce", actor: actorUrl(env, user), published: new Date().toISOString(), to: [PUBLIC_COLLECTION], cc: [`${actorUrl(env, user)}/followers`], object: target }; } export function updatePersonActivity(env: Env, user: User, doc: Json): Json { return { "@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT], id: activityUrl(env, id()), type: "Update", actor: actorUrl(env, user), to: [PUBLIC_COLLECTION], object: doc }; } export function noteObject(env: Env, user: User, status: Status, opts: { to?: string[]; cc?: string[]; attachments?: Json[]; tag?: Json[] } = {}): Json { const audience = statusAudience(env, user, status); return { id: status.object_id, type: "Note", summary: status.summary || null, sensitive: Boolean(status.sensitive), inReplyTo: status.in_reply_to_id ? objectUrl(env, status.in_reply_to_id) : null, attributedTo: actorUrl(env, user), content: status.content, published: status.created_at, updated: status.edited_at ?? null, url: statusUrl(env, user, status.id), to: opts.to ?? audience.to, cc: opts.cc ?? audience.cc, attachment: opts.attachments ?? [], tag: opts.tag ?? [] }; } export { AVATAR_SVG, HEADER_SVG };