diff --git a/index.js b/index.js index 243d864..6ebb92d 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,7 @@ import express from "express"; import { setupFederation, buildPersonActor } from "./lib/federation-setup.js"; +import { createMastodonRouter } from "./lib/mastodon/router.js"; import { initRedisCache } from "./lib/redis-cache.js"; import { lookupWithSecurity } from "./lib/lookup-helpers.js"; import { @@ -1137,6 +1138,10 @@ export default class ActivityPubEndpoint { Indiekit.addCollection("ap_key_freshness"); // Async inbox processing queue Indiekit.addCollection("ap_inbox_queue"); + // Mastodon Client API collections + Indiekit.addCollection("ap_oauth_apps"); + Indiekit.addCollection("ap_oauth_tokens"); + Indiekit.addCollection("ap_markers"); // Store collection references (posts resolved lazily) const indiekitCollections = Indiekit.collections; @@ -1170,6 +1175,10 @@ export default class ActivityPubEndpoint { ap_key_freshness: indiekitCollections.get("ap_key_freshness"), // Async inbox processing queue ap_inbox_queue: indiekitCollections.get("ap_inbox_queue"), + // Mastodon Client API collections + ap_oauth_apps: indiekitCollections.get("ap_oauth_apps"), + ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"), + ap_markers: indiekitCollections.get("ap_markers"), get posts() { return indiekitCollections.get("posts"); }, @@ -1391,6 +1400,24 @@ export default class ActivityPubEndpoint { { processedAt: 1 }, { expireAfterSeconds: 86_400, background: true }, ); + + // Mastodon Client API indexes + this._collections.ap_oauth_apps.createIndex( + { clientId: 1 }, + { unique: true, background: true }, + ); + this._collections.ap_oauth_tokens.createIndex( + { accessToken: 1 }, + { unique: true, background: true }, + ); + this._collections.ap_oauth_tokens.createIndex( + { code: 1 }, + { unique: true, sparse: true, background: true }, + ); + this._collections.ap_markers.createIndex( + { userId: 1, timeline: 1 }, + { unique: true, background: true }, + ); } catch { // Index creation failed — collections not yet available. // Indexes already exist from previous startups; non-fatal. @@ -1457,6 +1484,26 @@ export default class ActivityPubEndpoint { routesPublic: this.contentNegotiationRoutes, }); + // Mastodon Client API — virtual endpoint at root + // Mastodon-compatible clients (Phanpy, Elk, etc.) expect /api/v1/*, + // /api/v2/*, /oauth/* at the domain root, not under /activitypub. + const pluginRef = this; + const mastodonRouter = createMastodonRouter({ + collections: this._collections, + pluginOptions: { + handle: this.options.actor?.handle || "user", + publicationUrl: this._publicationUrl, + federation: this._federation, + followActor: (url, info) => pluginRef.followActor(url, info), + unfollowActor: (url) => pluginRef.unfollowActor(url), + }, + }); + Indiekit.addEndpoint({ + name: "Mastodon Client API", + mountPath: "/", + routesPublic: mastodonRouter, + }); + // Register syndicator (appears in post editing UI) Indiekit.addSyndicator(this.syndicator); diff --git a/lib/mastodon/entities/account.js b/lib/mastodon/entities/account.js new file mode 100644 index 0000000..a6adfbb --- /dev/null +++ b/lib/mastodon/entities/account.js @@ -0,0 +1,200 @@ +/** + * Account entity serializer for Mastodon Client API. + * + * Converts local profile (ap_profile) and remote actor objects + * (from timeline author, follower/following docs) into the + * Mastodon Account JSON shape that masto.js expects. + */ +import { accountId } from "../helpers/id-mapping.js"; +import { sanitizeHtml, stripHtml } from "./sanitize.js"; + +/** + * Serialize an actor as a Mastodon Account entity. + * + * Handles two shapes: + * - Local profile: { _id, name, summary, url, icon, image, actorType, + * manuallyApprovesFollowers, attachments, createdAt, ... } + * - Remote author (from timeline): { name, url, photo, handle, emojis, bot } + * - Follower/following doc: { actorUrl, name, handle, avatar, ... } + * + * @param {object} actor - Actor document (profile, author, or follower) + * @param {object} options + * @param {string} options.baseUrl - Server base URL + * @param {boolean} [options.isLocal=false] - Whether this is the local user + * @param {string} [options.handle] - Local actor handle (for local accounts) + * @returns {object} Mastodon Account entity + */ +export function serializeAccount(actor, { baseUrl, isLocal = false, handle = "" }) { + if (!actor) { + return null; + } + + const id = accountId(actor, isLocal); + + // Resolve username and acct + let username; + let acct; + if (isLocal) { + username = handle || extractUsername(actor.url) || "user"; + acct = username; // local accounts use bare username + } else { + // Remote: extract from handle (@user@domain) or URL + const remoteHandle = actor.handle || ""; + if (remoteHandle.startsWith("@")) { + username = remoteHandle.split("@")[1] || ""; + acct = remoteHandle.slice(1); // strip leading @ + } else if (remoteHandle.includes("@")) { + username = remoteHandle.split("@")[0]; + acct = remoteHandle; + } else { + username = extractUsername(actor.url || actor.actorUrl) || "unknown"; + const domain = extractDomain(actor.url || actor.actorUrl); + acct = domain ? `${username}@${domain}` : username; + } + } + + // Resolve display name + const displayName = actor.name || actor.displayName || username || ""; + + // Resolve URLs for avatar and header + const avatarUrl = + actor.icon || actor.avatarUrl || actor.photo || actor.avatar || ""; + const headerUrl = actor.image || actor.bannerUrl || ""; + + // Resolve URL + const url = actor.url || actor.actorUrl || ""; + + // Resolve note/summary + const note = actor.summary || ""; + + // Bot detection + const bot = + actor.bot === true || + actor.actorType === "Service" || + actor.actorType === "Application"; + + // Profile fields from attachments + const fields = (actor.attachments || actor.fields || []).map((f) => ({ + name: f.name || "", + value: sanitizeHtml(f.value || ""), + verified_at: null, + })); + + // Custom emojis + const emojis = (actor.emojis || []).map((e) => ({ + shortcode: e.shortcode || "", + url: e.url || "", + static_url: e.url || "", + visible_in_picker: true, + })); + + return { + id, + username, + acct, + url, + display_name: displayName, + note: sanitizeHtml(note), + avatar: avatarUrl || `${baseUrl}/placeholder-avatar.png`, + avatar_static: avatarUrl || `${baseUrl}/placeholder-avatar.png`, + header: headerUrl || "", + header_static: headerUrl || "", + locked: actor.manuallyApprovesFollowers || false, + fields, + emojis, + bot, + group: actor.actorType === "Group" || false, + discoverable: true, + noindex: false, + created_at: actor.createdAt || new Date().toISOString(), + last_status_at: actor.lastStatusAt || null, + statuses_count: actor.statusesCount || 0, + followers_count: actor.followersCount || 0, + following_count: actor.followingCount || 0, + moved: actor.movedTo || null, + suspended: false, + limited: false, + memorial: false, + roles: [], + hide_collections: false, + }; +} + +/** + * Serialize the local profile as a CredentialAccount (includes source + role). + * + * @param {object} profile - ap_profile document + * @param {object} options + * @param {string} options.baseUrl - Server base URL + * @param {string} options.handle - Local actor handle + * @param {object} [options.counts] - { statuses, followers, following } + * @returns {object} Mastodon CredentialAccount entity + */ +export function serializeCredentialAccount(profile, { baseUrl, handle, counts = {} }) { + const account = serializeAccount(profile, { + baseUrl, + isLocal: true, + handle, + }); + + // Add counts if provided + account.statuses_count = counts.statuses || 0; + account.followers_count = counts.followers || 0; + account.following_count = counts.following || 0; + + // CredentialAccount extensions + account.source = { + privacy: "public", + sensitive: false, + language: "", + note: stripHtml(profile.summary || ""), + fields: (profile.attachments || []).map((f) => ({ + name: f.name || "", + value: f.value || "", + verified_at: null, + })), + follow_requests_count: 0, + }; + + account.role = { + id: "-99", + name: "", + permissions: "0", + color: "", + highlighted: false, + }; + + return account; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Extract username from a URL path. + * Handles /@username, /users/username patterns. + */ +function extractUsername(url) { + if (!url) return ""; + try { + const { pathname } = new URL(url); + const atMatch = pathname.match(/\/@([^/]+)/); + if (atMatch) return atMatch[1]; + const usersMatch = pathname.match(/\/users\/([^/]+)/); + if (usersMatch) return usersMatch[1]; + return ""; + } catch { + return ""; + } +} + +/** + * Extract domain from a URL. + */ +function extractDomain(url) { + if (!url) return ""; + try { + return new URL(url).hostname; + } catch { + return ""; + } +} diff --git a/lib/mastodon/entities/instance.js b/lib/mastodon/entities/instance.js new file mode 100644 index 0000000..1e23f3b --- /dev/null +++ b/lib/mastodon/entities/instance.js @@ -0,0 +1 @@ +// Instance v1/v2 serializer — implemented in Task 8 diff --git a/lib/mastodon/entities/media.js b/lib/mastodon/entities/media.js new file mode 100644 index 0000000..25f3ce0 --- /dev/null +++ b/lib/mastodon/entities/media.js @@ -0,0 +1,38 @@ +/** + * MediaAttachment entity serializer for Mastodon Client API. + * + * Converts stored media metadata to Mastodon MediaAttachment shape. + */ + +/** + * Serialize a MediaAttachment entity. + * + * @param {object} media - Media document from ap_media collection + * @returns {object} Mastodon MediaAttachment entity + */ +export function serializeMediaAttachment(media) { + const type = detectMediaType(media.contentType || media.type || ""); + + return { + id: media._id ? media._id.toString() : media.id || "", + type, + url: media.url || "", + preview_url: media.thumbnailUrl || media.url || "", + remote_url: null, + text_url: media.url || "", + meta: media.meta || {}, + description: media.description || media.alt || null, + blurhash: media.blurhash || null, + }; +} + +/** + * Map MIME type or simple type string to Mastodon media type. + */ +function detectMediaType(contentType) { + if (contentType.startsWith("image/") || contentType === "image") return "image"; + if (contentType.startsWith("video/") || contentType === "video") return "video"; + if (contentType.startsWith("audio/") || contentType === "audio") return "audio"; + if (contentType.startsWith("image/gif")) return "gifv"; + return "unknown"; +} diff --git a/lib/mastodon/entities/notification.js b/lib/mastodon/entities/notification.js new file mode 100644 index 0000000..a22e904 --- /dev/null +++ b/lib/mastodon/entities/notification.js @@ -0,0 +1,118 @@ +/** + * Notification entity serializer for Mastodon Client API. + * + * Converts ap_notifications documents into the Mastodon Notification JSON shape. + * + * Internal type -> Mastodon type mapping: + * like -> favourite + * boost -> reblog + * follow -> follow + * reply -> mention + * mention -> mention + * dm -> mention (status will have visibility: "direct") + */ +import { serializeAccount } from "./account.js"; +import { serializeStatus } from "./status.js"; + +/** + * Map internal notification types to Mastodon API types. + */ +const TYPE_MAP = { + like: "favourite", + boost: "reblog", + follow: "follow", + follow_request: "follow_request", + reply: "mention", + mention: "mention", + dm: "mention", + report: "admin.report", +}; + +/** + * Serialize a notification document as a Mastodon Notification entity. + * + * @param {object} notif - ap_notifications document + * @param {object} options + * @param {string} options.baseUrl - Server base URL + * @param {Map} [options.statusMap] - Pre-fetched statuses keyed by targetUrl + * @param {object} [options.interactionState] - { favouritedIds, rebloggedIds, bookmarkedIds } + * @returns {object|null} Mastodon Notification entity + */ +export function serializeNotification(notif, { baseUrl, statusMap, interactionState }) { + if (!notif) return null; + + const mastodonType = TYPE_MAP[notif.type] || notif.type; + + // Build the actor account from notification fields + const account = serializeAccount( + { + name: notif.actorName, + url: notif.actorUrl, + photo: notif.actorPhoto, + handle: notif.actorHandle, + }, + { baseUrl }, + ); + + // Resolve the associated status (for favourite, reblog, mention types) + let status = null; + if (notif.targetUrl && statusMap) { + const timelineItem = statusMap.get(notif.targetUrl); + if (timelineItem) { + status = serializeStatus(timelineItem, { + baseUrl, + favouritedIds: interactionState?.favouritedIds || new Set(), + rebloggedIds: interactionState?.rebloggedIds || new Set(), + bookmarkedIds: interactionState?.bookmarkedIds || new Set(), + pinnedIds: new Set(), + }); + } + } + + // For mentions/replies that don't have a matching timeline item, + // construct a minimal status from the notification content + if (!status && notif.content && (mastodonType === "mention")) { + status = { + id: notif._id.toString(), + created_at: notif.published || notif.createdAt || new Date().toISOString(), + in_reply_to_id: null, + in_reply_to_account_id: null, + sensitive: false, + spoiler_text: "", + visibility: notif.type === "dm" ? "direct" : "public", + language: null, + uri: notif.uid || "", + url: notif.targetUrl || notif.uid || "", + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + edited_at: null, + favourited: false, + reblogged: false, + muted: false, + bookmarked: false, + pinned: false, + content: notif.content?.html || notif.content?.text || "", + filtered: null, + reblog: null, + application: null, + account, + media_attachments: [], + mentions: [], + tags: [], + emojis: [], + card: null, + poll: null, + }; + } + + return { + id: notif._id.toString(), + type: mastodonType, + created_at: notif.published instanceof Date + ? notif.published.toISOString() + : notif.published || notif.createdAt || new Date().toISOString(), + account, + status, + }; +} diff --git a/lib/mastodon/entities/relationship.js b/lib/mastodon/entities/relationship.js new file mode 100644 index 0000000..df5aede --- /dev/null +++ b/lib/mastodon/entities/relationship.js @@ -0,0 +1,38 @@ +/** + * Relationship entity serializer for Mastodon Client API. + * + * Represents the relationship between the authenticated user + * and another account. + */ + +/** + * Serialize a Relationship entity. + * + * @param {string} id - Account ID + * @param {object} state - Relationship state + * @param {boolean} [state.following=false] + * @param {boolean} [state.followed_by=false] + * @param {boolean} [state.blocking=false] + * @param {boolean} [state.muting=false] + * @param {boolean} [state.requested=false] + * @returns {object} Mastodon Relationship entity + */ +export function serializeRelationship(id, state = {}) { + return { + id, + following: state.following || false, + showing_reblogs: state.following || false, + notifying: false, + languages: [], + followed_by: state.followed_by || false, + blocking: state.blocking || false, + blocked_by: false, + muting: state.muting || false, + muting_notifications: state.muting || false, + requested: state.requested || false, + requested_by: false, + domain_blocking: false, + endorsed: false, + note: "", + }; +} diff --git a/lib/mastodon/entities/sanitize.js b/lib/mastodon/entities/sanitize.js new file mode 100644 index 0000000..8c8da1a --- /dev/null +++ b/lib/mastodon/entities/sanitize.js @@ -0,0 +1,111 @@ +/** + * XSS HTML sanitizer for Mastodon Client API responses. + * + * Strips dangerous HTML while preserving safe markup that + * Mastodon clients expect (links, paragraphs, line breaks, + * inline formatting, mentions, hashtags). + */ + +/** + * Allowed HTML tags in Mastodon API content fields. + * Matches what Mastodon itself permits in status content. + */ +const ALLOWED_TAGS = new Set([ + "a", + "br", + "p", + "span", + "strong", + "em", + "b", + "i", + "u", + "s", + "del", + "pre", + "code", + "blockquote", + "ul", + "ol", + "li", +]); + +/** + * Allowed attributes per tag. + */ +const ALLOWED_ATTRS = { + a: new Set(["href", "rel", "class", "target"]), + span: new Set(["class"]), +}; + +/** + * Sanitize HTML content for safe inclusion in API responses. + * + * Strips all tags not in the allowlist and removes disallowed attributes. + * This is a lightweight sanitizer — for production, consider a + * battle-tested library like DOMPurify or sanitize-html. + * + * @param {string} html - Raw HTML string + * @returns {string} Sanitized HTML + */ +export function sanitizeHtml(html) { + if (!html || typeof html !== "string") return ""; + + return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tagName) => { + const tag = tagName.toLowerCase(); + + // Closing tag + if (match.startsWith("` : ""; + } + + // Opening tag — check if allowed + if (!ALLOWED_TAGS.has(tag)) return ""; + + // Self-closing br + if (tag === "br") return "
"; + + // Strip disallowed attributes + const allowedAttrs = ALLOWED_ATTRS[tag]; + if (!allowedAttrs) return `<${tag}>`; + + const attrs = []; + const attrRegex = /([a-z][a-z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/gi; + let attrMatch; + while ((attrMatch = attrRegex.exec(match)) !== null) { + const attrName = attrMatch[1].toLowerCase(); + if (attrName === tag) continue; // skip tag name itself + const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? ""; + if (allowedAttrs.has(attrName)) { + // Block javascript: URIs in href + if (attrName === "href" && /^\s*javascript:/i.test(attrValue)) continue; + attrs.push(`${attrName}="${escapeAttr(attrValue)}"`); + } + } + + return attrs.length > 0 ? `<${tag} ${attrs.join(" ")}>` : `<${tag}>`; + }); +} + +/** + * Escape HTML attribute value. + * @param {string} value + * @returns {string} + */ +function escapeAttr(value) { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +/** + * Strip all HTML tags, returning plain text. + * @param {string} html + * @returns {string} + */ +export function stripHtml(html) { + if (!html || typeof html !== "string") return ""; + return html.replace(/<[^>]*>/g, "").trim(); +} diff --git a/lib/mastodon/entities/status.js b/lib/mastodon/entities/status.js new file mode 100644 index 0000000..b561675 --- /dev/null +++ b/lib/mastodon/entities/status.js @@ -0,0 +1,289 @@ +/** + * Status entity serializer for Mastodon Client API. + * + * Converts ap_timeline documents into the Mastodon Status JSON shape. + * + * CORRECTED field mappings (based on actual extractObjectData output): + * content <- content.html (NOT contentHtml) + * uri <- uid (NOT activityUrl) + * account <- author { name, url, photo, handle, emojis, bot } + * media <- photo[] + video[] + audio[] (NOT single attachments[]) + * card <- linkPreviews[0] (NOT single card) + * tags <- category[] (NOT tags[]) + * counts <- counts.boosts, counts.likes, counts.replies + * boost <- type:"boost" + boostedBy (flat, NOT nested sharedItem) + */ +import { serializeAccount } from "./account.js"; +import { sanitizeHtml } from "./sanitize.js"; + +/** + * Serialize an ap_timeline document as a Mastodon Status entity. + * + * @param {object} item - ap_timeline document + * @param {object} options + * @param {string} options.baseUrl - Server base URL + * @param {Set} [options.favouritedIds] - UIDs the user has liked + * @param {Set} [options.rebloggedIds] - UIDs the user has boosted + * @param {Set} [options.bookmarkedIds] - UIDs the user has bookmarked + * @param {Set} [options.pinnedIds] - UIDs the user has pinned + * @returns {object} Mastodon Status entity + */ +export function serializeStatus(item, { baseUrl, favouritedIds, rebloggedIds, bookmarkedIds, pinnedIds }) { + if (!item) return null; + + const id = item._id.toString(); + const uid = item.uid || ""; + const url = item.url || uid; + + // Handle boosts — reconstruct nested reblog wrapper + if (item.type === "boost" && item.boostedBy) { + // The outer status represents the boost action + // The inner status is the original post (the item itself minus boost metadata) + const innerItem = { ...item, type: "note", boostedBy: undefined, boostedAt: undefined }; + const innerStatus = serializeStatus(innerItem, { + baseUrl, + favouritedIds, + rebloggedIds, + bookmarkedIds, + pinnedIds, + }); + + return { + id, + created_at: item.boostedAt || item.createdAt || new Date().toISOString(), + in_reply_to_id: null, + in_reply_to_account_id: null, + sensitive: false, + spoiler_text: "", + visibility: item.visibility || "public", + language: null, + uri: uid, + url, + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + edited_at: null, + favourited: false, + reblogged: rebloggedIds?.has(uid) || false, + muted: false, + bookmarked: false, + pinned: false, + content: "", + filtered: null, + reblog: innerStatus, + application: null, + account: serializeAccount(item.boostedBy, { baseUrl }), + media_attachments: [], + mentions: [], + tags: [], + emojis: [], + card: null, + poll: null, + }; + } + + // Regular status (note, article, question) + const content = item.content?.html || item.content?.text || ""; + const spoilerText = item.summary || ""; + const sensitive = item.sensitive || false; + const visibility = item.visibility || "public"; + const language = item.language || null; + const published = item.published || item.createdAt || new Date().toISOString(); + const editedAt = item.updated || item.updatedAt || null; + + // Media attachments — merge photo, video, audio arrays + const mediaAttachments = []; + let attachmentCounter = 0; + + if (item.photo?.length > 0) { + for (const p of item.photo) { + mediaAttachments.push({ + id: `${id}-${attachmentCounter++}`, + type: "image", + url: typeof p === "string" ? p : p.url, + preview_url: typeof p === "string" ? p : p.url, + remote_url: typeof p === "string" ? p : p.url, + text_url: null, + meta: buildImageMeta(p), + description: typeof p === "object" ? p.alt || "" : "", + blurhash: null, + }); + } + } + + if (item.video?.length > 0) { + for (const v of item.video) { + mediaAttachments.push({ + id: `${id}-${attachmentCounter++}`, + type: "video", + url: typeof v === "string" ? v : v.url, + preview_url: typeof v === "string" ? v : v.url, + remote_url: typeof v === "string" ? v : v.url, + text_url: null, + meta: null, + description: typeof v === "object" ? v.alt || "" : "", + blurhash: null, + }); + } + } + + if (item.audio?.length > 0) { + for (const a of item.audio) { + mediaAttachments.push({ + id: `${id}-${attachmentCounter++}`, + type: "audio", + url: typeof a === "string" ? a : a.url, + preview_url: typeof a === "string" ? a : a.url, + remote_url: typeof a === "string" ? a : a.url, + text_url: null, + meta: null, + description: typeof a === "object" ? a.alt || "" : "", + blurhash: null, + }); + } + } + + // Link preview -> card + const card = serializeCard(item.linkPreviews?.[0]); + + // Tags from category[] + const tags = (item.category || []).map((tag) => ({ + name: tag, + url: `${baseUrl}/tags/${encodeURIComponent(tag)}`, + })); + + // Mentions + const mentions = (item.mentions || []).map((m) => ({ + id: "0", // We don't have stable IDs for mentioned accounts + username: m.name || "", + url: m.url || "", + acct: m.name || "", + })); + + // Custom emojis + const emojis = (item.emojis || []).map((e) => ({ + shortcode: e.shortcode || "", + url: e.url || "", + static_url: e.url || "", + visible_in_picker: true, + })); + + // Counts + const repliesCount = item.counts?.replies ?? 0; + const reblogsCount = item.counts?.boosts ?? 0; + const favouritesCount = item.counts?.likes ?? 0; + + // Poll + const poll = serializePoll(item, id); + + // Interaction state + const favourited = favouritedIds?.has(uid) || false; + const reblogged = rebloggedIds?.has(uid) || false; + const bookmarked = bookmarkedIds?.has(uid) || false; + const pinned = pinnedIds?.has(uid) || false; + + return { + id, + created_at: published, + in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID + in_reply_to_account_id: null, // TODO: resolve + sensitive, + spoiler_text: spoilerText, + visibility, + language, + uri: uid, + url, + replies_count: repliesCount, + reblogs_count: reblogsCount, + favourites_count: favouritesCount, + edited_at: editedAt || null, + favourited, + reblogged, + muted: false, + bookmarked, + pinned, + content: sanitizeHtml(content), + filtered: null, + reblog: null, + application: null, + account: item.author + ? serializeAccount(item.author, { baseUrl }) + : null, + media_attachments: mediaAttachments, + mentions, + tags, + emojis, + card, + poll, + }; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Serialize a linkPreview object as a Mastodon PreviewCard. + */ +function serializeCard(preview) { + if (!preview) return null; + + return { + url: preview.url || "", + title: preview.title || "", + description: preview.description || "", + type: "link", + author_name: "", + author_url: "", + provider_name: preview.domain || "", + provider_url: "", + html: "", + width: 0, + height: 0, + image: preview.image || null, + embed_url: "", + blurhash: null, + language: null, + published_at: null, + }; +} + +/** + * Build image meta object for media attachments. + */ +function buildImageMeta(photo) { + if (typeof photo === "string") return null; + if (!photo.width && !photo.height) return null; + + return { + original: { + width: photo.width || 0, + height: photo.height || 0, + size: photo.width && photo.height ? `${photo.width}x${photo.height}` : null, + aspect: photo.width && photo.height ? photo.width / photo.height : null, + }, + }; +} + +/** + * Serialize poll data from a timeline item. + */ +function serializePoll(item, statusId) { + if (!item.pollOptions?.length) return null; + + const totalVotes = item.pollOptions.reduce((sum, o) => sum + (o.votes || 0), 0); + + return { + id: statusId, + expires_at: item.pollEndTime || null, + expired: item.pollClosed || false, + multiple: false, + votes_count: totalVotes, + voters_count: item.votersCount || null, + options: item.pollOptions.map((o) => ({ + title: o.name || "", + votes_count: o.votes || 0, + })), + emojis: [], + voted: false, + own_votes: [], + }; +} diff --git a/lib/mastodon/helpers/id-mapping.js b/lib/mastodon/helpers/id-mapping.js new file mode 100644 index 0000000..76e6d23 --- /dev/null +++ b/lib/mastodon/helpers/id-mapping.js @@ -0,0 +1,32 @@ +/** + * Deterministic ID mapping for Mastodon Client API. + * + * Local accounts use MongoDB _id.toString(). + * Remote actors use sha256(actorUrl).slice(0, 24) for stable IDs + * without requiring a dedicated accounts collection. + */ +import crypto from "node:crypto"; + +/** + * Generate a deterministic ID for a remote actor URL. + * @param {string} actorUrl - The remote actor's URL + * @returns {string} 24-character hex ID + */ +export function remoteActorId(actorUrl) { + return crypto.createHash("sha256").update(actorUrl).digest("hex").slice(0, 24); +} + +/** + * Get the Mastodon API ID for an account. + * @param {object} actor - Actor object (local profile or remote author) + * @param {boolean} isLocal - Whether this is the local profile + * @returns {string} + */ +export function accountId(actor, isLocal = false) { + if (isLocal && actor._id) { + return actor._id.toString(); + } + // Remote actors: use URL-based deterministic hash + const url = actor.url || actor.actorUrl || ""; + return url ? remoteActorId(url) : "0"; +} diff --git a/lib/mastodon/helpers/interactions.js b/lib/mastodon/helpers/interactions.js new file mode 100644 index 0000000..3072b02 --- /dev/null +++ b/lib/mastodon/helpers/interactions.js @@ -0,0 +1,278 @@ +/** + * Shared interaction logic for like/unlike, boost/unboost, bookmark/unbookmark. + * + * Extracted from admin controllers (interactions-like.js, interactions-boost.js) + * so that both the admin UI and Mastodon Client API can reuse the same core logic. + * + * Each function accepts a context object instead of Express req/res, + * making them transport-agnostic. + */ + +import { resolveAuthor } from "../../resolve-author.js"; + +/** + * Like a post — send Like activity and track in ap_interactions. + * + * @param {object} params + * @param {string} params.targetUrl - URL of the post to like + * @param {object} params.federation - Fedify federation instance + * @param {string} params.handle - Local actor handle + * @param {string} params.publicationUrl - Publication base URL + * @param {object} params.collections - MongoDB collections (Map or object) + * @param {object} params.interactions - ap_interactions collection + * @returns {Promise<{ activityId: string }>} + */ +export async function likePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) { + const { Like } = await import("@fedify/fedify/vocab"); + const ctx = federation.createContext( + new URL(publicationUrl), + { handle, publicationUrl }, + ); + + const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); + const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); + + const uuid = crypto.randomUUID(); + const baseUrl = publicationUrl.replace(/\/$/, ""); + const activityId = `${baseUrl}/activitypub/likes/${uuid}`; + + const like = new Like({ + id: new URL(activityId), + actor: ctx.getActorUri(handle), + object: new URL(targetUrl), + }); + + if (recipient) { + await ctx.sendActivity({ identifier: handle }, recipient, like, { + orderingKey: targetUrl, + }); + } + + if (interactions) { + await interactions.updateOne( + { objectUrl: targetUrl, type: "like" }, + { + $set: { + objectUrl: targetUrl, + type: "like", + activityId, + recipientUrl: recipient?.id?.href || "", + createdAt: new Date().toISOString(), + }, + }, + { upsert: true }, + ); + } + + return { activityId }; +} + +/** + * Unlike a post — send Undo(Like) activity and remove from ap_interactions. + * + * @param {object} params + * @param {string} params.targetUrl - URL of the post to unlike + * @param {object} params.federation - Fedify federation instance + * @param {string} params.handle - Local actor handle + * @param {string} params.publicationUrl - Publication base URL + * @param {object} params.collections - MongoDB collections + * @param {object} params.interactions - ap_interactions collection + * @returns {Promise} + */ +export async function unlikePost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) { + const existing = interactions + ? await interactions.findOne({ objectUrl: targetUrl, type: "like" }) + : null; + + if (!existing) { + return; + } + + const { Like, Undo } = await import("@fedify/fedify/vocab"); + const ctx = federation.createContext( + new URL(publicationUrl), + { handle, publicationUrl }, + ); + + const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); + const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); + + if (recipient) { + const like = new Like({ + id: existing.activityId ? new URL(existing.activityId) : undefined, + actor: ctx.getActorUri(handle), + object: new URL(targetUrl), + }); + + const undo = new Undo({ + actor: ctx.getActorUri(handle), + object: like, + }); + + await ctx.sendActivity({ identifier: handle }, recipient, undo, { + orderingKey: targetUrl, + }); + } + + if (interactions) { + await interactions.deleteOne({ objectUrl: targetUrl, type: "like" }); + } +} + +/** + * Boost a post — send Announce activity and track in ap_interactions. + * + * @param {object} params + * @param {string} params.targetUrl - URL of the post to boost + * @param {object} params.federation - Fedify federation instance + * @param {string} params.handle - Local actor handle + * @param {string} params.publicationUrl - Publication base URL + * @param {object} params.collections - MongoDB collections + * @param {object} params.interactions - ap_interactions collection + * @returns {Promise<{ activityId: string }>} + */ +export async function boostPost({ targetUrl, federation, handle, publicationUrl, collections, interactions }) { + const { Announce } = await import("@fedify/fedify/vocab"); + const ctx = federation.createContext( + new URL(publicationUrl), + { handle, publicationUrl }, + ); + + const uuid = crypto.randomUUID(); + const baseUrl = publicationUrl.replace(/\/$/, ""); + const activityId = `${baseUrl}/activitypub/boosts/${uuid}`; + + const publicAddress = new URL("https://www.w3.org/ns/activitystreams#Public"); + const followersUri = ctx.getFollowersUri(handle); + + const announce = new Announce({ + id: new URL(activityId), + actor: ctx.getActorUri(handle), + object: new URL(targetUrl), + to: publicAddress, + cc: followersUri, + }); + + // Send to followers + await ctx.sendActivity({ identifier: handle }, "followers", announce, { + preferSharedInbox: true, + syncCollection: true, + orderingKey: targetUrl, + }); + + // Also send directly to the original post author + const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); + const recipient = await resolveAuthor(targetUrl, ctx, documentLoader, collections); + if (recipient) { + try { + await ctx.sendActivity({ identifier: handle }, recipient, announce, { + orderingKey: targetUrl, + }); + } catch { + // Non-critical — follower delivery already happened + } + } + + if (interactions) { + await interactions.updateOne( + { objectUrl: targetUrl, type: "boost" }, + { + $set: { + objectUrl: targetUrl, + type: "boost", + activityId, + createdAt: new Date().toISOString(), + }, + }, + { upsert: true }, + ); + } + + return { activityId }; +} + +/** + * Unboost a post — send Undo(Announce) activity and remove from ap_interactions. + * + * @param {object} params + * @param {string} params.targetUrl - URL of the post to unboost + * @param {object} params.federation - Fedify federation instance + * @param {string} params.handle - Local actor handle + * @param {string} params.publicationUrl - Publication base URL + * @param {object} params.interactions - ap_interactions collection + * @returns {Promise} + */ +export async function unboostPost({ targetUrl, federation, handle, publicationUrl, interactions }) { + const existing = interactions + ? await interactions.findOne({ objectUrl: targetUrl, type: "boost" }) + : null; + + if (!existing) { + return; + } + + const { Announce, Undo } = await import("@fedify/fedify/vocab"); + const ctx = federation.createContext( + new URL(publicationUrl), + { handle, publicationUrl }, + ); + + const announce = new Announce({ + id: existing.activityId ? new URL(existing.activityId) : undefined, + actor: ctx.getActorUri(handle), + object: new URL(targetUrl), + }); + + const undo = new Undo({ + actor: ctx.getActorUri(handle), + object: announce, + }); + + await ctx.sendActivity({ identifier: handle }, "followers", undo, { + preferSharedInbox: true, + syncCollection: true, + orderingKey: targetUrl, + }); + + if (interactions) { + await interactions.deleteOne({ objectUrl: targetUrl, type: "boost" }); + } +} + +/** + * Bookmark a post — local-only, no federation. + * + * @param {object} params + * @param {string} params.targetUrl - URL of the post to bookmark + * @param {object} params.interactions - ap_interactions collection + * @returns {Promise} + */ +export async function bookmarkPost({ targetUrl, interactions }) { + if (!interactions) return; + + await interactions.updateOne( + { objectUrl: targetUrl, type: "bookmark" }, + { + $set: { + objectUrl: targetUrl, + type: "bookmark", + createdAt: new Date().toISOString(), + }, + }, + { upsert: true }, + ); +} + +/** + * Remove a bookmark — local-only, no federation. + * + * @param {object} params + * @param {string} params.targetUrl - URL of the post to unbookmark + * @param {object} params.interactions - ap_interactions collection + * @returns {Promise} + */ +export async function unbookmarkPost({ targetUrl, interactions }) { + if (!interactions) return; + + await interactions.deleteOne({ objectUrl: targetUrl, type: "bookmark" }); +} diff --git a/lib/mastodon/helpers/pagination.js b/lib/mastodon/helpers/pagination.js new file mode 100644 index 0000000..f266107 --- /dev/null +++ b/lib/mastodon/helpers/pagination.js @@ -0,0 +1,130 @@ +/** + * Mastodon-compatible cursor pagination helpers. + * + * Uses MongoDB ObjectId as cursor (chronologically ordered). + * Emits RFC 8288 Link headers that masto.js / Phanpy parse. + */ +import { ObjectId } from "mongodb"; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 40; + +/** + * Parse and clamp the limit parameter. + * + * @param {string|number} raw - Raw limit value from query string + * @returns {number} + */ +export function parseLimit(raw) { + const n = Number.parseInt(String(raw), 10); + if (!Number.isFinite(n) || n < 1) return DEFAULT_LIMIT; + return Math.min(n, MAX_LIMIT); +} + +/** + * Build a MongoDB filter object for cursor-based pagination. + * + * Mastodon cursor params (all optional, applied to `_id`): + * max_id — return items older than this ID (exclusive) + * min_id — return items newer than this ID (exclusive), closest first + * since_id — return items newer than this ID (exclusive), most recent first + * + * @param {object} baseFilter - Existing MongoDB filter to extend + * @param {object} cursors + * @param {string} [cursors.max_id] + * @param {string} [cursors.min_id] + * @param {string} [cursors.since_id] + * @returns {{ filter: object, sort: object, reverse: boolean }} + */ +export function buildPaginationQuery(baseFilter, { max_id, min_id, since_id } = {}) { + const filter = { ...baseFilter }; + let sort = { _id: -1 }; // newest first (default) + let reverse = false; + + if (max_id) { + try { + filter._id = { ...filter._id, $lt: new ObjectId(max_id) }; + } catch { + // Invalid ObjectId — ignore + } + } + + if (since_id) { + try { + filter._id = { ...filter._id, $gt: new ObjectId(since_id) }; + } catch { + // Invalid ObjectId — ignore + } + } + + if (min_id) { + try { + filter._id = { ...filter._id, $gt: new ObjectId(min_id) }; + // min_id returns results closest to the cursor, so sort ascending + // then reverse the results before returning + sort = { _id: 1 }; + reverse = true; + } catch { + // Invalid ObjectId — ignore + } + } + + return { filter, sort, reverse }; +} + +/** + * Set the Link pagination header on an Express response. + * + * @param {object} res - Express response object + * @param {object} req - Express request object (for building URLs) + * @param {Array} items - Result items (must have `_id` or `id`) + * @param {number} limit - The limit used for the query + */ +export function setPaginationHeaders(res, req, items, limit) { + if (!items?.length) return; + + // Only emit Link if we got a full page (may have more) + if (items.length < limit) return; + + const firstId = itemId(items[0]); + const lastId = itemId(items[items.length - 1]); + + if (!firstId || !lastId) return; + + const baseUrl = `${req.protocol}://${req.get("host")}${req.path}`; + + // Preserve existing query params (like types[] for notifications) + const existingParams = new URLSearchParams(); + for (const [key, value] of Object.entries(req.query)) { + if (key === "max_id" || key === "min_id" || key === "since_id") continue; + if (Array.isArray(value)) { + for (const v of value) existingParams.append(key, v); + } else { + existingParams.set(key, String(value)); + } + } + + const links = []; + + // rel="next" — older items (max_id = last item's ID) + const nextParams = new URLSearchParams(existingParams); + nextParams.set("max_id", lastId); + links.push(`<${baseUrl}?${nextParams.toString()}>; rel="next"`); + + // rel="prev" — newer items (min_id = first item's ID) + const prevParams = new URLSearchParams(existingParams); + prevParams.set("min_id", firstId); + links.push(`<${baseUrl}?${prevParams.toString()}>; rel="prev"`); + + res.set("Link", links.join(", ")); +} + +/** + * Extract the string ID from an item. + */ +function itemId(item) { + if (!item) return null; + if (item._id) return item._id.toString(); + if (item.id) return String(item.id); + return null; +} diff --git a/lib/mastodon/middleware/cors.js b/lib/mastodon/middleware/cors.js new file mode 100644 index 0000000..a8dfeca --- /dev/null +++ b/lib/mastodon/middleware/cors.js @@ -0,0 +1,25 @@ +/** + * CORS middleware for Mastodon Client API routes. + * + * Mandatory for browser-based SPA clients like Phanpy that make + * cross-origin requests. Without this, the browser's Same-Origin + * Policy blocks all API calls. + */ + +const ALLOWED_METHODS = "GET, HEAD, POST, PUT, DELETE, PATCH"; +const ALLOWED_HEADERS = "Authorization, Content-Type, Idempotency-Key"; +const EXPOSED_HEADERS = "Link"; + +export function corsMiddleware(req, res, next) { + res.set("Access-Control-Allow-Origin", "*"); + res.set("Access-Control-Allow-Methods", ALLOWED_METHODS); + res.set("Access-Control-Allow-Headers", ALLOWED_HEADERS); + res.set("Access-Control-Expose-Headers", EXPOSED_HEADERS); + + // Handle preflight requests + if (req.method === "OPTIONS") { + return res.status(204).end(); + } + + next(); +} diff --git a/lib/mastodon/middleware/error-handler.js b/lib/mastodon/middleware/error-handler.js new file mode 100644 index 0000000..b965e6e --- /dev/null +++ b/lib/mastodon/middleware/error-handler.js @@ -0,0 +1,37 @@ +/** + * Error handling middleware for Mastodon Client API routes. + * + * Ensures all errors return JSON in Mastodon's expected format + * instead of HTML error pages that masto.js cannot parse. + * + * Standard format: { "error": "description" } + * OAuth format: { "error": "error_type", "error_description": "..." } + */ + +// eslint-disable-next-line no-unused-vars +export function errorHandler(err, req, res, _next) { + const status = err.status || err.statusCode || 500; + + // OAuth errors use RFC 6749 format + if (err.oauthError) { + return res.status(status).json({ + error: err.oauthError, + error_description: err.message || "An error occurred", + }); + } + + // Standard Mastodon error format + res.status(status).json({ + error: err.message || "An unexpected error occurred", + }); +} + +/** + * 501 catch-all for unimplemented API endpoints. + * Must be mounted AFTER all implemented routes. + */ +export function notImplementedHandler(req, res) { + res.status(501).json({ + error: "Not implemented", + }); +} diff --git a/lib/mastodon/middleware/scope-required.js b/lib/mastodon/middleware/scope-required.js new file mode 100644 index 0000000..acba953 --- /dev/null +++ b/lib/mastodon/middleware/scope-required.js @@ -0,0 +1,86 @@ +/** + * Scope enforcement middleware for Mastodon Client API. + * + * Supports scope hierarchy: parent scope covers all children. + * "read" grants "read:accounts", "read:statuses", etc. + * "write" grants "write:statuses", "write:favourites", etc. + * + * Legacy "follow" scope maps to read/write for blocks, follows, and mutes. + */ + +/** + * Scopes that the legacy "follow" scope grants access to. + */ +const FOLLOW_SCOPE_EXPANSION = [ + "read:blocks", + "write:blocks", + "read:follows", + "write:follows", + "read:mutes", + "write:mutes", +]; + +/** + * Create middleware that checks if the token has the required scope. + * + * @param {...string} requiredScopes - One or more scopes (any match = pass) + * @returns {Function} Express middleware + */ +export function scopeRequired(...requiredScopes) { + return (req, res, next) => { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ + error: "The access token is invalid", + }); + } + + const grantedScopes = token.scopes || []; + + const hasScope = requiredScopes.some((required) => + checkScope(grantedScopes, required), + ); + + if (!hasScope) { + return res.status(403).json({ + error: `This action is outside the authorized scopes. Required: ${requiredScopes.join(" or ")}`, + }); + } + + next(); + }; +} + +/** + * Check if granted scopes satisfy a required scope. + * + * Rules: + * - Exact match: "read:accounts" satisfies "read:accounts" + * - Parent match: "read" satisfies "read:accounts" + * - "follow" expands to read/write for blocks, follows, mutes + * - "profile" satisfies "read:accounts" (for verify_credentials) + * + * @param {string[]} granted - Scopes on the token + * @param {string} required - Scope being checked + * @returns {boolean} + */ +function checkScope(granted, required) { + // Exact match + if (granted.includes(required)) return true; + + // Parent scope: "read" covers "read:*", "write" covers "write:*" + const [parent] = required.split(":"); + if (parent && granted.includes(parent)) return true; + + // Legacy "follow" scope expansion + if (granted.includes("follow") && FOLLOW_SCOPE_EXPANSION.includes(required)) { + return true; + } + + // "profile" scope can satisfy "read:accounts" + if (required === "read:accounts" && granted.includes("profile")) { + return true; + } + + return false; +} diff --git a/lib/mastodon/middleware/token-required.js b/lib/mastodon/middleware/token-required.js new file mode 100644 index 0000000..6517278 --- /dev/null +++ b/lib/mastodon/middleware/token-required.js @@ -0,0 +1,57 @@ +/** + * Bearer token validation middleware for Mastodon Client API. + * + * Extracts the Bearer token from the Authorization header, + * validates it against the ap_oauth_tokens collection, + * and attaches token data to `req.mastodonToken`. + */ + +/** + * Require a valid Bearer token. Returns 401 if invalid/missing. + */ +export async function tokenRequired(req, res, next) { + const token = await resolveToken(req); + + if (!token) { + return res.status(401).json({ + error: "The access token is invalid", + }); + } + + req.mastodonToken = token; + next(); +} + +/** + * Optional token — sets req.mastodonToken to null if absent. + * For public endpoints that personalize when authenticated. + */ +export async function optionalToken(req, res, next) { + req.mastodonToken = await resolveToken(req); + next(); +} + +/** + * Extract and validate Bearer token from request. + * @returns {object|null} Token document or null + */ +async function resolveToken(req) { + const authHeader = req.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) return null; + + const accessToken = authHeader.slice(7); + if (!accessToken) return null; + + const collections = req.app.locals.mastodonCollections; + const token = await collections.ap_oauth_tokens.findOne({ + accessToken, + revokedAt: null, + }); + + if (!token) return null; + + // Check expiry if set + if (token.expiresAt && token.expiresAt < new Date()) return null; + + return token; +} diff --git a/lib/mastodon/router.js b/lib/mastodon/router.js new file mode 100644 index 0000000..ffdd92d --- /dev/null +++ b/lib/mastodon/router.js @@ -0,0 +1,96 @@ +/** + * Mastodon Client API — main router. + * + * Combines all sub-routers, applies CORS and error handling middleware. + * Mounted at "/" via Indiekit.addEndpoint() so Mastodon clients can access + * /api/v1/*, /api/v2/*, /oauth/* at the domain root. + */ +import express from "express"; +import { corsMiddleware } from "./middleware/cors.js"; +import { tokenRequired, optionalToken } from "./middleware/token-required.js"; +import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js"; + +// Route modules +import oauthRouter from "./routes/oauth.js"; +import instanceRouter from "./routes/instance.js"; +import accountsRouter from "./routes/accounts.js"; +import statusesRouter from "./routes/statuses.js"; +import timelinesRouter from "./routes/timelines.js"; +import notificationsRouter from "./routes/notifications.js"; +import searchRouter from "./routes/search.js"; +import mediaRouter from "./routes/media.js"; +import stubsRouter from "./routes/stubs.js"; + +/** + * Create the combined Mastodon API router. + * + * @param {object} options + * @param {object} options.collections - MongoDB collections object + * @param {object} [options.pluginOptions] - Plugin options (handle, etc.) + * @returns {import("express").Router} Express router + */ +export function createMastodonRouter({ collections, pluginOptions = {} }) { + const router = express.Router(); // eslint-disable-line new-cap + + // ─── Body parsers ─────────────────────────────────────────────────────── + // Mastodon clients send JSON, form-urlencoded, and occasionally text/plain. + // These must be applied before route handlers. + router.use("/api", express.json()); + router.use("/api", express.urlencoded({ extended: true })); + router.use("/oauth", express.json()); + router.use("/oauth", express.urlencoded({ extended: true })); + + // ─── CORS ─────────────────────────────────────────────────────────────── + router.use("/api", corsMiddleware); + router.use("/oauth/token", corsMiddleware); + router.use("/oauth/revoke", corsMiddleware); + router.use("/.well-known/oauth-authorization-server", corsMiddleware); + + // ─── Inject collections + plugin options into req ─────────────────────── + router.use("/api", (req, res, next) => { + req.app.locals.mastodonCollections = collections; + req.app.locals.mastodonPluginOptions = pluginOptions; + next(); + }); + router.use("/oauth", (req, res, next) => { + req.app.locals.mastodonCollections = collections; + req.app.locals.mastodonPluginOptions = pluginOptions; + next(); + }); + router.use("/.well-known/oauth-authorization-server", (req, res, next) => { + req.app.locals.mastodonCollections = collections; + req.app.locals.mastodonPluginOptions = pluginOptions; + next(); + }); + + // ─── Token resolution ─────────────────────────────────────────────────── + // Apply optional token resolution to all API routes so handlers can check + // req.mastodonToken. Specific routes that require auth use tokenRequired. + router.use("/api", optionalToken); + + // ─── OAuth routes (no token required for most) ────────────────────────── + router.use(oauthRouter); + + // ─── Public API routes (no auth required) ─────────────────────────────── + router.use(instanceRouter); + + // ─── Authenticated API routes ─────────────────────────────────────────── + router.use(accountsRouter); + router.use(statusesRouter); + router.use(timelinesRouter); + router.use(notificationsRouter); + router.use(searchRouter); + router.use(mediaRouter); + router.use(stubsRouter); + + // ─── Catch-all for unimplemented endpoints ────────────────────────────── + // Express 5 path-to-regexp v8: use {*name} for wildcard + router.all("/api/v1/{*rest}", notImplementedHandler); + router.all("/api/v2/{*rest}", notImplementedHandler); + + // ─── Error handler ────────────────────────────────────────────────────── + router.use("/api", errorHandler); + router.use("/oauth", errorHandler); + + return router; +} diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js new file mode 100644 index 0000000..702dd39 --- /dev/null +++ b/lib/mastodon/routes/accounts.js @@ -0,0 +1,552 @@ +/** + * Account endpoints for Mastodon Client API. + * + * Phase 1: verify_credentials, preferences, account lookup + * Phase 2: relationships, follow/unfollow, account statuses + */ +import express from "express"; +import { serializeCredentialAccount, serializeAccount } from "../entities/account.js"; +import { accountId, remoteActorId } from "../helpers/id-mapping.js"; + +const router = express.Router(); // eslint-disable-line new-cap + +// ─── GET /api/v1/accounts/verify_credentials ───────────────────────────────── + +router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const baseUrl = `${req.protocol}://${req.get("host")}`; + const collections = req.app.locals.mastodonCollections; + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + const handle = pluginOptions.handle || "user"; + + const profile = await collections.ap_profile.findOne({}); + if (!profile) { + return res.status(404).json({ error: "Profile not found" }); + } + + // Get counts + let counts = {}; + try { + const [statuses, followers, following] = await Promise.all([ + collections.ap_timeline.countDocuments({ + "author.url": profile.url, + }), + collections.ap_followers.countDocuments({}), + collections.ap_following.countDocuments({}), + ]); + counts = { statuses, followers, following }; + } catch { + counts = { statuses: 0, followers: 0, following: 0 }; + } + + const account = serializeCredentialAccount(profile, { + baseUrl, + handle, + counts, + }); + + res.json(account); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/preferences ───────────────────────────────────────────────── + +router.get("/api/v1/preferences", (req, res) => { + res.json({ + "posting:default:visibility": "public", + "posting:default:sensitive": false, + "posting:default:language": "en", + "reading:expand:media": "default", + "reading:expand:spoilers": false, + }); +}); + +// ─── GET /api/v1/accounts/lookup ───────────────────────────────────────────── + +router.get("/api/v1/accounts/lookup", async (req, res, next) => { + try { + const { acct } = req.query; + if (!acct) { + return res.status(400).json({ error: "Missing acct parameter" }); + } + + const baseUrl = `${req.protocol}://${req.get("host")}`; + const collections = req.app.locals.mastodonCollections; + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + const handle = pluginOptions.handle || "user"; + + // Check if looking up local account + const bareAcct = acct.startsWith("@") ? acct.slice(1) : acct; + const localDomain = req.get("host"); + + if ( + bareAcct === handle || + bareAcct === `${handle}@${localDomain}` + ) { + const profile = await collections.ap_profile.findOne({}); + if (profile) { + return res.json( + serializeAccount(profile, { baseUrl, isLocal: true, handle }), + ); + } + } + + // Check followers/following for known remote actors + const follower = await collections.ap_followers.findOne({ + $or: [ + { handle: `@${bareAcct}` }, + { handle: bareAcct }, + ], + }); + if (follower) { + return res.json( + serializeAccount( + { name: follower.name, url: follower.actorUrl, photo: follower.avatar, handle: follower.handle }, + { baseUrl }, + ), + ); + } + + return res.status(404).json({ error: "Record not found" }); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/accounts/:id ──────────────────────────────────────────────── + +router.get("/api/v1/accounts/:id", async (req, res, next) => { + try { + const { id } = req.params; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const collections = req.app.locals.mastodonCollections; + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + const handle = pluginOptions.handle || "user"; + + // Check if it's the local profile + const profile = await collections.ap_profile.findOne({}); + if (profile && profile._id.toString() === id) { + return res.json( + serializeAccount(profile, { baseUrl, isLocal: true, handle }), + ); + } + + // Search known actors (followers, following, timeline authors) + // by checking if the deterministic hash matches + const follower = await collections.ap_followers + .find({}) + .toArray(); + for (const f of follower) { + if (remoteActorId(f.actorUrl) === id) { + return res.json( + serializeAccount( + { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle }, + { baseUrl }, + ), + ); + } + } + + const following = await collections.ap_following + .find({}) + .toArray(); + for (const f of following) { + if (remoteActorId(f.actorUrl) === id) { + return res.json( + serializeAccount( + { name: f.name, url: f.actorUrl, photo: f.avatar, handle: f.handle }, + { baseUrl }, + ), + ); + } + } + + // Try timeline authors + const timelineItem = await collections.ap_timeline.findOne({ + $expr: { $ne: [{ $type: "$author.url" }, "missing"] }, + }); + // For now, if not found in known actors, return 404 + return res.status(404).json({ error: "Record not found" }); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/accounts/relationships ────────────────────────────────────── + +router.get("/api/v1/accounts/relationships", async (req, res, next) => { + try { + // id[] can come as single value or array + let ids = req.query["id[]"] || req.query.id || []; + if (!Array.isArray(ids)) ids = [ids]; + + if (ids.length === 0) { + return res.json([]); + } + + const collections = req.app.locals.mastodonCollections; + + // Load all followers/following for efficient lookup + const [followers, following, blocked, muted] = await Promise.all([ + collections.ap_followers.find({}).toArray(), + collections.ap_following.find({}).toArray(), + collections.ap_blocked.find({}).toArray(), + collections.ap_muted.find({}).toArray(), + ]); + + const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl))); + const followingIds = new Set(following.map((f) => remoteActorId(f.actorUrl))); + const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url))); + const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url))); + + const relationships = ids.map((id) => ({ + id, + following: followingIds.has(id), + showing_reblogs: followingIds.has(id), + notifying: false, + languages: [], + followed_by: followerIds.has(id), + blocking: blockedIds.has(id), + blocked_by: false, + muting: mutedIds.has(id), + muting_notifications: mutedIds.has(id), + requested: false, + requested_by: false, + domain_blocking: false, + endorsed: false, + note: "", + })); + + res.json(relationships); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/accounts/familiar_followers ───────────────────────────────── + +router.get("/api/v1/accounts/familiar_followers", (req, res) => { + // Stub — returns empty for each requested ID + let ids = req.query["id[]"] || req.query.id || []; + if (!Array.isArray(ids)) ids = [ids]; + res.json(ids.map((id) => ({ id, accounts: [] }))); +}); + +// ─── POST /api/v1/accounts/:id/follow ─────────────────────────────────────── + +router.post("/api/v1/accounts/:id/follow", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { id } = req.params; + const collections = req.app.locals.mastodonCollections; + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + + // Resolve the account ID to an actor URL + const actorUrl = await resolveActorUrl(id, collections); + if (!actorUrl) { + return res.status(404).json({ error: "Record not found" }); + } + + // Use the plugin's followActor method + if (pluginOptions.followActor) { + const result = await pluginOptions.followActor(actorUrl); + if (!result.ok) { + return res.status(422).json({ error: result.error || "Follow failed" }); + } + } + + // Return relationship + const followingIds = new Set(); + const following = await collections.ap_following.find({}).toArray(); + for (const f of following) { + followingIds.add(remoteActorId(f.actorUrl)); + } + + const followerIds = new Set(); + const followers = await collections.ap_followers.find({}).toArray(); + for (const f of followers) { + followerIds.add(remoteActorId(f.actorUrl)); + } + + res.json({ + id, + following: true, + showing_reblogs: true, + notifying: false, + languages: [], + followed_by: followerIds.has(id), + blocking: false, + blocked_by: false, + muting: false, + muting_notifications: false, + requested: false, + requested_by: false, + domain_blocking: false, + endorsed: false, + note: "", + }); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/accounts/:id/unfollow ───────────────────────────────────── + +router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { id } = req.params; + const collections = req.app.locals.mastodonCollections; + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + + const actorUrl = await resolveActorUrl(id, collections); + if (!actorUrl) { + return res.status(404).json({ error: "Record not found" }); + } + + if (pluginOptions.unfollowActor) { + const result = await pluginOptions.unfollowActor(actorUrl); + if (!result.ok) { + return res.status(422).json({ error: result.error || "Unfollow failed" }); + } + } + + const followerIds = new Set(); + const followers = await collections.ap_followers.find({}).toArray(); + for (const f of followers) { + followerIds.add(remoteActorId(f.actorUrl)); + } + + res.json({ + id, + following: false, + showing_reblogs: true, + notifying: false, + languages: [], + followed_by: followerIds.has(id), + blocking: false, + blocked_by: false, + muting: false, + muting_notifications: false, + requested: false, + requested_by: false, + domain_blocking: false, + endorsed: false, + note: "", + }); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/accounts/:id/mute ──────────────────────────────────────── + +router.post("/api/v1/accounts/:id/mute", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { id } = req.params; + const collections = req.app.locals.mastodonCollections; + + const actorUrl = await resolveActorUrl(id, collections); + if (actorUrl && collections.ap_muted) { + await collections.ap_muted.updateOne( + { url: actorUrl }, + { $set: { url: actorUrl, createdAt: new Date().toISOString() } }, + { upsert: true }, + ); + } + + res.json({ + id, + following: false, + showing_reblogs: true, + notifying: false, + languages: [], + followed_by: false, + blocking: false, + blocked_by: false, + muting: true, + muting_notifications: true, + requested: false, + requested_by: false, + domain_blocking: false, + endorsed: false, + note: "", + }); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/accounts/:id/unmute ─────────────────────────────────────── + +router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { id } = req.params; + const collections = req.app.locals.mastodonCollections; + + const actorUrl = await resolveActorUrl(id, collections); + if (actorUrl && collections.ap_muted) { + await collections.ap_muted.deleteOne({ url: actorUrl }); + } + + res.json({ + id, + following: false, + showing_reblogs: true, + notifying: false, + languages: [], + followed_by: false, + blocking: false, + blocked_by: false, + muting: false, + muting_notifications: false, + requested: false, + requested_by: false, + domain_blocking: false, + endorsed: false, + note: "", + }); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/accounts/:id/block ─────────────────────────────────────── + +router.post("/api/v1/accounts/:id/block", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { id } = req.params; + const collections = req.app.locals.mastodonCollections; + + const actorUrl = await resolveActorUrl(id, collections); + if (actorUrl && collections.ap_blocked) { + await collections.ap_blocked.updateOne( + { url: actorUrl }, + { $set: { url: actorUrl, createdAt: new Date().toISOString() } }, + { upsert: true }, + ); + } + + res.json({ + id, + following: false, + showing_reblogs: true, + notifying: false, + languages: [], + followed_by: false, + blocking: true, + blocked_by: false, + muting: false, + muting_notifications: false, + requested: false, + requested_by: false, + domain_blocking: false, + endorsed: false, + note: "", + }); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/accounts/:id/unblock ────────────────────────────────────── + +router.post("/api/v1/accounts/:id/unblock", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { id } = req.params; + const collections = req.app.locals.mastodonCollections; + + const actorUrl = await resolveActorUrl(id, collections); + if (actorUrl && collections.ap_blocked) { + await collections.ap_blocked.deleteOne({ url: actorUrl }); + } + + res.json({ + id, + following: false, + showing_reblogs: true, + notifying: false, + languages: [], + followed_by: false, + blocking: false, + blocked_by: false, + muting: false, + muting_notifications: false, + requested: false, + requested_by: false, + domain_blocking: false, + endorsed: false, + note: "", + }); + } catch (error) { + next(error); + } +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Resolve an account ID back to an actor URL by scanning followers/following. + */ +async function resolveActorUrl(id, collections) { + // Check if it's the local profile + const profile = await collections.ap_profile.findOne({}); + if (profile && profile._id.toString() === id) { + return profile.url; + } + + // Check followers + const followers = await collections.ap_followers.find({}).toArray(); + for (const f of followers) { + if (remoteActorId(f.actorUrl) === id) { + return f.actorUrl; + } + } + + // Check following + const following = await collections.ap_following.find({}).toArray(); + for (const f of following) { + if (remoteActorId(f.actorUrl) === id) { + return f.actorUrl; + } + } + + return null; +} + +export default router; diff --git a/lib/mastodon/routes/instance.js b/lib/mastodon/routes/instance.js new file mode 100644 index 0000000..5cd7d53 --- /dev/null +++ b/lib/mastodon/routes/instance.js @@ -0,0 +1,207 @@ +/** + * Instance info endpoints for Mastodon Client API. + * + * GET /api/v2/instance — v2 format (primary) + * GET /api/v1/instance — v1 format (fallback for older clients) + */ +import express from "express"; +import { serializeAccount } from "../entities/account.js"; + +const router = express.Router(); // eslint-disable-line new-cap + +// ─── GET /api/v2/instance ──────────────────────────────────────────────────── + +router.get("/api/v2/instance", async (req, res, next) => { + try { + const baseUrl = `${req.protocol}://${req.get("host")}`; + const domain = req.get("host"); + const collections = req.app.locals.mastodonCollections; + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + + const profile = await collections.ap_profile.findOne({}); + const contactAccount = profile + ? serializeAccount(profile, { + baseUrl, + isLocal: true, + handle: pluginOptions.handle || "user", + }) + : null; + + res.json({ + domain, + title: profile?.name || domain, + version: "4.0.0 (compatible; Indiekit ActivityPub)", + source_url: "https://github.com/getindiekit/indiekit", + description: profile?.summary || `An Indiekit instance at ${domain}`, + usage: { + users: { + active_month: 1, + }, + }, + thumbnail: { + url: profile?.icon || `${baseUrl}/favicon.ico`, + blurhash: null, + versions: {}, + }, + icon: [], + languages: ["en"], + configuration: { + urls: { + streaming: "", + }, + accounts: { + max_featured_tags: 10, + max_pinned_statuses: 10, + }, + statuses: { + max_characters: 5000, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }, + media_attachments: { + supported_mime_types: [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", + "video/webm", + "audio/mpeg", + "audio/ogg", + ], + image_size_limit: 16_777_216, + image_matrix_limit: 16_777_216, + video_size_limit: 67_108_864, + video_frame_rate_limit: 60, + video_matrix_limit: 16_777_216, + }, + polls: { + max_options: 4, + max_characters_per_option: 50, + min_expiration: 300, + max_expiration: 2_592_000, + }, + translation: { + enabled: false, + }, + vapid: { + public_key: "", + }, + }, + registrations: { + enabled: false, + approval_required: true, + message: null, + url: null, + }, + api_versions: { + mastodon: 0, + }, + contact: { + email: "", + account: contactAccount, + }, + rules: [], + }); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/instance ──────────────────────────────────────────────────── + +router.get("/api/v1/instance", async (req, res, next) => { + try { + const baseUrl = `${req.protocol}://${req.get("host")}`; + const domain = req.get("host"); + const collections = req.app.locals.mastodonCollections; + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + + const profile = await collections.ap_profile.findOne({}); + + // Get approximate counts + let statusCount = 0; + let domainCount = 0; + try { + statusCount = await collections.ap_timeline.countDocuments({}); + // Rough domain count from unique follower domains + const followers = await collections.ap_followers + .find({}, { projection: { actorUrl: 1 } }) + .toArray(); + const domains = new Set( + followers + .map((f) => { + try { + return new URL(f.actorUrl).hostname; + } catch { + return null; + } + }) + .filter(Boolean), + ); + domainCount = domains.size; + } catch { + // Non-critical + } + + res.json({ + uri: domain, + title: profile?.name || domain, + short_description: profile?.summary || "", + description: profile?.summary || `An Indiekit instance at ${domain}`, + email: "", + version: "4.0.0 (compatible; Indiekit ActivityPub)", + urls: { + streaming_api: "", + }, + stats: { + user_count: 1, + status_count: statusCount, + domain_count: domainCount, + }, + thumbnail: profile?.icon || null, + languages: ["en"], + registrations: false, + approval_required: true, + invites_enabled: false, + configuration: { + statuses: { + max_characters: 5000, + max_media_attachments: 4, + characters_reserved_per_url: 23, + }, + media_attachments: { + supported_mime_types: [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ], + image_size_limit: 16_777_216, + image_matrix_limit: 16_777_216, + video_size_limit: 67_108_864, + video_frame_rate_limit: 60, + video_matrix_limit: 16_777_216, + }, + polls: { + max_options: 4, + max_characters_per_option: 50, + min_expiration: 300, + max_expiration: 2_592_000, + }, + }, + contact_account: profile + ? serializeAccount(profile, { + baseUrl, + isLocal: true, + handle: pluginOptions.handle || "user", + }) + : null, + rules: [], + }); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/lib/mastodon/routes/media.js b/lib/mastodon/routes/media.js new file mode 100644 index 0000000..fc7cfd6 --- /dev/null +++ b/lib/mastodon/routes/media.js @@ -0,0 +1,43 @@ +/** + * Media endpoints for Mastodon Client API. + * + * POST /api/v2/media — upload media attachment (stub — returns 422 until storage is configured) + * POST /api/v1/media — legacy upload endpoint (redirects to v2) + * GET /api/v1/media/:id — get media attachment status + * PUT /api/v1/media/:id — update media metadata (description/focus) + */ +import express from "express"; + +const router = express.Router(); // eslint-disable-line new-cap + +// ─── POST /api/v2/media ───────────────────────────────────────────────────── + +router.post("/api/v2/media", (req, res) => { + // Media upload requires multer/multipart handling + storage backend. + // For now, return 422 so clients show a user-friendly error. + res.status(422).json({ + error: "Media uploads are not yet supported on this server", + }); +}); + +// ─── POST /api/v1/media (legacy) ──────────────────────────────────────────── + +router.post("/api/v1/media", (req, res) => { + res.status(422).json({ + error: "Media uploads are not yet supported on this server", + }); +}); + +// ─── GET /api/v1/media/:id ────────────────────────────────────────────────── + +router.get("/api/v1/media/:id", (req, res) => { + res.status(404).json({ error: "Record not found" }); +}); + +// ─── PUT /api/v1/media/:id ────────────────────────────────────────────────── + +router.put("/api/v1/media/:id", (req, res) => { + res.status(404).json({ error: "Record not found" }); +}); + +export default router; diff --git a/lib/mastodon/routes/notifications.js b/lib/mastodon/routes/notifications.js new file mode 100644 index 0000000..4bd1379 --- /dev/null +++ b/lib/mastodon/routes/notifications.js @@ -0,0 +1,257 @@ +/** + * Notification endpoints for Mastodon Client API. + * + * GET /api/v1/notifications — list notifications with pagination + * GET /api/v1/notifications/:id — single notification + * POST /api/v1/notifications/clear — clear all notifications + * POST /api/v1/notifications/:id/dismiss — dismiss single notification + */ +import express from "express"; +import { ObjectId } from "mongodb"; +import { serializeNotification } from "../entities/notification.js"; +import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; + +const router = express.Router(); // eslint-disable-line new-cap + +/** + * Mastodon type -> internal type reverse mapping for filtering. + */ +const REVERSE_TYPE_MAP = { + favourite: "like", + reblog: "boost", + follow: "follow", + follow_request: "follow_request", + mention: { $in: ["reply", "mention", "dm"] }, + poll: "poll", + update: "update", + "admin.report": "report", +}; + +// ─── GET /api/v1/notifications ────────────────────────────────────────────── + +router.get("/api/v1/notifications", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const limit = parseLimit(req.query.limit); + + // Build base filter + const baseFilter = {}; + + // types[] — include only these Mastodon types + const includeTypes = normalizeArray(req.query["types[]"] || req.query.types); + if (includeTypes.length > 0) { + const internalTypes = resolveInternalTypes(includeTypes); + if (internalTypes.length > 0) { + baseFilter.type = { $in: internalTypes }; + } + } + + // exclude_types[] — exclude these Mastodon types + const excludeTypes = normalizeArray(req.query["exclude_types[]"] || req.query.exclude_types); + if (excludeTypes.length > 0) { + const excludeInternal = resolveInternalTypes(excludeTypes); + if (excludeInternal.length > 0) { + baseFilter.type = { ...baseFilter.type, $nin: excludeInternal }; + } + } + + // Apply cursor pagination + const { filter, sort, reverse } = buildPaginationQuery(baseFilter, { + max_id: req.query.max_id, + min_id: req.query.min_id, + since_id: req.query.since_id, + }); + + let items = await collections.ap_notifications + .find(filter) + .sort(sort) + .limit(limit) + .toArray(); + + if (reverse) { + items.reverse(); + } + + // Batch-fetch referenced timeline items to avoid N+1 + const statusMap = await batchFetchStatuses(collections, items); + + // Serialize notifications + const notifications = items.map((notif) => + serializeNotification(notif, { + baseUrl, + statusMap, + interactionState: { + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + }, + }), + ).filter(Boolean); + + // Set pagination headers + setPaginationHeaders(res, req, items, limit); + + res.json(notifications); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/notifications/:id ────────────────────────────────────────── + +router.get("/api/v1/notifications/:id", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + let objectId; + try { + objectId = new ObjectId(req.params.id); + } catch { + return res.status(404).json({ error: "Record not found" }); + } + + const notif = await collections.ap_notifications.findOne({ _id: objectId }); + if (!notif) { + return res.status(404).json({ error: "Record not found" }); + } + + const statusMap = await batchFetchStatuses(collections, [notif]); + + const notification = serializeNotification(notif, { + baseUrl, + statusMap, + interactionState: { + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + }, + }); + + res.json(notification); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/notifications/clear ─────────────────────────────────────── + +router.post("/api/v1/notifications/clear", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const collections = req.app.locals.mastodonCollections; + await collections.ap_notifications.deleteMany({}); + res.json({}); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/notifications/:id/dismiss ───────────────────────────────── + +router.post("/api/v1/notifications/:id/dismiss", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const collections = req.app.locals.mastodonCollections; + + let objectId; + try { + objectId = new ObjectId(req.params.id); + } catch { + return res.status(404).json({ error: "Record not found" }); + } + + await collections.ap_notifications.deleteOne({ _id: objectId }); + res.json({}); + } catch (error) { + next(error); + } +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Normalize query param to array (handles string or array). + */ +function normalizeArray(param) { + if (!param) return []; + return Array.isArray(param) ? param : [param]; +} + +/** + * Convert Mastodon notification types to internal types. + */ +function resolveInternalTypes(mastodonTypes) { + const result = []; + for (const t of mastodonTypes) { + const mapped = REVERSE_TYPE_MAP[t]; + if (mapped) { + if (mapped.$in) { + result.push(...mapped.$in); + } else { + result.push(mapped); + } + } + } + return result; +} + +/** + * Batch-fetch timeline items referenced by notifications. + * + * @param {object} collections + * @param {Array} notifications + * @returns {Promise>} Map of targetUrl -> timeline item + */ +async function batchFetchStatuses(collections, notifications) { + const statusMap = new Map(); + + const targetUrls = [ + ...new Set( + notifications + .map((n) => n.targetUrl) + .filter(Boolean), + ), + ]; + + if (targetUrls.length === 0 || !collections.ap_timeline) { + return statusMap; + } + + const items = await collections.ap_timeline + .find({ + $or: [ + { uid: { $in: targetUrls } }, + { url: { $in: targetUrls } }, + ], + }) + .toArray(); + + for (const item of items) { + if (item.uid) statusMap.set(item.uid, item); + if (item.url) statusMap.set(item.url, item); + } + + return statusMap; +} + +export default router; diff --git a/lib/mastodon/routes/oauth.js b/lib/mastodon/routes/oauth.js new file mode 100644 index 0000000..a83b8f1 --- /dev/null +++ b/lib/mastodon/routes/oauth.js @@ -0,0 +1,545 @@ +/** + * OAuth2 routes for Mastodon Client API. + * + * Handles app registration, authorization, token exchange, and revocation. + */ +import crypto from "node:crypto"; +import express from "express"; + +const router = express.Router(); // eslint-disable-line new-cap + +/** + * Generate cryptographically random hex string. + * @param {number} bytes - Number of random bytes + * @returns {string} Hex-encoded random string + */ +function randomHex(bytes) { + return crypto.randomBytes(bytes).toString("hex"); +} + +/** + * Parse redirect_uris from request — accepts space-separated string or array. + * @param {string|string[]} value + * @returns {string[]} + */ +function parseRedirectUris(value) { + if (!value) return ["urn:ietf:wg:oauth:2.0:oob"]; + if (Array.isArray(value)) return value.map((v) => v.trim()); + return value + .trim() + .split(/\s+/) + .filter(Boolean); +} + +/** + * Parse scopes from request — accepts space-separated string. + * @param {string} value + * @returns {string[]} + */ +function parseScopes(value) { + if (!value) return ["read"]; + return value + .trim() + .split(/\s+/) + .filter(Boolean); +} + +// ─── POST /api/v1/apps — Register client application ──────────────────────── + +router.post("/api/v1/apps", async (req, res, next) => { + try { + const { client_name, redirect_uris, scopes, website } = req.body; + + const clientId = randomHex(16); + const clientSecret = randomHex(32); + const redirectUris = parseRedirectUris(redirect_uris); + const parsedScopes = parseScopes(scopes); + + const doc = { + clientId, + clientSecret, + name: client_name || "", + redirectUris, + scopes: parsedScopes, + website: website || null, + confidential: true, + createdAt: new Date(), + }; + + const collections = req.app.locals.mastodonCollections; + await collections.ap_oauth_apps.insertOne(doc); + + res.json({ + id: doc._id?.toString() || clientId, + name: doc.name, + website: doc.website, + redirect_uris: redirectUris, + redirect_uri: redirectUris.join(" "), + client_id: clientId, + client_secret: clientSecret, + client_secret_expires_at: 0, + vapid_key: "", + }); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/apps/verify_credentials ───────────────────────────────────── + +router.get("/api/v1/apps/verify_credentials", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const collections = req.app.locals.mastodonCollections; + const app = await collections.ap_oauth_apps.findOne({ + clientId: token.clientId, + }); + + if (!app) { + return res.status(404).json({ error: "Application not found" }); + } + + res.json({ + id: app._id.toString(), + name: app.name, + website: app.website, + scopes: app.scopes, + redirect_uris: app.redirectUris, + redirect_uri: app.redirectUris.join(" "), + }); + } catch (error) { + next(error); + } +}); + +// ─── GET /.well-known/oauth-authorization-server ───────────────────────────── + +router.get("/.well-known/oauth-authorization-server", (req, res) => { + const baseUrl = `${req.protocol}://${req.get("host")}`; + + res.json({ + issuer: baseUrl, + authorization_endpoint: `${baseUrl}/oauth/authorize`, + token_endpoint: `${baseUrl}/oauth/token`, + revocation_endpoint: `${baseUrl}/oauth/revoke`, + scopes_supported: [ + "read", + "write", + "follow", + "push", + "profile", + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + ], + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "client_credentials"], + token_endpoint_auth_methods_supported: [ + "client_secret_basic", + "client_secret_post", + "none", + ], + code_challenge_methods_supported: ["S256"], + service_documentation: "https://docs.joinmastodon.org/api/", + app_registration_endpoint: `${baseUrl}/api/v1/apps`, + }); +}); + +// ─── GET /oauth/authorize — Show authorization page ────────────────────────── + +router.get("/oauth/authorize", async (req, res, next) => { + try { + const { + client_id, + redirect_uri, + response_type, + scope, + code_challenge, + code_challenge_method, + force_login, + } = req.query; + + if (response_type !== "code") { + return res.status(400).json({ + error: "unsupported_response_type", + error_description: "Only response_type=code is supported", + }); + } + + const collections = req.app.locals.mastodonCollections; + const app = await collections.ap_oauth_apps.findOne({ clientId: client_id }); + + if (!app) { + return res.status(400).json({ + error: "invalid_client", + error_description: "Client application not found", + }); + } + + // Determine redirect URI — use provided or default to first registered + const resolvedRedirectUri = + redirect_uri || app.redirectUris[0] || "urn:ietf:wg:oauth:2.0:oob"; + + // Validate redirect_uri is registered + if (!app.redirectUris.includes(resolvedRedirectUri)) { + return res.status(400).json({ + error: "invalid_redirect_uri", + error_description: "Redirect URI not registered for this application", + }); + } + + // Validate requested scopes are subset of app scopes + const requestedScopes = scope ? scope.split(/\s+/) : app.scopes; + + // Check if user is logged in via IndieAuth session + const session = req.session; + if (!session?.access_token && !force_login) { + // Not logged in — redirect to Indiekit login, then back here + const returnUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`; + return res.redirect( + `/auth?redirect=${encodeURIComponent(returnUrl)}`, + ); + } + + // Render simple authorization page + const appName = app.name || "An application"; + res.type("html").send(` + + + + + Authorize ${appName} + + + +

Authorize ${appName}

+

${appName} wants to access your account with these permissions:

+
+ ${requestedScopes.map((s) => `${s}`).join("")} +
+
+ + + + + + +
+ + +
+
+ +`); + } catch (error) { + next(error); + } +}); + +// ─── POST /oauth/authorize — Process authorization decision ────────────────── + +router.post("/oauth/authorize", async (req, res, next) => { + try { + const { + client_id, + redirect_uri, + scope, + code_challenge, + code_challenge_method, + decision, + } = req.body; + + // User denied + if (decision === "deny") { + if (redirect_uri && redirect_uri !== "urn:ietf:wg:oauth:2.0:oob") { + const url = new URL(redirect_uri); + url.searchParams.set("error", "access_denied"); + url.searchParams.set( + "error_description", + "The resource owner denied the request", + ); + return res.redirect(url.toString()); + } + return res.status(403).json({ + error: "access_denied", + error_description: "The resource owner denied the request", + }); + } + + // Generate authorization code + const code = randomHex(32); + const collections = req.app.locals.mastodonCollections; + + await collections.ap_oauth_tokens.insertOne({ + code, + clientId: client_id, + scopes: scope ? scope.split(/\s+/) : ["read"], + redirectUri: redirect_uri, + codeChallenge: code_challenge || null, + codeChallengeMethod: code_challenge_method || null, + accessToken: null, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes + usedAt: null, + revokedAt: null, + }); + + // Out-of-band: show code on page + if (!redirect_uri || redirect_uri === "urn:ietf:wg:oauth:2.0:oob") { + return res.type("html").send(` + + + + + Authorization Code + + + +

Authorization Code

+

Copy this code and paste it into the application:

+ ${code} + +`); + } + + // Redirect with code + const url = new URL(redirect_uri); + url.searchParams.set("code", code); + res.redirect(url.toString()); + } catch (error) { + next(error); + } +}); + +// ─── POST /oauth/token — Exchange code for access token ────────────────────── + +router.post("/oauth/token", async (req, res, next) => { + try { + const { grant_type, code, redirect_uri, code_verifier } = req.body; + + // Extract client credentials from request (3 methods) + const { clientId, clientSecret } = extractClientCredentials(req); + + const collections = req.app.locals.mastodonCollections; + + if (grant_type === "client_credentials") { + // Client credentials grant — limited access for pre-login API calls + if (!clientId || !clientSecret) { + return res.status(401).json({ + error: "invalid_client", + error_description: "Client authentication required", + }); + } + + const app = await collections.ap_oauth_apps.findOne({ + clientId, + clientSecret, + confidential: true, + }); + + if (!app) { + return res.status(401).json({ + error: "invalid_client", + error_description: "Invalid client credentials", + }); + } + + const accessToken = randomHex(64); + await collections.ap_oauth_tokens.insertOne({ + code: null, + clientId, + scopes: ["read"], + redirectUri: null, + codeChallenge: null, + codeChallengeMethod: null, + accessToken, + createdAt: new Date(), + expiresAt: null, + usedAt: null, + revokedAt: null, + grantType: "client_credentials", + }); + + return res.json({ + access_token: accessToken, + token_type: "Bearer", + scope: "read", + created_at: Math.floor(Date.now() / 1000), + }); + } + + if (grant_type !== "authorization_code") { + return res.status(400).json({ + error: "unsupported_grant_type", + error_description: "Only authorization_code and client_credentials are supported", + }); + } + + if (!code) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing authorization code", + }); + } + + // Atomic claim-or-fail: find the code and mark it used in one operation + const grant = await collections.ap_oauth_tokens.findOneAndUpdate( + { + code, + usedAt: null, + revokedAt: null, + expiresAt: { $gt: new Date() }, + }, + { $set: { usedAt: new Date() } }, + { returnDocument: "before" }, + ); + + if (!grant) { + return res.status(400).json({ + error: "invalid_grant", + error_description: + "Authorization code is invalid, expired, or already used", + }); + } + + // Validate redirect_uri matches + if (redirect_uri && grant.redirectUri && redirect_uri !== grant.redirectUri) { + return res.status(400).json({ + error: "invalid_grant", + error_description: "Redirect URI mismatch", + }); + } + + // Verify PKCE code_verifier if code_challenge was stored + if (grant.codeChallenge) { + if (!code_verifier) { + return res.status(400).json({ + error: "invalid_grant", + error_description: "Missing code_verifier for PKCE", + }); + } + + const expectedChallenge = crypto + .createHash("sha256") + .update(code_verifier) + .digest("base64url"); + + if (expectedChallenge !== grant.codeChallenge) { + return res.status(400).json({ + error: "invalid_grant", + error_description: "Invalid code_verifier", + }); + } + } + + // Generate access token + const accessToken = randomHex(64); + await collections.ap_oauth_tokens.updateOne( + { _id: grant._id }, + { $set: { accessToken } }, + ); + + res.json({ + access_token: accessToken, + token_type: "Bearer", + scope: grant.scopes.join(" "), + created_at: Math.floor(grant.createdAt.getTime() / 1000), + }); + } catch (error) { + next(error); + } +}); + +// ─── POST /oauth/revoke — Revoke a token ──────────────────────────────────── + +router.post("/oauth/revoke", async (req, res, next) => { + try { + const { token } = req.body; + + if (!token) { + return res.status(400).json({ + error: "invalid_request", + error_description: "Missing token parameter", + }); + } + + const collections = req.app.locals.mastodonCollections; + await collections.ap_oauth_tokens.updateOne( + { accessToken: token }, + { $set: { revokedAt: new Date() } }, + ); + + // RFC 7009: always return 200 even if token wasn't found + res.json({}); + } catch (error) { + next(error); + } +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Extract client credentials from request using 3 methods: + * 1. HTTP Basic Auth (client_secret_basic) + * 2. POST body (client_secret_post) + * 3. client_id only (none — public clients) + */ +function extractClientCredentials(req) { + // Method 1: HTTP Basic Auth + const authHeader = req.get("authorization"); + if (authHeader?.startsWith("Basic ")) { + const decoded = Buffer.from(authHeader.slice(6), "base64").toString(); + const colonIndex = decoded.indexOf(":"); + if (colonIndex > 0) { + return { + clientId: decoded.slice(0, colonIndex), + clientSecret: decoded.slice(colonIndex + 1), + }; + } + } + + // Method 2 & 3: POST body + return { + clientId: req.body.client_id || null, + clientSecret: req.body.client_secret || null, + }; +} + +export default router; diff --git a/lib/mastodon/routes/search.js b/lib/mastodon/routes/search.js new file mode 100644 index 0000000..03a47c5 --- /dev/null +++ b/lib/mastodon/routes/search.js @@ -0,0 +1,146 @@ +/** + * Search endpoint for Mastodon Client API. + * + * GET /api/v2/search — search accounts, statuses, and hashtags + */ +import express from "express"; +import { serializeStatus } from "../entities/status.js"; +import { serializeAccount } from "../entities/account.js"; +import { parseLimit } from "../helpers/pagination.js"; + +const router = express.Router(); // eslint-disable-line new-cap + +// ─── GET /api/v2/search ───────────────────────────────────────────────────── + +router.get("/api/v2/search", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const query = (req.query.q || "").trim(); + const type = req.query.type; // "accounts", "statuses", "hashtags", or undefined (all) + const limit = parseLimit(req.query.limit); + const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0); + + if (!query) { + return res.json({ accounts: [], statuses: [], hashtags: [] }); + } + + const results = { accounts: [], statuses: [], hashtags: [] }; + + // ─── Account search ────────────────────────────────────────────────── + if (!type || type === "accounts") { + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const nameRegex = new RegExp(escapedQuery, "i"); + + // Search followers and following by display name or handle + const accountDocs = []; + + if (collections.ap_followers) { + const followers = await collections.ap_followers + .find({ + $or: [ + { name: nameRegex }, + { preferredUsername: nameRegex }, + { url: nameRegex }, + ], + }) + .limit(limit) + .toArray(); + accountDocs.push(...followers); + } + + if (collections.ap_following) { + const following = await collections.ap_following + .find({ + $or: [ + { name: nameRegex }, + { preferredUsername: nameRegex }, + { url: nameRegex }, + ], + }) + .limit(limit) + .toArray(); + accountDocs.push(...following); + } + + // Deduplicate by URL + const seen = new Set(); + for (const doc of accountDocs) { + const url = doc.url || doc.id; + if (url && !seen.has(url)) { + seen.add(url); + results.accounts.push( + serializeAccount(doc, { baseUrl, isRemote: true }), + ); + } + if (results.accounts.length >= limit) break; + } + } + + // ─── Status search ─────────────────────────────────────────────────── + if (!type || type === "statuses") { + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const contentRegex = new RegExp(escapedQuery, "i"); + + const items = await collections.ap_timeline + .find({ + isContext: { $ne: true }, + $or: [ + { "content.text": contentRegex }, + { "content.html": contentRegex }, + ], + }) + .sort({ _id: -1 }) + .skip(offset) + .limit(limit) + .toArray(); + + results.statuses = items.map((item) => + serializeStatus(item, { + baseUrl, + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }), + ); + } + + // ─── Hashtag search ────────────────────────────────────────────────── + if (!type || type === "hashtags") { + const escapedQuery = query + .replace(/^#/, "") + .replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const tagRegex = new RegExp(escapedQuery, "i"); + + // Find distinct category values matching the query + const allCategories = await collections.ap_timeline.distinct("category", { + category: tagRegex, + }); + + // Flatten and deduplicate (category can be string or array) + const tagSet = new Set(); + for (const cat of allCategories) { + if (Array.isArray(cat)) { + for (const c of cat) { + if (typeof c === "string" && tagRegex.test(c)) tagSet.add(c); + } + } else if (typeof cat === "string" && tagRegex.test(cat)) { + tagSet.add(cat); + } + } + + results.hashtags = [...tagSet].slice(0, limit).map((name) => ({ + name, + url: `${baseUrl}/tags/${encodeURIComponent(name)}`, + history: [], + })); + } + + res.json(results); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js new file mode 100644 index 0000000..8630d36 --- /dev/null +++ b/lib/mastodon/routes/statuses.js @@ -0,0 +1,634 @@ +/** + * Status endpoints for Mastodon Client API. + * + * GET /api/v1/statuses/:id — single status + * GET /api/v1/statuses/:id/context — thread context (ancestors + descendants) + * POST /api/v1/statuses/:id/favourite — like a post + * POST /api/v1/statuses/:id/unfavourite — unlike a post + * POST /api/v1/statuses/:id/reblog — boost a post + * POST /api/v1/statuses/:id/unreblog — unboost a post + * POST /api/v1/statuses/:id/bookmark — bookmark a post + * POST /api/v1/statuses/:id/unbookmark — remove bookmark + */ +import express from "express"; +import { ObjectId } from "mongodb"; +import { serializeStatus } from "../entities/status.js"; +import { serializeAccount } from "../entities/account.js"; +import { + likePost, unlikePost, + boostPost, unboostPost, + bookmarkPost, unbookmarkPost, +} from "../helpers/interactions.js"; + +const router = express.Router(); // eslint-disable-line new-cap + +// ─── GET /api/v1/statuses/:id ─────────────────────────────────────────────── + +router.get("/api/v1/statuses/:id", async (req, res, next) => { + try { + const { id } = req.params; + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + let objectId; + try { + objectId = new ObjectId(id); + } catch { + return res.status(404).json({ error: "Record not found" }); + } + + const item = await collections.ap_timeline.findOne({ _id: objectId }); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + // Load interaction state if authenticated + const interactionState = await loadItemInteractions(collections, item); + + const status = serializeStatus(item, { + baseUrl, + ...interactionState, + pinnedIds: new Set(), + }); + + res.json(status); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/statuses/:id/context ─────────────────────────────────────── + +router.get("/api/v1/statuses/:id/context", async (req, res, next) => { + try { + const { id } = req.params; + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + let objectId; + try { + objectId = new ObjectId(id); + } catch { + return res.status(404).json({ error: "Record not found" }); + } + + const item = await collections.ap_timeline.findOne({ _id: objectId }); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + // Find ancestors: walk up the inReplyTo chain + const ancestors = []; + let currentReplyTo = item.inReplyTo; + const visited = new Set(); + + while (currentReplyTo && ancestors.length < 40) { + if (visited.has(currentReplyTo)) break; + visited.add(currentReplyTo); + + const parent = await collections.ap_timeline.findOne({ + $or: [{ uid: currentReplyTo }, { url: currentReplyTo }], + }); + if (!parent) break; + + ancestors.unshift(parent); + currentReplyTo = parent.inReplyTo; + } + + // Find descendants: items that reply to this post's uid or url + const targetUrls = [item.uid, item.url].filter(Boolean); + let descendants = []; + + if (targetUrls.length > 0) { + // Get direct replies first + const directReplies = await collections.ap_timeline + .find({ inReplyTo: { $in: targetUrls } }) + .sort({ _id: 1 }) + .limit(60) + .toArray(); + + descendants = directReplies; + + // Also fetch replies to direct replies (2 levels deep) + if (directReplies.length > 0) { + const replyUrls = directReplies + .flatMap((r) => [r.uid, r.url].filter(Boolean)); + const nestedReplies = await collections.ap_timeline + .find({ inReplyTo: { $in: replyUrls } }) + .sort({ _id: 1 }) + .limit(60) + .toArray(); + descendants.push(...nestedReplies); + } + } + + // Serialize all items + const emptyInteractions = { + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }; + + const serializeOpts = { baseUrl, ...emptyInteractions }; + + res.json({ + ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)), + descendants: descendants.map((d) => serializeStatus(d, serializeOpts)), + }); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/statuses ─────────────────────────────────────────────────── + +router.post("/api/v1/statuses", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const collections = req.app.locals.mastodonCollections; + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + const { + status: statusText, + spoiler_text: spoilerText, + visibility = "public", + sensitive = false, + language, + in_reply_to_id: inReplyToId, + media_ids: mediaIds, + } = req.body; + + if (!statusText && (!mediaIds || mediaIds.length === 0)) { + return res.status(422).json({ error: "Validation failed: Text content is required" }); + } + + // Resolve in_reply_to if provided + let inReplyTo = null; + if (inReplyToId) { + try { + const replyItem = await collections.ap_timeline.findOne({ + _id: new ObjectId(inReplyToId), + }); + if (replyItem) { + inReplyTo = replyItem.uid || replyItem.url; + } + } catch { + // Invalid ObjectId — ignore + } + } + + // Load local profile for the author field + const profile = await collections.ap_profile.findOne({}); + const handle = pluginOptions.handle || "user"; + const publicationUrl = pluginOptions.publicationUrl || baseUrl; + const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`; + + // Generate post ID and URL + const postId = crypto.randomUUID(); + const postUrl = `${publicationUrl.replace(/\/$/, "")}/posts/${postId}`; + const uid = postUrl; + + // Build the timeline item + const now = new Date().toISOString(); + const timelineItem = { + uid, + url: postUrl, + type: "note", + content: { + text: statusText || "", + html: linkifyAndParagraph(statusText || ""), + }, + summary: spoilerText || "", + sensitive: sensitive === true || sensitive === "true", + visibility: visibility || "public", + language: language || null, + inReplyTo, + published: now, + createdAt: now, + author: { + name: profile?.name || handle, + url: actorUrl, + photo: profile?.icon || "", + handle: `@${handle}`, + emojis: [], + bot: false, + }, + photo: [], + video: [], + audio: [], + category: extractHashtags(statusText || ""), + counts: { replies: 0, boosts: 0, likes: 0 }, + linkPreviews: [], + mentions: [], + emojis: [], + }; + + // Insert into timeline + const result = await collections.ap_timeline.insertOne(timelineItem); + timelineItem._id = result.insertedId; + + // Trigger federation asynchronously (don't block the response) + if (pluginOptions.federation) { + federatePost(timelineItem, pluginOptions).catch((err) => { + console.error("[Mastodon API] Federation failed:", err.message); + }); + } + + // Serialize and return + const serialized = serializeStatus(timelineItem, { + baseUrl, + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }); + + res.json(serialized); + } catch (error) { + next(error); + } +}); + +// ─── DELETE /api/v1/statuses/:id ──────────────────────────────────────────── + +router.delete("/api/v1/statuses/:id", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { id } = req.params; + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + let objectId; + try { + objectId = new ObjectId(id); + } catch { + return res.status(404).json({ error: "Record not found" }); + } + + const item = await collections.ap_timeline.findOne({ _id: objectId }); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + // Verify ownership — only allow deleting own posts + const profile = await collections.ap_profile.findOne({}); + if (profile && item.author?.url !== profile.url) { + return res.status(403).json({ error: "This action is not allowed" }); + } + + // Serialize before deleting (Mastodon returns the deleted status with text source) + const serialized = serializeStatus(item, { + baseUrl, + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }); + serialized.text = item.content?.text || ""; + + // Delete from timeline + await collections.ap_timeline.deleteOne({ _id: objectId }); + + // Clean up interactions + if (collections.ap_interactions && item.uid) { + await collections.ap_interactions.deleteMany({ objectUrl: item.uid }); + } + + // TODO: Broadcast Delete activity via federation + + res.json(serialized); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/statuses/:id/favourited_by ───────────────────────────────── + +router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => { + // Stub — we don't track who favourited remotely + res.json([]); +}); + +// ─── GET /api/v1/statuses/:id/reblogged_by ────────────────────────────────── + +router.get("/api/v1/statuses/:id/reblogged_by", async (req, res) => { + // Stub — we don't track who boosted remotely + res.json([]); +}); + +// ─── POST /api/v1/statuses/:id/favourite ──────────────────────────────────── + +router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { item, collections, baseUrl } = await resolveStatusForInteraction(req); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + const opts = getFederationOpts(req); + await likePost({ + targetUrl: item.uid || item.url, + ...opts, + interactions: collections.ap_interactions, + }); + + const interactionState = await loadItemInteractions(collections, item); + // Force favourited=true since we just liked it + interactionState.favouritedIds.add(item.uid); + + res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/statuses/:id/unfavourite ────────────────────────────────── + +router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { item, collections, baseUrl } = await resolveStatusForInteraction(req); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + const opts = getFederationOpts(req); + await unlikePost({ + targetUrl: item.uid || item.url, + ...opts, + interactions: collections.ap_interactions, + }); + + const interactionState = await loadItemInteractions(collections, item); + interactionState.favouritedIds.delete(item.uid); + + res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/statuses/:id/reblog ─────────────────────────────────────── + +router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { item, collections, baseUrl } = await resolveStatusForInteraction(req); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + const opts = getFederationOpts(req); + await boostPost({ + targetUrl: item.uid || item.url, + ...opts, + interactions: collections.ap_interactions, + }); + + const interactionState = await loadItemInteractions(collections, item); + interactionState.rebloggedIds.add(item.uid); + + res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/statuses/:id/unreblog ───────────────────────────────────── + +router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { item, collections, baseUrl } = await resolveStatusForInteraction(req); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + const opts = getFederationOpts(req); + await unboostPost({ + targetUrl: item.uid || item.url, + ...opts, + interactions: collections.ap_interactions, + }); + + const interactionState = await loadItemInteractions(collections, item); + interactionState.rebloggedIds.delete(item.uid); + + res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/statuses/:id/bookmark ───────────────────────────────────── + +router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { item, collections, baseUrl } = await resolveStatusForInteraction(req); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + await bookmarkPost({ + targetUrl: item.uid || item.url, + interactions: collections.ap_interactions, + }); + + const interactionState = await loadItemInteractions(collections, item); + interactionState.bookmarkedIds.add(item.uid); + + res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + } catch (error) { + next(error); + } +}); + +// ─── POST /api/v1/statuses/:id/unbookmark ─────────────────────────────────── + +router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const { item, collections, baseUrl } = await resolveStatusForInteraction(req); + if (!item) { + return res.status(404).json({ error: "Record not found" }); + } + + await unbookmarkPost({ + targetUrl: item.uid || item.url, + interactions: collections.ap_interactions, + }); + + const interactionState = await loadItemInteractions(collections, item); + interactionState.bookmarkedIds.delete(item.uid); + + res.json(serializeStatus(item, { baseUrl, ...interactionState, pinnedIds: new Set() })); + } catch (error) { + next(error); + } +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Resolve a timeline item from the :id param, plus common context. + */ +async function resolveStatusForInteraction(req) { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + let objectId; + try { + objectId = new ObjectId(req.params.id); + } catch { + return { item: null, collections, baseUrl }; + } + + const item = await collections.ap_timeline.findOne({ _id: objectId }); + return { item, collections, baseUrl }; +} + +/** + * Build federation options from request context for interaction helpers. + */ +function getFederationOpts(req) { + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + return { + federation: pluginOptions.federation, + handle: pluginOptions.handle || "user", + publicationUrl: pluginOptions.publicationUrl, + collections: req.app.locals.mastodonCollections, + }; +} + +async function loadItemInteractions(collections, item) { + const favouritedIds = new Set(); + const rebloggedIds = new Set(); + const bookmarkedIds = new Set(); + + if (!collections.ap_interactions || !item.uid) { + return { favouritedIds, rebloggedIds, bookmarkedIds }; + } + + const lookupUrls = [item.uid, item.url].filter(Boolean); + const interactions = await collections.ap_interactions + .find({ objectUrl: { $in: lookupUrls } }) + .toArray(); + + for (const i of interactions) { + const uid = item.uid; + if (i.type === "like") favouritedIds.add(uid); + else if (i.type === "boost") rebloggedIds.add(uid); + else if (i.type === "bookmark") bookmarkedIds.add(uid); + } + + return { favouritedIds, rebloggedIds, bookmarkedIds }; +} + +/** + * Convert plain text to basic HTML (paragraphs + linkified URLs). + */ +function linkifyAndParagraph(text) { + if (!text) return ""; + const paragraphs = text.split(/\n\n+/).filter(Boolean); + return paragraphs + .map((p) => { + const withBreaks = p.replace(/\n/g, "
"); + const linked = withBreaks.replace( + /(?])(https?:\/\/[^\s<"]+)/g, + '$1', + ); + return `

${linked}

`; + }) + .join(""); +} + +/** + * Extract #hashtags from text content. + */ +function extractHashtags(text) { + if (!text) return []; + const tags = []; + const regex = /#([\w]+)/g; + let match; + while ((match = regex.exec(text)) !== null) { + tags.push(match[1]); + } + return [...new Set(tags)]; +} + +/** + * Federate a newly created post via ActivityPub. + * Runs asynchronously — errors logged, don't block API response. + */ +async function federatePost(item, pluginOptions) { + const { jf2ToAS2Activity } = await import("../../jf2-to-as2.js"); + + const handle = pluginOptions.handle || "user"; + const publicationUrl = pluginOptions.publicationUrl; + const federation = pluginOptions.federation; + const actorUrl = `${publicationUrl.replace(/\/$/, "")}/users/${handle}`; + + const ctx = federation.createContext( + new URL(publicationUrl), + { handle, publicationUrl }, + ); + + const properties = { + "post-type": "note", + url: item.url, + content: item.content, + summary: item.summary || undefined, + "in-reply-to": item.inReplyTo || undefined, + category: item.category, + visibility: item.visibility, + }; + + const activity = jf2ToAS2Activity(properties, actorUrl, publicationUrl, { + visibility: item.visibility, + }); + + if (activity) { + await ctx.sendActivity({ identifier: handle }, "followers", activity, { + preferSharedInbox: true, + }); + console.info(`[Mastodon API] Federated post: ${item.url}`); + } +} + +export default router; diff --git a/lib/mastodon/routes/stubs.js b/lib/mastodon/routes/stubs.js new file mode 100644 index 0000000..eb9a73e --- /dev/null +++ b/lib/mastodon/routes/stubs.js @@ -0,0 +1,380 @@ +/** + * Stub and lightweight endpoints for Mastodon Client API. + * + * Some endpoints have real implementations (markers, bookmarks, favourites). + * Others return empty/minimal responses to prevent client errors. + * + * Phanpy calls these on startup, navigation, and various page loads: + * - markers (BackgroundService, every page load) + * - follow_requests (home + notifications pages) + * - announcements (notifications page) + * - custom_emojis (compose screen) + * - filters (status rendering) + * - lists (sidebar navigation) + * - mutes, blocks (nav menu) + * - featured_tags (profile view) + * - bookmarks, favourites (dedicated pages) + * - trends (explore page) + * - followed_tags (followed tags page) + * - suggestions (explore page) + */ +import express from "express"; +import { serializeStatus } from "../entities/status.js"; +import { parseLimit, buildPaginationQuery, setPaginationHeaders } from "../helpers/pagination.js"; + +const router = express.Router(); // eslint-disable-line new-cap + +// ─── Markers ──────────────────────────────────────────────────────────────── + +router.get("/api/v1/markers", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const timelines = [].concat(req.query["timeline[]"] || req.query.timeline || []); + + if (!timelines.length || !collections.ap_markers) { + return res.json({}); + } + + const docs = await collections.ap_markers + .find({ timeline: { $in: timelines } }) + .toArray(); + + const result = {}; + for (const doc of docs) { + result[doc.timeline] = { + last_read_id: doc.last_read_id, + version: doc.version || 0, + updated_at: doc.updated_at || new Date().toISOString(), + }; + } + + res.json(result); + } catch (error) { + next(error); + } +}); + +router.post("/api/v1/markers", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + if (!collections.ap_markers) { + return res.json({}); + } + + const result = {}; + for (const timeline of ["home", "notifications"]) { + const data = req.body[timeline]; + if (!data?.last_read_id) continue; + + const now = new Date().toISOString(); + await collections.ap_markers.updateOne( + { timeline }, + { + $set: { last_read_id: data.last_read_id, updated_at: now }, + $inc: { version: 1 }, + $setOnInsert: { timeline }, + }, + { upsert: true }, + ); + + const doc = await collections.ap_markers.findOne({ timeline }); + result[timeline] = { + last_read_id: doc.last_read_id, + version: doc.version || 0, + updated_at: doc.updated_at || now, + }; + } + + res.json(result); + } catch (error) { + next(error); + } +}); + +// ─── Follow requests ──────────────────────────────────────────────────────── + +router.get("/api/v1/follow_requests", (req, res) => { + res.json([]); +}); + +// ─── Announcements ────────────────────────────────────────────────────────── + +router.get("/api/v1/announcements", (req, res) => { + res.json([]); +}); + +// ─── Custom emojis ────────────────────────────────────────────────────────── + +router.get("/api/v1/custom_emojis", (req, res) => { + res.json([]); +}); + +// ─── Filters (v2) ─────────────────────────────────────────────────────────── + +router.get("/api/v2/filters", (req, res) => { + res.json([]); +}); + +router.get("/api/v1/filters", (req, res) => { + res.json([]); +}); + +// ─── Lists ────────────────────────────────────────────────────────────────── + +router.get("/api/v1/lists", (req, res) => { + res.json([]); +}); + +// ─── Mutes ────────────────────────────────────────────────────────────────── + +router.get("/api/v1/mutes", (req, res) => { + res.json([]); +}); + +// ─── Blocks ───────────────────────────────────────────────────────────────── + +router.get("/api/v1/blocks", (req, res) => { + res.json([]); +}); + +// ─── Bookmarks ────────────────────────────────────────────────────────────── + +router.get("/api/v1/bookmarks", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const limit = parseLimit(req.query.limit); + + if (!collections.ap_interactions) { + return res.json([]); + } + + const baseFilter = { type: "bookmark" }; + const { filter, sort, reverse } = buildPaginationQuery(baseFilter, { + max_id: req.query.max_id, + min_id: req.query.min_id, + since_id: req.query.since_id, + }); + + let interactions = await collections.ap_interactions + .find(filter) + .sort(sort) + .limit(limit) + .toArray(); + + if (reverse) interactions.reverse(); + + // Batch-fetch the actual timeline items + const objectUrls = interactions.map((i) => i.objectUrl).filter(Boolean); + if (!objectUrls.length) { + return res.json([]); + } + + const items = await collections.ap_timeline + .find({ $or: [{ uid: { $in: objectUrls } }, { url: { $in: objectUrls } }] }) + .toArray(); + + const itemMap = new Map(); + for (const item of items) { + if (item.uid) itemMap.set(item.uid, item); + if (item.url) itemMap.set(item.url, item); + } + + const statuses = []; + for (const interaction of interactions) { + const item = itemMap.get(interaction.objectUrl); + if (item) { + statuses.push( + serializeStatus(item, { + baseUrl, + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set([item.uid]), + pinnedIds: new Set(), + }), + ); + } + } + + setPaginationHeaders(res, req, interactions, limit); + res.json(statuses); + } catch (error) { + next(error); + } +}); + +// ─── Favourites ───────────────────────────────────────────────────────────── + +router.get("/api/v1/favourites", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const limit = parseLimit(req.query.limit); + + if (!collections.ap_interactions) { + return res.json([]); + } + + const baseFilter = { type: "like" }; + const { filter, sort, reverse } = buildPaginationQuery(baseFilter, { + max_id: req.query.max_id, + min_id: req.query.min_id, + since_id: req.query.since_id, + }); + + let interactions = await collections.ap_interactions + .find(filter) + .sort(sort) + .limit(limit) + .toArray(); + + if (reverse) interactions.reverse(); + + const objectUrls = interactions.map((i) => i.objectUrl).filter(Boolean); + if (!objectUrls.length) { + return res.json([]); + } + + const items = await collections.ap_timeline + .find({ $or: [{ uid: { $in: objectUrls } }, { url: { $in: objectUrls } }] }) + .toArray(); + + const itemMap = new Map(); + for (const item of items) { + if (item.uid) itemMap.set(item.uid, item); + if (item.url) itemMap.set(item.url, item); + } + + const statuses = []; + for (const interaction of interactions) { + const item = itemMap.get(interaction.objectUrl); + if (item) { + statuses.push( + serializeStatus(item, { + baseUrl, + favouritedIds: new Set([item.uid]), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }), + ); + } + } + + setPaginationHeaders(res, req, interactions, limit); + res.json(statuses); + } catch (error) { + next(error); + } +}); + +// ─── Featured tags ────────────────────────────────────────────────────────── + +router.get("/api/v1/featured_tags", (req, res) => { + res.json([]); +}); + +// ─── Followed tags ────────────────────────────────────────────────────────── + +router.get("/api/v1/followed_tags", (req, res) => { + res.json([]); +}); + +// ─── Suggestions ──────────────────────────────────────────────────────────── + +router.get("/api/v2/suggestions", (req, res) => { + res.json([]); +}); + +// ─── Trends ───────────────────────────────────────────────────────────────── + +router.get("/api/v1/trends/statuses", (req, res) => { + res.json([]); +}); + +router.get("/api/v1/trends/tags", (req, res) => { + res.json([]); +}); + +router.get("/api/v1/trends/links", (req, res) => { + res.json([]); +}); + +// ─── Scheduled statuses ───────────────────────────────────────────────────── + +router.get("/api/v1/scheduled_statuses", (req, res) => { + res.json([]); +}); + +// ─── Conversations ────────────────────────────────────────────────────────── + +router.get("/api/v1/conversations", (req, res) => { + res.json([]); +}); + +// ─── Domain blocks ────────────────────────────────────────────────────────── + +router.get("/api/v1/domain_blocks", (req, res) => { + res.json([]); +}); + +// ─── Endorsements ─────────────────────────────────────────────────────────── + +router.get("/api/v1/endorsements", (req, res) => { + res.json([]); +}); + +// ─── Account statuses ─────────────────────────────────────────────────────── + +router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + + // Try to find the profile to see if this is the local user + const profile = await collections.ap_profile.findOne({}); + const isLocal = profile && profile._id.toString() === req.params.id; + + if (isLocal && profile?.url) { + // Return statuses authored by local user + const { serializeStatus } = await import("../entities/status.js"); + const { parseLimit } = await import("../helpers/pagination.js"); + + const limit = parseLimit(req.query.limit); + const items = await collections.ap_timeline + .find({ "author.url": profile.url, isContext: { $ne: true } }) + .sort({ _id: -1 }) + .limit(limit) + .toArray(); + + const statuses = items.map((item) => + serializeStatus(item, { + baseUrl, + favouritedIds: new Set(), + rebloggedIds: new Set(), + bookmarkedIds: new Set(), + pinnedIds: new Set(), + }), + ); + + return res.json(statuses); + } + + // Remote account or unknown — return empty + res.json([]); + } catch (error) { + next(error); + } +}); + +// ─── Account followers/following ──────────────────────────────────────────── + +router.get("/api/v1/accounts/:id/followers", (req, res) => { + res.json([]); +}); + +router.get("/api/v1/accounts/:id/following", (req, res) => { + res.json([]); +}); + +export default router; diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js new file mode 100644 index 0000000..4a4763e --- /dev/null +++ b/lib/mastodon/routes/timelines.js @@ -0,0 +1,281 @@ +/** + * Timeline endpoints for Mastodon Client API. + * + * GET /api/v1/timelines/home — home timeline (authenticated) + * GET /api/v1/timelines/public — public/federated timeline + * GET /api/v1/timelines/tag/:hashtag — hashtag timeline + */ +import express from "express"; +import { serializeStatus } from "../entities/status.js"; +import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; +import { loadModerationData, applyModerationFilters } from "../../item-processing.js"; + +const router = express.Router(); // eslint-disable-line new-cap + +// ─── GET /api/v1/timelines/home ───────────────────────────────────────────── + +router.get("/api/v1/timelines/home", async (req, res, next) => { + try { + const token = req.mastodonToken; + if (!token) { + return res.status(401).json({ error: "The access token is invalid" }); + } + + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const limit = parseLimit(req.query.limit); + + // Base filter: exclude context-only items and private/direct posts + const baseFilter = { + isContext: { $ne: true }, + visibility: { $nin: ["direct"] }, + }; + + // Apply cursor-based pagination + const { filter, sort, reverse } = buildPaginationQuery(baseFilter, { + max_id: req.query.max_id, + min_id: req.query.min_id, + since_id: req.query.since_id, + }); + + // Fetch items from timeline + let items = await collections.ap_timeline + .find(filter) + .sort(sort) + .limit(limit) + .toArray(); + + // Reverse if min_id was used (ascending sort → need descending order) + if (reverse) { + items.reverse(); + } + + // Apply mute/block filtering + const modCollections = { + ap_muted: collections.ap_muted, + ap_blocked: collections.ap_blocked, + ap_profile: collections.ap_profile, + }; + const moderation = await loadModerationData(modCollections); + items = applyModerationFilters(items, moderation); + + // Load interaction state (likes, boosts, bookmarks) for the authenticated user + const { favouritedIds, rebloggedIds, bookmarkedIds } = await loadInteractionState( + collections, + items, + ); + + // Serialize to Mastodon Status entities + const statuses = items.map((item) => + serializeStatus(item, { + baseUrl, + favouritedIds, + rebloggedIds, + bookmarkedIds, + pinnedIds: new Set(), + }), + ); + + // Set pagination Link headers + setPaginationHeaders(res, req, items, limit); + + res.json(statuses); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/timelines/public ─────────────────────────────────────────── + +router.get("/api/v1/timelines/public", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const limit = parseLimit(req.query.limit); + + // Public timeline: only public visibility, no context items + const baseFilter = { + isContext: { $ne: true }, + visibility: "public", + }; + + // Only original posts (exclude boosts from public timeline unless local=true) + if (req.query.only_media === "true") { + baseFilter.$or = [ + { "photo.0": { $exists: true } }, + { "video.0": { $exists: true } }, + { "audio.0": { $exists: true } }, + ]; + } + + const { filter, sort, reverse } = buildPaginationQuery(baseFilter, { + max_id: req.query.max_id, + min_id: req.query.min_id, + since_id: req.query.since_id, + }); + + let items = await collections.ap_timeline + .find(filter) + .sort(sort) + .limit(limit) + .toArray(); + + if (reverse) { + items.reverse(); + } + + // Apply mute/block filtering + const modCollections = { + ap_muted: collections.ap_muted, + ap_blocked: collections.ap_blocked, + ap_profile: collections.ap_profile, + }; + const moderation = await loadModerationData(modCollections); + items = applyModerationFilters(items, moderation); + + // Load interaction state if authenticated + let favouritedIds = new Set(); + let rebloggedIds = new Set(); + let bookmarkedIds = new Set(); + + if (req.mastodonToken) { + ({ favouritedIds, rebloggedIds, bookmarkedIds } = await loadInteractionState( + collections, + items, + )); + } + + const statuses = items.map((item) => + serializeStatus(item, { + baseUrl, + favouritedIds, + rebloggedIds, + bookmarkedIds, + pinnedIds: new Set(), + }), + ); + + setPaginationHeaders(res, req, items, limit); + res.json(statuses); + } catch (error) { + next(error); + } +}); + +// ─── GET /api/v1/timelines/tag/:hashtag ───────────────────────────────────── + +router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { + try { + const collections = req.app.locals.mastodonCollections; + const baseUrl = `${req.protocol}://${req.get("host")}`; + const limit = parseLimit(req.query.limit); + const hashtag = req.params.hashtag; + + const baseFilter = { + isContext: { $ne: true }, + visibility: { $in: ["public", "unlisted"] }, + category: hashtag, + }; + + const { filter, sort, reverse } = buildPaginationQuery(baseFilter, { + max_id: req.query.max_id, + min_id: req.query.min_id, + since_id: req.query.since_id, + }); + + let items = await collections.ap_timeline + .find(filter) + .sort(sort) + .limit(limit) + .toArray(); + + if (reverse) { + items.reverse(); + } + + // Load interaction state if authenticated + let favouritedIds = new Set(); + let rebloggedIds = new Set(); + let bookmarkedIds = new Set(); + + if (req.mastodonToken) { + ({ favouritedIds, rebloggedIds, bookmarkedIds } = await loadInteractionState( + collections, + items, + )); + } + + const statuses = items.map((item) => + serializeStatus(item, { + baseUrl, + favouritedIds, + rebloggedIds, + bookmarkedIds, + pinnedIds: new Set(), + }), + ); + + setPaginationHeaders(res, req, items, limit); + res.json(statuses); + } catch (error) { + next(error); + } +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Load interaction state (favourited, reblogged, bookmarked) for a set of timeline items. + * + * Queries ap_interactions for likes and boosts matching the items' UIDs. + * + * @param {object} collections - MongoDB collections + * @param {Array} items - Timeline items + * @returns {Promise<{ favouritedIds: Set, rebloggedIds: Set, bookmarkedIds: Set }>} + */ +async function loadInteractionState(collections, items) { + const favouritedIds = new Set(); + const rebloggedIds = new Set(); + const bookmarkedIds = new Set(); + + if (!items.length || !collections.ap_interactions) { + return { favouritedIds, rebloggedIds, bookmarkedIds }; + } + + // Collect all UIDs and URLs to look up + const lookupUrls = new Set(); + const urlToUid = new Map(); + for (const item of items) { + if (item.uid) { + lookupUrls.add(item.uid); + urlToUid.set(item.uid, item.uid); + } + if (item.url && item.url !== item.uid) { + lookupUrls.add(item.url); + urlToUid.set(item.url, item.uid || item.url); + } + } + + if (lookupUrls.size === 0) { + return { favouritedIds, rebloggedIds, bookmarkedIds }; + } + + const interactions = await collections.ap_interactions + .find({ objectUrl: { $in: [...lookupUrls] } }) + .toArray(); + + for (const interaction of interactions) { + const uid = urlToUid.get(interaction.objectUrl) || interaction.objectUrl; + if (interaction.type === "like") { + favouritedIds.add(uid); + } else if (interaction.type === "boost") { + rebloggedIds.add(uid); + } else if (interaction.type === "bookmark") { + bookmarkedIds.add(uid); + } + } + + return { favouritedIds, rebloggedIds, bookmarkedIds }; +} + +export default router; diff --git a/package.json b/package.json index cce331b..109bc38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "2.15.4", + "version": "3.0.0", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit",