56a8b08498
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>
240 lines
7.1 KiB
JavaScript
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;
|