From 421f119d140927e1333ca0b9a6c32645646b8c7d Mon Sep 17 00:00:00 2001 From: Sven Date: Sun, 12 Apr 2026 13:59:35 +0200 Subject: [PATCH] feat: consolidated patch-ap-mastodon-misc (compose, og-image, interactions, inbox) Absorbs patch-ap-compose-default-checked, patch-ap-og-image, patch-ap-repost-announce-fix, patch-ap-interactions-send-guard, patch-ap-interactions-cleanup-preserve, patch-ap-interactions-accounts-uid, patch-inbox-ignore-view-activity, and patch-inbox-skip-view-activity-parse into a single script using the shared applyPatch helper pattern. Added inbox-ignore-view-activity-handler-v2 variant to handle the evolved upstream inbox-listeners.js that uses enqueueActivity instead of try/catch. Co-Authored-By: Claude Sonnet 4.6 --- scripts/patch-ap-mastodon-misc.mjs | 571 +++++++++++++++++++++++++++++ 1 file changed, 571 insertions(+) create mode 100644 scripts/patch-ap-mastodon-misc.mjs diff --git a/scripts/patch-ap-mastodon-misc.mjs b/scripts/patch-ap-mastodon-misc.mjs new file mode 100644 index 00000000..ba0f138b --- /dev/null +++ b/scripts/patch-ap-mastodon-misc.mjs @@ -0,0 +1,571 @@ +/** + * Consolidated patch: AP compose, OG image, repost announce, interactions + * send guard, interactions cleanup preserve, interactions accounts uid, + * inbox ignore View activity, inbox skip View activity parse. + * + * Absorbs: + * - patch-ap-compose-default-checked + * - patch-ap-og-image + * - patch-ap-repost-announce-fix + * - patch-ap-interactions-send-guard + * - patch-ap-interactions-cleanup-preserve + * - patch-ap-interactions-accounts-uid + * - patch-inbox-ignore-view-activity + * - patch-inbox-skip-view-activity-parse + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const AP_BASE = "@rmdes/indiekit-endpoint-activitypub"; +const AP_ROOTS = [ + `node_modules/${AP_BASE}`, + `node_modules/@indiekit/indiekit/node_modules/${AP_BASE}`, +]; + +function apPath(rel) { + return AP_ROOTS.map(r => `${r}/${rel}`); +} + +async function fileExists(p) { + try { await access(p); return true; } catch { return false; } +} + +async function applyPatch(filePath, marker, oldSnippet, newSnippet) { + if (!(await fileExists(filePath))) return "file_not_found"; + const src = await readFile(filePath, "utf8"); + if (src.includes(marker)) return "already_applied"; + if (!src.includes(oldSnippet)) return "snippet_not_found"; + await writeFile(filePath, src.replace(oldSnippet, newSnippet), "utf8"); + return "applied"; +} + +// applyRegexPatch: like applyPatch but uses a regex instead of a literal string match. +// oldRegex must be a RegExp (tested + replaced against src). +async function applyRegexPatch(filePath, marker, oldRegex, newSnippet) { + if (!(await fileExists(filePath))) return "file_not_found"; + const src = await readFile(filePath, "utf8"); + if (src.includes(marker)) return "already_applied"; + if (!oldRegex.test(src)) return "snippet_not_found"; + await writeFile(filePath, src.replace(oldRegex, newSnippet), "utf8"); + return "applied"; +} + +const SCRIPT = "patch-ap-mastodon-misc"; + +// ── patch-ap-og-image: regex-based, two replacements per file ───────────────── +// This patch is handled separately below the main loop because it uses regexes +// and applies two independent replacements to the same file. + +const OG_MARKER = "// og-image-v2"; +const OG_CANDIDATES = apPath("lib/jf2-to-as2.js"); + +const CN_BLOCK_RE = + / const og(?:Slug|Match) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:Slug|Match)\) \{[\s\S]*?\n \}/; + +const AS2_BLOCK_RE = + / const og(?:SlugF|MatchF) = postUrl && postUrl\.match\([^\n]+\n if \(og(?:SlugF|MatchF)\) \{[\s\S]*?\n \}/; + +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 + }); + }`; + +// ── Standard patches (string-replace) ───────────────────────────────────────── + +const PATCHES = [ + // ── patch-ap-compose-default-checked ────────────────────────────────────── + { + name: "ap-compose-default-checked", + files: [ + "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/controllers/compose.js", + ], + marker: "// [patch] ap-compose-default-checked", + oldSnippet: ` // 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"; + }`, + newSnippet: ` // Pre-check syndication targets based on their configured checked state // [patch] ap-compose-default-checked + for (const target of syndicationTargets) { // [patch] ap-compose-default-checked + target.defaultChecked = target.checked === true; // [patch] ap-compose-default-checked + } // [patch] ap-compose-default-checked`, + }, + + // ── patch-ap-repost-announce-fix ────────────────────────────────────────── + { + name: "ap-repost-announce-fix", + files: apPath("lib/jf2-to-as2.js"), + marker: "// [patch] ap-repost-announce-fix", + oldSnippet: ` // 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. + }`, + newSnippet: ` // 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)) { // [patch] ap-repost-announce-fix + 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. + }`, + }, + + // ── patch-ap-interactions-send-guard: likePost ──────────────────────────── + { + name: "ap-interactions-send-guard-like", + files: [ + "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", + ], + marker: "// [patch] ap-interactions-send-guard", + oldSnippet: ` if (recipient) { + await ctx.sendActivity({ identifier: handle }, recipient, like, { + orderingKey: targetUrl, + }); + }`, + newSnippet: ` if (recipient) { + try { // [patch] ap-interactions-send-guard + await ctx.sendActivity({ identifier: handle }, recipient, like, { + orderingKey: targetUrl, + }); + } catch { /* delivery failed — interaction still recorded locally */ } + }`, + }, + + // ── patch-ap-interactions-send-guard: boostPost ─────────────────────────── + { + name: "ap-interactions-send-guard-boost", + files: [ + "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", + ], + marker: "// [patch] ap-interactions-send-guard", + oldSnippet: ` // Send to followers + await ctx.sendActivity({ identifier: handle }, "followers", announce, { + preferSharedInbox: true, + syncCollection: true, + orderingKey: targetUrl, + });`, + newSnippet: ` // Send to followers + try { // [patch] ap-interactions-send-guard + await ctx.sendActivity({ identifier: handle }, "followers", announce, { + preferSharedInbox: true, + syncCollection: true, + orderingKey: targetUrl, + }); + } catch { /* delivery failed — interaction still recorded locally */ }`, + }, + + // ── patch-ap-interactions-cleanup-preserve ──────────────────────────────── + { + name: "ap-interactions-cleanup-preserve", + files: apPath("lib/timeline-cleanup.js"), + marker: "// [patch] ap-interactions-cleanup-preserve", + oldSnippet: ` 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; + }`, + newSnippet: ` const removedUids = toDelete.map((item) => item.uid).filter(Boolean); + + // Preserve items the user has interacted with (liked, bookmarked, boosted). // [patch] ap-interactions-cleanup-preserve + // 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; + }`, + }, + + // ── patch-ap-interactions-accounts-uid ─────────────────────────────────── + { + name: "ap-interactions-accounts-uid", + files: [ + "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", + ], + marker: "// [patch] ap-interactions-accounts-uid", + oldSnippet: ` 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); + } + }`, + newSnippet: ` const urlToUid = new Map(); // [patch] ap-interactions-accounts-uid + for (const i of items) { + if (i.uid) { + urlToUid.set(i.uid, i.uid); + if (i.url && i.url !== i.uid) urlToUid.set(i.url, i.uid); + } + } + const lookupUrls = [...urlToUid.keys()]; + 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); + } + }`, + }, + + // ── patch-inbox-ignore-view-activity: import ────────────────────────────── + { + name: "inbox-ignore-view-activity-import", + files: apPath("lib/inbox-listeners.js"), + marker: "// View imported", + oldSnippet: ` Undo, + Update, +} from "@fedify/fedify/vocab";`, + newSnippet: ` Undo, + Update, + View, // View imported +} from "@fedify/fedify/vocab";`, + }, + + // ── patch-inbox-ignore-view-activity: handler ──────────────────────────── + // Two variants: one for files with the console.warn block (original upstream), + // one for files with the simpler enqueueActivity-only structure (evolved upstream). + { + name: "inbox-ignore-view-activity-handler", + files: apPath("lib/inbox-listeners.js"), + 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 () => {}); +}`, + }, + { + name: "inbox-ignore-view-activity-handler-v2", + files: apPath("lib/inbox-listeners.js"), + marker: "// PeerTube View handler", + oldSnippet: ` await enqueueActivity(collections, { + activityType: "Flag", + actorUrl, + rawJson: await flag.toJsonLd(), + }); + }); +}`, + newSnippet: ` await enqueueActivity(collections, { + activityType: "Flag", + actorUrl, + rawJson: await flag.toJsonLd(), + }); + }) + // ── View (PeerTube watch) ───────────────────────────────────────────── + // PeerTube broadcasts View (WatchAction) activities to all followers + // whenever someone watches a video. Fedify has no built-in handler for + // this type, producing noisy "Unsupported activity type" log errors. + // Silently accept and discard. // PeerTube View handler + .on(View, async () => {}); +}`, + }, + + // ── patch-inbox-skip-view-activity-parse: fromExpressRequest fix ────────── + { + name: "from-express-request-activity-json-fix", + files: apPath("lib/federation-bridge.js"), + 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-inbox-skip-view-activity-parse: upgrade v1 raw-body fix ───────── + { + name: "from-express-request-raw-body-fix", + files: apPath("lib/federation-bridge.js"), + 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-inbox-skip-view-activity-parse: middleware buffer _rawBody fix ── + { + name: "inbox-buffer-raw-body-fix", + files: apPath("lib/federation-bridge.js"), + 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-inbox-skip-view-activity-parse: v2 guard replacing v1 ─────────── + { + name: "inbox-skip-view-activity-parse-v2", + files: apPath("lib/federation-bridge.js"), + 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-inbox-skip-view-activity-parse: v2 guard on fresh file ────────── + { + name: "inbox-skip-view-activity-parse-v2-fresh", + files: apPath("lib/federation-bridge.js"), + 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);`, + }, +]; + +// ── Run standard patches ─────────────────────────────────────────────────────── + +let total = 0; +for (const p of PATCHES) { + let done = false; + for (const f of p.files) { + const r = await applyPatch(f, p.marker, p.oldSnippet, p.newSnippet); + if (r === "applied") { + console.log(`[postinstall] ${SCRIPT}: applied ${p.name} to ${f}`); + total++; done = true; break; + } else if (r === "already_applied") { + console.log(`[postinstall] ${SCRIPT}: ${p.name} already applied in ${f}`); + done = true; break; + } else if (r === "snippet_not_found") { + console.warn(`[postinstall] ${SCRIPT}: ${p.name} — snippet not found in ${f}, skipping`); + } + } + if (!done) console.log(`[postinstall] ${SCRIPT}: ${p.name} — no target file found`); +} + +// ── Run og-image patch (regex-based, two replacements per file) ─────────────── + +{ + let ogDone = false; + for (const f of OG_CANDIDATES) { + if (!(await fileExists(f))) continue; + const src = await readFile(f, "utf8"); + if (src.includes(OG_MARKER)) { + console.log(`[postinstall] ${SCRIPT}: ap-og-image already applied in ${f}`); + ogDone = true; break; + } + let updated = src; + let changed = false; + if (CN_BLOCK_RE.test(updated)) { + updated = updated.replace(CN_BLOCK_RE, NEW_CN); + changed = true; + } else { + console.warn(`[postinstall] ${SCRIPT}: ap-og-image — jf2ToActivityStreams OG block not found in ${f}`); + } + if (AS2_BLOCK_RE.test(updated)) { + updated = updated.replace(AS2_BLOCK_RE, NEW_AS2); + changed = true; + } else { + console.warn(`[postinstall] ${SCRIPT}: ap-og-image — jf2ToAS2Activity OG block not found in ${f}`); + } + if (changed && updated !== src) { + await writeFile(f, updated, "utf8"); + console.log(`[postinstall] ${SCRIPT}: applied ap-og-image to ${f}`); + total++; ogDone = true; break; + } + } + if (!ogDone) console.log(`[postinstall] ${SCRIPT}: ap-og-image — no target file found or no changes`); +} + +console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);