diff --git a/scripts/patch-ap-syndication.mjs b/scripts/patch-ap-syndication.mjs new file mode 100644 index 00000000..eff241e4 --- /dev/null +++ b/scripts/patch-ap-syndication.mjs @@ -0,0 +1,156 @@ +/** + * Consolidated patch: AP syndication guards in syndicator.js. + * + * Absorbs: + * - patch-ap-syndicate-dedup (prevent double-posting) + * - patch-ap-syndicate-skip-checkin (skip location checkins) + * - patch-ap-syndicate-skip-draft (skip draft posts) + * - patch-ap-syndicate-skip-unlisted (skip unlisted posts) + * + * ORDER MATTERS: dedup → checkin → draft → unlisted + * checkin/draft/unlisted chain (each newSnippet is the next oldSnippet). + * dedup patches a separate anchor and must run first (before checkin + * rewrites the surrounding context). + */ + +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; } +} + +/** + * Apply a single patch to a file. + * Returns: "applied" | "already_applied" | "snippet_not_found" | "file_not_found" + */ +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"; +} + +const SCRIPT = "patch-ap-syndication"; + +const PATCHES = [ + // ORDER: dedup → checkin → draft → unlisted (each builds on prior output) + + { + name: "dedup", + files: apPath("lib/syndicator.js"), + marker: "// [patch] ap-syndicate-dedup", + oldSnippet: ` try { + const actorUrl = plugin._getActorUrl();`, + newSnippet: ` // Dedup: skip re-federation if we've already sent an activity for this URL. // [patch] ap-syndicate-dedup + // ap_activities is the authoritative record of "already federated". + try { + const existingActivity = await plugin._collections.ap_activities?.findOne({ + direction: "outbound", + type: { $in: ["Create", "Announce", "Update"] }, + objectUrl: properties.url, + }); + if (existingActivity) { + console.info(\`[ActivityPub] Skipping duplicate syndication for \${properties.url} — already sent (\${existingActivity.type})\`); + return properties.url || undefined; + } + } catch { /* DB unavailable — proceed */ } + + try { + const actorUrl = plugin._getActorUrl();`, + }, + + { + name: "skip-checkin", + files: apPath("lib/syndicator.js"), + marker: "// [patch] ap-syndicate-skip-checkin", + oldSnippet: ` async syndicate(properties) { + if (!plugin._federation) { + return undefined; + }`, + newSnippet: ` async syndicate(properties) { + if (!plugin._federation) { + return undefined; + } + + // 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; + }`, + }, + + { + name: "skip-draft", + files: apPath("lib/syndicator.js"), + marker: "// [patch] ap-syndicate-skip-draft", + oldSnippet: ` // 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; + }`, + newSnippet: ` // 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. // [patch] ap-syndicate-skip-draft + if (properties["post-status"] === "draft") { + console.info(\`[ActivityPub] Skipping syndication for draft post: \${properties.url}\`); + return undefined; + }`, + }, + + { + name: "skip-unlisted", + files: apPath("lib/syndicator.js"), + marker: "// [patch] ap-syndicate-skip-unlisted", + oldSnippet: ` // 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; + }`, + newSnippet: ` // 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. // [patch] ap-syndicate-skip-unlisted + if (properties.visibility === "unlisted") { + console.info(\`[ActivityPub] Skipping syndication for unlisted post: \${properties.url}\`); + return undefined; + }`, + }, +]; + +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`); +} +console.log(`[postinstall] ${SCRIPT}: done (${total} patch(es) applied)`);