From 35ed4a333eba47d96121327f4c84ed8f8da90ae9 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 16:05:32 +0100 Subject: [PATCH 01/13] feat: enrich embedded account stats in timeline responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phanpy never calls /accounts/:id for timeline authors — it trusts the embedded account object in each status. These showed 0 counts because timeline author data doesn't include follower stats. Fix: after serializing statuses, batch-resolve unique authors that have 0 counts via Fedify AP collection fetch (5 concurrent). Results are cached (1h TTL) so subsequent page loads are instant. Applied to all three timeline endpoints (home, public, hashtag). --- lib/mastodon/helpers/enrich-accounts.js | 97 +++++++++++++++++++++++++ lib/mastodon/routes/timelines.js | 29 +++++++- package.json | 2 +- 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 lib/mastodon/helpers/enrich-accounts.js diff --git a/lib/mastodon/helpers/enrich-accounts.js b/lib/mastodon/helpers/enrich-accounts.js new file mode 100644 index 0000000..9ecc04e --- /dev/null +++ b/lib/mastodon/helpers/enrich-accounts.js @@ -0,0 +1,97 @@ +/** + * Enrich embedded account objects in serialized statuses with real + * follower/following/post counts from remote AP collections. + * + * Phanpy (and some other clients) never call /accounts/:id — they + * trust the account object embedded in each status. Without enrichment, + * these show 0/0/0 for all remote accounts. + * + * Uses the account stats cache to avoid redundant fetches. Only resolves + * unique authors with 0 counts that aren't already cached. + */ +import { getCachedAccountStats } from "./account-cache.js"; +import { resolveRemoteAccount } from "./resolve-account.js"; + +/** + * Enrich account objects in a list of serialized statuses. + * Resolves unique authors in parallel (max 5 concurrent). + * + * @param {Array} statuses - Serialized Mastodon Status objects (mutated in place) + * @param {object} pluginOptions - Plugin options with federation context + * @param {string} baseUrl - Server base URL + */ +export async function enrichAccountStats(statuses, pluginOptions, baseUrl) { + if (!statuses?.length || !pluginOptions?.federation) return; + + // Collect unique author URLs that need enrichment + const accountsToEnrich = new Map(); // url -> [account references] + for (const status of statuses) { + collectAccount(status.account, accountsToEnrich); + if (status.reblog?.account) { + collectAccount(status.reblog.account, accountsToEnrich); + } + } + + if (accountsToEnrich.size === 0) return; + + // Resolve in parallel with concurrency limit + const entries = [...accountsToEnrich.entries()]; + const CONCURRENCY = 5; + for (let i = 0; i < entries.length; i += CONCURRENCY) { + const batch = entries.slice(i, i + CONCURRENCY); + await Promise.all( + batch.map(async ([url, accounts]) => { + try { + const resolved = await resolveRemoteAccount(url, pluginOptions, baseUrl); + if (resolved) { + for (const account of accounts) { + account.followers_count = resolved.followers_count; + account.following_count = resolved.following_count; + account.statuses_count = resolved.statuses_count; + if (resolved.created_at && account.created_at) { + account.created_at = resolved.created_at; + } + if (resolved.note) account.note = resolved.note; + if (resolved.fields?.length) account.fields = resolved.fields; + if (resolved.avatar && resolved.avatar !== account.avatar) { + account.avatar = resolved.avatar; + account.avatar_static = resolved.avatar; + } + if (resolved.header) { + account.header = resolved.header; + account.header_static = resolved.header; + } + } + } + } catch { + // Silently skip failed resolutions + } + }), + ); + } +} + +/** + * Collect an account reference for enrichment if it has 0 counts + * and isn't already cached. + */ +function collectAccount(account, map) { + if (!account?.url) return; + if (account.followers_count > 0 || account.statuses_count > 0) return; + + // Check cache first — if cached, apply immediately + const cached = getCachedAccountStats(account.url); + if (cached) { + account.followers_count = cached.followersCount || 0; + account.following_count = cached.followingCount || 0; + account.statuses_count = cached.statusesCount || 0; + if (cached.createdAt) account.created_at = cached.createdAt; + return; + } + + // Queue for remote resolution + if (!map.has(account.url)) { + map.set(account.url, []); + } + map.get(account.url).push(account); +} diff --git a/lib/mastodon/routes/timelines.js b/lib/mastodon/routes/timelines.js index 4a4763e..5e628e5 100644 --- a/lib/mastodon/routes/timelines.js +++ b/lib/mastodon/routes/timelines.js @@ -9,6 +9,7 @@ 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"; +import { enrichAccountStats } from "../helpers/enrich-accounts.js"; const router = express.Router(); // eslint-disable-line new-cap @@ -76,6 +77,11 @@ router.get("/api/v1/timelines/home", async (req, res, next) => { }), ); + // Enrich embedded account objects with real follower/following/post counts. + // Phanpy never calls /accounts/:id — it trusts embedded account data. + const pluginOptions = req.app.locals.mastodonPluginOptions || {}; + await enrichAccountStats(statuses, pluginOptions, baseUrl); + // Set pagination Link headers setPaginationHeaders(res, req, items, limit); @@ -99,7 +105,22 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { visibility: "public", }; - // Only original posts (exclude boosts from public timeline unless local=true) + // Local timeline: only posts from the local instance author + if (req.query.local === "true") { + const profile = await collections.ap_profile.findOne({}); + if (profile?.url) { + baseFilter["author.url"] = profile.url; + } + } + + // Remote-only: exclude local author posts + if (req.query.remote === "true") { + const profile = await collections.ap_profile.findOne({}); + if (profile?.url) { + baseFilter["author.url"] = { $ne: profile.url }; + } + } + if (req.query.only_media === "true") { baseFilter.$or = [ { "photo.0": { $exists: true } }, @@ -155,6 +176,9 @@ router.get("/api/v1/timelines/public", async (req, res, next) => { }), ); + const pluginOpts = req.app.locals.mastodonPluginOptions || {}; + await enrichAccountStats(statuses, pluginOpts, baseUrl); + setPaginationHeaders(res, req, items, limit); res.json(statuses); } catch (error) { @@ -215,6 +239,9 @@ router.get("/api/v1/timelines/tag/:hashtag", async (req, res, next) => { }), ); + const pluginOpts = req.app.locals.mastodonPluginOptions || {}; + await enrichAccountStats(statuses, pluginOpts, baseUrl); + setPaginationHeaders(res, req, items, limit); res.json(statuses); } catch (error) { diff --git a/package.json b/package.json index ec9a191..88b9754 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.6.8", + "version": "3.6.9", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 30eff8e6c7a2e8c321e4e5842f133154a8838436 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 16:45:58 +0100 Subject: [PATCH 02/13] fix: status lookup fails due to published date format mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findTimelineItemById decoded the cursor (ms-since-epoch) back to an ISO date via toISOString() which produces "2026-03-21T15:33:50.000Z". But the stored published dates lack the .000Z milliseconds suffix — they're "2026-03-21T15:33:50Z". The exact string match failed for every single status, breaking /statuses/:id, /statuses/:id/context, and all interaction endpoints (favourite, boost, bookmark, delete). Fix: try both formats — with .000Z first, then without. --- lib/mastodon/routes/statuses.js | 11 ++++++++++- package.json | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index c22102f..22873d4 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -567,8 +567,17 @@ async function findTimelineItemById(collection, id) { // Try cursor-based lookup first (published date from ms-since-epoch) const publishedDate = decodeCursor(id); if (publishedDate) { - const item = await collection.findOne({ published: publishedDate }); + // Try exact match first (with .000Z suffix from toISOString) + let item = await collection.findOne({ published: publishedDate }); if (item) return item; + + // Try without milliseconds — stored dates often lack .000Z + // e.g., "2026-03-21T15:33:50Z" vs "2026-03-21T15:33:50.000Z" + const withoutMs = publishedDate.replace(/\.000Z$/, "Z"); + if (withoutMs !== publishedDate) { + item = await collection.findOne({ published: withoutMs }); + if (item) return item; + } } // Fall back to ObjectId lookup (legacy IDs) diff --git a/package.json b/package.json index 88b9754..36083ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.6.9", + "version": "3.7.0", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From ccb9cc99a200e7b2698c09552dec57633241d7b4 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 17:50:48 +0100 Subject: [PATCH 03/13] fix: follow/unfollow fails for remotely resolved profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /accounts/:id/follow returned 404 for actors resolved via Fedify (like @_followback@tags.pub) because resolveActorUrl only checked local data (followers/following/timeline). These actors aren't in local collections — they were resolved on-demand via WebFinger. Fix: add reverse lookup map (accountId hash → actorUrl) to the account cache. When resolveRemoteAccount resolves a profile, the hash-to-URL mapping is stored alongside the stats. resolveActorUrl checks this cache before scanning local collections. --- lib/mastodon/helpers/account-cache.js | 19 +++++++++++++++++++ lib/mastodon/routes/accounts.js | 5 +++++ package.json | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/mastodon/helpers/account-cache.js b/lib/mastodon/helpers/account-cache.js index 0407855..e061e58 100644 --- a/lib/mastodon/helpers/account-cache.js +++ b/lib/mastodon/helpers/account-cache.js @@ -7,12 +7,18 @@ * LRU-style with TTL — entries expire after 1 hour. */ +import { remoteActorId } from "./id-mapping.js"; + const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const MAX_ENTRIES = 500; // Map const cache = new Map(); +// Reverse map: accountId (hash) → actorUrl +// Populated alongside the stats cache for follow/unfollow lookups +const idToUrl = new Map(); + /** * Store account stats in cache. * @param {string} actorUrl - The actor's URL (cache key) @@ -28,6 +34,10 @@ export function cacheAccountStats(actorUrl, stats) { } cache.set(actorUrl, { ...stats, cachedAt: Date.now() }); + + // Maintain reverse lookup + const hashId = remoteActorId(actorUrl); + if (hashId) idToUrl.set(hashId, actorUrl); } /** @@ -49,3 +59,12 @@ export function getCachedAccountStats(actorUrl) { return entry; } + +/** + * Reverse lookup: get actor URL from account hash ID. + * @param {string} hashId - The 24-char hex account ID + * @returns {string|null} Actor URL or null + */ +export function getActorUrlFromId(hashId) { + return idToUrl.get(hashId) || null; +} diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index a5cfb8e..66239cb 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -10,6 +10,7 @@ import { serializeStatus } from "../entities/status.js"; import { accountId, remoteActorId } from "../helpers/id-mapping.js"; import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; import { resolveRemoteAccount } from "../helpers/resolve-account.js"; +import { getActorUrlFromId } from "../helpers/account-cache.js"; const router = express.Router(); // eslint-disable-line new-cap @@ -714,6 +715,10 @@ async function resolveActorUrl(id, collections) { return profile.url; } + // Check account cache reverse lookup (populated by resolveRemoteAccount) + const cachedUrl = getActorUrlFromId(id); + if (cachedUrl) return cachedUrl; + // Check followers const followers = await collections.ap_followers.find({}).toArray(); for (const f of followers) { diff --git a/package.json b/package.json index 36083ba..f9408b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.7.0", + "version": "3.7.1", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From cad9829cd73472362113fe515ac75694cc539f18 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 18:06:14 +0100 Subject: [PATCH 04/13] fix: fallback to unsigned lookup when authenticated fetch fails in followActor Some servers (e.g., tags.pub relay) reject or mishandle HTTP-signed GET requests during actor resolution. The authenticated document loader is tried first (required by Authorized Fetch servers like hachyderm.io), then falls back to unsigned fetch if it returns null. Same pattern should apply to unfollowActor. --- index.js | 10 ++++++++-- package.json | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/index.js b/index.js index b53cdb7..9f2d331 100644 --- a/index.js +++ b/index.js @@ -721,13 +721,19 @@ export default class ActivityPubEndpoint { ); // Resolve the remote actor to get their inbox - // Use authenticated document loader for servers requiring Authorized Fetch + // Try authenticated document loader first (for Authorized Fetch servers), + // fall back to unsigned if that fails (some servers reject signed GETs) const documentLoader = await ctx.getDocumentLoader({ identifier: handle, }); - const remoteActor = await lookupWithSecurity(ctx,actorUrl, { + let remoteActor = await lookupWithSecurity(ctx, actorUrl, { documentLoader, }); + if (!remoteActor) { + // Retry without authentication — some servers (e.g., tags.pub) + // may reject or mishandle signed GET requests + remoteActor = await lookupWithSecurity(ctx, actorUrl); + } if (!remoteActor) { return { ok: false, error: "Could not resolve remote actor" }; } diff --git a/package.json b/package.json index f9408b5..7f719e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.7.1", + "version": "3.7.2", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 94c454623435fff9714bd401e4dd7a2015c4833e Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 19:01:05 +0100 Subject: [PATCH 05/13] feat: linkify URLs and extract @mentions in status creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mastodon clients send plain text — the server must convert bare URLs and @user@domain mentions into HTML links. Previously, URLs appeared as plain text and mentions were not stored as mention objects. - Bare URLs (http/https) are wrapped in tags - @user@domain patterns are converted to profile links with h-card markup - Mentions are extracted into the mentions[] array with name and URL - Only processes content that doesn't already contain tags (avoids double-linkifying Micropub-rendered content) --- lib/mastodon/routes/statuses.js | 73 ++++++++++++++++++++++++++++++++- package.json | 2 +- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 22873d4..47a6789 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -247,12 +247,17 @@ router.post("/api/v1/statuses", async (req, res, next) => { }); }; + // Process content: linkify URLs and extract @mentions + const rawContent = data.properties.content || { text: statusText || "", html: "" }; + const processedContent = processStatusContent(rawContent, statusText || ""); + const mentions = extractMentions(statusText || ""); + const now = new Date().toISOString(); const timelineItem = await addTimelineItem(collections, { uid: postUrl, url: postUrl, type: data.properties["post-type"] || "note", - content: data.properties.content || { text: statusText || "", html: "" }, + content: processedContent, summary: spoilerText || "", sensitive: sensitive === true || sensitive === "true", visibility: visibility || "public", @@ -274,7 +279,7 @@ router.post("/api/v1/statuses", async (req, res, next) => { category: categories, counts: { replies: 0, boosts: 0, likes: 0 }, linkPreviews: [], - mentions: [], + mentions, emojis: [], }); @@ -636,4 +641,68 @@ async function loadItemInteractions(collections, item) { return { favouritedIds, rebloggedIds, bookmarkedIds }; } +/** + * Process status content: linkify bare URLs and convert @mentions to links. + * + * Mastodon clients send plain text — the server is responsible for + * converting URLs and mentions into HTML links. + * + * @param {object} content - { text, html } from Micropub pipeline + * @param {string} rawText - Original status text from client + * @returns {object} { text, html } with linkified content + */ +function processStatusContent(content, rawText) { + let html = content.html || content.text || rawText || ""; + + // If the HTML is just plain text wrapped in

, process it + // Don't touch HTML that already has links (from Micropub rendering) + if (!html.includes(""')\]]+)/g, + '$1', + ); + + // Convert @user@domain mentions to profile links + html = html.replace( + /(?:^|\s)(@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}))/g, + (match, full, username, domain) => + match.replace( + full, + `@${username}@${domain}`, + ), + ); + } + + return { + text: content.text || rawText || "", + html, + }; +} + +/** + * Extract @user@domain mentions from text into mention objects. + * + * @param {string} text - Status text + * @returns {Array<{name: string, url: string}>} Mention objects + */ +function extractMentions(text) { + if (!text) return []; + const mentionRegex = /@([a-zA-Z0-9_]+)@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g; + const mentions = []; + const seen = new Set(); + let match; + while ((match = mentionRegex.exec(text)) !== null) { + const [, username, domain] = match; + const key = `${username}@${domain}`.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + mentions.push({ + name: `@${username}@${domain}`, + url: `https://${domain}/@${username}`, + }); + } + return mentions; +} + export default router; diff --git a/package.json b/package.json index 7f719e7..718f1a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.7.2", + "version": "3.7.3", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 76e9ba0b3520000c59c21ddb3a11e98d42dc7cb4 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 19:16:05 +0100 Subject: [PATCH 06/13] fix: centralize unsigned fallback in lookupWithSecurity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some servers (e.g., tags.pub) return 400 for signed GET requests. Previously only followActor had an unsigned fallback — all other callers (resolve, unfollowActor, profile viewer, messages, post detail, OG unfurl) would silently fail. Fix: moved the fallback logic into lookupWithSecurity itself. When an authenticated documentLoader is provided and the lookup fails, it automatically retries without the loader (unsigned GET). This fixes ALL AP resolution paths in one place — resolve, follow, unfollow, profile viewing, message sending, quote fetching. Removed individual fallbacks in followActor and resolve controller since the central helper now handles it. --- index.js | 10 ++-------- lib/controllers/resolve.js | 3 ++- lib/lookup-helpers.js | 32 +++++++++++++++++++++++++++----- package.json | 2 +- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/index.js b/index.js index 9f2d331..de05988 100644 --- a/index.js +++ b/index.js @@ -721,19 +721,13 @@ export default class ActivityPubEndpoint { ); // Resolve the remote actor to get their inbox - // Try authenticated document loader first (for Authorized Fetch servers), - // fall back to unsigned if that fails (some servers reject signed GETs) + // lookupWithSecurity handles signed→unsigned fallback automatically const documentLoader = await ctx.getDocumentLoader({ identifier: handle, }); - let remoteActor = await lookupWithSecurity(ctx, actorUrl, { + const remoteActor = await lookupWithSecurity(ctx, actorUrl, { documentLoader, }); - if (!remoteActor) { - // Retry without authentication — some servers (e.g., tags.pub) - // may reject or mishandle signed GET requests - remoteActor = await lookupWithSecurity(ctx, actorUrl); - } if (!remoteActor) { return { ok: false, error: "Could not resolve remote actor" }; } diff --git a/lib/controllers/resolve.js b/lib/controllers/resolve.js index 466acde..4cf3f25 100644 --- a/lib/controllers/resolve.js +++ b/lib/controllers/resolve.js @@ -60,7 +60,8 @@ export function resolveController(mountPath, plugin) { let object; try { - object = await lookupWithSecurity(ctx,lookupInput, { documentLoader }); + // lookupWithSecurity handles signed→unsigned fallback automatically + object = await lookupWithSecurity(ctx, lookupInput, { documentLoader }); } catch (error) { console.warn( `[resolve] lookupObject failed for "${query}":`, diff --git a/lib/lookup-helpers.js b/lib/lookup-helpers.js index 149c932..c542fc5 100644 --- a/lib/lookup-helpers.js +++ b/lib/lookup-helpers.js @@ -14,14 +14,36 @@ * Using `crossOrigin: "ignore"` tells Fedify to silently discard objects * whose id doesn't match the fetch origin, rather than throwing. * + * When an authenticated document loader is provided (for Authorized Fetch + * compatibility), the lookup is tried with it first. If it fails (some + * servers like tags.pub return 400 for signed GETs), a fallback to the + * default unsigned loader is attempted automatically. + * * @param {object} ctx - Fedify Context * @param {string|URL} input - URL or handle to look up * @param {object} [options] - Additional options passed to lookupObject * @returns {Promise} Resolved object or null */ -export function lookupWithSecurity(ctx, input, options = {}) { - return ctx.lookupObject(input, { - crossOrigin: "ignore", - ...options, - }); +export async function lookupWithSecurity(ctx, input, options = {}) { + const baseOptions = { crossOrigin: "ignore", ...options }; + + let result = null; + try { + result = await ctx.lookupObject(input, baseOptions); + } catch { + // signed lookup threw — fall through to unsigned + } + + // If signed lookup failed and we used a custom documentLoader, + // retry without it (unsigned GET) + if (!result && options.documentLoader) { + try { + const { documentLoader: _, ...unsignedOptions } = baseOptions; + result = await ctx.lookupObject(input, unsignedOptions); + } catch { + // unsigned also failed — return null + } + } + + return result; } diff --git a/package.json b/package.json index 718f1a8..98784ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.7.3", + "version": "3.7.4", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From c30657ef71dd37064b155fa58ba328d0bb7cb2f2 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 20:03:19 +0100 Subject: [PATCH 07/13] feat: surface moderation data in federation admin + Mastodon API 1. Federation admin page (/admin/federation): new Moderation section showing blocked servers (with hostnames), blocked accounts, and muted accounts/keywords 2. GET /api/v1/domain_blocks: returns actual blocked server hostnames from ap_blocked_servers (was stub returning []) 3. Relationship responses: domain_blocking field now checks if the account's domain matches a blocked server hostname (was always false) --- lib/controllers/federation-mgmt.js | 11 +++++-- lib/mastodon/routes/accounts.js | 20 ++++++++++-- lib/mastodon/routes/stubs.js | 11 +++++-- package.json | 2 +- views/activitypub-federation-mgmt.njk | 47 +++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 7 deletions(-) diff --git a/lib/controllers/federation-mgmt.js b/lib/controllers/federation-mgmt.js index bda6a73..36ce931 100644 --- a/lib/controllers/federation-mgmt.js +++ b/lib/controllers/federation-mgmt.js @@ -41,12 +41,16 @@ export function federationMgmtController(mountPath, plugin) { const redisUrl = plugin.options.redisUrl || ""; - // Parallel: collection stats + posts + recent activities - const [collectionStats, postsResult, recentActivities] = + // Parallel: collection stats + posts + recent activities + moderation data + const pluginCollections = plugin._collections || {}; + const [collectionStats, postsResult, recentActivities, blockedServers, blockedAccounts, mutedAccounts] = await Promise.all([ getCollectionStats(collections, { redisUrl }), getPaginatedPosts(collections, request.query.page), getRecentActivities(collections), + pluginCollections.ap_blocked_servers?.find({}).sort({ blockedAt: -1 }).toArray() || [], + pluginCollections.ap_blocked?.find({}).sort({ blockedAt: -1 }).toArray() || [], + pluginCollections.ap_muted?.find({}).sort({ mutedAt: -1 }).toArray() || [], ]); const csrfToken = getToken(request.session); @@ -62,6 +66,9 @@ export function federationMgmtController(mountPath, plugin) { posts: postsResult.posts, cursor: postsResult.cursor, recentActivities, + blockedServers: blockedServers || [], + blockedAccounts: blockedAccounts || [], + mutedAccounts: mutedAccounts || [], csrfToken, mountPath, publicationUrl: plugin._publicationUrl, diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index 66239cb..c9d3c1d 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -170,11 +170,12 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => { const collections = req.app.locals.mastodonCollections; - const [followers, following, blocked, muted] = await Promise.all([ + const [followers, following, blocked, muted, blockedServers] = await Promise.all([ collections.ap_followers.find({}).toArray(), collections.ap_following.find({}).toArray(), collections.ap_blocked.find({}).toArray(), collections.ap_muted.find({}).toArray(), + collections.ap_blocked_servers?.find({}).toArray() || [], ]); const followerIds = new Set(followers.map((f) => remoteActorId(f.actorUrl))); @@ -182,6 +183,21 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => { const blockedIds = new Set(blocked.map((b) => remoteActorId(b.url))); const mutedIds = new Set(muted.filter((m) => m.url).map((m) => remoteActorId(m.url))); + // Build domain-blocked actor ID set by checking known actors against blocked server hostnames + const blockedDomains = new Set(blockedServers.map((s) => s.hostname).filter(Boolean)); + const domainBlockedIds = new Set(); + if (blockedDomains.size > 0) { + const allActors = [...followers, ...following]; + for (const actor of allActors) { + try { + const domain = new URL(actor.actorUrl).hostname; + if (blockedDomains.has(domain)) { + domainBlockedIds.add(remoteActorId(actor.actorUrl)); + } + } catch { /* skip invalid URLs */ } + } + } + const relationships = ids.map((id) => ({ id, following: followingIds.has(id), @@ -195,7 +211,7 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => { muting_notifications: mutedIds.has(id), requested: false, requested_by: false, - domain_blocking: false, + domain_blocking: domainBlockedIds.has(id), endorsed: false, note: "", })); diff --git a/lib/mastodon/routes/stubs.js b/lib/mastodon/routes/stubs.js index eb9a73e..70c9458 100644 --- a/lib/mastodon/routes/stubs.js +++ b/lib/mastodon/routes/stubs.js @@ -314,8 +314,15 @@ router.get("/api/v1/conversations", (req, res) => { // ─── Domain blocks ────────────────────────────────────────────────────────── -router.get("/api/v1/domain_blocks", (req, res) => { - res.json([]); +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([]); + } }); // ─── Endorsements ─────────────────────────────────────────────────────────── diff --git a/package.json b/package.json index 98784ef..9798d17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.7.4", + "version": "3.7.5", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-federation-mgmt.njk b/views/activitypub-federation-mgmt.njk index dd52522..d76b37e 100644 --- a/views/activitypub-federation-mgmt.njk +++ b/views/activitypub-federation-mgmt.njk @@ -116,6 +116,53 @@ {% endif %} + {# --- Moderation Overview --- #} +

+

Moderation

+ {% if blockedServers.length > 0 %} +

Blocked servers ({{ blockedServers.length }})

+
+ {% for server in blockedServers %} +
+ 🚫 {{ server.hostname }} + {% if server.blockedAt %} + {{ server.blockedAt | date("PPp") }} + {% endif %} +
+ {% endfor %} +
+ {% else %} + {{ prose({ text: "No servers blocked." }) }} + {% endif %} + + {% if blockedAccounts.length > 0 %} +

Blocked accounts ({{ blockedAccounts.length }})

+
+ {% for account in blockedAccounts %} +
+ 🚫 {{ account.url or account.handle or "Unknown" }} + {% if account.blockedAt %} + {{ account.blockedAt | date("PPp") }} + {% endif %} +
+ {% endfor %} +
+ {% else %} + {{ prose({ text: "No accounts blocked." }) }} + {% endif %} + + {% if mutedAccounts.length > 0 %} +

Muted ({{ mutedAccounts.length }})

+
+ {% for muted in mutedAccounts %} +
+ 🔇 {{ muted.url or muted.keyword or "Unknown" }} +
+ {% endfor %} +
+ {% endif %} +
+ {# --- JSON Modal --- #}
From 0d8b2d0f11fc21f1ad8f7ac418df1a757035ab66 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sat, 21 Mar 2026 20:50:36 +0100 Subject: [PATCH 08/13] docs: update CLAUDE.md and README.md with Mastodon Client API layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md: - Architecture: add full lib/mastodon/ tree (entities, helpers, middleware, routes) - Data flow: add Mastodon API path (client → /api/v1/* → ap_timeline + Fedify) - Collections: add ap_oauth_apps, ap_oauth_tokens, ap_markers; fix ap_blocked_servers field name - Gotchas #34-35: Mastodon API architecture decisions (pagination, own-post detection, account enrichment, OAuth native app redirect, token storage, route ordering, unsigned fallback, backfill, content processing) - Route table: add all Mastodon Client API endpoints README.md: - Updated description to mention Mastodon Client API compatibility - Added full Mastodon Client API feature section - Added moderation overview to Admin UI features --- CLAUDE.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- README.md | 20 +++++++++++- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 886760f..d80f775 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,39 @@ index.js ← Plugin entry, route registration, syndicat │ ├── server-blocks.js ← Server-level domain blocking │ ├── followed-tags.js ← Hashtag follow/unfollow storage │ └── messages.js ← Direct message storage +├── lib/mastodon/ ← Mastodon Client API (Phanpy/Elk/Moshidon/Fedilab compatibility) +│ ├── router.js ← Main router: body parsers, CORS, token resolution, sub-routers +│ ├── backfill-timeline.js ← Startup backfill: posts collection → ap_timeline +│ ├── entities/ ← Mastodon JSON entity serializers +│ │ ├── account.js ← Account entity (local + remote, with stats cache enrichment) +│ │ ├── status.js ← Status entity (published-based cursor IDs, own-post detection) +│ │ ├── notification.js ← Notification entity +│ │ ├── sanitize.js ← HTML sanitization for API responses +│ │ ├── relationship.js ← Relationship entity +│ │ ├── media.js ← Media attachment entity +│ │ └── instance.js ← Instance info entity +│ ├── helpers/ +│ │ ├── pagination.js ← Published-date cursor pagination (NOT ObjectId-based) +│ │ ├── id-mapping.js ← Deterministic account IDs: sha256(actorUrl).slice(0,24) +│ │ ├── interactions.js ← Like/boost/bookmark via Fedify AP activities +│ │ ├── resolve-account.js ← Remote account resolution via Fedify WebFinger + actor fetch +│ │ ├── account-cache.js ← In-memory LRU cache for account stats (500 entries, 1h TTL) +│ │ └── enrich-accounts.js ← Batch-enrich embedded account stats in timeline responses +│ ├── middleware/ +│ │ ├── cors.js ← CORS for browser-based SPA clients +│ │ ├── token-required.js ← Bearer token → ap_oauth_tokens lookup +│ │ ├── scope-required.js ← OAuth scope validation +│ │ └── error-handler.js ← JSON error responses for API routes +│ └── routes/ +│ ├── oauth.js ← OAuth2 server: app registration, authorize, token, revoke +│ ├── accounts.js ← Account lookup, relationships, follow/unfollow, statuses +│ ├── statuses.js ← Status CRUD, context/thread, favourite, boost, bookmark +│ ├── timelines.js ← Home/public/hashtag timelines with account enrichment +│ ├── notifications.js ← Notification listing with type filtering +│ ├── search.js ← Account/status/hashtag search with remote resolution +│ ├── instance.js ← Instance info, nodeinfo, custom emoji, preferences +│ ├── media.js ← Media upload (stub) +│ └── stubs.js ← 25+ stub endpoints preventing client errors ├── lib/controllers/ ← Express route handlers (admin UI) │ ├── dashboard.js, reader.js, compose.js, profile.js, profile.remote.js │ ├── public-profile.js ← Public profile page (HTML fallback for actor URL) @@ -67,7 +100,7 @@ index.js ← Plugin entry, route registration, syndicat │ ├── my-profile.js ← Self-profile view │ ├── resolve.js ← Actor/post resolution endpoint │ ├── authorize-interaction.js ← Remote interaction authorization -│ ├── federation-mgmt.js ← Federation management (server blocks) +│ ├── federation-mgmt.js ← Federation management (server blocks, moderation overview) │ └── federation-delete.js ← Account deletion / federation cleanup ├── views/ ← Nunjucks templates │ ├── activitypub-*.njk ← Page templates @@ -78,7 +111,7 @@ index.js ← Plugin entry, route registration, syndicat │ ├── reader-infinite-scroll.js ← Alpine.js components (infinite scroll, new posts banner, read tracking) │ ├── reader-tabs.js ← Alpine.js tab persistence │ └── icon.svg ← Plugin icon -└── locales/en.json ← i18n strings +└── locales/{en,de,es,fr,...}.json ← i18n strings (15 locales) ``` ## Data Flow @@ -90,6 +123,8 @@ Inbound: Remote inbox POST → Fedify → inbox-listeners.js → ap_inbox_queue Reply forwarding: inbox-listeners.js checks if reply is to our post → ctx.forwardActivity() → follower inboxes Reader: Followed account posts → Create inbox → timeline-store → ap_timeline → reader UI Explore: Public Mastodon API → fetchMastodonTimeline() → mapMastodonToItem() → explore UI +Mastodon: Client (Phanpy/Elk/Moshidon) → /api/v1/* → ap_timeline + Fedify → JSON responses + POST /api/v1/statuses → Micropub pipeline → content file + ap_timeline + AP syndication All views (reader, explore, tag timeline, hashtag explore, API endpoints) share a single processing pipeline via item-processing.js: @@ -118,9 +153,12 @@ processing pipeline via item-processing.js: | `ap_explore_tabs` | Saved explore instances | `instance` (unique), `label` | | `ap_reports` | Outbound Flag activities | `actorUrl`, `reportedAt` | | `ap_pending_follows` | Follow requests awaiting approval | `actorUrl` (unique), `receivedAt` | -| `ap_blocked_servers` | Blocked server domains | `domain` (unique) | +| `ap_blocked_servers` | Blocked server domains | `hostname` (unique) | | `ap_key_freshness` | Remote actor key verification timestamps | `actorUrl` (unique), `lastVerifiedAt` | | `ap_inbox_queue` | Persistent async inbox queue | `activityId`, `status`, `enqueuedAt` | +| `ap_oauth_apps` | Mastodon API client registrations | `clientId` (unique), `clientSecret`, `redirectUris` | +| `ap_oauth_tokens` | OAuth2 authorization codes + access tokens | `code` (unique sparse), `accessToken` (unique sparse) | +| `ap_markers` | Read position markers (Mastodon API) | `userId`, `timeline` | ## Critical Patterns and Gotchas @@ -361,6 +399,33 @@ The `visibility` field is stored on `ap_timeline` documents for future filtering `lib/key-refresh.js` tracks when remote actor keys were last verified in `ap_key_freshness`. `touchKeyFreshness()` is called for every inbound activity. This allows skipping redundant key re-fetches for actors we've recently verified, reducing network round-trips. +### 34. Mastodon Client API — Architecture (v3.0.0+) + +The Mastodon Client API is mounted at `/` (domain root) via `Indiekit.addEndpoint()` to serve `/api/v1/*`, `/api/v2/*`, and `/oauth/*` endpoints that Mastodon-compatible clients expect. + +**Key design decisions:** + +- **Published-date pagination** — Status IDs are `encodeCursor(published)` (ms since epoch), NOT MongoDB ObjectIds. This ensures chronological timeline sort regardless of insertion order (backfilled posts get new ObjectIds but retain original published dates). +- **Status lookup** — `findTimelineItemById()` decodes cursor → published date → MongoDB lookup. Must try both `"2026-03-21T15:33:50.000Z"` (with ms) and `"2026-03-21T15:33:50Z"` (without) because stored dates vary. +- **Own-post detection** — `setLocalIdentity(publicationUrl, handle)` called at init. `serializeAccount()` compares `author.url === publicationUrl` to pass `isLocal: true`. +- **Account enrichment** — Phanpy never calls `/accounts/:id` for timeline authors. `enrichAccountStats()` batch-resolves unique authors via Fedify after serialization, cached in memory (500 entries, 1h TTL). +- **OAuth for native apps** — Android Custom Tabs block 302 redirects to custom URI schemes (`moshidon-android-auth://`, `fedilab://`). Use HTML page with JS `window.location` redirect instead. +- **OAuth token storage** — Auth code documents MUST NOT set `accessToken: null` — use field absence. MongoDB sparse unique indexes skip absent fields but enforce uniqueness on explicit `null`. +- **Route ordering** — `/accounts/relationships` and `/accounts/familiar_followers` MUST be defined BEFORE `/accounts/:id` in Express, otherwise `:id` matches "relationships" as a parameter. +- **Unsigned fallback** — `lookupWithSecurity()` tries authenticated (signed) GET first, falls back to unsigned if it fails. Some servers (tags.pub) reject signed GETs with 400. +- **Backfill** — `backfill-timeline.js` runs on startup, converts Micropub posts → `ap_timeline` format with content synthesis (bookmarks → "Bookmarked: URL"), hashtag extraction, and absolute URL resolution. + +### 35. Mastodon API — Content Processing + +When creating posts via `POST /api/v1/statuses`: +- Bare URLs are linkified to `` tags +- `@user@domain` mentions are converted to profile links with `h-card` markup +- Mentions are extracted into `mentions[]` array with name and URL +- Hashtags are extracted from content text and merged with Micropub categories +- Content is stored in `ap_timeline` immediately (visible in Mastodon API) +- Content file is created via Micropub pipeline (visible on website after Eleventy rebuild) +- Relative media URLs are resolved to absolute using the publication URL + ## Date Handling Convention **All dates MUST be stored as ISO 8601 strings.** This is mandatory across all Indiekit plugins. @@ -441,6 +506,27 @@ On restart, `refollow:pending` entries are reset to `import` to prevent stale cl | `*` | `{mount}/__debug__/*` | Fedify debug dashboard (if enabled) | Password | | `GET` | `{mount}/users/:identifier` | Public profile page (HTML fallback) | No | | `GET` | `/*` (root) | Content negotiation (AP clients only) | No | +| | **Mastodon Client API (mounted at `/`)** | | +| `POST` | `/api/v1/apps` | Register OAuth client | No | +| `GET` | `/oauth/authorize` | Authorization page | IndieAuth | +| `POST` | `/oauth/authorize` | Process authorization | IndieAuth | +| `POST` | `/oauth/token` | Token exchange | No | +| `POST` | `/oauth/revoke` | Revoke token | No | +| `GET` | `/api/v1/accounts/verify_credentials` | Current user | Bearer | +| `GET` | `/api/v1/accounts/lookup` | Account lookup (with Fedify remote resolution) | Bearer | +| `GET` | `/api/v1/accounts/relationships` | Follow/block/mute state | Bearer | +| `GET` | `/api/v1/accounts/:id` | Account details (with remote AP collection counts) | Bearer | +| `GET` | `/api/v1/accounts/:id/statuses` | Account posts | Bearer | +| `POST` | `/api/v1/accounts/:id/follow,unfollow` | Follow/unfollow via Fedify | Bearer | +| `POST` | `/api/v1/accounts/:id/block,unblock,mute,unmute` | Moderation | Bearer | +| `GET` | `/api/v1/timelines/home,public,tag/:hashtag` | Timelines (published-date sort) | Bearer | +| `GET/POST` | `/api/v1/statuses` | Get/create status (via Micropub pipeline) | Bearer | +| `GET` | `/api/v1/statuses/:id/context` | Thread (ancestors + descendants) | Bearer | +| `POST` | `/api/v1/statuses/:id/favourite,reblog,bookmark` | Interactions via Fedify | Bearer | +| `GET` | `/api/v1/notifications` | Notifications with type filtering | Bearer | +| `GET` | `/api/v2/search` | Search with remote resolution | Bearer | +| `GET` | `/api/v1/domain_blocks` | Blocked server domains | Bearer | +| `GET` | `/api/v1/instance`, `/api/v2/instance` | Instance info | No | ## Dependencies diff --git a/README.md b/README.md index 4e723bf..7b6791f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @rmdes/indiekit-endpoint-activitypub -ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.0. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform. +ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.0. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform. Includes a Mastodon-compatible Client API so you can use Phanpy, Elk, Moshidon, Fedilab, and other Mastodon clients with your own AP instance. ## Features @@ -79,6 +79,23 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o - OpenTelemetry tracing for federation activity - Real-time activity inspection +**Mastodon Client API** *(v3.0.0+)* +- Full Mastodon REST API compatibility — use Phanpy, Elk, Moshidon, Fedilab, or any Mastodon-compatible client +- OAuth2 with PKCE (S256) — app registration, authorization, token exchange +- HTML+JS redirect for native Android apps (Chrome Custom Tabs block 302 to custom URI schemes) +- Home, public, and hashtag timelines with chronological published-date pagination +- Status creation via Micropub pipeline — posts flow through Indiekit → content file → AP syndication +- URL auto-linkification and @mention extraction in posted content +- Thread context (ancestors + descendants) +- Remote profile resolution via Fedify WebFinger with follower/following/post counts from AP collections +- Account stats enrichment — embedded account data in timeline responses includes real counts +- Favourite, boost, bookmark interactions federated via Fedify AP activities +- Notifications with type filtering +- Search across accounts, statuses, and hashtags with remote resolution +- Domain blocks API +- Timeline backfill from posts collection on startup (bookmarks, likes, reposts get synthesized content) +- In-memory account stats cache (500 entries, 1h TTL) for performance + **Admin UI** - Dashboard with follower/following counts and recent activity - Profile editor (name, bio, avatar, header, profile links with rel="me" verification) @@ -86,6 +103,7 @@ ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built o - Featured tags (hashtag collection) - Activity log (inbound/outbound) - Follower and following lists with source tracking +- Federation management page with moderation overview (blocked servers, blocked accounts, muted) ## Requirements From 944917b3f08dc8d1381f5525923efa48ec850831 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 22 Mar 2026 00:22:47 +0100 Subject: [PATCH 09/13] feat: tags.pub global hashtag discovery integration (v3.8.0) - Add setGlobalFollow/removeGlobalFollow/getFollowedTagsWithState to followed-tags storage; unfollowTag now preserves global follow state - Add followTagGloballyController/unfollowTagGloballyController that send AP Follow/Undo via Fedify to tags.pub actor URLs - Register POST /admin/reader/follow-tag-global and unfollow-tag-global routes with plugin reference for Fedify access - Tag timeline controller passes isGloballyFollowed + error query param - Tag timeline template adds global follow/unfollow buttons with globe indicator and inline error display - Wire GET /api/v1/followed_tags to return real data with globalFollow state - Add i18n keys: followGlobally, unfollowGlobally, globallyFollowing, globalFollowError --- index.js | 9 ++- lib/controllers/follow-tag.js | 86 ++++++++++++++++++++++++++++- lib/controllers/tag-timeline.js | 9 ++- lib/mastodon/routes/stubs.js | 26 ++++++++- lib/storage/followed-tags.js | 88 +++++++++++++++++++++++++++++- locales/en.json | 6 +- package.json | 2 +- views/activitypub-tag-timeline.njk | 22 +++++++- 8 files changed, 237 insertions(+), 11 deletions(-) diff --git a/index.js b/index.js index de05988..6bfd41c 100644 --- a/index.js +++ b/index.js @@ -79,7 +79,12 @@ import { instanceCheckApiController, popularAccountsApiController, } from "./lib/controllers/explore.js"; -import { followTagController, unfollowTagController } from "./lib/controllers/follow-tag.js"; +import { + followTagController, + unfollowTagController, + followTagGloballyController, + unfollowTagGloballyController, +} from "./lib/controllers/follow-tag.js"; import { listTabsController, addTabController, @@ -296,6 +301,8 @@ export default class ActivityPubEndpoint { router.patch("/admin/reader/api/tabs/reorder", reorderTabsController(mp)); router.post("/admin/reader/follow-tag", followTagController(mp)); router.post("/admin/reader/unfollow-tag", unfollowTagController(mp)); + router.post("/admin/reader/follow-tag-global", followTagGloballyController(mp, this)); + router.post("/admin/reader/unfollow-tag-global", unfollowTagGloballyController(mp, this)); router.get("/admin/reader/notifications", notificationsController(mp)); router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp)); router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp)); diff --git a/lib/controllers/follow-tag.js b/lib/controllers/follow-tag.js index 98f0e18..1b1adf5 100644 --- a/lib/controllers/follow-tag.js +++ b/lib/controllers/follow-tag.js @@ -3,7 +3,13 @@ */ import { validateToken } from "../csrf.js"; -import { followTag, unfollowTag } from "../storage/followed-tags.js"; +import { + followTag, + unfollowTag, + setGlobalFollow, + removeGlobalFollow, + getTagsPubActorUrl, +} from "../storage/followed-tags.js"; export function followTagController(mountPath) { return async (request, response, next) => { @@ -60,3 +66,81 @@ export function unfollowTagController(mountPath) { } }; } + +export function followTagGloballyController(mountPath, plugin) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + + // CSRF validation + if (!validateToken(request)) { + return response.status(403).json({ error: "Invalid CSRF token" }); + } + + const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : ""; + if (!tag) { + return response.redirect(`${mountPath}/admin/reader`); + } + + const actorUrl = getTagsPubActorUrl(tag); + + // Send AP Follow activity via Fedify + const result = await plugin.followActor(actorUrl); + if (!result.ok) { + const errorMsg = encodeURIComponent(result.error || "Follow failed"); + return response.redirect( + `${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}&error=${errorMsg}` + ); + } + + // Store global follow state + const collections = { + ap_followed_tags: application?.collections?.get("ap_followed_tags"), + }; + await setGlobalFollow(collections, tag, actorUrl); + + return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`); + } catch (error) { + next(error); + } + }; +} + +export function unfollowTagGloballyController(mountPath, plugin) { + return async (request, response, next) => { + try { + const { application } = request.app.locals; + + // CSRF validation + if (!validateToken(request)) { + return response.status(403).json({ error: "Invalid CSRF token" }); + } + + const tag = typeof request.body.tag === "string" ? request.body.tag.trim() : ""; + if (!tag) { + return response.redirect(`${mountPath}/admin/reader`); + } + + const actorUrl = getTagsPubActorUrl(tag); + + // Send AP Undo(Follow) activity via Fedify + const result = await plugin.unfollowActor(actorUrl); + if (!result.ok) { + const errorMsg = encodeURIComponent(result.error || "Unfollow failed"); + return response.redirect( + `${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}&error=${errorMsg}` + ); + } + + // Remove global follow state + const collections = { + ap_followed_tags: application?.collections?.get("ap_followed_tags"), + }; + await removeGlobalFollow(collections, tag); + + return response.redirect(`${mountPath}/admin/reader/tag?tag=${encodeURIComponent(tag)}`); + } catch (error) { + next(error); + } + }; +} diff --git a/lib/controllers/tag-timeline.js b/lib/controllers/tag-timeline.js index b61b200..19ecdf8 100644 --- a/lib/controllers/tag-timeline.js +++ b/lib/controllers/tag-timeline.js @@ -45,17 +45,20 @@ export function tagTimelineController(mountPath) { interactionsCol: application?.collections?.get("ap_interactions"), }); - // Check if this hashtag is followed + // Check if this hashtag is followed (local and/or global) const followedTagsCol = application?.collections?.get("ap_followed_tags"); let isFollowed = false; + let isGloballyFollowed = false; if (followedTagsCol) { const followed = await followedTagsCol.findOne({ tag: { $regex: new RegExp(`^${tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i") } }); - isFollowed = !!followed; + isFollowed = !!(followed?.followedAt); + isGloballyFollowed = !!(followed?.globalFollow); } const csrfToken = getToken(request.session); + const error = typeof request.query.error === "string" ? request.query.error : null; response.render("activitypub-tag-timeline", { title: `#${tag}`, @@ -68,6 +71,8 @@ export function tagTimelineController(mountPath) { csrfToken, mountPath, isFollowed, + isGloballyFollowed, + error, }); } catch (error) { next(error); diff --git a/lib/mastodon/routes/stubs.js b/lib/mastodon/routes/stubs.js index 70c9458..8aa063a 100644 --- a/lib/mastodon/routes/stubs.js +++ b/lib/mastodon/routes/stubs.js @@ -21,6 +21,7 @@ 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"; const router = express.Router(); // eslint-disable-line new-cap @@ -276,8 +277,29 @@ router.get("/api/v1/featured_tags", (req, res) => { // ─── Followed tags ────────────────────────────────────────────────────────── -router.get("/api/v1/followed_tags", (req, res) => { - res.json([]); +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); + } }); // ─── Suggestions ──────────────────────────────────────────────────────────── diff --git a/lib/storage/followed-tags.js b/lib/storage/followed-tags.js index 53ec79d..079ca38 100644 --- a/lib/storage/followed-tags.js +++ b/lib/storage/followed-tags.js @@ -15,6 +15,17 @@ export async function getFollowedTags(collections) { return docs.map((d) => d.tag); } +/** + * Get all followed hashtags with full state (local + global follow tracking) + * @param {object} collections - MongoDB collections + * @returns {Promise>} + */ +export async function getFollowedTagsWithState(collections) { + const { ap_followed_tags } = collections; + if (!ap_followed_tags) return []; + return ap_followed_tags.find({}).sort({ followedAt: -1 }).toArray(); +} + /** * Follow a hashtag * @param {object} collections - MongoDB collections @@ -36,16 +47,31 @@ export async function followTag(collections, tag) { } /** - * Unfollow a hashtag + * Unfollow a hashtag locally. + * If a global follow (tags.pub) is active, preserves the document with global state intact. + * Only deletes the document entirely when no global follow is active. * @param {object} collections - MongoDB collections * @param {string} tag - Hashtag string (without # prefix) - * @returns {Promise} true if removed, false if not found + * @returns {Promise} true if removed/updated, false if not found */ export async function unfollowTag(collections, tag) { const { ap_followed_tags } = collections; const normalizedTag = tag.toLowerCase().trim().replace(/^#/, ""); if (!normalizedTag) return false; + // Check if a global follow is active before deleting + const existing = await ap_followed_tags.findOne({ tag: normalizedTag }); + if (!existing) return false; + + if (existing.globalFollow) { + // Preserve the document — only unset the local follow fields + await ap_followed_tags.updateOne( + { tag: normalizedTag }, + { $unset: { followedAt: "" } } + ); + return true; + } + const result = await ap_followed_tags.deleteOne({ tag: normalizedTag }); return result.deletedCount > 0; } @@ -63,3 +89,61 @@ export async function isTagFollowed(collections, tag) { const doc = await ap_followed_tags.findOne({ tag: normalizedTag }); return !!doc; } + +/** + * Returns the deterministic tags.pub actor URL for a hashtag. + * @param {string} tag - Hashtag string (without # prefix) + * @returns {string} Actor URL + */ +export function getTagsPubActorUrl(tag) { + return `https://tags.pub/user/${tag.toLowerCase()}`; +} + +/** + * Set global follow state for a hashtag (upsert — works even with no local follow). + * @param {object} collections - MongoDB collections + * @param {string} tag - Hashtag string (without # prefix) + * @param {string} actorUrl - The tags.pub actor URL + * @returns {Promise} + */ +export async function setGlobalFollow(collections, tag, actorUrl) { + const { ap_followed_tags } = collections; + const normalizedTag = tag.toLowerCase().trim().replace(/^#/, ""); + if (!normalizedTag) return; + + await ap_followed_tags.updateOne( + { tag: normalizedTag }, + { + $set: { globalFollow: true, globalActorUrl: actorUrl }, + $setOnInsert: { tag: normalizedTag }, + }, + { upsert: true } + ); +} + +/** + * Remove global follow state for a hashtag. + * If no local follow exists (no followedAt), deletes the document entirely. + * @param {object} collections - MongoDB collections + * @param {string} tag - Hashtag string (without # prefix) + * @returns {Promise} + */ +export async function removeGlobalFollow(collections, tag) { + const { ap_followed_tags } = collections; + const normalizedTag = tag.toLowerCase().trim().replace(/^#/, ""); + if (!normalizedTag) return; + + const existing = await ap_followed_tags.findOne({ tag: normalizedTag }); + if (!existing) return; + + if (existing.followedAt) { + // Local follow is still active — just unset the global fields + await ap_followed_tags.updateOne( + { tag: normalizedTag }, + { $unset: { globalFollow: "", globalActorUrl: "" } } + ); + } else { + // No local follow — delete the document entirely + await ap_followed_tags.deleteOne({ tag: normalizedTag }); + } +} diff --git a/locales/en.json b/locales/en.json index 98ce7da..e5931d2 100644 --- a/locales/en.json +++ b/locales/en.json @@ -297,7 +297,11 @@ "noPosts": "No posts found with #%s in your timeline.", "followTag": "Follow hashtag", "unfollowTag": "Unfollow hashtag", - "following": "Following" + "following": "Following", + "followGlobally": "Follow globally via tags.pub", + "unfollowGlobally": "Unfollow global", + "globallyFollowing": "Following globally", + "globalFollowError": "Failed to follow globally: %s" }, "pagination": { "newer": "← Newer", diff --git a/package.json b/package.json index 9798d17..e9d1d89 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.7.5", + "version": "3.8.0", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", diff --git a/views/activitypub-tag-timeline.njk b/views/activitypub-tag-timeline.njk index 6a9988f..416e594 100644 --- a/views/activitypub-tag-timeline.njk +++ b/views/activitypub-tag-timeline.njk @@ -4,12 +4,15 @@ {# Tag header #}
-

#{{ hashtag }}

+

#{{ hashtag }}{% if isGloballyFollowed %} 🌐{% endif %}

{{ __("activitypub.reader.tagTimeline.postsTagged", items.length) }}

+ {% if error %} +

{{ __("activitypub.reader.tagTimeline.globalFollowError", error) }}

+ {% endif %} {% if isFollowed %} {% endif %} + {% if isGloballyFollowed %} + + {% else %} + + {% endif %}
← {{ __("activitypub.reader.title") }} From f69776b183a48763a733cdfa2deb32bcec498ebf Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 22 Mar 2026 00:25:29 +0100 Subject: [PATCH 10/13] fix: isTagFollowed false positive for global-only follows; # stripping in getTagsPubActorUrl - isTagFollowed() now checks doc?.followedAt instead of !!doc, so it correctly returns false for global-only follows (document exists but no local followedAt) - getTagsPubActorUrl() strips leading # so URLs like ?tag=%23indieweb don't produce invalid https://tags.pub/user/#indieweb actor URLs - Remove stale "Task 5" plan reference comment in tag timeline template --- lib/storage/followed-tags.js | 4 ++-- views/activitypub-tag-timeline.njk | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/storage/followed-tags.js b/lib/storage/followed-tags.js index 079ca38..c0cc120 100644 --- a/lib/storage/followed-tags.js +++ b/lib/storage/followed-tags.js @@ -87,7 +87,7 @@ export async function isTagFollowed(collections, tag) { if (!ap_followed_tags) return false; const normalizedTag = tag.toLowerCase().trim().replace(/^#/, ""); const doc = await ap_followed_tags.findOne({ tag: normalizedTag }); - return !!doc; + return !!(doc?.followedAt); } /** @@ -96,7 +96,7 @@ export async function isTagFollowed(collections, tag) { * @returns {string} Actor URL */ export function getTagsPubActorUrl(tag) { - return `https://tags.pub/user/${tag.toLowerCase()}`; + return `https://tags.pub/user/${tag.toLowerCase().replace(/^#/, "")}`; } /** diff --git a/views/activitypub-tag-timeline.njk b/views/activitypub-tag-timeline.njk index 416e594..e8fa036 100644 --- a/views/activitypub-tag-timeline.njk +++ b/views/activitypub-tag-timeline.njk @@ -81,7 +81,7 @@ {% endif %} - {# Infinite scroll sentinel (Task 5) #} + {# Infinite scroll load-more trigger #} {% if before %}
Date: Sun, 22 Mar 2026 00:26:44 +0100 Subject: [PATCH 11/13] chore: bump to 3.8.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e9d1d89..8866b33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.8.0", + "version": "3.8.1", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 9a0d6d208e430dd97d395f0e8804cc2cfcafb8af Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 22 Mar 2026 13:22:27 +0100 Subject: [PATCH 12/13] fix: serve AP JSON for actor URLs without explicit text/html Accept header Fedify's acceptsJsonLd() returns false for Accept: */* or no Accept header because it only checks for explicit application/activity+json in the list. Remote servers fetching actor URLs for HTTP Signature verification (e.g. tags.pub) often omit Accept or use */*, getting HTML back instead of the actor JSON and causing "public key not found" failures. Add middleware to upgrade ambiguous Accept headers to application/activity+json for GET requests to /users/:id paths. Explicit text/html requests (browsers) are unaffected. Also fix followActor() storing inbox: "" for actors where Fedify uses remoteActor.inboxId?.href (not remoteActor.inbox?.id?.href). The inbox URL is stored correctly now for all actor types. --- index.js | 17 ++++++++++++++++- package.json | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 6bfd41c..0f76131 100644 --- a/index.js +++ b/index.js @@ -238,6 +238,21 @@ export default class ActivityPubEndpoint { console.info(`[federation-diag] POST ${req.path} from=${ua.slice(0, 60)} bodyParsed=${bodyParsed} readable=${req.readable}`); } + // Fedify's acceptsJsonLd() treats Accept: */* as NOT accepting JSON-LD + // (it only returns true for explicit application/activity+json etc.). + // Remote servers fetching actor URLs for HTTP Signature verification + // (e.g. tags.pub) often omit Accept or use */* — they get HTML back + // instead of the actor JSON, causing "public key not found" errors. + // Fix: for GET requests to actor paths, upgrade ambiguous Accept headers + // to application/activity+json so Fedify serves JSON-LD. Explicit + // text/html requests (browsers) are unaffected. + if (req.method === "GET" && /^\/users\/[^/]+\/?$/.test(req.path)) { + const accept = req.get("accept") || ""; + if (!accept.includes("text/html") && !accept.includes("application/xhtml+xml")) { + req.headers["accept"] = "application/activity+json"; + } + } + return self._fedifyMiddleware(req, res, next); }); @@ -764,7 +779,7 @@ export default class ActivityPubEndpoint { (remoteActor.icon ? (await remoteActor.icon)?.url?.href || "" : ""); - const inbox = remoteActor.inbox?.id?.href || ""; + const inbox = remoteActor.inboxId?.href || ""; const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || ""; await this._collections.ap_following.updateOne( diff --git a/package.json b/package.json index 8866b33..4aa72a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.8.1", + "version": "3.8.2", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit", From 4495667ed9f5edf0d10acee61f9f0991a9745dc7 Mon Sep 17 00:00:00 2001 From: Ricardo Date: Sun, 22 Mar 2026 15:00:14 +0100 Subject: [PATCH 13/13] fix: remove RSA Multikey from assertionMethod to fix tags.pub signature verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fedify 2.0 migration added assertionMethods = keyPairs.map(k => k.multikey), which places the RSA Multikey (id: #main-key) into assertionMethod alongside the Ed25519 Multikey (id: #key-2). This creates a keyId collision: the RSA CryptographicKey in publicKey and the RSA Multikey in assertionMethod both use #main-key. Servers that traverse JSON-LD properties alphabetically (assertionMethod before publicKey) find the Multikey first — which lacks publicKeyPem — and return "public key not found". Fix: filter assertionMethods to only Ed25519 keys (Object Integrity Proofs). RSA keys already have their correct representation in publicKey (HTTP Signatures). This matches Mastodon's behavior and is semantically correct per the two key systems. --- lib/federation-setup.js | 18 ++++++++++++++++-- package.json | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/lib/federation-setup.js b/lib/federation-setup.js index 920f225..3fd9269 100644 --- a/lib/federation-setup.js +++ b/lib/federation-setup.js @@ -154,7 +154,16 @@ export function setupFederation(options) { }; if (keyPairs.length > 0) { appOptions.publicKey = keyPairs[0].cryptographicKey; - appOptions.assertionMethods = keyPairs.map((k) => k.multikey); + // Only include Ed25519 keys in assertionMethod (Object Integrity Proofs). + // RSA keys belong only in publicKey (HTTP Signatures). Putting the RSA + // Multikey in assertionMethod with the same #main-key id as the + // CryptographicKey in publicKey causes id collisions — servers that + // traverse JSON-LD properties alphabetically (assertionMethod before + // publicKey) find the Multikey first, which has no publicKeyPem, + // and fail signature verification. + appOptions.assertionMethods = keyPairs + .filter((k) => k.privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") + .map((k) => k.multikey); } return new Application(appOptions); } @@ -753,7 +762,12 @@ export async function buildPersonActor( if (keyPairs.length > 0) { personOptions.publicKey = keyPairs[0].cryptographicKey; - personOptions.assertionMethods = keyPairs.map((k) => k.multikey); + // Only include Ed25519 keys in assertionMethod (Object Integrity Proofs). + // RSA keys belong only in publicKey (HTTP Signatures). See instance actor + // above for the full explanation of why this filter is necessary. + personOptions.assertionMethods = keyPairs + .filter((k) => k.privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") + .map((k) => k.multikey); } // Build profile field attachments (PropertyValue). diff --git a/package.json b/package.json index 4aa72a5..9603821 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rmdes/indiekit-endpoint-activitypub", - "version": "3.8.2", + "version": "3.8.3", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "keywords": [ "indiekit",