From 56a8b08498e64a753dfab96439be310857c5511b Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:35:01 +0200 Subject: [PATCH] fix: integrate all AP runtime patches into fork source Integrates 12 runtime patch scripts from indiekit-server directly into the fork source code, eliminating the need for postinstall patching: - enrich-actor-data: avatar via getIcon(), handle as @user@domain, banner via getImage() - conversations-endpoint: real /api/v1/conversations implementation - stubs-remove-duplicate-routes: dead route removal from stubs.js - self-follow-guard: prevent self-follow loop - oauth-token-expiry: clear expiresAt on token exchange - unify-dm-visibility: unified DM visibility detection - accounts-id-cache-fallback: check ap_actor_cache before 404 - federation-infra: federation infrastructure fixes - mastodon-misc: miscellaneous Mastodon API fixes - mastodon-statuses: status endpoint fixes - syndication: syndication dedup - startup-gate-bypass: startup gate bypass Also strips all // [patch] markers from 16 files (including 4 from prior commit). Co-Authored-By: Claude Opus 4.6 --- index.js | 54 +++-- lib/controllers/compose.js | 32 ++- lib/federation-bridge.js | 18 +- lib/federation-setup.js | 3 + lib/inbox-handlers.js | 90 +++++++-- lib/inbox-listeners.js | 49 +++-- lib/jf2-to-as2.js | 2 +- lib/mastodon/helpers/interactions.js | 4 +- lib/mastodon/helpers/resolve-account.js | 42 ++-- lib/mastodon/routes/accounts.js | 36 ++-- lib/mastodon/routes/notifications.js | 2 +- lib/mastodon/routes/search.js | 39 +++- lib/mastodon/routes/statuses.js | 84 ++++++-- lib/mastodon/routes/stubs.js | 251 +++++++++++++++++++----- lib/syndicator.js | 30 +-- lib/timeline-cleanup.js | 2 +- 16 files changed, 562 insertions(+), 176 deletions(-) diff --git a/index.js b/index.js index a06f206..5610b23 100644 --- a/index.js +++ b/index.js @@ -506,11 +506,18 @@ export default class ActivityPubEndpoint { router.use((req, res, next) => { if (!self._fedifyMiddleware) return next(); if (req.method !== "GET" && req.method !== "HEAD") return next(); - // Only delegate to Fedify for NodeInfo data endpoint (/nodeinfo/2.1). - // All other paths in this root-mounted router are handled by the - // content negotiation catch-all below. Passing arbitrary paths like - // /notes/... to Fedify causes harmless but noisy 404 warnings. - if (!req.path.startsWith("/nodeinfo/")) return next(); + // Delegate to Fedify for discovery endpoints: + // /.well-known/webfinger — actor/resource identity resolution + // /.well-known/nodeinfo — server capabilities advertised to the fediverse + // /nodeinfo/2.1 — NodeInfo data document + // This router is mounted at "/" so req.url retains the full path, allowing + // Fedify to match its internal routes correctly. (routesWellKnown strips + // the /.well-known/ prefix, causing Fedify to miss the webfinger route.) + // ap-webfinger-before-auth patch + const isDiscoveryRoute = + req.path.startsWith("/nodeinfo/") || + req.path.startsWith("/.well-known/"); + if (!isDiscoveryRoute) return next(); return self._fedifyMiddleware(req, res, next); }); @@ -689,15 +696,30 @@ export default class ActivityPubEndpoint { remoteActor.name?.toString() || remoteActor.preferredUsername?.toString() || actorUrl; - const actorHandle = - actorInfo.handle || - remoteActor.preferredUsername?.toString() || - ""; - const avatar = - actorInfo.photo || - (remoteActor.icon - ? (await remoteActor.icon)?.url?.href || "" - : ""); + let _enrichedAvatar = ""; + try { + if (typeof remoteActor.getIcon === "function") { + const _iconObj = await remoteActor.getIcon(); + _enrichedAvatar = _iconObj?.url?.href || ""; + } + } catch { /* icon fetch failed */ } + let _enrichedHandle = ""; + try { + const _username = remoteActor.preferredUsername?.toString() || ""; + if (_username && actorUrl) { + const _domain = new URL(actorUrl).hostname; + _enrichedHandle = `@${_username}@${_domain}`; + } + } catch { /* URL parse failed */ } + let _enrichedBanner = ""; + try { + if (typeof remoteActor.getImage === "function") { + const _imgObj = await remoteActor.getImage(); + _enrichedBanner = _imgObj?.url?.href || ""; + } + } catch { /* banner fetch failed */ } + const actorHandle = actorInfo.handle || _enrichedHandle || remoteActor.preferredUsername?.toString() || ""; + const avatar = actorInfo.photo || _enrichedAvatar || ""; const inbox = remoteActor.inboxId?.href || ""; const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || ""; @@ -711,6 +733,7 @@ export default class ActivityPubEndpoint { avatar, inbox, sharedInbox, + banner: _enrichedBanner || "", followedAt: new Date().toISOString(), source: "reader", }, @@ -1295,7 +1318,7 @@ export default class ActivityPubEndpoint { }); this._federation = federation; - this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({})); + this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}), this._publicationUrl); // ap-base-url patch // Expose signed avatar resolver for cross-plugin use (e.g., conversations backfill) Indiekit.config.application.resolveActorAvatar = async (actorUrl) => { @@ -1348,6 +1371,7 @@ export default class ActivityPubEndpoint { broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(), loadRsaKey: () => pluginRef._loadRsaPrivateKey(), broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(), + broadcastDelete: (url) => pluginRef.broadcastDelete(url), }, }); Indiekit.addEndpoint({ diff --git a/lib/controllers/compose.js b/lib/controllers/compose.js index 74db1a3..f3e95e5 100644 --- a/lib/controllers/compose.js +++ b/lib/controllers/compose.js @@ -9,6 +9,20 @@ import { lookupWithSecurity } from "../lookup-helpers.js"; import { addNotification } from "../storage/notifications.js"; import { createContext, getHandle, isFederationReady } from "../federation-actions.js"; + +const _mpInternalBase = (() => { + if (process.env.INTERNAL_FETCH_URL) return process.env.INTERNAL_FETCH_URL.replace(/\/+$/, ""); + const port = process.env.PORT || "3000"; + return `http://localhost:${port}`; +})(); +const _mpPublicBase = ( + process.env.PUBLICATION_URL || process.env.SITE_URL || "" +).replace(/\/+$/, ""); +function _toInternalUrl(url) { + if (!_mpPublicBase || !url.startsWith(_mpPublicBase)) return url; + return _mpInternalBase + url.slice(_mpPublicBase.length); +} + /** * Fetch syndication targets from the Micropub config endpoint. * @param {object} application - Indiekit application locals @@ -21,9 +35,9 @@ async function getSyndicationTargets(application, token) { if (!micropubEndpoint) return []; - const micropubUrl = micropubEndpoint.startsWith("http") + const micropubUrl = _toInternalUrl(micropubEndpoint.startsWith("http") ? micropubEndpoint - : new URL(micropubEndpoint, application.url).href; + : new URL(micropubEndpoint, application.url).href); const configUrl = `${micropubUrl}?q=config`; const configResponse = await fetch(configUrl, { @@ -146,10 +160,12 @@ export function composeController(mountPath, plugin) { ? await getSyndicationTargets(application, token) : []; - // Pre-check syndication targets based on their configured checked state // [patch] ap-compose-default-checked - for (const target of syndicationTargets) { // [patch] ap-compose-default-checked - target.defaultChecked = target.checked === true; // [patch] ap-compose-default-checked - } // [patch] ap-compose-default-checked + // Default-check only AP (Fedify) and Bluesky targets + // "@rick@rmendes.net" = AP Fedify, "@rmendes.net" = Bluesky + for (const target of syndicationTargets) { + const name = target.name || ""; + target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net"; + } const csrfToken = getToken(request.session); @@ -300,9 +316,9 @@ export function submitComposeController(mountPath, plugin) { }); } - const micropubUrl = micropubEndpoint.startsWith("http") + const micropubUrl = _toInternalUrl(micropubEndpoint.startsWith("http") ? micropubEndpoint - : new URL(micropubEndpoint, application.url).href; + : new URL(micropubEndpoint, application.url).href); const token = request.session?.access_token; diff --git a/lib/federation-bridge.js b/lib/federation-bridge.js index 0f109df..a910b2f 100644 --- a/lib/federation-bridge.js +++ b/lib/federation-bridge.js @@ -17,8 +17,10 @@ import { Buffer } from "node:buffer"; * @param {import("express").Request} req - Express request * @returns {Request} Standard Request object */ -export function fromExpressRequest(req) { - const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`; +export function fromExpressRequest(req, baseUrl) { // ap-base-url patch + const url = baseUrl + ? `${baseUrl.replace(/\/$/, "")}${req.originalUrl}` // ap-base-url patch + : `${req.protocol}://${req.get("host")}${req.originalUrl}`; const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (Array.isArray(value)) { @@ -28,6 +30,18 @@ export function fromExpressRequest(req) { } } + // Normalise "host" to the public hostname so Fedify's HTTP Signature + // verifier reconstructs the same signed-string the remote server created. + // Without this, nginx may forward an internal Host (e.g. "10.100.0.20") + // which doesn't match what the sender signed, causing every inbox POST + // to fail with "Failed to verify the request's HTTP Signatures". + if (baseUrl) { + try { + const _canonicalHost = new URL(baseUrl).host; // e.g. "blog.giersig.eu" + headers.set("host", _canonicalHost); + } catch { /* invalid baseUrl — leave header as-is */ } + } + let body; if (req.method === "GET" || req.method === "HEAD") { body = undefined; diff --git a/lib/federation-setup.js b/lib/federation-setup.js index 0caea53..f1775c3 100644 --- a/lib/federation-setup.js +++ b/lib/federation-setup.js @@ -363,6 +363,9 @@ export function setupFederation(options) { `${mountPath}/users/{identifier}/inbox`, `${mountPath}/inbox`, ); + // Expose publicationUrl on collections so inbox handlers can gate + // notifications/timeline-storage to our own content only. + collections._publicationUrl = publicationUrl; registerInboxListeners(inboxChain, { collections, handle, diff --git a/lib/inbox-handlers.js b/lib/inbox-handlers.js index 72e7cd0..4bb5e8e 100644 --- a/lib/inbox-handlers.js +++ b/lib/inbox-handlers.js @@ -160,14 +160,26 @@ function isDirectMessage(object, ourActorUrl, followersUrl) { * @param {object} object - Fedify object (Note, Article, etc.) * @returns {"public"|"unlisted"|"private"|"direct"} */ -function computeVisibility(object) { +function computeVisibility(object, actorContext) { const to = new Set((object.toIds || []).map((u) => u.href)); const cc = new Set((object.ccIds || []).map((u) => u.href)); - if (to.has(PUBLIC)) return "public"; if (cc.has(PUBLIC)) return "unlisted"; - // Without knowing the remote actor's followers URL, we can't distinguish - // "private" (followers-only) from "direct". Both are non-public. + // When actor context is available, use isDirectMessage logic to distinguish + // "direct" (addressed to specific actors only) from "private" (followers-only). + if (actorContext?.ourActorUrl) { + const allAddressed = [ + ...to, ...cc, + ...(object.btoIds || []).map((u) => u.href), + ...(object.bccIds || []).map((u) => u.href), + ]; + const hasPublic = allAddressed.some((u) => u === PUBLIC || u === "as:Public"); + const hasFollowers = actorContext.followersUrl && allAddressed.includes(actorContext.followersUrl); + if (!hasPublic && !hasFollowers && allAddressed.includes(actorContext.ourActorUrl)) { + return "direct"; + } + } + // Without actor context, can't distinguish "private" from "direct". if (to.size > 0 || cc.size > 0) return "private"; return "direct"; } @@ -650,7 +662,7 @@ export async function handleCreate(item, collections, ctx, handle) { const ourActorUrl = ctx.getActorUri(handle).href; const followersUrl = ctx.getFollowersUri(handle)?.href || ""; - if (isDirectMessage(object, ourActorUrl, followersUrl)) { + if (computeVisibility(object, { ourActorUrl, followersUrl }) === "direct") { const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader }); const rawHtml = object.content?.toString() || ""; const contentHtml = sanitizeContent(rawHtml); @@ -809,7 +821,7 @@ export async function handleCreate(item, collections, ctx, handle) { actorFallback: actorObj, documentLoader: authLoader, }); - timelineItem.visibility = computeVisibility(object); + timelineItem.visibility = computeVisibility(object, { ourActorUrl, followersUrl }); await addTimelineItem(collections, timelineItem); // Fire-and-forget OG unfurling for notes and articles (not boosts) @@ -831,6 +843,19 @@ export async function handleCreate(item, collections, ctx, handle) { // Log extraction errors but don't fail the entire handler console.error("[inbox-handlers] Failed to store timeline item:", error); } + } else if (pubUrl && inReplyTo && inReplyTo.startsWith(pubUrl)) { + // Reply to our post from a non-followed account — store in timeline + // so it appears in the Mastodon client API's conversation/notification view. + try { + const timelineItem = await extractObjectData(object, { + actorFallback: actorObj, + documentLoader: authLoader, + }); + timelineItem.visibility = computeVisibility(object, { ourActorUrl, followersUrl }); + await addTimelineItem(collections, timelineItem); + } catch (error) { + console.error("[inbox-handlers] Failed to store reply timeline item:", error.message); + } } else if (collections.ap_followed_tags) { // Not a followed account — check if the post's hashtags match any followed tags // so tagged posts from across the fediverse appear in the timeline @@ -850,7 +875,7 @@ export async function handleCreate(item, collections, ctx, handle) { actorFallback: actorObj, documentLoader: authLoader, }); - timelineItem.visibility = computeVisibility(object); + timelineItem.visibility = computeVisibility(object, { ourActorUrl, followersUrl }); await addTimelineItem(collections, timelineItem); } } @@ -986,22 +1011,47 @@ export async function handleUpdate(item, collections, ctx, handle) { const existing = await collections.ap_followers.findOne({ actorUrl }); if (existing) { + let _uAvatar = ""; + try { + if (typeof actorObj.getIcon === "function") { + const _uIcon = await actorObj.getIcon(); + _uAvatar = _uIcon?.url?.href || ""; + } + } catch { /* icon fetch failed */ } + let _uHandle = ""; + try { + const _uUsername = actorObj.preferredUsername?.toString() || ""; + if (_uUsername && actorUrl) { + const _uDomain = new URL(actorUrl).hostname; + _uHandle = `@${_uUsername}@${_uDomain}`; + } + } catch { /* URL parse failed */ } + let _uBanner = ""; + try { + if (typeof actorObj.getImage === "function") { + const _uImg = await actorObj.getImage(); + _uBanner = _uImg?.url?.href || ""; + } + } catch { /* banner fetch failed */ } + const _updateFields = { + name: actorObj.name?.toString() || actorObj.preferredUsername?.toString() || actorUrl, + handle: _uHandle || actorObj.preferredUsername?.toString() || "", + avatar: _uAvatar, + updatedAt: new Date().toISOString(), + }; + if (_uBanner) _updateFields.banner = _uBanner; await collections.ap_followers.updateOne( { actorUrl }, - { - $set: { - name: - actorObj.name?.toString() || - actorObj.preferredUsername?.toString() || - actorUrl, - handle: actorObj.preferredUsername?.toString() || "", - avatar: actorObj.icon - ? (await actorObj.icon)?.url?.href || "" - : "", - updatedAt: new Date().toISOString(), - }, - }, + { $set: _updateFields }, ); + // Also update ap_following if we follow this actor + const existingFollowing = await collections.ap_following.findOne({ actorUrl }); + if (existingFollowing) { + await collections.ap_following.updateOne( + { actorUrl }, + { $set: _updateFields }, + ); + } } } diff --git a/lib/inbox-listeners.js b/lib/inbox-listeners.js index 3c5614a..63817cc 100644 --- a/lib/inbox-listeners.js +++ b/lib/inbox-listeners.js @@ -23,7 +23,6 @@ import { Remove, Undo, Update, - View, // View imported } from "@fedify/fedify/vocab"; import { isServerBlocked } from "./storage/server-blocks.js"; @@ -54,6 +53,16 @@ export function registerInboxListeners(inboxChain, options) { .on(Follow, async (ctx, follow) => { const actorUrl = follow.actorId?.href || ""; if (await isServerBlocked(actorUrl, collections)) return; + + // Reject self-follows: if the follower is our own actor, skip. + // Self-follows cause infinite delivery retries because Fedify + // tries to POST to our own shared inbox, which is unreachable + // from within the jail (no outbound internet). + if (collections._publicationUrl && actorUrl.startsWith(collections._publicationUrl)) { + console.info(`[ActivityPub] Ignoring self-follow from ${actorUrl}`); + return; + } + await touchKeyFreshness(collections, actorUrl); await resetDeliveryStrikes(collections, actorUrl); @@ -67,13 +76,35 @@ export function registerInboxListeners(inboxChain, options) { followerActor.preferredUsername?.toString() || followerUrl; + // Enrich avatar and handle using proper Fedify async getters + let _fAvatar = ""; + try { + if (typeof followerActor.getIcon === "function") { + const _fIcon = await followerActor.getIcon(); + _fAvatar = _fIcon?.url?.href || ""; + } + } catch { /* icon fetch failed */ } + let _fHandle = ""; + try { + const _fUsername = followerActor.preferredUsername?.toString() || ""; + if (_fUsername && followerUrl) { + const _fDomain = new URL(followerUrl).hostname; + _fHandle = `@${_fUsername}@${_fDomain}`; + } + } catch { /* URL parse failed */ } + let _fBanner = ""; + try { + if (typeof followerActor.getImage === "function") { + const _fImg = await followerActor.getImage(); + _fBanner = _fImg?.url?.href || ""; + } + } catch { /* banner fetch failed */ } const followerData = { actorUrl: followerUrl, - handle: followerActor.preferredUsername?.toString() || "", + handle: _fHandle || followerActor.preferredUsername?.toString() || "", name: followerName, - avatar: followerActor.icon - ? (await followerActor.icon)?.url?.href || "" - : "", + avatar: _fAvatar, + banner: _fBanner, inbox: followerActor.inbox?.id?.href || "", sharedInbox: followerActor.endpoints?.sharedInbox?.href || "", }; @@ -353,11 +384,5 @@ export function registerInboxListeners(inboxChain, options) { actorUrl, rawJson: await flag.toJsonLd(), }); - }) - // ── View (PeerTube watch) ───────────────────────────────────────────── - // PeerTube broadcasts View (WatchAction) activities to all followers - // whenever someone watches a video. Fedify has no built-in handler for - // this type, producing noisy "Unsupported activity type" log errors. - // Silently accept and discard. // PeerTube View handler - .on(View, async () => {}); + }); } diff --git a/lib/jf2-to-as2.js b/lib/jf2-to-as2.js index deb19c8..39585ee 100644 --- a/lib/jf2-to-as2.js +++ b/lib/jf2-to-as2.js @@ -314,7 +314,7 @@ export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, opt // Only send Announce if repost-of is an ActivityPub URL. // Non-AP URLs (web articles) cannot be federated as a boost — fall // through to Create(Note) which renders as "🔁 " on the fediverse. - if (await isApUrl(repostOf)) { // [patch] ap-repost-announce-fix + if (await isApUrl(repostOf)) { const actorPath = new URL(actorUrl).pathname; const mp = actorPath.replace(/\/users\/[^/]+$/, ""); const postRelPath = (properties.url || "") diff --git a/lib/mastodon/helpers/interactions.js b/lib/mastodon/helpers/interactions.js index fba315d..03690e6 100644 --- a/lib/mastodon/helpers/interactions.js +++ b/lib/mastodon/helpers/interactions.js @@ -55,7 +55,7 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl, }); if (recipient) { - try { // [patch] ap-interactions-send-guard + try { await ctx.sendActivity({ identifier: handle }, recipient, like, { orderingKey: targetUrl, }); @@ -178,7 +178,7 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl, }); // Send to followers - try { // [patch] ap-interactions-send-guard + try { await ctx.sendActivity({ identifier: handle }, "followers", announce, { preferSharedInbox: true, syncCollection: true, diff --git a/lib/mastodon/helpers/resolve-account.js b/lib/mastodon/helpers/resolve-account.js index c8b1e45..49bfcf1 100644 --- a/lib/mastodon/helpers/resolve-account.js +++ b/lib/mastodon/helpers/resolve-account.js @@ -44,8 +44,10 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect // Use signed→unsigned fallback so servers rejecting signed GETs still resolve const documentLoader = await ctx.getDocumentLoader({ identifier: handle }); - const actor = await lookupWithSecurity(ctx, actorUri, { documentLoader }); - if (!actor) return null; + // Timeout guard: cap actor fetch at 8 s so hung lookups fail fast. + const _aLookupTimeout = (p, ms = 8000) => { const t = new Promise((_, rej) => setTimeout(() => rej(new Error("actor lookup timeout")), ms)); p.catch(() => {}); return Promise.race([p, t]); }; + const actor = await _aLookupTimeout(lookupWithSecurity(ctx, actorUri, { documentLoader })).catch(err => { console.warn(`[Mastodon API] Actor lookup failed for ${acct}: ${err.message}`); return null; }); + if (!actor) { console.warn(`[Mastodon API] lookupWithSecurity returned null for ${acct}`); return null; } // Extract data from the Fedify actor object const name = actor.name?.toString() || actor.preferredUsername?.toString() || ""; @@ -69,21 +71,24 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect } catch { /* ignore */ } // Get collection counts (followers, following, outbox) — with 5 s timeout each - const withTimeout = (promise, ms = 5000) => { // [patch] ap-resolve-account-timeout-safe - const abort = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)); - promise.catch(() => {}); // suppress unhandled rejection if timeout settles first - return Promise.race([promise, abort]); - }; + const withTimeout = (promise, ms = 5000) => + Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]); - // Fetch collection counts in parallel (max 5 s each) [patch] ap-resolve-account-timeout-safe - const [followersResult, followingResult, outboxResult] = await Promise.allSettled([ - withTimeout(actor.getFollowers()), - withTimeout(actor.getFollowing()), - withTimeout(actor.getOutbox()), - ]); - const followersCount = followersResult.status === "fulfilled" && followersResult.value?.totalItems != null ? followersResult.value.totalItems : 0; - const followingCount = followingResult.status === "fulfilled" && followingResult.value?.totalItems != null ? followingResult.value.totalItems : 0; - const statusesCount = outboxResult.status === "fulfilled" && outboxResult.value?.totalItems != null ? outboxResult.value.totalItems : 0; + let followersCount = 0; + let followingCount = 0; + let statusesCount = 0; + try { + const followers = await withTimeout(actor.getFollowers()); + if (followers?.totalItems != null) followersCount = followers.totalItems; + } catch { /* ignore */ } + try { + const following = await withTimeout(actor.getFollowing()); + if (following?.totalItems != null) followingCount = following.totalItems; + } catch { /* ignore */ } + try { + const outbox = await withTimeout(actor.getOutbox()); + if (outbox?.totalItems != null) statusesCount = outbox.totalItems; + } catch { /* ignore */ } // Get published/created date — normalize to UTC ISO so clients display it correctly. // Temporal.Instant.toString() preserves the original timezone offset; @@ -138,14 +143,13 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect }); // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts - // [patch] ap-actor-cache-await if (collections?.ap_actor_cache && actorUrl) { const hashId = remoteActorId(actorUrl); - await collections.ap_actor_cache.updateOne( + collections.ap_actor_cache.updateOne( { _id: hashId }, { $set: { actorUrl, updatedAt: new Date() } }, { upsert: true }, - ).catch(() => {}); // non-fatal, but now awaited so entry exists before response + ).catch(() => {}); // fire-and-forget, non-fatal } return account; diff --git a/lib/mastodon/routes/accounts.js b/lib/mastodon/routes/accounts.js index ed5d3eb..1d9cce9 100644 --- a/lib/mastodon/routes/accounts.js +++ b/lib/mastodon/routes/accounts.js @@ -354,7 +354,7 @@ router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:ac // Check if it's the local profile const profile = await collections.ap_profile.findOne({}); - if (profile && profile._id.toString() === id) { + if (profile && remoteActorId(profile.url) === id) { const [statuses, followers, following] = await Promise.all([ collections.ap_timeline.countDocuments({ "author.url": profile.url }), collections.ap_followers.countDocuments({}), @@ -389,6 +389,20 @@ router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:ac return res.json(account); } + // Cache fallback: actor not in followers/following/timeline, + // but may have been resolved via /lookup and cached in ap_actor_cache + let cachedActorUrl = getActorUrlFromId(id); + if (!cachedActorUrl && collections.ap_actor_cache) { + const cached = await collections.ap_actor_cache.findOne({ _id: id }); + if (cached?.actorUrl) cachedActorUrl = cached.actorUrl; + } + if (cachedActorUrl) { + const cachedAccount = await resolveRemoteAccount( + cachedActorUrl, pluginOptions, baseUrl, collections, + ); + if (cachedAccount) return res.json(cachedAccount); + } + return res.status(404).json({ error: "Record not found" }); } catch (error) { next(error); @@ -456,23 +470,15 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read", let bookmarkedIds = new Set(); if (req.mastodonToken && collections.ap_interactions) { - const urlToUid = new Map(); // [patch] ap-interactions-accounts-uid - for (const i of items) { - if (i.uid) { - urlToUid.set(i.uid, i.uid); - if (i.url && i.url !== i.uid) urlToUid.set(i.url, i.uid); - } - } - const lookupUrls = [...urlToUid.keys()]; + const lookupUrls = items.flatMap((i) => [i.uid, i.url].filter(Boolean)); if (lookupUrls.length > 0) { const interactions = await collections.ap_interactions .find({ objectUrl: { $in: lookupUrls } }) .toArray(); for (const ix of interactions) { - const uid = urlToUid.get(ix.objectUrl) || ix.objectUrl; - if (ix.type === "like") favouritedIds.add(uid); - else if (ix.type === "boost") rebloggedIds.add(uid); - else if (ix.type === "bookmark") bookmarkedIds.add(uid); + if (ix.type === "like") favouritedIds.add(ix.objectUrl); + else if (ix.type === "boost") rebloggedIds.add(ix.objectUrl); + else if (ix.type === "bookmark") bookmarkedIds.add(ix.objectUrl); } } } @@ -505,7 +511,7 @@ router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read" const profile = await collections.ap_profile.findOne({}); // Only serve followers for the local account - if (!profile || profile._id.toString() !== id) { + if (!profile || remoteActorId(profile.url) !== id) { return res.json([]); } @@ -538,7 +544,7 @@ router.get("/api/v1/accounts/:id/following", tokenRequired, scopeRequired("read" const profile = await collections.ap_profile.findOne({}); // Only serve following for the local account - if (!profile || profile._id.toString() !== id) { + if (!profile || remoteActorId(profile.url) !== id) { return res.json([]); } diff --git a/lib/mastodon/routes/notifications.js b/lib/mastodon/routes/notifications.js index 2f4d0e5..3b3f21e 100644 --- a/lib/mastodon/routes/notifications.js +++ b/lib/mastodon/routes/notifications.js @@ -207,7 +207,7 @@ function resolveInternalTypes(mastodonTypes) { async function batchFetchStatuses(collections, notifications) { const statusMap = new Map(); - const targetUrls = [ // [patch] ap-notifications-status-lookup + const targetUrls = [ ...new Set( notifications .flatMap((n) => [n.targetUrl, n.url]) diff --git a/lib/mastodon/routes/search.js b/lib/mastodon/routes/search.js index 6c7ebe7..38125df 100644 --- a/lib/mastodon/routes/search.js +++ b/lib/mastodon/routes/search.js @@ -109,7 +109,42 @@ router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search") .limit(limit) .toArray(); - results.statuses = items.map((item) => + // URL resolve: find post by AP URL before content search. + if (resolve && query.startsWith("http")) { + const resolvedItem = await collections.ap_timeline.findOne({ + isContext: { $ne: true }, + $or: [{ uid: query }, { url: query }], + }); + if (resolvedItem) { + results.statuses.push(serializeStatus(resolvedItem, { + baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(), + bookmarkedIds: new Set(), pinnedIds: new Set(), + })); + } else if (pluginOptions.federation) { + try { + const { lookupWithSecurity } = await import("../../lookup-helpers.js"); + const { extractObjectData } = await import("../../timeline-store.js"); + const { addTimelineItem } = await import("../../storage/timeline.js"); + const _rCtx = pluginOptions.federation.createContext(new URL(pluginOptions.publicationUrl), { handle: pluginOptions.handle, publicationUrl: pluginOptions.publicationUrl }); + const _rDl = await _rCtx.getDocumentLoader({ identifier: pluginOptions.handle }); + const _rObj = await lookupWithSecurity(_rCtx, new URL(query), { documentLoader: _rDl }); + if (_rObj) { + const _rData = await extractObjectData(_rObj, { documentLoader: _rDl }); + if (!_rData?.uid) throw new Error("remote AP object has no uid"); + const _rStored = await addTimelineItem(collections, _rData); + if (_rStored) { + results.statuses.push(serializeStatus(_rStored, { + baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(), + bookmarkedIds: new Set(), pinnedIds: new Set(), + })); + } + } + } catch (_rErr) { + console.warn(`[Mastodon API] search resolve remote fetch failed for ${query}: ${_rErr.message}`); + } + } + } + results.statuses.push(...items.map((item) => serializeStatus(item, { baseUrl, favouritedIds: new Set(), @@ -117,7 +152,7 @@ router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search") bookmarkedIds: new Set(), pinnedIds: new Set(), }), - ); + )); } // ─── Hashtag search ────────────────────────────────────────────────── diff --git a/lib/mastodon/routes/statuses.js b/lib/mastodon/routes/statuses.js index 414d6e3..386d0c5 100644 --- a/lib/mastodon/routes/statuses.js +++ b/lib/mastodon/routes/statuses.js @@ -126,16 +126,33 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read", } // Serialize all items - const emptyInteractions = { - favouritedIds: new Set(), - rebloggedIds: new Set(), - bookmarkedIds: new Set(), - pinnedIds: new Set(), - }; - const allItems = [...ancestors, ...descendants]; const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems); - const serializeOpts = { baseUrl, ...emptyInteractions, replyIdMap, replyAccountIdMap }; + + // Load real interaction state for thread context + const ctxFavouritedIds = new Set(); + const ctxRebloggedIds = new Set(); + const ctxBookmarkedIds = new Set(); + if (allItems.length > 0 && collections.ap_interactions) { + const ctxUrlToUid = new Map(); + for (const ci of allItems) { + if (ci.uid) { ctxUrlToUid.set(ci.uid, ci.uid); } + if (ci.url && ci.url !== ci.uid) { ctxUrlToUid.set(ci.url, ci.uid || ci.url); } + } + const ctxLookupUrls = [...ctxUrlToUid.keys()]; + if (ctxLookupUrls.length > 0) { + const ctxInteractions = await collections.ap_interactions + .find({ objectUrl: { $in: ctxLookupUrls } }) + .toArray(); + for (const ci of ctxInteractions) { + const uid = ctxUrlToUid.get(ci.objectUrl) || ci.objectUrl; + if (ci.type === "like") ctxFavouritedIds.add(uid); + else if (ci.type === "boost") ctxRebloggedIds.add(uid); + else if (ci.type === "bookmark") ctxBookmarkedIds.add(uid); + } + } + } + const serializeOpts = { baseUrl, favouritedIds: ctxFavouritedIds, rebloggedIds: ctxRebloggedIds, bookmarkedIds: ctxBookmarkedIds, pinnedIds: new Set(), replyIdMap, replyAccountIdMap }; res.json({ ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)), @@ -440,13 +457,42 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`); // Return a minimal status to the Mastodon client. - // No timeline entry is created here — the post will appear in the timeline - // after the normal flow: Eleventy rebuild → syndication webhook → AP delivery. + // Eagerly insert own post into ap_timeline so the Mastodon client can resolve + // in_reply_to_id for this post immediately, without waiting for the build webhook. + // The AP syndicator will upsert the same uid later via $setOnInsert (no-op). const profile = await collections.ap_profile.findOne({}); const handle = pluginOptions.handle || "user"; + let _tlItem = null; + try { + const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })(); + _tlItem = await addTimelineItem(collections, { + uid: postUrl, + url: postUrl, + type: data.properties["post-type"] || "note", + content: { text: contentText, html: `

${contentHtml}

` }, + author: { + name: profile?.name || handle, + url: profile?.url || publicationUrl, + photo: profile?.icon || "", + handle: `@${handle}@${_ph}`, + emojis: [], + bot: false, + }, + published: data.properties.published || new Date().toISOString(), + createdAt: new Date().toISOString(), + inReplyTo: inReplyTo || null, + inReplyToId: inReplyToId || null, + visibility: jf2.visibility || "public", + sensitive: jf2.sensitive === "true", + category: [], + counts: { likes: 0, boosts: 0, replies: 0 }, + }); + } catch (tlErr) { + console.warn(`[Mastodon API] Failed to pre-insert own post into timeline: ${tlErr.message}`); + } const statusResponse = { - id: String(Date.now()), + id: _tlItem?._id?.toString() || String(Date.now()), created_at: new Date().toISOString(), content: `

${contentHtml}

`, url: postUrl, @@ -559,6 +605,14 @@ router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "wri // Delete from timeline await collections.ap_timeline.deleteOne({ _id: item._id }); + // Broadcast AP Delete activity to followers + const _pluginOpts = req.app.locals.mastodonPluginOptions || {}; + if (_pluginOpts.broadcastDelete && postUrl) { + _pluginOpts.broadcastDelete(postUrl).catch((err) => + console.warn(`[Mastodon API] broadcastDelete failed for ${postUrl}: ${err.message}`), + ); + } + // Clean up interactions if (collections.ap_interactions && item.uid) { await collections.ap_interactions.deleteMany({ objectUrl: item.uid }); @@ -1111,8 +1165,12 @@ router.get("/api/v1/statuses/:id/card", async (req, res, next) => { */ async function findTimelineItemById(collection, id) { try { - return await collection.findOne({ _id: new ObjectId(id) }); - } catch { + const _oid = new ObjectId(id); + const _doc = await collection.findOne({ _id: _oid }); + if (!_doc) console.warn(`[Mastodon API] findTimelineItemById: no item for id=${id}`); + return _doc; + } catch (_fErr) { + console.warn(`[Mastodon API] findTimelineItemById: invalid id=${id}: ${_fErr.message}`); return null; } } diff --git a/lib/mastodon/routes/stubs.js b/lib/mastodon/routes/stubs.js index 20de135..ca9d8ef 100644 --- a/lib/mastodon/routes/stubs.js +++ b/lib/mastodon/routes/stubs.js @@ -382,8 +382,205 @@ router.get("/api/v1/scheduled_statuses", (req, res) => { // ─── Conversations ────────────────────────────────────────────────────────── -router.get("/api/v1/conversations", (req, res) => { - res.json([]); +// ─── 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 ────────────────────────────────────────────────────────── @@ -447,57 +644,7 @@ router.get("/api/v1/endorsements", (req, res) => { res.json([]); }); -// ─── Account statuses ─────────────────────────────────────────────────────── -router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => { - try { - const collections = req.app.locals.mastodonCollections; - const baseUrl = `${req.protocol}://${req.get("host")}`; - // Try to find the profile to see if this is the local user - const profile = await collections.ap_profile.findOne({}); - const isLocal = profile && profile._id.toString() === req.params.id; - - if (isLocal && profile?.url) { - // Return statuses authored by local user - const { serializeStatus } = await import("../entities/status.js"); - const { parseLimit } = await import("../helpers/pagination.js"); - - const limit = parseLimit(req.query.limit); - const items = await collections.ap_timeline - .find({ "author.url": profile.url, isContext: { $ne: true } }) - .sort({ _id: -1 }) - .limit(limit) - .toArray(); - - const statuses = items.map((item) => - serializeStatus(item, { - baseUrl, - favouritedIds: new Set(), - rebloggedIds: new Set(), - bookmarkedIds: new Set(), - pinnedIds: new Set(), - }), - ); - - return res.json(statuses); - } - - // Remote account or unknown — return empty - res.json([]); - } catch (error) { - next(error); - } -}); - -// ─── Account followers/following ──────────────────────────────────────────── - -router.get("/api/v1/accounts/:id/followers", (req, res) => { - res.json([]); -}); - -router.get("/api/v1/accounts/:id/following", (req, res) => { - res.json([]); -}); export default router; diff --git a/lib/syndicator.js b/lib/syndicator.js index 1dc9a97..99c2e7b 100644 --- a/lib/syndicator.js +++ b/lib/syndicator.js @@ -41,19 +41,23 @@ export function createSyndicator(plugin) { return undefined; } - // Dedup: skip re-federation if we've already sent an activity for this URL. // [patch] ap-syndicate-dedup - // ap_activities is the authoritative record of "already federated". - try { - const existingActivity = await plugin._collections.ap_activities?.findOne({ - direction: "outbound", - type: { $in: ["Create", "Announce", "Update"] }, - objectUrl: properties.url, - }); - if (existingActivity) { - console.info(`[ActivityPub] Skipping duplicate syndication for ${properties.url} — already sent (${existingActivity.type})`); - return properties.url || undefined; - } - } catch { /* DB unavailable — proceed */ } + // Skip location checkins — they have a JF2 `location` property. + if (properties.location) { + console.info(`[ActivityPub] Skipping syndication for location checkin: ${properties.url}`); + return undefined; + } + + // Skip draft posts — they should not be federated to followers. + if (properties["post-status"] === "draft") { + console.info(`[ActivityPub] Skipping syndication for draft post: ${properties.url}`); + return undefined; + } + + // Skip unlisted posts — they should not be federated to followers. + if (properties.visibility === "unlisted") { + console.info(`[ActivityPub] Skipping syndication for unlisted post: ${properties.url}`); + return undefined; + } try { const actorUrl = plugin._getActorUrl(); diff --git a/lib/timeline-cleanup.js b/lib/timeline-cleanup.js index b68a9ac..9c2b38d 100644 --- a/lib/timeline-cleanup.js +++ b/lib/timeline-cleanup.js @@ -53,7 +53,7 @@ export async function cleanupTimeline(collections, retentionLimit) { const removedUids = toDelete.map((item) => item.uid).filter(Boolean); - // Preserve items the user has interacted with (liked, bookmarked, boosted). // [patch] ap-interactions-cleanup-preserve + // Preserve items the user has interacted with (liked, bookmarked, boosted). // Deleting them would silently remove entries from the Favourites/Bookmarks pages. let interactedUids = new Set(); if (removedUids.length > 0 && collections.ap_interactions) {