/** * 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)`); }