From 2a674c8eea1ebde30b904bba39870ea50cbbad73 Mon Sep 17 00:00:00 2001 From: Sven Date: Sun, 22 Mar 2026 11:10:23 +0100 Subject: [PATCH] feat(draft): prevent draft posts from being syndicated or federated Add two new patches: - patch-ap-skip-draft-syndication: guards the AP syndicator's syndicate() method against draft posts (mirrors existing unlisted visibility check) - patch-microsub-compose-draft-guard: forwards post-status from microsub compose to Micropub and suppresses mp-syndicate-to targets for drafts The syndicate endpoint DB queries already filter post-status != draft (patch-federation-unlisted-guards). These patches add defence in depth at the AP syndicator and at the microsub compose submission layer. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 4 +- scripts/patch-ap-skip-draft-syndication.mjs | 109 ++++++++++++++ .../patch-microsub-compose-draft-guard.mjs | 139 ++++++++++++++++++ 3 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 scripts/patch-ap-skip-draft-syndication.mjs create mode 100644 scripts/patch-microsub-compose-draft-guard.mjs diff --git a/package.json b/package.json index 67fc3d18..44c1c6d4 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-ap-url-lookup-api.mjs && node scripts/patch-ap-allow-private-address.mjs && node scripts/patch-ap-like-note-dispatcher.mjs && node scripts/patch-ap-like-activity-id.mjs && node scripts/patch-ap-like-activity-dispatcher.mjs && node scripts/patch-ap-url-lookup-api-like.mjs && node scripts/patch-ap-repost-commentary.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-ap-remove-federation-diag.mjs", - "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-ap-url-lookup-api.mjs && node scripts/patch-ap-allow-private-address.mjs && node scripts/patch-ap-like-note-dispatcher.mjs && node scripts/patch-ap-like-activity-id.mjs && node scripts/patch-ap-like-activity-dispatcher.mjs && node scripts/patch-ap-url-lookup-api-like.mjs && node scripts/patch-ap-repost-commentary.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-ap-remove-federation-diag.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", + "postinstall": "xattr -w com.apple.fileprovider.ignore#P 1 node_modules 2>/dev/null || true && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-ap-url-lookup-api.mjs && node scripts/patch-ap-allow-private-address.mjs && node scripts/patch-ap-like-note-dispatcher.mjs && node scripts/patch-ap-like-activity-id.mjs && node scripts/patch-ap-like-activity-dispatcher.mjs && node scripts/patch-ap-url-lookup-api-like.mjs && node scripts/patch-ap-repost-commentary.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-ap-remove-federation-diag.mjs && node scripts/patch-ap-skip-draft-syndication.mjs", + "serve": "export NODE_ENV=${NODE_ENV:-production} INDIEKIT_DEBUG=${INDIEKIT_DEBUG:-0} && node scripts/preflight-production-security.mjs && node scripts/preflight-mongo-connection.mjs && node scripts/preflight-activitypub-rsa-key.mjs && node scripts/preflight-activitypub-profile-urls.mjs && node scripts/patch-lightningcss.mjs && node scripts/patch-endpoint-media-scope.mjs && node scripts/patch-endpoint-media-sharp-runtime.mjs && node scripts/patch-frontend-sharp-runtime.mjs && node scripts/patch-endpoint-files-upload-route.mjs && node scripts/patch-endpoint-files-upload-locales.mjs && node scripts/patch-endpoint-activitypub-locales.mjs && node scripts/patch-endpoint-homepage-locales.mjs && node scripts/patch-endpoint-homepage-identity-defaults.mjs && node scripts/patch-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-locales.mjs && node scripts/patch-endpoint-posts-locales.mjs && node scripts/patch-endpoint-conversations-locales.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-indiekit-routes-rate-limits.mjs && node scripts/patch-indiekit-error-production-stack.mjs && node scripts/patch-indieauth-devmode-guard.mjs && node scripts/patch-listening-endpoint-runtime-guards.mjs && node scripts/patch-endpoint-github-changelog-categories.mjs && node scripts/patch-endpoint-github-contributions-log.mjs && node scripts/patch-microsub-reader-ap-dispatch.mjs && node scripts/patch-microsub-compose-draft-guard.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-syndicate-normalize-syndication-array.mjs && node scripts/patch-ap-url-lookup-api.mjs && node scripts/patch-ap-allow-private-address.mjs && node scripts/patch-ap-like-note-dispatcher.mjs && node scripts/patch-ap-like-activity-id.mjs && node scripts/patch-ap-like-activity-dispatcher.mjs && node scripts/patch-ap-url-lookup-api-like.mjs && node scripts/patch-ap-repost-commentary.mjs && node scripts/patch-endpoint-posts-fetch-diagnostic.mjs && node scripts/patch-micropub-fetch-internal-url.mjs && node scripts/patch-webmention-sender-hentry-syntax.mjs && node scripts/patch-webmention-sender-retry.mjs && node scripts/patch-webmention-sender-livefetch.mjs && node scripts/patch-webmention-sender-empty-details.mjs && node scripts/patch-bluesky-syndicator-internal-url.mjs && node scripts/patch-ap-remove-federation-diag.mjs && node scripts/patch-ap-skip-draft-syndication.mjs && node node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/scripts/patch-ap-skip-draft-syndication.mjs b/scripts/patch-ap-skip-draft-syndication.mjs new file mode 100644 index 00000000..51869106 --- /dev/null +++ b/scripts/patch-ap-skip-draft-syndication.mjs @@ -0,0 +1,109 @@ +/** + * 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-microsub-compose-draft-guard.mjs b/scripts/patch-microsub-compose-draft-guard.mjs new file mode 100644 index 00000000..6c3a00ef --- /dev/null +++ b/scripts/patch-microsub-compose-draft-guard.mjs @@ -0,0 +1,139 @@ +/** + * Patch: honour post-status in the microsub compose submitCompose handler. + * + * When a post is submitted via the microsub compose form with + * post-status: draft: + * 1. Forward the post-status to Micropub so the post is saved as a draft. + * 2. Suppress all mp-syndicate-to targets — draft posts must never be + * syndicated (not to Mastodon, Bluesky, or ActivityPub). + * + * The syndicate endpoint already filters out drafts at the DB-query level + * (patch-federation-unlisted-guards), and the AP syndicator has its own + * guard (patch-ap-skip-draft-syndication), but preventing syndication + * targets from being stored in the first place is the cleanest approach. + */ +import { access, readFile, writeFile } from "node:fs/promises"; + +const candidates = [ + "node_modules/@rmdes/indiekit-endpoint-microsub/lib/controllers/reader.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-microsub/lib/controllers/reader.js", +]; + +const patchSpecs = [ + { + name: "microsub-compose-extract-post-status", + oldSnippet: [ + ` const syndicateTo = request.body["mp-syndicate-to"];`, + ``, + ` // Debug logging`, + ` console.info(`, + ` "[Microsub] submitCompose request.body:",`, + ` JSON.stringify(request.body),`, + ` );`, + ` console.info("[Microsub] Extracted values:", {`, + ` content,`, + ` inReplyTo,`, + ` likeOf,`, + ` repostOf,`, + ` bookmarkOf,`, + ` syndicateTo,`, + ` });`, + ].join("\n"), + newSnippet: [ + ` const syndicateTo = request.body["mp-syndicate-to"];`, + ` const postStatus = request.body["post-status"];`, + ` const isDraft = postStatus === "draft";`, + ``, + ` // Debug logging`, + ` console.info(`, + ` "[Microsub] submitCompose request.body:",`, + ` JSON.stringify(request.body),`, + ` );`, + ` console.info("[Microsub] Extracted values:", {`, + ` content,`, + ` inReplyTo,`, + ` likeOf,`, + ` repostOf,`, + ` bookmarkOf,`, + ` syndicateTo,`, + ` postStatus,`, + ` });`, + ].join("\n"), + }, + { + name: "microsub-compose-draft-suppresses-syndication", + oldSnippet: [ + ` // Add syndication targets`, + ` if (syndicateTo) {`, + ` const targets = Array.isArray(syndicateTo) ? syndicateTo : [syndicateTo];`, + ` for (const target of targets) {`, + ` micropubData.append("mp-syndicate-to", target);`, + ` }`, + ` }`, + ].join("\n"), + newSnippet: [ + ` // Set post status (e.g. draft) — must be appended before syndication logic`, + ` if (postStatus) {`, + ` micropubData.append("post-status", postStatus);`, + ` }`, + ``, + ` // Add syndication targets — suppressed entirely for draft posts`, + ` if (syndicateTo && !isDraft) {`, + ` const targets = Array.isArray(syndicateTo) ? syndicateTo : [syndicateTo];`, + ` for (const target of targets) {`, + ` micropubData.append("mp-syndicate-to", target);`, + ` }`, + ` }`, + ].join("\n"), + }, +]; + +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) { + for (const filePath of candidates) { + if (!(await exists(filePath))) { + continue; + } + + checkedFiles.add(filePath); + + const source = await readFile(filePath, "utf8"); + + if (source.includes(spec.newSnippet)) { + // Already patched + continue; + } + + if (!source.includes(spec.oldSnippet)) { + console.warn( + `[postinstall] Skipping ${spec.name} patch for ${filePath}: upstream format changed`, + ); + continue; + } + + const updated = source.replace(spec.oldSnippet, spec.newSnippet); + await writeFile(filePath, updated, "utf8"); + patchedFiles.add(filePath); + } +} + +if (checkedFiles.size === 0) { + console.log("[postinstall] No microsub reader files found for draft guard patch"); +} else if (patchedFiles.size === 0) { + console.log("[postinstall] microsub compose draft guard already applied"); +} else { + console.log( + `[postinstall] Patched microsub compose draft guard in ${patchedFiles.size} file(s)`, + ); +}