diff --git a/scripts/patch-ap-account-lookup-cache-fallback.mjs b/scripts/patch-ap-account-lookup-cache-fallback.mjs deleted file mode 100644 index a451caf6..00000000 --- a/scripts/patch-ap-account-lookup-cache-fallback.mjs +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Patch: check ap_actor_cache in GET /api/v1/accounts/:id before returning 404. - * - * Root cause: - * resolveActorData() only searches followers, following, and ap_timeline. - * When a user searches for a brand-new remote account (resolve=true), the - * search call populates ap_actor_cache but the actor isn't in any of those - * three collections yet. So the next request — GET /api/v1/accounts/:id from - * Phanpy to load the profile page — returns 404, leaving the follow button - * non-functional even though the cache entry is present. - * - * Fix: - * After resolveActorData returns null, check the in-memory idToUrl map and - * ap_actor_cache (both populated by resolveRemoteAccount). If a URL is found, - * call resolveRemoteAccount directly and return the result. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js", -]; - -const MARKER = "// [patch] ap-account-lookup-cache-fallback"; - -const OLD_SNIPPET = ` return res.status(404).json({ error: "Record not found" }); - } catch (error) { - next(error); - } -}); - -// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────`; - -const NEW_SNIPPET = ` // Check ap_actor_cache — populated by resolveRemoteAccount after search/lookup [patch] ap-account-lookup-cache-fallback - const actorCacheUrl = getActorUrlFromId(id) - || (collections.ap_actor_cache ? (await collections.ap_actor_cache.findOne({ _id: id }))?.actorUrl : null); - if (actorCacheUrl) { - const remoteAccount = await resolveRemoteAccount(actorCacheUrl, pluginOptions, baseUrl, collections); - if (remoteAccount) return res.json(remoteAccount); - } - - return res.status(404).json({ error: "Record not found" }); - } catch (error) { - next(error); - } -}); - -// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - checked += 1; - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-account-lookup-cache-fallback: already applied to ${filePath}`); - continue; - } - - // Upstream may have already added a cachedUrl block in GET /api/v1/accounts/:id - // (e.g. fix: ap actor cache commit). If so, the functionality is already present — skip. - if (source.includes("const cachedUrl = getActorUrlFromId(id)")) { - console.log(`[postinstall] patch-ap-account-lookup-cache-fallback: already fixed upstream in ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn( - `[postinstall] patch-ap-account-lookup-cache-fallback: target snippet not found in ${filePath} — skipping`, - ); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - - if (updated === source) { - console.log(`[postinstall] patch-ap-account-lookup-cache-fallback: no changes applied to ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-account-lookup-cache-fallback to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-account-lookup-cache-fallback: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-account-lookup-cache-fallback: already up to date"); -} else { - console.log( - `[postinstall] patch-ap-account-lookup-cache-fallback: patched ${patched}/${checked} file(s)`, - ); -} diff --git a/scripts/patch-ap-actor-cache-await.mjs b/scripts/patch-ap-actor-cache-await.mjs deleted file mode 100644 index a65876f8..00000000 --- a/scripts/patch-ap-actor-cache-await.mjs +++ /dev/null @@ -1,100 +0,0 @@ -/** - * Patch: await the ap_actor_cache write in resolveRemoteAccount. - * - * Root cause: - * resolveRemoteAccount() populates the in-memory idToUrl map synchronously - * (via cacheAccountStats), so same-session follow requests find the actor. - * But the ap_actor_cache MongoDB write is fire-and-forget. If the server - * restarts between search and follow (or if the client caches the search - * result), the in-memory cache is gone and the MongoDB fallback may not - * have been written yet → resolveActorUrl returns null → 404. - * - * Fix: - * Await the ap_actor_cache upsert (still catch errors so it's non-fatal). - * This ensures the entry is in MongoDB before the search response is sent, - * making follow reliable even after server restarts. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js", -]; - -const MARKER = "// [patch] ap-actor-cache-await"; - -const OLD_SNIPPET = ` // Persist actor URL mapping to MongoDB so follow/unfollow survives server restarts - if (collections?.ap_actor_cache && actorUrl) { - const hashId = remoteActorId(actorUrl); - collections.ap_actor_cache.updateOne( - { _id: hashId }, - { $set: { actorUrl, updatedAt: new Date() } }, - { upsert: true }, - ).catch(() => {}); // fire-and-forget, non-fatal - }`; - -const NEW_SNIPPET = ` // 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( - { _id: hashId }, - { $set: { actorUrl, updatedAt: new Date() } }, - { upsert: true }, - ).catch(() => {}); // non-fatal, but now awaited so entry exists before response - }`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - checked += 1; - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-actor-cache-await: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn( - `[postinstall] patch-ap-actor-cache-await: target snippet not found in ${filePath} — skipping`, - ); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - - if (updated === source) { - console.log(`[postinstall] patch-ap-actor-cache-await: no changes applied to ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-actor-cache-await to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-actor-cache-await: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-actor-cache-await: already up to date"); -} else { - console.log( - `[postinstall] patch-ap-actor-cache-await: patched ${patched}/${checked} file(s)`, - ); -} diff --git a/scripts/patch-ap-compose-default-checked.mjs b/scripts/patch-ap-compose-default-checked.mjs deleted file mode 100644 index 5f5520eb..00000000 --- a/scripts/patch-ap-compose-default-checked.mjs +++ /dev/null @@ -1,90 +0,0 @@ -/** - * Patch: fix hardcoded defaultChecked handles in AP reader compose controller. - * - * Root cause: - * composeController() in compose.js sets target.defaultChecked using a - * hardcoded name comparison: - * - * target.defaultChecked = name === "@rick@rmendes.net" || name === "@rmendes.net"; - * - * These are the original developer's handles and will never match any target - * on this installation. As a result, ALL syndication checkboxes in the AP - * reader compose form are rendered unchecked, so replies composed through the - * AP reader are never syndicated to ActivityPub. - * - * Fix: - * Replace the hardcoded comparison with `target.checked === true`. - * The Micropub config endpoint (q=config) already returns each syndicator's - * `checked` state. The AP syndicator has `checked: true` in indiekit.config.mjs, - * so the AP checkbox will be pre-checked by default, matching the same behaviour - * as the microsub reader compose form. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-compose-default-checked"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js", -]; - -const OLD_SNIPPET = ` // 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 NEW_SNIPPET = ` // Pre-check syndication targets based on their configured checked state ${MARKER} - for (const target of syndicationTargets) { ${MARKER} - target.defaultChecked = target.checked === true; ${MARKER} - } ${MARKER}`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-compose-default-checked: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn(`[postinstall] patch-ap-compose-default-checked: target snippet not found in ${filePath}`); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - - if (updated === source) { - console.log(`[postinstall] patch-ap-compose-default-checked: no changes in ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-compose-default-checked to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-compose-default-checked: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-compose-default-checked: already up to date"); -} else { - console.log(`[postinstall] patch-ap-compose-default-checked: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-federation-bridge-base-url.mjs b/scripts/patch-ap-federation-bridge-base-url.mjs deleted file mode 100644 index cc5dd21e..00000000 --- a/scripts/patch-ap-federation-bridge-base-url.mjs +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Patch: override Fedify request URL with the configured publication URL. - * - * Root cause: - * fromExpressRequest() in federation-bridge.js builds the Request URL as - * `${req.protocol}://${req.get("host")}${req.originalUrl}`. Fedify only handles - * requests whose URL matches its configured base URL (https://blog.giersig.eu). - * If nginx does not forward `Host: blog.giersig.eu` and `X-Forwarded-Proto: https`, - * the URL Fedify sees will be wrong (e.g. http://127.0.0.1:3000/...) and Fedify - * calls next() → the request falls through to auth middleware → returns 302 to - * the login page. This breaks webfinger, nodeinfo, actor lookups, and AP inbox - * delivery for any server that cannot follow the redirect. - * - * Fix: - * - Add an optional third parameter `publicationUrl` to createFedifyMiddleware(). - * - Pass it through to fromExpressRequest(), which uses it as the URL base when - * provided, ignoring req.protocol / req.get("host") entirely. - * - In index.js, pass `this._publicationUrl` to createFedifyMiddleware() so all - * Fedify-delegated requests use the correct canonical URL. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// ap-base-url patch"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", -]; - -const indexCandidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", -]; - -// --------------------------------------------------------------------------- -// Patches for federation-bridge.js -// --------------------------------------------------------------------------- - -const OLD_FROM_EXPRESS_SIG = `export function fromExpressRequest(req) { - const url = \`\${req.protocol}://\${req.get("host")}\${req.originalUrl}\`;`; - -const NEW_FROM_EXPRESS_SIG = `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 OLD_MIDDLEWARE_SIG = `export function createFedifyMiddleware(federation, contextDataFactory) {`; - -const NEW_MIDDLEWARE_SIG = `export function createFedifyMiddleware(federation, contextDataFactory, publicationUrl) { // ap-base-url patch`; - -const OLD_FROM_EXPRESS_CALL = ` const request = fromExpressRequest(req);`; - -const NEW_FROM_EXPRESS_CALL = ` const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`; - -// --------------------------------------------------------------------------- -// Patch for index.js -// --------------------------------------------------------------------------- - -const OLD_INDEX_CALL = ` this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}));`; - -const NEW_INDEX_CALL = ` this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}), this._publicationUrl); // ap-base-url patch`; - -// --------------------------------------------------------------------------- - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let patched = 0; -let checked = 0; - -// Patch federation-bridge.js -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-federation-bridge-base-url: already applied to ${filePath}`); - continue; - } - - let updated = source; - let changed = false; - - if (updated.includes(OLD_FROM_EXPRESS_SIG)) { - updated = updated.replace(OLD_FROM_EXPRESS_SIG, NEW_FROM_EXPRESS_SIG); - changed = true; - } else { - console.warn(`[postinstall] patch-ap-federation-bridge-base-url: fromExpressRequest signature not found in ${filePath}`); - } - - if (updated.includes(OLD_MIDDLEWARE_SIG)) { - updated = updated.replace(OLD_MIDDLEWARE_SIG, NEW_MIDDLEWARE_SIG); - changed = true; - } else { - console.warn(`[postinstall] patch-ap-federation-bridge-base-url: createFedifyMiddleware signature not found in ${filePath}`); - } - - if (updated.includes(OLD_FROM_EXPRESS_CALL)) { - updated = updated.replace(OLD_FROM_EXPRESS_CALL, NEW_FROM_EXPRESS_CALL); - changed = true; - } else { - console.warn(`[postinstall] patch-ap-federation-bridge-base-url: fromExpressRequest call not found in ${filePath}`); - } - - if (!changed || updated === source) { - console.log(`[postinstall] patch-ap-federation-bridge-base-url: no changes applied to ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-federation-bridge-base-url to ${filePath}`); -} - -// Patch index.js -for (const filePath of indexCandidates) { - if (!(await exists(filePath))) continue; - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-federation-bridge-base-url: index.js already patched at ${filePath}`); - continue; - } - - if (!source.includes(OLD_INDEX_CALL)) { - console.warn(`[postinstall] patch-ap-federation-bridge-base-url: createFedifyMiddleware call not found in ${filePath}`); - continue; - } - - const updated = source.replace(OLD_INDEX_CALL, NEW_INDEX_CALL); - - if (updated === source) { - console.log(`[postinstall] patch-ap-federation-bridge-base-url: no changes in index.js at ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-federation-bridge-base-url (index.js) to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-federation-bridge-base-url: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-federation-bridge-base-url: already up to date"); -} else { - console.log(`[postinstall] patch-ap-federation-bridge-base-url: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-inbox-delivery-debug.mjs b/scripts/patch-ap-inbox-delivery-debug.mjs deleted file mode 100644 index 6061ca18..00000000 --- a/scripts/patch-ap-inbox-delivery-debug.mjs +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Patch: add inbox delivery diagnostics. - * - * Problems: - * 1. The ["fedify","federation","inbox"] LogTape category is hardcoded to - * lowestLevel "fatal", hiding HTTP Signature verification failures (401s). - * Remote servers that receive 401s stop retrying → activities are lost. - * 2. No request-level logging for incoming inbox POSTs, so we can't tell - * whether remote servers are even attempting delivery. - * - * Fix A (federation-setup.js): - * Change inbox log category from "fatal" → "error". - * Real verification failures (wrong key, clock skew, digest mismatch) surface. - * High-volume 404/410 key-fetch warnings from deleted actors stay silent. - * - * Fix B (federation-bridge.js): - * Add a console.info before fromExpressRequest() that logs every POST to an - * inbox path (path + content-type + raw body length). Fires BEFORE Fedify's - * signature check, confirming whether remote servers reach our inbox at all. - * Guarded by AP_LOG_LEVEL=debug or AP_DEBUG=1 to keep production logs quiet. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER_A = "// [patch] ap-inbox-delivery-debug-A"; -const MARKER_B = "// [patch] ap-inbox-delivery-debug-B"; - -// ── Fix A: federation-setup.js — inbox logger level ────────────────────────── - -const setupCandidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js", -]; - -const OLD_INBOX_LOGGER = ` { - // Noise guard: HTTP Signature verification failures are expected for - // incoming activities from servers with expired/gone keys (e.g. deleted - // actors, migrated servers). These produce high log volume with no - // actionable signal — suppress everything below fatal. - category: ["fedify", "federation", "inbox"], - sinks: ["console"], - lowestLevel: "fatal", - },`; - -const NEW_INBOX_LOGGER = ` { - // Surfacing real verification failures (wrong key, clock skew, digest - // mismatch) at "error" level while keeping high-volume key-fetch - // 404/410 warnings from deleted actors silent. ${MARKER_A} - category: ["fedify", "federation", "inbox"], - sinks: ["console"], - lowestLevel: "error", - },`; - -// ── Fix B: federation-bridge.js — request-level inbox logging ──────────────── - -const bridgeCandidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", -]; - -// Insert a debug log right before "const request = fromExpressRequest(req, publicationUrl);" -// This is patched by ap-base-url already so that comment marker is present. -const OLD_BRIDGE_REQUEST = ` const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`; - -const NEW_BRIDGE_REQUEST = ` // Log incoming inbox POSTs before Fedify signature check. ${MARKER_B} - // Enabled by AP_LOG_LEVEL=debug or AP_DEBUG=1. - if ( - (process.env.AP_LOG_LEVEL === "debug" || process.env.AP_DEBUG === "1") && - req.method === "POST" && - (req.path.includes("/inbox") || req.path.includes("/users/")) - ) { - const _bct = (req.headers["content-type"] || "").split(";")[0].trim(); - const _bsz = req._rawBody?.length ?? (req.body ? "pre-parsed" : "none"); - console.info(\`[AP-inbox] POST \${req.path} ct=\${_bct} body=\${_bsz}B\`); - } - const request = fromExpressRequest(req, publicationUrl); // ap-base-url patch`; - -async function exists(p) { - try { await access(p); return true; } catch { return false; } -} - -async function applyPatch(candidates, oldSnippet, newSnippet, label, marker) { - let checked = 0; - let patched = 0; - for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked++; - const source = await readFile(filePath, "utf8"); - if (source.includes(marker)) { - console.log(`[postinstall] patch-ap-inbox-delivery-debug: ${label} already applied to ${filePath}`); - continue; - } - if (!source.includes(oldSnippet)) { - console.warn(`[postinstall] patch-ap-inbox-delivery-debug: ${label} snippet not found in ${filePath}`); - continue; - } - await writeFile(filePath, source.replace(oldSnippet, newSnippet), "utf8"); - patched++; - console.log(`[postinstall] Applied patch-ap-inbox-delivery-debug (${label}) to ${filePath}`); - } - return { checked, patched }; -} - -const a = await applyPatch(setupCandidates, OLD_INBOX_LOGGER, NEW_INBOX_LOGGER, "inbox-logger-level", MARKER_A); -const b = await applyPatch(bridgeCandidates, OLD_BRIDGE_REQUEST, NEW_BRIDGE_REQUEST, "bridge-request-log", MARKER_B); - -const total = a.checked + b.checked; -const totalPatched = a.patched + b.patched; - -if (total === 0) { - console.log("[postinstall] patch-ap-inbox-delivery-debug: no target files found"); -} else if (totalPatched === 0) { - console.log("[postinstall] patch-ap-inbox-delivery-debug: already up to date"); -} else { - console.log(`[postinstall] patch-ap-inbox-delivery-debug: patched ${totalPatched} file(s)`); -} diff --git a/scripts/patch-ap-inbox-publication-url.mjs b/scripts/patch-ap-inbox-publication-url.mjs deleted file mode 100644 index dab36e10..00000000 --- a/scripts/patch-ap-inbox-publication-url.mjs +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Patch: fix inbound reply/like/boost handling — publicationUrl missing in inbox handlers. - * - * Root cause: - * setupFederation() passes `publicationUrl` to its own scope but does NOT - * pass it to registerInboxListeners(). All inbox handlers (handleCreate, - * handleLike, handleAnnounce) read `collections._publicationUrl` to gate - * notifications and timeline storage, but that property is never set on the - * collections object. - * - * Consequence: - * - handleCreate: `if (pubUrl && inReplyTo.startsWith(pubUrl))` is always - * false → reply notifications are never created. - * - handleAnnounce: boost notifications for our content never created. - * - handleCreate: replies to our posts from non-followed accounts are never - * stored in ap_timeline → invisible in Mastodon client conversation views. - * - * Fix A (federation-setup.js): - * Set `collections._publicationUrl = publicationUrl` immediately before - * registerInboxListeners() so the value flows through to all handlers. - * - * Fix B (inbox-handlers.js): - * In handleCreate, add an else-if branch that stores replies to our own posts - * in ap_timeline even when the replier is not in ap_following. This runs only - * when pubUrl is correctly set (Fix A). - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-inbox-publication-url"; - -// ── Fix A: federation-setup.js ──────────────────────────────────────────────── - -const federationCandidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-setup.js", -]; - -const OLD_REGISTER = ` registerInboxListeners(inboxChain, { - collections, - handle, - storeRawActivities, - });`; - -const NEW_REGISTER = ` // Expose publicationUrl on collections so inbox handlers can gate ${MARKER} - // notifications/timeline-storage to our own content only. - collections._publicationUrl = publicationUrl; - registerInboxListeners(inboxChain, { - collections, - handle, - storeRawActivities, - });`; - -// ── Fix B: inbox-handlers.js ────────────────────────────────────────────────── - -const handlersCandidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-handlers.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-handlers.js", -]; - -const OLD_FOLLOWED_TAGS = ` } else if (collections.ap_followed_tags) { - // Not a followed account — check if the post's hashtags match any followed tags`; - -const NEW_FOLLOWED_TAGS = ` } else if (pubUrl && inReplyTo && inReplyTo.startsWith(pubUrl)) { - // Reply to our post from a non-followed account — store in timeline ${MARKER} - // 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); - 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`; - -async function exists(p) { - try { await access(p); return true; } catch { return false; } -} - -async function patch(candidates, oldSnippet, newSnippet, label) { - let checked = 0; let patched = 0; - for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked++; - const source = await readFile(filePath, "utf8"); - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-inbox-publication-url: ${label} already applied to ${filePath}`); - continue; - } - if (!source.includes(oldSnippet)) { - console.warn(`[postinstall] patch-ap-inbox-publication-url: ${label} snippet not found in ${filePath}`); - continue; - } - await writeFile(filePath, source.replace(oldSnippet, newSnippet), "utf8"); - patched++; - console.log(`[postinstall] Applied patch-ap-inbox-publication-url (${label}) to ${filePath}`); - } - return { checked, patched }; -} - -const a = await patch(federationCandidates, OLD_REGISTER, NEW_REGISTER, "set _publicationUrl"); -const b = await patch(handlersCandidates, OLD_FOLLOWED_TAGS, NEW_FOLLOWED_TAGS, "store reply from non-follower"); - -const total = a.patched + b.patched; -if (a.checked + b.checked === 0) { - console.log("[postinstall] patch-ap-inbox-publication-url: no target files found"); -} else if (total === 0) { - console.log("[postinstall] patch-ap-inbox-publication-url: already up to date"); -} else { - console.log(`[postinstall] patch-ap-inbox-publication-url: patched ${total} file(s)`); -} diff --git a/scripts/patch-ap-interactions-accounts-uid.mjs b/scripts/patch-ap-interactions-accounts-uid.mjs deleted file mode 100644 index b9fa6ba7..00000000 --- a/scripts/patch-ap-interactions-accounts-uid.mjs +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Patch: fix interaction state in GET /api/v1/accounts/:id/statuses. - * - * Root cause: - * The account statuses route loads interaction state (liked/boosted/bookmarked) - * for the returned timeline items. It builds the lookup URL list correctly - * (both item.uid and item.url), but then adds ix.objectUrl directly to the - * favouritedIds/rebloggedIds/bookmarkedIds Sets. serializeStatus() checks - * favouritedIds.has(item.uid) — so when ix.objectUrl === item.url and - * item.url !== item.uid, the check fails and the post shows as not-liked. - * - * Fix: - * Build a urlToUid map (same approach as loadInteractionState in timelines.js) - * and resolve ix.objectUrl to the canonical uid before adding to the Sets. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-interactions-accounts-uid"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/accounts.js", -]; - -const OLD_SNIPPET = ` 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) { - 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); - } - }`; - -const NEW_SNIPPET = ` const urlToUid = new Map(); ${MARKER} - 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()]; - 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); - } - }`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-interactions-accounts-uid: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn(`[postinstall] patch-ap-interactions-accounts-uid: snippet not found in ${filePath}`); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-interactions-accounts-uid to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-interactions-accounts-uid: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-interactions-accounts-uid: already up to date"); -} else { - console.log(`[postinstall] patch-ap-interactions-accounts-uid: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-interactions-cleanup-preserve.mjs b/scripts/patch-ap-interactions-cleanup-preserve.mjs deleted file mode 100644 index 590d3e52..00000000 --- a/scripts/patch-ap-interactions-cleanup-preserve.mjs +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Patch: preserve liked/bookmarked/boosted items during timeline cleanup. - * - * Root cause: - * cleanupTimeline() removes old remote posts from ap_timeline and also - * deletes their ap_interactions entries. Any post the user has liked, - * bookmarked, or boosted disappears from GET /api/v1/favourites and - * GET /api/v1/bookmarks after the next daily cleanup run. - * - * Fix: - * Before deleting, fetch the set of objectUrls that have ap_interactions - * entries (likes, bookmarks, boosts). Filter those out of the deletion - * candidate list so they are preserved in ap_timeline (and their - * ap_interactions entries are never touched). - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-interactions-cleanup-preserve"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/timeline-cleanup.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/timeline-cleanup.js", -]; - -const OLD_SNIPPET = ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean); - - // Delete old timeline items by UID - const deleteResult = await collections.ap_timeline.deleteMany({ - _id: { $in: toDelete.map((item) => item._id) }, - }); - - // Clean up stale interactions for removed items - let interactionsRemoved = 0; - if (removedUids.length > 0 && collections.ap_interactions) { - const interactionResult = await collections.ap_interactions.deleteMany({ - objectUrl: { $in: removedUids }, - }); - interactionsRemoved = interactionResult.deletedCount || 0; - }`; - -const NEW_SNIPPET = ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean); - - // Preserve items the user has interacted with (liked, bookmarked, boosted). ${MARKER} - // Deleting them would silently remove entries from the Favourites/Bookmarks pages. - let interactedUids = new Set(); - if (removedUids.length > 0 && collections.ap_interactions) { - const interacted = await collections.ap_interactions.distinct("objectUrl"); - interactedUids = new Set(interacted); - } - const itemsToDelete = toDelete.filter((item) => !interactedUids.has(item.uid)); - const uidsToDelete = itemsToDelete.map((item) => item.uid).filter(Boolean); - - if (!itemsToDelete.length) { - return { removed: 0, interactionsRemoved: 0 }; - } - - // Delete old timeline items by UID - const deleteResult = await collections.ap_timeline.deleteMany({ - _id: { $in: itemsToDelete.map((item) => item._id) }, - }); - - // Clean up stale interactions for removed items - let interactionsRemoved = 0; - if (uidsToDelete.length > 0 && collections.ap_interactions) { - const interactionResult = await collections.ap_interactions.deleteMany({ - objectUrl: { $in: uidsToDelete }, - }); - interactionsRemoved = interactionResult.deletedCount || 0; - }`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-interactions-cleanup-preserve: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn(`[postinstall] patch-ap-interactions-cleanup-preserve: snippet not found in ${filePath}`); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-interactions-cleanup-preserve to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-interactions-cleanup-preserve: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-interactions-cleanup-preserve: already up to date"); -} else { - console.log(`[postinstall] patch-ap-interactions-cleanup-preserve: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-interactions-context-state.mjs b/scripts/patch-ap-interactions-context-state.mjs deleted file mode 100644 index 6707995b..00000000 --- a/scripts/patch-ap-interactions-context-state.mjs +++ /dev/null @@ -1,104 +0,0 @@ -/** - * Patch: load real interaction state for thread context ancestors/descendants. - * - * Root cause: - * GET /api/v1/statuses/:id/context serializes all ancestors and descendants - * using emptyInteractions (all empty Sets). Any post in the thread that the - * user has liked/bookmarked/boosted shows as not-liked/not-bookmarked/not-boosted. - * - * Fix: - * Replace emptyInteractions with a batch lookup against ap_interactions for - * all items in the thread (same approach as loadInteractionState in timelines.js). - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-interactions-context-state"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", -]; - -const OLD_SNIPPET = ` // 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 };`; - -const NEW_SNIPPET = ` // Serialize all items - const allItems = [...ancestors, ...descendants]; - const { replyIdMap, replyAccountIdMap } = await resolveReplyIds(collections.ap_timeline, allItems); - - // Load real interaction state for thread context ${MARKER} - 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 };`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-interactions-context-state: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn(`[postinstall] patch-ap-interactions-context-state: snippet not found in ${filePath}`); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-interactions-context-state to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-interactions-context-state: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-interactions-context-state: already up to date"); -} else { - console.log(`[postinstall] patch-ap-interactions-context-state: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-interactions-send-guard.mjs b/scripts/patch-ap-interactions-send-guard.mjs deleted file mode 100644 index af3afbb6..00000000 --- a/scripts/patch-ap-interactions-send-guard.mjs +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Patch: guard sendActivity calls in likePost and boostPost with try/catch. - * - * Root cause: - * - likePost: `ctx.sendActivity({ identifier }, recipient, like, ...)` is not - * wrapped in try/catch. If Fedify or the underlying queue (Redis) throws, the - * error propagates up through the route handler → 500, and the ap_interactions - * DB write that follows is never reached (like not recorded locally either). - * - * - boostPost: `ctx.sendActivity({ identifier }, "followers", announce, ...)` is - * the very first await in the function — also not wrapped. Same consequence: - * any delivery error aborts the function before the DB write, returning 500. - * - * Fix: - * Wrap both sendActivity calls in try/catch so federation delivery failures are - * non-fatal. The interaction is still recorded in ap_interactions so the client - * sees the correct UI state. Delivery of the boost to the original post author is - * already guarded (separate try/catch added previously). - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-interactions-send-guard"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/interactions.js", -]; - -// ── Change 1: guard likePost sendActivity ───────────────────────────────────── - -const OLD_LIKE_SEND = ` if (recipient) { - await ctx.sendActivity({ identifier: handle }, recipient, like, { - orderingKey: targetUrl, - }); - }`; - -const NEW_LIKE_SEND = ` if (recipient) { - try { ${MARKER} - await ctx.sendActivity({ identifier: handle }, recipient, like, { - orderingKey: targetUrl, - }); - } catch { /* delivery failed — interaction still recorded locally */ } - }`; - -// ── Change 2: guard boostPost sendActivity("followers") ────────────────────── - -const OLD_BOOST_SEND = ` // Send to followers - await ctx.sendActivity({ identifier: handle }, "followers", announce, { - preferSharedInbox: true, - syncCollection: true, - orderingKey: targetUrl, - });`; - -const NEW_BOOST_SEND = ` // Send to followers - try { ${MARKER} - await ctx.sendActivity({ identifier: handle }, "followers", announce, { - preferSharedInbox: true, - syncCollection: true, - orderingKey: targetUrl, - }); - } catch { /* delivery failed — interaction still recorded locally */ }`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-interactions-send-guard: already applied to ${filePath}`); - continue; - } - - let updated = source; - let changes = 0; - - if (!updated.includes(OLD_LIKE_SEND)) { - console.warn(`[postinstall] patch-ap-interactions-send-guard: likePost snippet not found in ${filePath}`); - } else { - updated = updated.replace(OLD_LIKE_SEND, NEW_LIKE_SEND); - changes++; - } - - if (!updated.includes(OLD_BOOST_SEND)) { - console.warn(`[postinstall] patch-ap-interactions-send-guard: boostPost snippet not found in ${filePath}`); - } else { - updated = updated.replace(OLD_BOOST_SEND, NEW_BOOST_SEND); - changes++; - } - - if (changes === 0) { - console.log(`[postinstall] patch-ap-interactions-send-guard: no changes in ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-interactions-send-guard to ${filePath} (${changes}/2 changes)`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-interactions-send-guard: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-interactions-send-guard: already up to date"); -} else { - console.log(`[postinstall] patch-ap-interactions-send-guard: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-mastodon-delete-fix.mjs b/scripts/patch-ap-mastodon-delete-fix.mjs deleted file mode 100644 index df144a56..00000000 --- a/scripts/patch-ap-mastodon-delete-fix.mjs +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Patch: fix DELETE /api/v1/statuses/:id — two bugs. - * - * Bug 1 (ReferenceError — primary failure): - * Line: await collections.ap_timeline.deleteOne({ _id: objectId }); - * `objectId` is never defined in the route handler. MongoDB ObjectId is - * imported as the class `ObjectId`, not an instance. Every delete request - * throws ReferenceError → 500 → the timeline entry is never removed. - * Fix: use `item._id` (the document's own _id from findTimelineItemById). - * - * Bug 2 (AP Delete not broadcast): - * The route calls postContent.delete() directly, bypassing the Indiekit - * framework that normally invokes syndicator.delete(). No Delete(Note) - * activity is ever sent to followers — they keep seeing the post. - * Fix: - * a) Add broadcastDelete: (url) => pluginRef.broadcastDelete(url) to - * mastodonPluginOptions in index.js so the router can reach it. - * b) Call req.app.locals.mastodonPluginOptions.broadcastDelete(postUrl) - * in the delete route after the timeline entry is removed. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-mastodon-delete-fix"; - -const indexCandidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", -]; - -const statusesCandidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", -]; - -// ── Change A: expose broadcastDelete in mastodonPluginOptions (index.js) ────── - -const OLD_PLUGIN_OPTS = ` loadRsaKey: () => pluginRef._loadRsaPrivateKey(), - broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),`; - -const NEW_PLUGIN_OPTS = ` loadRsaKey: () => pluginRef._loadRsaPrivateKey(), - broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(), - broadcastDelete: (url) => pluginRef.broadcastDelete(url), ${MARKER}`; - -// ── Change B: fix objectId → item._id (statuses.js) ────────────────────────── - -const OLD_DELETE_ONE = ` // Delete from timeline - await collections.ap_timeline.deleteOne({ _id: objectId });`; - -const NEW_DELETE_ONE = ` // Delete from timeline - await collections.ap_timeline.deleteOne({ _id: item._id }); ${MARKER}`; - -// ── Change C: call broadcastDelete after timeline removal (statuses.js) ─────── -// NOTE: Change B (objectId → item._id) was already fixed upstream. -// OLD_AFTER_DELETE matches the upstream code directly (no MARKER dependency). - -const OLD_AFTER_DELETE = ` // Delete from timeline - await collections.ap_timeline.deleteOne({ _id: item._id }); - - // Clean up interactions`; - -const NEW_AFTER_DELETE = ` // Delete from timeline - await collections.ap_timeline.deleteOne({ _id: item._id }); ${MARKER} - - // Broadcast AP Delete activity to followers ${MARKER} - 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`; - -async function exists(p) { - try { await access(p); return true; } catch { return false; } -} - -async function patchFile(filePath, replacements) { - const source = await readFile(filePath, "utf8"); - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-mastodon-delete-fix: already applied to ${filePath}`); - return false; - } - - let updated = source; - let applied = 0; - - for (const { old: oldSnippet, newSnippet, label } of replacements) { - if (!updated.includes(oldSnippet)) { - console.warn(`[postinstall] patch-ap-mastodon-delete-fix: snippet "${label}" not found in ${filePath}`); - continue; - } - updated = updated.replace(oldSnippet, newSnippet); - applied++; - } - - if (applied === 0) return false; - - await writeFile(filePath, updated, "utf8"); - console.log(`[postinstall] Applied patch-ap-mastodon-delete-fix to ${filePath} (${applied} change(s))`); - return true; -} - -let totalPatched = 0; -let totalChecked = 0; - -// Patch index.js candidates (Change A) -for (const filePath of indexCandidates) { - if (!(await exists(filePath))) continue; - totalChecked++; - const ok = await patchFile(filePath, [ - { old: OLD_PLUGIN_OPTS, newSnippet: NEW_PLUGIN_OPTS, label: "broadcastDelete in pluginOptions" }, - ]); - if (ok) totalPatched++; -} - -// Patch statuses.js candidates (Change C only — Change B already fixed upstream) -for (const filePath of statusesCandidates) { - if (!(await exists(filePath))) continue; - totalChecked++; - const ok = await patchFile(filePath, [ - { old: OLD_AFTER_DELETE, newSnippet: NEW_AFTER_DELETE, label: "broadcastDelete call" }, - ]); - if (ok) totalPatched++; -} - -if (totalChecked === 0) { - console.log("[postinstall] patch-ap-mastodon-delete-fix: no target files found"); -} else if (totalPatched === 0) { - console.log("[postinstall] patch-ap-mastodon-delete-fix: already up to date"); -} else { - console.log(`[postinstall] patch-ap-mastodon-delete-fix: patched ${totalPatched} file(s)`); -} diff --git a/scripts/patch-ap-mastodon-reply-threading.mjs b/scripts/patch-ap-mastodon-reply-threading.mjs deleted file mode 100644 index 90618912..00000000 --- a/scripts/patch-ap-mastodon-reply-threading.mjs +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Patch: eagerly insert own post into ap_timeline after Mastodon API POST /statuses. - * - * Root cause: - * When a post is created via POST /api/v1/statuses (Mastodon client API), the - * handler creates the post through the Micropub pipeline but intentionally does - * NOT insert a timeline item immediately. The comment says: - * - * "No timeline entry is created here — the post will appear in the timeline - * after the normal flow: Eleventy rebuild → syndication webhook → AP delivery." - * - * This means there is a window (typically 30–120 s while Eleventy rebuilds) where - * the own post does NOT exist in ap_timeline. If the user tries to reply to their - * own newly-created post during this window, POST /api/v1/statuses receives - * `in_reply_to_id` for the new post, but `findTimelineItemById` returns null. - * With inReplyTo = null, the JF2 object has no "in-reply-to" property, and - * post-type-discovery classifies the reply as "note" instead of "reply". The - * reply is then saved at /notes/{slug}/ rather than /replies/{slug}/, and - * since there is no in-reply-to, the ActivityPub activity has no inReplyTo - * field and the thread is broken on remote Mastodon servers. - * - * Fix: - * After calling postContent.create(), immediately insert a provisional timeline - * item into ap_timeline using addTimelineItem() (which uses $setOnInsert — - * idempotent). The AP syndicator will later attempt the same upsert after the - * build webhook fires, which is a no-op since the document already exists. - * This ensures the post is resolvable via in_reply_to_id with zero delay. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-mastodon-reply-threading"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", -]; - -const OLD_SNIPPET = ` // 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. - const profile = await collections.ap_profile.findOne({}); - const handle = pluginOptions.handle || "user";`; - -const NEW_SNIPPET = ` // Return a minimal status to the Mastodon client. ${MARKER} - // Eagerly insert own post into ap_timeline so the Mastodon client can resolve ${MARKER} - // in_reply_to_id for this post immediately, without waiting for the build webhook. ${MARKER} - // The AP syndicator will upsert the same uid later via $setOnInsert (no-op). ${MARKER} - const profile = await collections.ap_profile.findOne({}); - const handle = pluginOptions.handle || "user"; - try { ${MARKER} - const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })(); ${MARKER} - await addTimelineItem(collections, { ${MARKER} - uid: postUrl, ${MARKER} - url: postUrl, ${MARKER} - type: data.properties["post-type"] || "note", ${MARKER} - content: { text: contentText, html: \`

\${contentHtml}

\` }, ${MARKER} - author: { ${MARKER} - name: profile?.name || handle, ${MARKER} - url: profile?.url || publicationUrl, ${MARKER} - photo: profile?.icon || "", ${MARKER} - handle: \`@\${handle}@\${_ph}\`, ${MARKER} - emojis: [], ${MARKER} - bot: false, ${MARKER} - }, ${MARKER} - published: data.properties.published || new Date().toISOString(), ${MARKER} - createdAt: new Date().toISOString(), ${MARKER} - inReplyTo: inReplyTo || null, ${MARKER} - visibility: jf2.visibility || "public", ${MARKER} - sensitive: jf2.sensitive === "true", ${MARKER} - category: [], ${MARKER} - counts: { likes: 0, boosts: 0, replies: 0 }, ${MARKER} - }); ${MARKER} - } catch (tlErr) { ${MARKER} - console.warn(\`[Mastodon API] Failed to pre-insert own post into timeline: \${tlErr.message}\`); ${MARKER} - } ${MARKER}`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-mastodon-reply-threading: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn(`[postinstall] patch-ap-mastodon-reply-threading: target snippet not found in ${filePath}`); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - - if (updated === source) { - console.log(`[postinstall] patch-ap-mastodon-reply-threading: no changes in ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-mastodon-reply-threading to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-mastodon-reply-threading: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-mastodon-reply-threading: already up to date"); -} else { - console.log(`[postinstall] patch-ap-mastodon-reply-threading: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-mastodon-status-id.mjs b/scripts/patch-ap-mastodon-status-id.mjs deleted file mode 100644 index 6a822c75..00000000 --- a/scripts/patch-ap-mastodon-status-id.mjs +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Patch: fix POST /api/v1/statuses response ID to match ap_timeline _id. - * - * Root cause: - * The POST /api/v1/statuses handler returns `id: String(Date.now())` — the - * wall-clock time when the response is sent. The ap_timeline item inserted by - * patch-ap-mastodon-reply-threading uses addTimelineItem(), which stores the - * item with a MongoDB-generated ObjectId as _id. - * - * When Phanpy/Elk receives the creation response and the user then replies to - * that post, the client sends `in_reply_to_id: `. - * The handler calls findTimelineItemById which does: - * collection.findOne({ _id: new ObjectId(id) }) - * A String(Date.now()) value is not a valid ObjectId → lookup returns null → - * inReplyTo = null → jf2["in-reply-to"] not set → getPostType returns "note" - * instead of "reply". - * - * Fix: - * 1. Capture the return value of addTimelineItem() into `_tlItem`. - * 2. Use `_tlItem?._id?.toString() || String(Date.now())` as the status ID. - * - * This ensures the creation response ID matches what findTimelineItemById will - * resolve in subsequent in_reply_to_id lookups. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-mastodon-status-id"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", -]; - -// Change 1: capture return value of addTimelineItem -const OLD_TL_INSERT = ` await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading`; -const NEW_TL_INSERT = ` _tlItem = await addTimelineItem(collections, { // [patch] ap-mastodon-reply-threading ${MARKER}`; - -// Change 2: declare _tlItem before the try block -const OLD_TRY = ` try { // [patch] ap-mastodon-reply-threading`; -const NEW_TRY = ` let _tlItem = null; ${MARKER} - try { // [patch] ap-mastodon-reply-threading`; - -// Change 3: use _tlItem._id as the status response ID -const OLD_ID = ` id: String(Date.now()),`; -const NEW_ID = ` id: _tlItem?._id?.toString() || String(Date.now()), ${MARKER}`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-mastodon-status-id: already applied to ${filePath}`); - continue; - } - - let missing = false; - for (const [label, snippet] of [ - ["await addTimelineItem", OLD_TL_INSERT], - ["try block", OLD_TRY], - ["response id", OLD_ID], - ]) { - if (!source.includes(snippet)) { - console.warn(`[postinstall] patch-ap-mastodon-status-id: "${label}" snippet not found in ${filePath}`); - missing = true; - } - } - if (missing) continue; - - // Apply in order: TRY first (adds let _tlItem before try), then INSERT (changes await to assign), then ID - let updated = source - .replace(OLD_TRY, NEW_TRY) - .replace(OLD_TL_INSERT, NEW_TL_INSERT) - .replace(OLD_ID, NEW_ID); - - if (updated === source) { - console.log(`[postinstall] patch-ap-mastodon-status-id: no changes in ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-mastodon-status-id to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-mastodon-status-id: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-mastodon-status-id: already up to date"); -} else { - console.log(`[postinstall] patch-ap-mastodon-status-id: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-notifications-status-lookup.mjs b/scripts/patch-ap-notifications-status-lookup.mjs deleted file mode 100644 index bc4ca4fb..00000000 --- a/scripts/patch-ap-notifications-status-lookup.mjs +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Patch: include notif.url in batchFetchStatuses so mention notifications - * resolve their associated status correctly. - * - * Root cause: - * batchFetchStatuses() only collected notif.targetUrl for the batch lookup. - * serializeNotification() looks up mentions by notif.url (the incoming - * reply URL), not notif.targetUrl (the own post being replied to). These - * are different URLs, so the statusMap never has an entry for the mention → - * fallback fires → status.id = notif._id.toString() (a notification ObjectId, - * not a timeline ObjectId) → Phanpy uses that ID for subsequent requests → - * GET /api/v1/statuses/:id/context returns 404 because ap_timeline has no - * document with that _id. - * - * Fix: - * Collect both notif.url and notif.targetUrl so the batch covers all URL - * shapes used by any notification type. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/notifications.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/notifications.js", -]; - -const MARKER = "// [patch] ap-notifications-status-lookup"; - -const OLD_SNIPPET = ` const targetUrls = [ - ...new Set( - notifications - .map((n) => n.targetUrl) - .filter(Boolean), - ), - ];`; - -const NEW_SNIPPET = ` const targetUrls = [ // [patch] ap-notifications-status-lookup - ...new Set( - notifications - .flatMap((n) => [n.targetUrl, n.url]) - .filter(Boolean), - ), - ];`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - checked += 1; - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-notifications-status-lookup: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn( - `[postinstall] patch-ap-notifications-status-lookup: target snippet not found in ${filePath} — skipping`, - ); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - - if (updated === source) { - console.log(`[postinstall] patch-ap-notifications-status-lookup: no changes applied to ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-notifications-status-lookup to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-notifications-status-lookup: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-notifications-status-lookup: already up to date"); -} else { - console.log( - `[postinstall] patch-ap-notifications-status-lookup: patched ${patched}/${checked} file(s)`, - ); -} diff --git a/scripts/patch-ap-og-image.mjs b/scripts/patch-ap-og-image.mjs deleted file mode 100644 index d63c150e..00000000 --- a/scripts/patch-ap-og-image.mjs +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Patch: fix OG image URL generation in ActivityPub jf2-to-as2.js. - * - * Root cause (original): - * jf2-to-as2.js used a date-based URL regex to extract the post slug, which - * never matches this blog's flat URLs (/articles/slug/ vs /articles/2024/.../slug/). - * The image property was never set, so no preview card reached Mastodon. - * - * Fix (v2 — this patch): - * For posts with a photo attachment (properties.photo), use the photo URL - * directly as the preview image — Eleventy does NOT generate /og/*.png for - * photo post types. - * For all other post types (replies, bookmarks, articles) fall back to - * /og/{slug}.png, which Eleventy does generate. - * - * Both jf2ToActivityStreams() (plain JSON-LD) and jf2ToAS2Activity() (Fedify - * vocab objects) are patched. Handles all known file states: - * - Original upstream code (ogMatch / ogMatchF variable names) - * - v1 patch (ogSlug / ogSlugF + // og-image fix comments) - * - Already v2 (// og-image-v2 marker) → skip - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js", -]; - -const MARKER = "// og-image-v2"; - -// --------------------------------------------------------------------------- -// Match the OG image block in jf2ToActivityStreams. -// Handles both the original upstream code (ogMatch) and the v1 patch (ogSlug). -// --------------------------------------------------------------------------- -const CN_BLOCK_RE = - / const og(?:Slug|Match) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:Slug|Match)\) \{[\s\S]*?\n \}/; - -// Match the OG image block in jf2ToAS2Activity (ogMatchF / ogSlugF variants). -const AS2_BLOCK_RE = - / const og(?:SlugF|MatchF) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:SlugF|MatchF)\) \{[\s\S]*?\n \}/; - -// --------------------------------------------------------------------------- -// v2 replacements: -// 1. Use properties.photo[0] URL for photo posts (resolveMediaUrl handles -// relative paths; guessMediaType detects jpeg/png/webp). -// 2. Fall back to /og/{slug}.png for replies, bookmarks, articles. -// -// Template literal escaping (patch string → injected JS source): -// \\/ → \/ (regex escaped slash) -// [\\\w-] → [\w-] (word-char class) -// \`\${ → `${ (start of injected template literal) -// --------------------------------------------------------------------------- -const NEW_CN = ` const _ogPhoto = properties.photo && asArray(properties.photo)[0]; // og-image-v2 - const _ogPhotoUrl = _ogPhoto && (typeof _ogPhoto === "string" ? _ogPhoto : _ogPhoto.url); // og-image-v2 - const ogSlug = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image-v2 - const _ogUrl = _ogPhotoUrl - ? resolveMediaUrl(_ogPhotoUrl, publicationUrl) // og-image-v2 - : ogSlug ? \`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlug}.png\` : null; // og-image-v2 - if (_ogUrl) { // og-image-v2 - object.image = { - type: "Image", - url: _ogUrl, // og-image-v2 - mediaType: _ogPhotoUrl ? guessMediaType(_ogUrl) : "image/png", // og-image-v2 - }; - }`; - -const NEW_AS2 = ` const _ogPhotoF = properties.photo && asArray(properties.photo)[0]; // og-image-v2 - const _ogPhotoUrlF = _ogPhotoF && (typeof _ogPhotoF === "string" ? _ogPhotoF : _ogPhotoF.url); // og-image-v2 - const ogSlugF = postUrl && postUrl.match(/\\/([\\\w-]+)\\/?$/)?.[1]; // og-image-v2 - const _ogUrlF = _ogPhotoUrlF - ? resolveMediaUrl(_ogPhotoUrlF, publicationUrl) // og-image-v2 - : ogSlugF ? \`\${publicationUrl.replace(/\\/$/, "")}/og/\${ogSlugF}.png\` : null; // og-image-v2 - if (_ogUrlF) { // og-image-v2 - noteOptions.image = new Image({ - url: new URL(_ogUrlF), // og-image-v2 - mediaType: _ogPhotoUrlF ? guessMediaType(_ogUrlF) : "image/png", // og-image-v2 - }); - }`; - -// --------------------------------------------------------------------------- - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - checked += 1; - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-og-image: already applied to ${filePath}`); - continue; - } - - let updated = source; - let changed = false; - - // Fix the jf2ToActivityStreams OG block - if (CN_BLOCK_RE.test(updated)) { - updated = updated.replace(CN_BLOCK_RE, NEW_CN); - changed = true; - } else { - console.warn( - `[postinstall] patch-ap-og-image: jf2ToActivityStreams OG block not found in ${filePath} — skipping`, - ); - } - - // Fix the jf2ToAS2Activity OG block - if (AS2_BLOCK_RE.test(updated)) { - updated = updated.replace(AS2_BLOCK_RE, NEW_AS2); - changed = true; - } else { - console.warn( - `[postinstall] patch-ap-og-image: jf2ToAS2Activity OG block not found in ${filePath} — skipping`, - ); - } - - if (!changed || updated === source) { - console.log(`[postinstall] patch-ap-og-image: no changes applied to ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-og-image to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-og-image: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-og-image: already up to date"); -} else { - console.log(`[postinstall] patch-ap-og-image: patched ${patched}/${checked} file(s)`); -} diff --git a/scripts/patch-ap-repost-announce-fix.mjs b/scripts/patch-ap-repost-announce-fix.mjs deleted file mode 100644 index c26c86b1..00000000 --- a/scripts/patch-ap-repost-announce-fix.mjs +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Patch: fix repost syndication to ActivityPub. - * - * Previously, any repost sent an Announce activity with the repost-of URL as - * the object — even when that URL is a plain web article (not an AP object). - * Remote servers receive the Announce but cannot resolve the object, so the - * boost never appears on followers' timelines. - * - * The same problem applies to Likes, where the fix already exists: check - * isApUrl() first. This patch applies the same logic to reposts: - * - * - repost-of is an AP URL → proper Announce with id + cc (native boost) - * - repost-of is NOT an AP URL → fall through to Create(Note) which renders - * as "🔁 " on the fediverse (same as the with-commentary path) - * - * Also adds missing `id` and `cc: followers` to the Announce (the interactive - * boostPost() method already generates these; the syndication path did not). - */ -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-repost-announce-fix"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/jf2-to-as2.js", -]; - -const OLD_SNIPPET = ` // Reposts are always public — upstream @rmdes addressing - if (postType === "repost") { - const repostOf = properties["repost-of"]; - if (!repostOf) return null; - const repostContent = properties.content?.html || properties.content || ""; - if (!repostContent) { - // Pure repost — send as a native Announce (boost) so remote servers - // can display it as a boost of the original post. - return new Announce({ - actor: actorUri, - object: new URL(repostOf), - to: new URL("https://www.w3.org/ns/activitystreams#Public"), - }); - } - // Has commentary — fall through to Create(Note) so the text is federated. - // The note content block below handles the "repost" post-type. - }`; - -const NEW_SNIPPET = ` // Reposts are always public — upstream @rmdes addressing - if (postType === "repost") { - const repostOf = Array.isArray(properties["repost-of"]) - ? properties["repost-of"][0] - : properties["repost-of"]; - if (!repostOf) return null; - const repostContent = properties.content?.html || properties.content || ""; - if (!repostContent) { - // 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)) { ${MARKER} - const actorPath = new URL(actorUrl).pathname; - const mp = actorPath.replace(/\\/users\\/[^/]+$/, ""); - const postRelPath = (properties.url || "") - .replace(publicationUrl.replace(/\\/$/, ""), "") - .replace(/^\\//, "") - .replace(/\\/$/, ""); - const announceId = \`\${publicationUrl.replace(/\\/$/, "")}\${mp}/activities/boost/\${postRelPath}\`; - return new Announce({ - id: new URL(announceId), - actor: actorUri, - object: new URL(repostOf), - to: new URL("https://www.w3.org/ns/activitystreams#Public"), - cc: new URL(\`\${actorUrl.replace(/\\/$/, "")}/followers\`), - }); - } - } - // Has commentary or non-AP repost-of URL — fall through to Create(Note) so the text is federated. - // The note content block below handles the "repost" post-type. - }`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn( - `[postinstall] Skipping ap-repost-announce-fix patch for ${filePath}: upstream format changed`, - ); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - await writeFile(filePath, updated, "utf8"); - patched += 1; -} - -if (checked === 0) { - console.log("[postinstall] No AP jf2-to-as2 files found for repost announce fix patch"); -} else if (patched === 0) { - console.log("[postinstall] ap-repost-announce-fix patch already applied"); -} else { - console.log( - `[postinstall] Patched AP repost announce fix in ${patched} file(s)`, - ); -} diff --git a/scripts/patch-ap-resolve-account-timeout-safe.mjs b/scripts/patch-ap-resolve-account-timeout-safe.mjs deleted file mode 100644 index cb9b2823..00000000 --- a/scripts/patch-ap-resolve-account-timeout-safe.mjs +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Patch: fix unhandled rejection crash + parallelise collection count fetches. - * - * Root cause (crash): - * withTimeout() races the original promise against a 5 s timer. - * When the timer fires first, the original getFollowers() / getFollowing() / - * getOutbox() promise is still running with no .catch() handler. If it later - * rejects (e.g. network error, TLS failure), Node.js ≥15 treats it as an - * unhandled rejection and crashes the process → nginx sees "Connection refused". - * - * Fix: - * Call promise.catch(() => {}) before racing so the rejection is always - * "handled", even if we never observe the result. - * - * Bonus — parallel fetches: - * The three collection-count fetches were sequential: worst case 3 × 5 s = 15 s. - * Replaced with Promise.allSettled so all three run concurrently; max wait = 5 s. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/helpers/resolve-account.js", -]; - -const MARKER = "// [patch] ap-resolve-account-timeout-safe"; - -const OLD_SNIPPET = ` const withTimeout = (promise, ms = 5000) => - Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]); - - 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 */ }`; - -const NEW_SNIPPET = ` 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]); - }; - - // 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;`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - checked += 1; - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-resolve-account-timeout-safe: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn( - `[postinstall] patch-ap-resolve-account-timeout-safe: target snippet not found in ${filePath} — skipping`, - ); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - - if (updated === source) { - console.log(`[postinstall] patch-ap-resolve-account-timeout-safe: no changes applied to ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-resolve-account-timeout-safe to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-resolve-account-timeout-safe: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-resolve-account-timeout-safe: already up to date"); -} else { - console.log( - `[postinstall] patch-ap-resolve-account-timeout-safe: patched ${patched}/${checked} file(s)`, - ); -} diff --git a/scripts/patch-ap-signature-host-header.mjs b/scripts/patch-ap-signature-host-header.mjs deleted file mode 100644 index 03023c17..00000000 --- a/scripts/patch-ap-signature-host-header.mjs +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Patch: fix HTTP Signature verification failures by normalising the `host` - * header in fromExpressRequest(). - * - * Root cause: - * HTTP Signatures (Mastodon, Pleroma, …) include "host:" in the signed - * components string. The signer uses the public hostname, e.g. - * "host: blog.giersig.eu". Fedify reconstructs that string from - * request.headers.get("host") when verifying. - * - * In our two-jail setup (nginx → node jail), nginx may proxy to the node - * jail with a different Host header than the public hostname (e.g. the - * internal IP "10.100.0.20" or "10.100.0.20:3000"). The - * patch-ap-federation-bridge-base-url patch already fixed URL routing - * (fromExpressRequest builds the correct canonical URL from publicationUrl), - * but the host HEADER value in the Headers object was still copied verbatim - * from req.headers — meaning Fedify's signature verifier reconstructed the - * signed string with the wrong host value and the check always failed. - * - * Fix: - * After copying headers from the Express request, override "host" with the - * hostname extracted from publicationUrl when publicationUrl is provided. - * This is safe even when nginx already forwards the correct Host header — - * the value is identical and the set() is a no-op in that case. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-signature-host-header"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", -]; - -// The headers-copy loop is followed immediately by the body reconstruction. -// Insert the host-override right after the closing brace of the loop. -const OLD_HEADERS_LOOP = ` for (const [key, value] of Object.entries(req.headers)) { - if (Array.isArray(value)) { - for (const v of value) headers.append(key, v); - } else if (typeof value === "string") { - headers.append(key, value); - } - } - - let body;`; - -const NEW_HEADERS_LOOP = ` for (const [key, value] of Object.entries(req.headers)) { - if (Array.isArray(value)) { - for (const v of value) headers.append(key, v); - } else if (typeof value === "string") { - headers.append(key, value); - } - } - - // 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". ${MARKER} - 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;`; - -async function exists(p) { - try { await access(p); return true; } catch { return false; } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked++; - - const source = await readFile(filePath, "utf8"); - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-signature-host-header: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_HEADERS_LOOP)) { - console.warn(`[postinstall] patch-ap-signature-host-header: target snippet not found in ${filePath}`); - continue; - } - - await writeFile(filePath, source.replace(OLD_HEADERS_LOOP, NEW_HEADERS_LOOP), "utf8"); - patched++; - console.log(`[postinstall] Applied patch-ap-signature-host-header to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-signature-host-header: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-signature-host-header: already up to date"); -} else { - console.log(`[postinstall] patch-ap-signature-host-header: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-skip-draft-syndication.mjs b/scripts/patch-ap-skip-draft-syndication.mjs deleted file mode 100644 index 51869106..00000000 --- a/scripts/patch-ap-skip-draft-syndication.mjs +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Patch: add a post-status === "draft" guard to the ActivityPub syndicator's - * syndicate() method, mirroring the existing visibility === "unlisted" guard. - * - * Without this patch, a draft post that somehow reaches the AP syndicator - * directly (bypassing the syndicate-endpoint DB-level filter) would be - * federated to followers. - */ -import { access, readFile, writeFile } from "node:fs/promises"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", -]; - -const oldSnippet = ` const visibility = String(properties?.visibility || "").toLowerCase(); - if (visibility === "unlisted") { - console.info( - "[ActivityPub] Skipping federation for unlisted post: " + - (properties?.url || "unknown"), - ); - await logActivity(self._collections.ap_activities, { - direction: "outbound", - type: "Syndicate", - actorUrl: self._publicationUrl, - objectUrl: properties?.url, - summary: "Syndication skipped: post visibility is unlisted", - }).catch(() => {}); - return undefined; - }`; - -const newSnippet = ` const postStatus = String(properties?.["post-status"] || "").toLowerCase(); - if (postStatus === "draft") { - console.info( - "[ActivityPub] Skipping federation for draft post: " + - (properties?.url || "unknown"), - ); - await logActivity(self._collections.ap_activities, { - direction: "outbound", - type: "Syndicate", - actorUrl: self._publicationUrl, - objectUrl: properties?.url, - summary: "Syndication skipped: post is a draft", - }).catch(() => {}); - return undefined; - } - - const visibility = String(properties?.visibility || "").toLowerCase(); - if (visibility === "unlisted") { - console.info( - "[ActivityPub] Skipping federation for unlisted post: " + - (properties?.url || "unknown"), - ); - await logActivity(self._collections.ap_activities, { - direction: "outbound", - type: "Syndicate", - actorUrl: self._publicationUrl, - objectUrl: properties?.url, - summary: "Syndication skipped: post visibility is unlisted", - }).catch(() => {}); - return undefined; - }`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(newSnippet)) { - continue; - } - - if (!source.includes(oldSnippet)) { - console.warn( - `[postinstall] Skipping ap-skip-draft-syndication patch for ${filePath}: upstream format changed`, - ); - continue; - } - - const updated = source.replace(oldSnippet, newSnippet); - await writeFile(filePath, updated, "utf8"); - patched += 1; -} - -if (checked === 0) { - console.log("[postinstall] No AP endpoint files found for draft guard patch"); -} else if (patched === 0) { - console.log("[postinstall] ap-skip-draft-syndication patch already applied"); -} else { - console.log( - `[postinstall] Patched AP draft syndication guard in ${patched} file(s)`, - ); -} diff --git a/scripts/patch-ap-status-reply-id.mjs b/scripts/patch-ap-status-reply-id.mjs deleted file mode 100644 index aa9bae3b..00000000 --- a/scripts/patch-ap-status-reply-id.mjs +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Patch: fix in_reply_to_id always being null in Mastodon status serializer. - * - * Bug: - * status.js line 207: - * in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID - * - * Both branches of the ternary return null, so in_reply_to_id is ALWAYS null. - * Mastodon clients (Phanpy, Elk) use this field to display reply threading — - * without it, replies appear as standalone posts with no thread context. - * - * Fix (two changes): - * - * A) status.js — use item.inReplyToId (the encoded cursor of the parent post) - * instead of the tautological null. - * - * B) statuses.js POST /api/v1/statuses handler — when pre-inserting own posts - * into ap_timeline (reply-threading patch), also store - * inReplyToId: inReplyToId || null - * (inReplyToId is already in scope as the raw in_reply_to_id param from the - * client, which IS a valid encodeCursor value.) - * - * Note: inbound AP replies from remote servers will still have inReplyToId = null - * until a separate patch populates it from ap_timeline lookups. Own replies via - * the Mastodon client API are fully fixed by this patch. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-status-reply-id"; - -// ── Change A: fix tautological null in status.js ────────────────────────────── - -const statusEntityCandidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/entities/status.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/entities/status.js", -]; - -const OLD_TAUTOLOGY = ` in_reply_to_id: item.inReplyTo ? null : null, // TODO: resolve to local ID`; -const NEW_REPLY_ID = ` in_reply_to_id: item.inReplyToId || null, ${MARKER}`; - -// ── Change B: store inReplyToId in the Mastodon API timeline insert ─────────── - -const statusesRouteCandidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/mastodon/routes/statuses.js", -]; - -const OLD_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading`; -const NEW_REPLY_INSERT = ` inReplyTo: inReplyTo || null, // [patch] ap-mastodon-reply-threading - inReplyToId: inReplyToId || null, ${MARKER}`; - -async function exists(p) { - try { await access(p); return true; } catch { return false; } -} - -// Upstream fix indicator for Change A — if present, the tautological null is -// already replaced by the upstream's replyIdMap-based lookup (better than our patch). -const UPSTREAM_FIX_A = `in_reply_to_id: replyIdMap?.get(item.inReplyTo)`; - -async function applyPatch(candidates, oldSnippet, newSnippet, label, upstreamFix) { - let checked = 0; - let patched = 0; - - for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked++; - - const source = await readFile(filePath, "utf8"); - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-status-reply-id: ${label} already applied to ${filePath}`); - continue; - } - - // If upstream has already fixed the issue (better fix), skip silently - if (upstreamFix && source.includes(upstreamFix)) { - console.log(`[postinstall] patch-ap-status-reply-id: ${label} already fixed upstream in ${filePath}`); - continue; - } - - if (!source.includes(oldSnippet)) { - console.warn(`[postinstall] patch-ap-status-reply-id: ${label} snippet not found in ${filePath}`); - continue; - } - - await writeFile(filePath, source.replace(oldSnippet, newSnippet), "utf8"); - patched++; - console.log(`[postinstall] Applied patch-ap-status-reply-id (${label}) to ${filePath}`); - } - - return { checked, patched }; -} - -const a = await applyPatch(statusEntityCandidates, OLD_TAUTOLOGY, NEW_REPLY_ID, "status entity", UPSTREAM_FIX_A); -const b = await applyPatch(statusesRouteCandidates, OLD_REPLY_INSERT, NEW_REPLY_INSERT, "timeline insert"); - -const totalChecked = a.checked + b.checked; -const totalPatched = a.patched + b.patched; - -if (totalChecked === 0) { - console.log("[postinstall] patch-ap-status-reply-id: no target files found"); -} else if (totalPatched === 0) { - console.log("[postinstall] patch-ap-status-reply-id: already up to date"); -} else { - console.log(`[postinstall] patch-ap-status-reply-id: patched ${totalPatched} file(s)`); -} diff --git a/scripts/patch-ap-syndicate-dedup.mjs b/scripts/patch-ap-syndicate-dedup.mjs deleted file mode 100644 index 11e397ed..00000000 --- a/scripts/patch-ap-syndicate-dedup.mjs +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Patch: dedup guard in AP syndicator.syndicate() to prevent double-posting. - * - * Root cause: - * The build CI calls POST /syndicate?source_url=X (force=true) after every - * Eleventy build. When syndicateToTargets() records the first syndication, it - * issues a Micropub update to save the syndication URL — which commits a new - * file to Gitea, triggering another build. That second build's CI call also - * hits the syndicate endpoint with force=true. - * - * In force mode with no mp-syndicate-to, syndicateToTargets() re-selects - * targets whose origin matches any existing syndication URL. Since the AP - * syndicator's UID (publicationUrl, e.g. "https://blog.giersig.eu/") and the - * first syndication return value (properties.url, e.g. - * "https://blog.giersig.eu/notes/my-post/") share the same origin, - * the AP syndicator is matched and called a second time → duplicate Create(Note). - * - * Fix: - * At the start of syndicate(), query ap_activities for an existing outbound - * Create/Announce/Update for properties.url. If found, log and return the - * existing URL without re-federating. - * - * This is self-contained (no CI or force-mode changes needed) and correct - * regardless of how syndication is triggered. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-syndicate-dedup"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js", -]; - -const OLD = ` try { - const actorUrl = plugin._getActorUrl();`; - -const NEW = ` // Dedup: skip re-federation if we've already sent an activity for this URL. ${MARKER} - // 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 */ } - - try { - const actorUrl = plugin._getActorUrl();`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-syndicate-dedup: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD)) { - console.warn(`[postinstall] patch-ap-syndicate-dedup: snippet not found in ${filePath}`); - continue; - } - - await writeFile(filePath, source.replace(OLD, NEW), "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-syndicate-dedup to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-syndicate-dedup: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-syndicate-dedup: already up to date"); -} else { - console.log(`[postinstall] patch-ap-syndicate-dedup: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-syndicate-skip-checkin.mjs b/scripts/patch-ap-syndicate-skip-checkin.mjs deleted file mode 100644 index 248be025..00000000 --- a/scripts/patch-ap-syndicate-skip-checkin.mjs +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Patch: skip AP syndication for location checkins. - * - * Location checkins arrive via Micropub as regular notes but carry a - * `location` property (geo URI or h-card object, normalised by jf2.js). - * They are personal log entries and should not be federated to followers. - * - * Fix: - * At the start of syndicate(), if properties.location is set, log and - * return undefined so Indiekit records no syndication URL for the post. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-syndicate-skip-checkin"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js", -]; - -const OLD = ` async syndicate(properties) { - if (!plugin._federation) { - return undefined; - }`; - -const NEW = ` async syndicate(properties) { - if (!plugin._federation) { - return undefined; - } - - // Skip location checkins — they have a JF2 \`location\` property. ${MARKER} - if (properties.location) { - console.info(\`[ActivityPub] Skipping syndication for location checkin: \${properties.url}\`); - return undefined; - }`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-syndicate-skip-checkin: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD)) { - console.warn(`[postinstall] patch-ap-syndicate-skip-checkin: snippet not found in ${filePath}`); - continue; - } - - await writeFile(filePath, source.replace(OLD, NEW), "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-syndicate-skip-checkin to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-syndicate-skip-checkin: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-syndicate-skip-checkin: already up to date"); -} else { - console.log(`[postinstall] patch-ap-syndicate-skip-checkin: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-syndicate-skip-draft.mjs b/scripts/patch-ap-syndicate-skip-draft.mjs deleted file mode 100644 index 4356eac4..00000000 --- a/scripts/patch-ap-syndicate-skip-draft.mjs +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Patch: skip AP syndication for posts with post-status="draft". - * - * Draft posts should not be federated to followers. - * They have `properties["post-status"] === "draft"`. - * - * Fix: - * At the start of syndicate(), after the checkin guard, if - * properties["post-status"] === "draft", log and return undefined so - * Indiekit records no syndication URL for the post. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-syndicate-skip-draft"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js", -]; - -const OLD = ` // Skip location checkins — they have a JF2 \`location\` property. // [patch] ap-syndicate-skip-checkin - if (properties.location) { - console.info(\`[ActivityPub] Skipping syndication for location checkin: \${properties.url}\`); - return undefined; - }`; - -const NEW = ` // Skip location checkins — they have a JF2 \`location\` property. // [patch] ap-syndicate-skip-checkin - 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. ${MARKER} - if (properties["post-status"] === "draft") { - console.info(\`[ActivityPub] Skipping syndication for draft post: \${properties.url}\`); - return undefined; - }`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-syndicate-skip-draft: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD)) { - console.warn(`[postinstall] patch-ap-syndicate-skip-draft: snippet not found in ${filePath}`); - continue; - } - - await writeFile(filePath, source.replace(OLD, NEW), "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-syndicate-skip-draft to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-syndicate-skip-draft: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-syndicate-skip-draft: already up to date"); -} else { - console.log(`[postinstall] patch-ap-syndicate-skip-draft: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-syndicate-skip-unlisted.mjs b/scripts/patch-ap-syndicate-skip-unlisted.mjs deleted file mode 100644 index bf852cbb..00000000 --- a/scripts/patch-ap-syndicate-skip-unlisted.mjs +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Patch: skip AP syndication for posts with visibility="unlisted". - * - * Unlisted posts should not be federated to followers. - * They have `properties.visibility === "unlisted"`. - * - * Fix: - * At the start of syndicate(), after the draft guard, if - * properties.visibility === "unlisted", log and return undefined so - * Indiekit records no syndication URL for the post. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const MARKER = "// [patch] ap-syndicate-skip-unlisted"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/syndicator.js", -]; - -const OLD = ` // Skip draft posts — they should not be federated to followers. // [patch] ap-syndicate-skip-draft - if (properties["post-status"] === "draft") { - console.info(\`[ActivityPub] Skipping syndication for draft post: \${properties.url}\`); - return undefined; - }`; - -const NEW = ` // Skip draft posts — they should not be federated to followers. // [patch] ap-syndicate-skip-draft - 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. ${MARKER} - if (properties.visibility === "unlisted") { - console.info(\`[ActivityPub] Skipping syndication for unlisted post: \${properties.url}\`); - return undefined; - }`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) continue; - checked += 1; - - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-syndicate-skip-unlisted: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD)) { - console.warn(`[postinstall] patch-ap-syndicate-skip-unlisted: snippet not found in ${filePath}`); - continue; - } - - await writeFile(filePath, source.replace(OLD, NEW), "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-syndicate-skip-unlisted to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-syndicate-skip-unlisted: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-syndicate-skip-unlisted: already up to date"); -} else { - console.log(`[postinstall] patch-ap-syndicate-skip-unlisted: patched ${patched} file(s)`); -} diff --git a/scripts/patch-ap-webfinger-before-auth.mjs b/scripts/patch-ap-webfinger-before-auth.mjs deleted file mode 100644 index 2cfd033f..00000000 --- a/scripts/patch-ap-webfinger-before-auth.mjs +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Patch: serve /.well-known/webfinger (and other /.well-known/ discovery routes) - * via Fedify BEFORE indiekit's auth middleware redirects them to the login page. - * - * Root cause: - * indiekit mounts the AP endpoint's `routesWellKnown` router at `/.well-known/`. - * Express strips that prefix from req.url, so Fedify sees "/webfinger" instead of - * "/.well-known/webfinger" and cannot match its internal route — it calls next(). - * The request then falls through to indiekit's auth middleware, which issues a 302 - * redirect to /session/login. Remote servers (e.g. digitalhub.social) receive - * the redirect instead of the JSON response and log a Webfinger error, causing all - * subsequent ActivityPub deliveries to that instance to fail with 401 Unauthorized. - * - * Fix: - * The AP endpoint also registers `contentNegotiationRoutes` at "/", where Express - * does NOT strip any prefix and req.path retains the full original path. This patch - * extends the Fedify delegation guard inside that router to also forward any request - * whose path starts with "/.well-known/", in addition to the existing "/nodeinfo/" - * delegation. Because `contentNegotiationRoutes` is injected before auth by - * patch-indiekit-routes-rate-limits.mjs, Fedify handles the request before auth - * middleware ever runs. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/index.js", -]; - -const MARKER = "// ap-webfinger-before-auth patch"; - -const OLD_SNIPPET = ` 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(); - return self._fedifyMiddleware(req, res, next);`; - -const NEW_SNIPPET = ` if (!self._fedifyMiddleware) return next(); - if (req.method !== "GET" && req.method !== "HEAD") 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);`; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -let checked = 0; -let patched = 0; - -for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - checked += 1; - const source = await readFile(filePath, "utf8"); - - if (source.includes(MARKER)) { - console.log(`[postinstall] patch-ap-webfinger-before-auth: already applied to ${filePath}`); - continue; - } - - if (!source.includes(OLD_SNIPPET)) { - console.warn( - `[postinstall] patch-ap-webfinger-before-auth: target snippet not found in ${filePath} — skipping`, - ); - continue; - } - - const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); - - if (updated === source) { - console.log(`[postinstall] patch-ap-webfinger-before-auth: no changes applied to ${filePath}`); - continue; - } - - await writeFile(filePath, updated, "utf8"); - patched += 1; - console.log(`[postinstall] Applied patch-ap-webfinger-before-auth to ${filePath}`); -} - -if (checked === 0) { - console.log("[postinstall] patch-ap-webfinger-before-auth: no target files found"); -} else if (patched === 0) { - console.log("[postinstall] patch-ap-webfinger-before-auth: already up to date"); -} else { - console.log( - `[postinstall] patch-ap-webfinger-before-auth: patched ${patched}/${checked} file(s)`, - ); -} diff --git a/scripts/patch-inbox-ignore-view-activity.mjs b/scripts/patch-inbox-ignore-view-activity.mjs deleted file mode 100644 index 4d308f79..00000000 --- a/scripts/patch-inbox-ignore-view-activity.mjs +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Patch: silently ignore PeerTube View (WatchAction) activities in the inbox. - * - * PeerTube broadcasts a non-standard ActivityStreams `View` activity to all - * followers whenever someone watches a video. Fedify has no built-in handler - * registered for this type, which causes a noisy - * "Unsupported activity type" error in the federation inbox log on every view. - * - * Fix: register a no-op `.on(View, ...)` handler at the end of the inbox - * listener chain so Fedify accepts and silently discards these activities - * instead of logging them as errors. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-listeners.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/inbox-listeners.js", -]; - -const patchSpecs = [ - { - name: "inbox-ignore-view-activity-import", - marker: "// View imported", - oldSnippet: ` Undo, - Update, -} from "@fedify/fedify/vocab";`, - newSnippet: ` Undo, - Update, - View, // View imported -} from "@fedify/fedify/vocab";`, - }, - { - name: "inbox-ignore-view-activity-handler", - marker: "// PeerTube View handler", - oldSnippet: ` console.info(\`[ActivityPub] Flag received from \${reporterName} — \${reportedIds.length} objects reported\`); - } catch (error) { - console.warn("[ActivityPub] Flag handler error:", error.message); - } - }); -}`, - newSnippet: ` console.info(\`[ActivityPub] Flag received from \${reporterName} — \${reportedIds.length} objects reported\`); - } catch (error) { - console.warn("[ActivityPub] Flag handler error:", error.message); - } - }) - // ── 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 () => {}); -}`, - }, -]; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -const checkedFiles = new Set(); -const patchedFiles = new Set(); - -for (const spec of patchSpecs) { - let foundAnyTarget = false; - - for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - foundAnyTarget = true; - checkedFiles.add(filePath); - - const source = await readFile(filePath, "utf8"); - - if (spec.marker && source.includes(spec.marker)) { - continue; - } - - if (!source.includes(spec.oldSnippet)) { - continue; - } - - const updated = source.replace(spec.oldSnippet, spec.newSnippet); - - if (updated === source) { - continue; - } - - await writeFile(filePath, updated, "utf8"); - patchedFiles.add(filePath); - } - - if (!foundAnyTarget) { - console.log(`[postinstall] ${spec.name}: no target files found`); - } -} - -if (checkedFiles.size === 0) { - console.log("[postinstall] No inbox-listeners files found for View activity patch"); -} else if (patchedFiles.size === 0) { - console.log("[postinstall] inbox-ignore-view-activity patches already applied"); -} else { - console.log( - `[postinstall] Patched inbox-ignore-view-activity in ${patchedFiles.size}/${checkedFiles.size} file(s)`, - ); -} diff --git a/scripts/patch-inbox-skip-view-activity-parse.mjs b/scripts/patch-inbox-skip-view-activity-parse.mjs deleted file mode 100644 index 647a7753..00000000 --- a/scripts/patch-inbox-skip-view-activity-parse.mjs +++ /dev/null @@ -1,234 +0,0 @@ -/** - * Patch: skip PeerTube View (WatchAction) activities before Fedify parses them. - * - * PeerTube's View activities embed Schema.org extensions such as - * `InteractionCounter` that Fedify's JSON-LD deserializer doesn't recognise. - * This causes a hard "Failed to parse activity" error *before* any inbox - * listener is reached, so the .on(View, ...) no-op handler added earlier - * never fires. - * - * Root cause of the previous (broken) patch: Express's JSON body parser only - * handles `application/json`, not `application/activity+json`. So `req.body` - * is always undefined for ActivityPub inbox POSTs, meaning the check - * `req.body?.type === "View"` never matched and Fedify still received the raw - * stream. - * - * Fix (two changes to federation-bridge.js): - * - * 1. In createFedifyMiddleware: for ActivityPub POST requests where the body - * hasn't been parsed yet, buffer the raw stream, JSON-parse it, and store - * the result on req.body before the guard runs. Then check type === "View" - * and return 200 if so (preventing retries from the sender). - * - * 2. In fromExpressRequest: extend the content-type check to also handle - * `application/activity+json` and `application/ld+json` bodies (i.e. use - * JSON.stringify(req.body) to reconstruct the stream), so that non-View - * ActivityPub activities are forwarded correctly to Fedify. - */ - -import { access, readFile, writeFile } from "node:fs/promises"; - -const candidates = [ - "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", - "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", -]; - -const patchSpecs = [ - // --- Patch 1: extend fromExpressRequest to handle activity+json bodies --- - { - name: "from-express-request-activity-json-fix", - marker: "// PeerTube activity+json body fix", - oldSnippet: ` if (ct.includes("application/json")) { - body = JSON.stringify(req.body); - } else if (ct.includes("application/x-www-form-urlencoded")) {`, - newSnippet: ` // PeerTube activity+json body fix - if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) { - // Use original raw bytes when available (set by createFedifyMiddleware buffer guard). - // JSON.stringify() changes byte layout, breaking Fedify's HTTP Signature Digest check. - body = req._rawBody || JSON.stringify(req.body); // raw body digest fix - } else if (ct.includes("application/x-www-form-urlencoded")) {`, - }, - - // --- Patch 1b: upgrade fromExpressRequest to use _rawBody (fixes Digest verification) --- - // Handles files where Patch 1 was already applied but without the _rawBody fix. - { - name: "from-express-request-raw-body-fix", - marker: "req._rawBody || JSON.stringify", - oldSnippet: ` // PeerTube activity+json body fix - if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) { - body = JSON.stringify(req.body); - } else if (ct.includes("application/x-www-form-urlencoded")) {`, - newSnippet: ` // PeerTube activity+json body fix - if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) { - // Use original raw bytes when available (set by createFedifyMiddleware buffer guard). - // JSON.stringify() changes byte layout, breaking Fedify's HTTP Signature Digest check. - body = req._rawBody || JSON.stringify(req.body); // raw body digest fix - } else if (ct.includes("application/x-www-form-urlencoded")) {`, - }, - - // --- Patch 1c: upgrade middleware buffer guard to set _rawBody (fixes Digest verification) --- - // Handles files where Patch 2 was already applied but without the _rawBody fix. - { - name: "inbox-buffer-raw-body-fix", - marker: "req._rawBody = _raw", - oldSnippet: ` const _chunks = []; - for await (const _chunk of req) { - _chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk)); - } - try { - req.body = JSON.parse(Buffer.concat(_chunks).toString("utf8"));`, - newSnippet: ` const _chunks = []; - for await (const _chunk of req) { - _chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk)); - } - const _raw = Buffer.concat(_chunks); // raw body digest fix - req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification - try { - req.body = JSON.parse(_raw.toString("utf8"));`, - }, - - // --- Patch 2a: replace the old (broken) v1 guard with the buffering v2 guard --- - // Handles the case where the previous version of this script was already run. - { - name: "inbox-skip-view-activity-parse-v2", - marker: "// PeerTube View parse skip v2", - oldSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify - // attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's - // Schema.org extensions (e.g. InteractionCounter), causing a - // "Failed to parse activity" error. Return 200 to prevent retries. - // PeerTube View parse skip - if (req.method === "POST" && req.body?.type === "View") { - return res.status(200).end(); - } - const request = fromExpressRequest(req);`, - newSnippet: ` // Short-circuit PeerTube View (WatchAction) activities before Fedify - // attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's - // Schema.org extensions (e.g. InteractionCounter), causing a - // "Failed to parse activity" error. Return 200 to prevent retries. - // PeerTube View parse skip v2 - const _apct = req.headers["content-type"] || ""; - if ( - req.method === "POST" && - !req.body && - req.readable && - (_apct.includes("activity+json") || _apct.includes("ld+json")) - ) { - // Express doesn't parse application/activity+json, so buffer it ourselves. - const _chunks = []; - for await (const _chunk of req) { - _chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk)); - } - const _raw = Buffer.concat(_chunks); // raw body digest fix - req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification - try { - req.body = JSON.parse(_raw.toString("utf8")); - } catch { - req.body = {}; - } - } - if (req.method === "POST" && req.body?.type === "View") { - return res.status(200).end(); - } - const request = fromExpressRequest(req);`, - }, - - // --- Patch 2b: apply the buffering v2 guard on a fresh (unpatched) file --- - // Handles the case where neither v1 nor v2 patch has been applied yet. - { - name: "inbox-skip-view-activity-parse-v2-fresh", - marker: "// PeerTube View parse skip v2", - oldSnippet: ` return async (req, res, next) => { - try { - const request = fromExpressRequest(req);`, - newSnippet: ` return async (req, res, next) => { - try { - // Short-circuit PeerTube View (WatchAction) activities before Fedify - // attempts JSON-LD parsing. Fedify's vocab parser throws on PeerTube's - // Schema.org extensions (e.g. InteractionCounter), causing a - // "Failed to parse activity" error. Return 200 to prevent retries. - // PeerTube View parse skip v2 - const _apct = req.headers["content-type"] || ""; - if ( - req.method === "POST" && - !req.body && - req.readable && - (_apct.includes("activity+json") || _apct.includes("ld+json")) - ) { - // Express doesn't parse application/activity+json, so buffer it ourselves. - const _chunks = []; - for await (const _chunk of req) { - _chunks.push(Buffer.isBuffer(_chunk) ? _chunk : Buffer.from(_chunk)); - } - const _raw = Buffer.concat(_chunks); // raw body digest fix - req._rawBody = _raw; // Preserve original bytes for Fedify HTTP Signature Digest verification - try { - req.body = JSON.parse(_raw.toString("utf8")); - } catch { - req.body = {}; - } - } - if (req.method === "POST" && req.body?.type === "View") { - return res.status(200).end(); - } - const request = fromExpressRequest(req);`, - }, -]; - -async function exists(filePath) { - try { - await access(filePath); - return true; - } catch { - return false; - } -} - -const checkedFiles = new Set(); -const patchedFiles = new Set(); - -for (const spec of patchSpecs) { - let foundAnyTarget = false; - - for (const filePath of candidates) { - if (!(await exists(filePath))) { - continue; - } - - foundAnyTarget = true; - checkedFiles.add(filePath); - - const source = await readFile(filePath, "utf8"); - - if (spec.marker && source.includes(spec.marker)) { - continue; - } - - if (!source.includes(spec.oldSnippet)) { - continue; - } - - const updated = source.replace(spec.oldSnippet, spec.newSnippet); - - if (updated === source) { - continue; - } - - await writeFile(filePath, updated, "utf8"); - patchedFiles.add(filePath); - console.log(`[postinstall] Applied ${spec.name} to ${filePath}`); - } - - if (!foundAnyTarget) { - console.log(`[postinstall] ${spec.name}: no target files found`); - } -} - -if (checkedFiles.size === 0) { - console.log("[postinstall] No federation-bridge files found for View activity parse-skip patch"); -} else if (patchedFiles.size === 0) { - console.log("[postinstall] inbox-skip-view-activity-parse patch already up to date"); -} else { - console.log( - `[postinstall] Patched inbox-skip-view-activity-parse in ${patchedFiles.size}/${checkedFiles.size} file(s)`, - ); -}