Files
svemagie 56a8b08498 fix: integrate all AP runtime patches into fork source
Integrates 12 runtime patch scripts from indiekit-server directly into
the fork source code, eliminating the need for postinstall patching:

- enrich-actor-data: avatar via getIcon(), handle as @user@domain, banner via getImage()
- conversations-endpoint: real /api/v1/conversations implementation
- stubs-remove-duplicate-routes: dead route removal from stubs.js
- self-follow-guard: prevent self-follow loop
- oauth-token-expiry: clear expiresAt on token exchange
- unify-dm-visibility: unified DM visibility detection
- accounts-id-cache-fallback: check ap_actor_cache before 404
- federation-infra: federation infrastructure fixes
- mastodon-misc: miscellaneous Mastodon API fixes
- mastodon-statuses: status endpoint fixes
- syndication: syndication dedup
- startup-gate-bypass: startup gate bypass

Also strips all // [patch] markers from 16 files (including 4 from prior commit).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-25 16:35:01 +02:00

240 lines
7.1 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";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.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", tokenRequired, scopeRequired("read", "read:notifications"), async (req, res, next) => {
try {
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", tokenRequired, scopeRequired("read", "read:notifications"), async (req, res, next) => {
try {
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", tokenRequired, scopeRequired("write", "write:notifications"), async (req, res, next) => {
try {
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", tokenRequired, scopeRequired("write", "write:notifications"), async (req, res, next) => {
try {
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
.flatMap((n) => [n.targetUrl, n.url])
.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;