780 lines
30 KiB
TypeScript
780 lines
30 KiB
TypeScript
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<Response> {
|
|
const resource = new URL(request.url).searchParams.get("resource") ?? "";
|
|
const match = resource.match(/^acct:([^@]+)@(.+)$/);
|
|
if (!match) return json({ error: "not_found" }, 404);
|
|
if (match[2].toLowerCase() !== hostFromBaseUrl(env).toLowerCase()) return json({ error: "not_found" }, 404);
|
|
const user = await getUserByUsername(env, match[1]);
|
|
if (!user) return json({ error: "not_found" }, 404);
|
|
|
|
return json({
|
|
subject: `acct:${user.username}@${hostFromBaseUrl(env)}`,
|
|
aliases: [actorUrl(env, user), 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<Response> {
|
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
|
|
<Link rel="lrdd" template="${baseUrl(env)}/.well-known/webfinger?resource={uri}"/>
|
|
</XRD>`;
|
|
return new Response(xml, { headers: { "content-type": "application/xrd+xml; charset=utf-8" } });
|
|
}
|
|
|
|
export function nodeInfoLinks(env: Env): Response {
|
|
return json({ links: [{ rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", href: `${baseUrl(env)}/nodeinfo/2.0` }] });
|
|
}
|
|
|
|
export async function nodeInfo(env: Env): Promise<Response> {
|
|
const users = await env.DB.prepare("SELECT COUNT(*) AS count FROM users").first<{ count: number }>();
|
|
const posts = await env.DB.prepare("SELECT COUNT(*) AS count FROM statuses").first<{ count: number }>();
|
|
return json({
|
|
version: "2.0",
|
|
software: { name: "toot-worker", version: "0.3.0" },
|
|
protocols: ["activitypub"],
|
|
services: { inbound: [], outbound: [] },
|
|
usage: { users: { total: users?.count ?? 0 }, localPosts: posts?.count ?? 0 },
|
|
openRegistrations: false,
|
|
metadata: { nodeName: env.INSTANCE_NAME, singleUserMode: true }
|
|
});
|
|
}
|
|
|
|
export async function actor(env: Env, username: string): Promise<Response> {
|
|
const user = await getUserByUsername(env, username);
|
|
if (!user) return json({ error: "not_found" }, 404);
|
|
return activityJson(await actorDocument(env, user));
|
|
}
|
|
|
|
export async function actorDocument(env: Env, user: User): Promise<Json> {
|
|
const url = actorUrl(env, user);
|
|
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<Response> {
|
|
const user = await getUserByUsername(env, username);
|
|
if (!user) return json({ error: "not_found" }, 404);
|
|
const url = new URL(request.url);
|
|
const wantsPage = url.searchParams.has("page");
|
|
const totalRow = await env.DB.prepare(
|
|
"SELECT COUNT(*) AS count FROM statuses WHERE user_id = ? 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<Status>();
|
|
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<Response> {
|
|
const user = await getUserByUsername(env, username);
|
|
if (!user) return json({ error: "not_found" }, 404);
|
|
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>();
|
|
return activityJson({
|
|
"@context": ACTIVITY_CONTEXT,
|
|
id: `${actorUrl(env, user)}/followers`,
|
|
type: "Collection",
|
|
totalItems: row?.count ?? 0
|
|
});
|
|
}
|
|
|
|
export async function followingCollection(env: Env, username: string): Promise<Response> {
|
|
const user = await getUserByUsername(env, username);
|
|
if (!user) return json({ error: "not_found" }, 404);
|
|
const row = await env.DB.prepare("SELECT COUNT(*) AS count FROM outgoing_follows WHERE local_user_id = ? AND accepted = 1").bind(user.id).first<{ count: number }>();
|
|
return activityJson({
|
|
"@context": ACTIVITY_CONTEXT,
|
|
id: `${actorUrl(env, user)}/following`,
|
|
type: "Collection",
|
|
totalItems: row?.count ?? 0
|
|
});
|
|
}
|
|
|
|
export async function activityObject(env: Env, objectId: string): Promise<Response> {
|
|
const status = await getStatus(env, objectId);
|
|
if (status) {
|
|
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<User>();
|
|
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<Response> {
|
|
const localUser = username ? await getUserByUsername(env, username) : null;
|
|
if (username && !localUser) return json({ error: "not_found" }, 404);
|
|
|
|
const bodyText = await request.text();
|
|
const activity = parseActivity(bodyText);
|
|
if (!activity || !activity.type) return json({ error: "invalid_activity" }, 400);
|
|
if (activity.activityId && await isDuplicateActivity(env, activity.activityId)) {
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
|
|
const verified = await verifyInboundSignature(request, bodyText, env);
|
|
if (!verified) return json({ error: "invalid_signature" }, 401);
|
|
if (activity.actor && verified.actor.id !== activity.actor) {
|
|
return json({ error: "actor_signature_mismatch" }, 401);
|
|
}
|
|
await recordRemoteActivity(env, activity, true);
|
|
|
|
const ctx: InboxContext = {
|
|
env,
|
|
activity,
|
|
actorId: verified.actor.id,
|
|
actorCache: verified.actor,
|
|
localUser
|
|
};
|
|
|
|
switch (activity.type) {
|
|
case "Follow":
|
|
return handleFollow(ctx);
|
|
case "Undo":
|
|
return handleUndo(ctx);
|
|
case "Accept":
|
|
return handleAccept(ctx);
|
|
case "Reject":
|
|
return handleReject(ctx);
|
|
case "Like":
|
|
return handleLike(ctx);
|
|
case "Announce":
|
|
return handleAnnounce(ctx);
|
|
case "Delete":
|
|
return handleDelete(ctx);
|
|
case "Update":
|
|
return handleUpdate(ctx);
|
|
case "Create":
|
|
return handleCreate(ctx);
|
|
default:
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
}
|
|
|
|
type InboxContext = {
|
|
env: Env;
|
|
activity: NonNullable<ReturnType<typeof parseActivity>>;
|
|
actorId: string;
|
|
actorCache: ActorCache;
|
|
localUser: User | null;
|
|
};
|
|
|
|
async function handleFollow(ctx: InboxContext): Promise<Response> {
|
|
const { env, activity, actorId, actorCache } = ctx;
|
|
const object = objectIdString(activity.body.object);
|
|
const localUser = await localUserFromTarget(env, object) ?? ctx.localUser;
|
|
if (!localUser) return json({ error: "unknown_local_user" }, 404);
|
|
|
|
const inbox = actorCache.shared_inbox ?? actorCache.inbox;
|
|
await env.DB.prepare(
|
|
"INSERT OR REPLACE INTO follows (id, follower_actor, local_user_id, inbox, accepted, created_at) VALUES (?, ?, ?, ?, 1, ?)"
|
|
).bind(id(), actorId, localUser.id, inbox, new Date().toISOString()).run();
|
|
|
|
await recordNotification(env, localUser.id, "follow", actorId, null);
|
|
|
|
await sendSignedActivity(env, localUser, actorCache.inbox, {
|
|
"@context": [ACTIVITY_CONTEXT, SECURITY_CONTEXT],
|
|
id: activityUrl(env, id()),
|
|
type: "Accept",
|
|
actor: actorUrl(env, localUser),
|
|
object: activity.body
|
|
});
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
|
|
async function handleUndo(ctx: InboxContext): Promise<Response> {
|
|
const { env, activity, actorId } = ctx;
|
|
const inner = objectAsJson(activity.body.object);
|
|
if (!inner) return new Response(null, { status: 202 });
|
|
const innerType = String(inner.type ?? "");
|
|
const localUser = ctx.localUser ?? await localUserFromTarget(env, objectIdString(inner.object));
|
|
if (!localUser) return new Response(null, { status: 202 });
|
|
|
|
if (innerType === "Follow") {
|
|
await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ? AND local_user_id = ?").bind(actorId, localUser.id).run();
|
|
} else if (innerType === "Like") {
|
|
const target = objectIdString(inner.object);
|
|
if (target) {
|
|
const status = await getStatusByObjectId(env, target);
|
|
if (status) await env.DB.prepare("DELETE FROM favourites WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run();
|
|
}
|
|
} else if (innerType === "Announce") {
|
|
const target = objectIdString(inner.object);
|
|
if (target) {
|
|
const status = await getStatusByObjectId(env, target);
|
|
if (status) await env.DB.prepare("DELETE FROM reblogs WHERE status_id = ? AND actor = ?").bind(status.id, actorId).run();
|
|
}
|
|
}
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
|
|
async function handleAccept(ctx: InboxContext): Promise<Response> {
|
|
const { env, activity, actorId } = ctx;
|
|
const inner = objectAsJson(activity.body.object);
|
|
if (!inner || String(inner.type ?? "") !== "Follow") return new Response(null, { status: 202 });
|
|
const innerActor = typeof inner.actor === "string" ? inner.actor : String((inner.actor as Json | undefined)?.id ?? "");
|
|
const localUser = await localUserFromTarget(env, innerActor);
|
|
if (!localUser) return new Response(null, { status: 202 });
|
|
await env.DB.prepare(
|
|
"UPDATE outgoing_follows SET accepted = 1 WHERE local_user_id = ? AND target_actor = ?"
|
|
).bind(localUser.id, actorId).run();
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
|
|
async function handleReject(ctx: InboxContext): Promise<Response> {
|
|
const { env, actorId } = ctx;
|
|
await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run();
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
|
|
async function handleLike(ctx: InboxContext): Promise<Response> {
|
|
const { env, activity, actorId } = ctx;
|
|
const target = objectIdString(activity.body.object);
|
|
if (!target) return new Response(null, { status: 202 });
|
|
const status = await getStatusByObjectId(env, target);
|
|
if (!status) return new Response(null, { status: 202 });
|
|
const existing = await findFavourite(env, status.id, actorId);
|
|
if (existing) return new Response(null, { status: 202 });
|
|
await env.DB.prepare(
|
|
"INSERT INTO favourites (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run();
|
|
await notifyForLocalStatus(env, status, "favourite", actorId);
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
|
|
async function handleAnnounce(ctx: InboxContext): Promise<Response> {
|
|
const { env, activity, actorId } = ctx;
|
|
const target = objectIdString(activity.body.object);
|
|
if (!target) return new Response(null, { status: 202 });
|
|
const status = await getStatusByObjectId(env, target);
|
|
if (!status) return new Response(null, { status: 202 });
|
|
const existing = await findReblog(env, status.id, actorId);
|
|
if (existing) return new Response(null, { status: 202 });
|
|
await env.DB.prepare(
|
|
"INSERT INTO reblogs (id, status_id, actor, activity_id, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
).bind(id(), status.id, actorId, activity.activityId || id(), new Date().toISOString()).run();
|
|
await notifyForLocalStatus(env, status, "reblog", actorId);
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
|
|
async function handleDelete(ctx: InboxContext): Promise<Response> {
|
|
const { env, activity, actorId } = ctx;
|
|
const target = objectIdString(activity.body.object);
|
|
if (!target) return new Response(null, { status: 202 });
|
|
|
|
if (target === actorId) {
|
|
await env.DB.prepare("DELETE FROM follows WHERE follower_actor = ?").bind(actorId).run();
|
|
await env.DB.prepare("DELETE FROM outgoing_follows WHERE target_actor = ?").bind(actorId).run();
|
|
await env.DB.prepare("DELETE FROM favourites WHERE actor = ?").bind(actorId).run();
|
|
await env.DB.prepare("DELETE FROM reblogs WHERE actor = ?").bind(actorId).run();
|
|
await env.DB.prepare("DELETE FROM notifications WHERE actor = ?").bind(actorId).run();
|
|
await 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<Response> {
|
|
const { env, activity, actorId } = ctx;
|
|
const obj = objectAsJson(activity.body.object);
|
|
if (!obj) return new Response(null, { status: 202 });
|
|
if (String(obj.type ?? "") === "Person" && obj.id === actorId) {
|
|
await upsertActorCache(env, obj as unknown as RemoteActor);
|
|
return new Response(null, { status: 202 });
|
|
}
|
|
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<Response> {
|
|
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<string>();
|
|
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<CachedStatus | null> {
|
|
if (typeof note.id !== "string") return null;
|
|
const cachedId = note.id;
|
|
const recipients = collectRecipients(activity, note);
|
|
const mentions = note.tag === undefined ? parseJsonArray<CachedStatusMention>(fallback?.mentions_json) : extractRemoteMentions(note);
|
|
const tags = note.tag === undefined ? parseJsonArray<CachedStatusTag>(fallback?.tags_json) : extractRemoteHashtags(note);
|
|
const localRecipients = recipients.length === 0 ? parseJsonArray<string>(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<T>(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<string> {
|
|
const out = new Set<string>();
|
|
for (const field of fields) {
|
|
if (Array.isArray(field)) {
|
|
for (const value of field) {
|
|
if (typeof value === "string") out.add(value);
|
|
}
|
|
} else if (typeof field === "string") {
|
|
out.add(field);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function localUserFromTarget(env: Env, actorId: string | null): Promise<User | null> {
|
|
if (!actorId) return null;
|
|
if (!actorId.startsWith(baseUrl(env))) return null;
|
|
const match = actorId.match(/\/users\/([^/?#]+)$/);
|
|
if (!match) return null;
|
|
return getUserByUsername(env, match[1]);
|
|
}
|
|
|
|
export async function createActivity(env: Env, user: User, status: Status, extra: { to?: string[]; cc?: string[] } = {}): Promise<Json> {
|
|
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<Json> {
|
|
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<Json[]> {
|
|
const media = await listMediaForStatus(env, statusId);
|
|
return media.map((item) => attachmentObject(env, item));
|
|
}
|
|
|
|
export async function loadStatusTags(env: Env, statusId: string): Promise<Json[]> {
|
|
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 };
|