Files
indiekit-endpoint-activitypub/lib/mastodon/routes/notifications.js
Ricardo 2c0cfffd54 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
2026-03-18 12:50:52 +01:00

258 lines
7.3 KiB
JavaScript

/**
* 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;