feat: add Mastodon Client API layer for Phanpy/Elk compatibility

Implement the Mastodon Client REST API (/api/v1/*, /api/v2/*) and OAuth2
server within the ActivityPub plugin, enabling Mastodon-compatible clients
to connect to the Fedify-based server.

Core features:
- OAuth2 with PKCE (S256) — app registration, authorization, token exchange
- Instance info + nodeinfo for client discovery
- Account lookup, verification, relationships, follow/unfollow/mute/block
- Home/public/hashtag timelines with cursor-based pagination
- Status viewing, creation, deletion, thread context
- Favourite, boost, bookmark interactions with AP federation
- Notifications with type filtering and pagination
- Search across accounts, statuses, and hashtags
- Markers for read position tracking
- Bookmarks and favourites collection lists
- 25+ stub endpoints preventing client errors on unimplemented features

Architecture:
- 24 new files under lib/mastodon/ (entities, helpers, middleware, routes)
- Virtual endpoint at "/" via Indiekit.addEndpoint() for domain-root access
- CORS + JSON error handling for browser-based clients
- Six-layer mute/block filtering reusing existing moderation infrastructure

BREAKING CHANGE: bumps to v3.0.0 — adds new MongoDB collections
(ap_oauth_apps, ap_oauth_tokens, ap_markers) and new route registrations

Confab-Link: http://localhost:8080/sessions/5360e3f5-b3cc-4bf3-8c31-5448e2b23947
This commit is contained in:
Ricardo
2026-03-18 12:50:52 +01:00
parent 2ca491f28b
commit 2c0cfffd54
26 changed files with 4629 additions and 1 deletions
+47
View File
@@ -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);
+200
View File
@@ -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 "";
}
}
+1
View File
@@ -0,0 +1 @@
// Instance v1/v2 serializer — implemented in Task 8
+38
View File
@@ -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";
}
+118
View File
@@ -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<string, object>} [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,
};
}
+38
View File
@@ -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: "",
};
}
+111
View File
@@ -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("</")) {
return ALLOWED_TAGS.has(tag) ? `</${tag}>` : "";
}
// Opening tag — check if allowed
if (!ALLOWED_TAGS.has(tag)) return "";
// Self-closing br
if (tag === "br") return "<br>";
// 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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* 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();
}
+289
View File
@@ -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<string>} [options.favouritedIds] - UIDs the user has liked
* @param {Set<string>} [options.rebloggedIds] - UIDs the user has boosted
* @param {Set<string>} [options.bookmarkedIds] - UIDs the user has bookmarked
* @param {Set<string>} [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: [],
};
}
+32
View File
@@ -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";
}
+278
View File
@@ -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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
export async function unbookmarkPost({ targetUrl, interactions }) {
if (!interactions) return;
await interactions.deleteOne({ objectUrl: targetUrl, type: "bookmark" });
}
+130
View File
@@ -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;
}
+25
View File
@@ -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();
}
+37
View File
@@ -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",
});
}
+86
View File
@@ -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;
}
+57
View File
@@ -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;
}
+96
View File
@@ -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;
}
+552
View File
@@ -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;
+207
View File
@@ -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;
+43
View File
@@ -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;
+257
View File
@@ -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<string, object>>} 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;
+545
View File
@@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize ${appName}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.4rem; }
.scopes { background: #f5f5f5; padding: 0.75rem 1rem; border-radius: 6px; margin: 1rem 0; }
.scopes code { display: block; margin: 0.25rem 0; }
.actions { display: flex; gap: 0.75rem; margin-top: 1.5rem; }
button { padding: 0.6rem 1.5rem; border-radius: 6px; font-size: 1rem; cursor: pointer; border: 1px solid #ccc; }
.approve { background: #2b90d9; color: white; border-color: #2b90d9; }
.deny { background: white; }
</style>
</head>
<body>
<h1>Authorize ${appName}</h1>
<p>${appName} wants to access your account with these permissions:</p>
<div class="scopes">
${requestedScopes.map((s) => `<code>${s}</code>`).join("")}
</div>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${client_id}">
<input type="hidden" name="redirect_uri" value="${resolvedRedirectUri}">
<input type="hidden" name="scope" value="${requestedScopes.join(" ")}">
<input type="hidden" name="code_challenge" value="${code_challenge || ""}">
<input type="hidden" name="code_challenge_method" value="${code_challenge_method || ""}">
<input type="hidden" name="response_type" value="code">
<div class="actions">
<button type="submit" name="decision" value="approve" class="approve">Authorize</button>
<button type="submit" name="decision" value="deny" class="deny">Deny</button>
</div>
</form>
</body>
</html>`);
} 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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorization Code</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
code { display: block; background: #f5f5f5; padding: 1rem; border-radius: 6px; word-break: break-all; margin: 1rem 0; }
</style>
</head>
<body>
<h1>Authorization Code</h1>
<p>Copy this code and paste it into the application:</p>
<code>${code}</code>
</body>
</html>`);
}
// 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;
+146
View File
@@ -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;
+634
View File
@@ -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, "<br>");
const linked = withBreaks.replace(
/(?<![=">])(https?:\/\/[^\s<"]+)/g,
'<a href="$1">$1</a>',
);
return `<p>${linked}</p>`;
})
.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;
+380
View File
@@ -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;
+281
View File
@@ -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<string>, rebloggedIds: Set<string>, bookmarkedIds: Set<string> }>}
*/
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;
+1 -1
View File
@@ -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",