Files
indiekit-server/scripts/patch-ap-syndicate-dedup.mjs
Sven c0f847c96f
All checks were successful
Deploy Indiekit Server / deploy (push) Successful in 1m18s
fix(activitypub) dedup: query ap_activities for an existing outbound Create/Announce/Update
2026-04-01 14:38:12 +02:00

96 lines
3.4 KiB
JavaScript

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