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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// Instance v1/v2 serializer — implemented in Task 8
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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: "",
|
||||
};
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip all HTML tags, returning plain text.
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
export function stripHtml(html) {
|
||||
if (!html || typeof html !== "string") return "";
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user