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>
651 lines
22 KiB
JavaScript
651 lines
22 KiB
JavaScript
/**
|
|
* 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";
|
|
import { getFollowedTagsWithState } from "../../storage/followed-tags.js";
|
|
import { tokenRequired } from "../middleware/token-required.js";
|
|
import { scopeRequired } from "../middleware/scope-required.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([]);
|
|
});
|
|
|
|
// ─── 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", async (req, res, next) => {
|
|
try {
|
|
const collections = req.app.locals.mastodonCollections;
|
|
if (!collections?.ap_followed_tags) {
|
|
return res.json([]);
|
|
}
|
|
|
|
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
|
|
const publicationUrl = pluginOptions.publicationUrl || "";
|
|
const tags = await getFollowedTagsWithState({ ap_followed_tags: collections.ap_followed_tags });
|
|
|
|
const response = tags.map((doc) => ({
|
|
id: doc._id.toString(),
|
|
name: doc.tag,
|
|
url: `${publicationUrl.replace(/\/$/, "")}/tags/${doc.tag}`,
|
|
history: [],
|
|
following: true,
|
|
}));
|
|
|
|
res.json(response);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── GET /api/v1/tags/:id ───────────────────────────────────────────────────
|
|
|
|
router.get("/api/v1/tags/:id", async (req, res) => {
|
|
const collections = req.app.locals.mastodonCollections;
|
|
const tag = req.params.id.toLowerCase().replace(/^#/, "");
|
|
let following = false;
|
|
|
|
if (collections.ap_followed_tags) {
|
|
const doc = await collections.ap_followed_tags.findOne({ tag });
|
|
following = !!doc;
|
|
}
|
|
|
|
res.json({
|
|
name: tag,
|
|
url: `${req.protocol}://${req.get("host")}/tags/${tag}`,
|
|
history: [],
|
|
following,
|
|
});
|
|
});
|
|
|
|
// ─── POST /api/v1/tags/:id/follow ──────────────────────────────────────────
|
|
|
|
router.post("/api/v1/tags/:id/follow", tokenRequired, scopeRequired("write", "write:follows"), async (req, res) => {
|
|
const collections = req.app.locals.mastodonCollections;
|
|
const tag = req.params.id.toLowerCase().replace(/^#/, "");
|
|
|
|
if (collections.ap_followed_tags) {
|
|
await collections.ap_followed_tags.updateOne(
|
|
{ tag },
|
|
{ $setOnInsert: { tag, createdAt: new Date().toISOString() } },
|
|
{ upsert: true },
|
|
);
|
|
}
|
|
|
|
res.json({
|
|
name: tag,
|
|
url: `${req.protocol}://${req.get("host")}/tags/${tag}`,
|
|
history: [],
|
|
following: true,
|
|
});
|
|
});
|
|
|
|
// ─── POST /api/v1/tags/:id/unfollow ────────────────────────────────────────
|
|
|
|
router.post("/api/v1/tags/:id/unfollow", tokenRequired, scopeRequired("write", "write:follows"), async (req, res) => {
|
|
const collections = req.app.locals.mastodonCollections;
|
|
const tag = req.params.id.toLowerCase().replace(/^#/, "");
|
|
|
|
if (collections.ap_followed_tags) {
|
|
await collections.ap_followed_tags.deleteOne({ tag });
|
|
}
|
|
|
|
res.json({
|
|
name: tag,
|
|
url: `${req.protocol}://${req.get("host")}/tags/${tag}`,
|
|
history: [],
|
|
following: false,
|
|
});
|
|
});
|
|
|
|
// ─── 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 ──────────────────────────────────────────────────────────
|
|
|
|
// ─── Conversations (Direct Messages) ────────────────────────────────────────
|
|
// Real implementation replacing the empty stub.
|
|
// Reads from ap_messages collection, groups by conversationId (actor URL).
|
|
|
|
router.get("/api/v1/conversations", async (req, res, next) => {
|
|
try {
|
|
const collections = req.app.locals.mastodonCollections;
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const { serializeAccount } = await import("../entities/account.js");
|
|
const { remoteActorId } = await import("../helpers/id-mapping.js");
|
|
const { parseLimit } = await import("../helpers/pagination.js");
|
|
if (!collections?.ap_messages) {
|
|
return res.json([]);
|
|
}
|
|
const limit = parseLimit(req.query.limit, 20);
|
|
// Aggregate conversations: group by conversationId, get last message + unread count
|
|
const pipeline = [
|
|
{ $sort: { published: -1 } },
|
|
{
|
|
$group: {
|
|
_id: "$conversationId",
|
|
lastMessageId: { $first: "$_id" },
|
|
lastUid: { $first: "$uid" },
|
|
lastContent: { $first: "$content" },
|
|
lastPublished: { $first: "$published" },
|
|
actorUrl: { $first: "$actorUrl" },
|
|
actorName: { $first: "$actorName" },
|
|
actorPhoto: { $first: "$actorPhoto" },
|
|
actorHandle: { $first: "$actorHandle" },
|
|
unreadCount: {
|
|
$sum: { $cond: [{ $eq: ["$read", false] }, 1, 0] },
|
|
},
|
|
},
|
|
},
|
|
{ $sort: { lastPublished: -1 } },
|
|
];
|
|
// Apply cursor pagination on the aggregation result
|
|
if (req.query.max_id) {
|
|
pipeline.splice(0, 0, {
|
|
$match: { _id: { $lt: req.query.max_id } },
|
|
});
|
|
}
|
|
pipeline.push({ $limit: limit });
|
|
const conversations = await collections.ap_messages
|
|
.aggregate(pipeline)
|
|
.toArray();
|
|
const result = conversations.map((conv) => {
|
|
const convId = remoteActorId(conv._id || conv.actorUrl);
|
|
// Build a minimal Mastodon Status for last_status
|
|
const lastStatus = {
|
|
id: conv.lastMessageId.toString(),
|
|
created_at: conv.lastPublished || new Date().toISOString(),
|
|
in_reply_to_id: null,
|
|
in_reply_to_account_id: null,
|
|
sensitive: false,
|
|
spoiler_text: "",
|
|
visibility: "direct",
|
|
language: null,
|
|
uri: conv.lastUid || "",
|
|
url: conv.lastUid || "",
|
|
replies_count: 0,
|
|
reblogs_count: 0,
|
|
favourites_count: 0,
|
|
edited_at: null,
|
|
favourited: false,
|
|
reblogged: false,
|
|
muted: false,
|
|
bookmarked: false,
|
|
pinned: false,
|
|
content: conv.lastContent?.html || conv.lastContent?.text || "",
|
|
filtered: null,
|
|
reblog: null,
|
|
application: null,
|
|
account: serializeAccount(
|
|
{
|
|
name: conv.actorName,
|
|
url: conv.actorUrl,
|
|
photo: conv.actorPhoto,
|
|
handle: conv.actorHandle,
|
|
},
|
|
{ baseUrl },
|
|
),
|
|
media_attachments: [],
|
|
mentions: [],
|
|
tags: [],
|
|
emojis: [],
|
|
card: null,
|
|
poll: null,
|
|
};
|
|
return {
|
|
id: convId,
|
|
unread: conv.unreadCount > 0,
|
|
last_status: lastStatus,
|
|
accounts: [
|
|
serializeAccount(
|
|
{
|
|
name: conv.actorName,
|
|
url: conv.actorUrl,
|
|
photo: conv.actorPhoto,
|
|
handle: conv.actorHandle,
|
|
},
|
|
{ baseUrl },
|
|
),
|
|
],
|
|
};
|
|
});
|
|
// Set Link header for pagination
|
|
if (result.length === limit && conversations.length > 0) {
|
|
const lastConv = conversations[conversations.length - 1];
|
|
const maxId = remoteActorId(lastConv._id || lastConv.actorUrl);
|
|
res.set("Link", `<${baseUrl}/api/v1/conversations?max_id=${maxId}>; rel="next"`);
|
|
}
|
|
res.json(result);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Mark conversation as read
|
|
router.post("/api/v1/conversations/:id/read", async (req, res, next) => {
|
|
try {
|
|
const collections = req.app.locals.mastodonCollections;
|
|
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
|
const { serializeAccount } = await import("../entities/account.js");
|
|
const { remoteActorId } = await import("../helpers/id-mapping.js");
|
|
if (!collections?.ap_messages) {
|
|
return res.status(404).json({ error: "Not found" });
|
|
}
|
|
// Find the conversation partner whose hashed actorUrl matches the :id
|
|
const allPartners = await collections.ap_messages.aggregate([
|
|
{ $group: { _id: "$conversationId" } },
|
|
]).toArray();
|
|
const partner = allPartners.find(
|
|
(p) => remoteActorId(p._id) === req.params.id
|
|
);
|
|
if (!partner) {
|
|
return res.status(404).json({ error: "Conversation not found" });
|
|
}
|
|
// Mark all messages from this partner as read
|
|
await collections.ap_messages.updateMany(
|
|
{ conversationId: partner._id, read: false },
|
|
{ $set: { read: true } },
|
|
);
|
|
// Return the updated conversation
|
|
const lastMsg = await collections.ap_messages
|
|
.findOne({ conversationId: partner._id }, { sort: { published: -1 } });
|
|
if (!lastMsg) {
|
|
return res.status(404).json({ error: "No messages" });
|
|
}
|
|
const convId = remoteActorId(partner._id);
|
|
const account = serializeAccount(
|
|
{
|
|
name: lastMsg.actorName,
|
|
url: lastMsg.actorUrl,
|
|
photo: lastMsg.actorPhoto,
|
|
handle: lastMsg.actorHandle,
|
|
},
|
|
{ baseUrl },
|
|
);
|
|
res.json({
|
|
id: convId,
|
|
unread: false,
|
|
last_status: {
|
|
id: lastMsg._id.toString(),
|
|
created_at: lastMsg.published || new Date().toISOString(),
|
|
in_reply_to_id: null,
|
|
in_reply_to_account_id: null,
|
|
sensitive: false,
|
|
spoiler_text: "",
|
|
visibility: "direct",
|
|
language: null,
|
|
uri: lastMsg.uid || "",
|
|
url: lastMsg.uid || "",
|
|
replies_count: 0,
|
|
reblogs_count: 0,
|
|
favourites_count: 0,
|
|
edited_at: null,
|
|
favourited: false,
|
|
reblogged: false,
|
|
muted: false,
|
|
bookmarked: false,
|
|
pinned: false,
|
|
content: lastMsg.content?.html || lastMsg.content?.text || "",
|
|
filtered: null,
|
|
reblog: null,
|
|
application: null,
|
|
account,
|
|
media_attachments: [],
|
|
mentions: [],
|
|
tags: [],
|
|
emojis: [],
|
|
card: null,
|
|
poll: null,
|
|
},
|
|
accounts: [account],
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── Domain blocks ──────────────────────────────────────────────────────────
|
|
|
|
router.get("/api/v1/domain_blocks", async (req, res) => {
|
|
try {
|
|
const collections = req.app.locals.mastodonCollections;
|
|
if (!collections?.ap_blocked_servers) return res.json([]);
|
|
const docs = await collections.ap_blocked_servers.find({}).toArray();
|
|
res.json(docs.map((d) => d.hostname).filter(Boolean));
|
|
} catch {
|
|
res.json([]);
|
|
}
|
|
});
|
|
|
|
// ─── POST /api/v1/domain_blocks ─────────────────────────────────────────────
|
|
|
|
router.post("/api/v1/domain_blocks", tokenRequired, scopeRequired("write", "write:blocks"), async (req, res, next) => {
|
|
try {
|
|
const collections = req.app.locals.mastodonCollections;
|
|
const domain = req.body.domain?.trim();
|
|
|
|
if (!domain) {
|
|
return res.status(422).json({ error: "domain is required" });
|
|
}
|
|
|
|
if (collections.ap_blocked_servers) {
|
|
await collections.ap_blocked_servers.updateOne(
|
|
{ hostname: domain },
|
|
{ $setOnInsert: { hostname: domain, createdAt: new Date().toISOString() } },
|
|
{ upsert: true },
|
|
);
|
|
}
|
|
|
|
res.json({});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── DELETE /api/v1/domain_blocks ───────────────────────────────────────────
|
|
|
|
router.delete("/api/v1/domain_blocks", tokenRequired, scopeRequired("write", "write:blocks"), async (req, res, next) => {
|
|
try {
|
|
const collections = req.app.locals.mastodonCollections;
|
|
const domain = req.body.domain?.trim();
|
|
|
|
if (domain && collections.ap_blocked_servers) {
|
|
await collections.ap_blocked_servers.deleteOne({ hostname: domain });
|
|
}
|
|
|
|
res.json({});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// ─── Endorsements ───────────────────────────────────────────────────────────
|
|
|
|
router.get("/api/v1/endorsements", (req, res) => {
|
|
res.json([]);
|
|
});
|
|
|
|
|
|
|
|
|
|
export default router;
|