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 express from "express";
|
||||||
|
|
||||||
import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
|
import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
|
||||||
|
import { createMastodonRouter } from "./lib/mastodon/router.js";
|
||||||
import { initRedisCache } from "./lib/redis-cache.js";
|
import { initRedisCache } from "./lib/redis-cache.js";
|
||||||
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
|
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
|
||||||
import {
|
import {
|
||||||
@@ -1137,6 +1138,10 @@ export default class ActivityPubEndpoint {
|
|||||||
Indiekit.addCollection("ap_key_freshness");
|
Indiekit.addCollection("ap_key_freshness");
|
||||||
// Async inbox processing queue
|
// Async inbox processing queue
|
||||||
Indiekit.addCollection("ap_inbox_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)
|
// Store collection references (posts resolved lazily)
|
||||||
const indiekitCollections = Indiekit.collections;
|
const indiekitCollections = Indiekit.collections;
|
||||||
@@ -1170,6 +1175,10 @@ export default class ActivityPubEndpoint {
|
|||||||
ap_key_freshness: indiekitCollections.get("ap_key_freshness"),
|
ap_key_freshness: indiekitCollections.get("ap_key_freshness"),
|
||||||
// Async inbox processing queue
|
// Async inbox processing queue
|
||||||
ap_inbox_queue: indiekitCollections.get("ap_inbox_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() {
|
get posts() {
|
||||||
return indiekitCollections.get("posts");
|
return indiekitCollections.get("posts");
|
||||||
},
|
},
|
||||||
@@ -1391,6 +1400,24 @@ export default class ActivityPubEndpoint {
|
|||||||
{ processedAt: 1 },
|
{ processedAt: 1 },
|
||||||
{ expireAfterSeconds: 86_400, background: true },
|
{ 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 {
|
} catch {
|
||||||
// Index creation failed — collections not yet available.
|
// Index creation failed — collections not yet available.
|
||||||
// Indexes already exist from previous startups; non-fatal.
|
// Indexes already exist from previous startups; non-fatal.
|
||||||
@@ -1457,6 +1484,26 @@ export default class ActivityPubEndpoint {
|
|||||||
routesPublic: this.contentNegotiationRoutes,
|
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)
|
// Register syndicator (appears in post editing UI)
|
||||||
Indiekit.addSyndicator(this.syndicator);
|
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",
|
"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.",
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
Reference in New Issue
Block a user