diff --git a/package.json b/package.json index a619a740..1330bc31 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-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-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-microsub-reader-ap-dispatch.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-prefill-url.mjs && node scripts/patch-microsub-feed-discovery.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-inbox-ignore-view-activity.mjs && node scripts/patch-inbox-skip-view-activity-parse.mjs && node scripts/patch-webmention-sender-content-scope.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-endpoint-posts-search-tags.mjs && node scripts/patch-syndicate-force-checked-default.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-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-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-microsub-reader-ap-dispatch.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-prefill-url.mjs && node scripts/patch-microsub-feed-discovery.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-webmention-sender-content-scope.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-endpoint-posts-search-tags.mjs && node scripts/patch-syndicate-force-checked-default.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-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-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-microsub-reader-ap-dispatch.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-prefill-url.mjs && node scripts/patch-microsub-feed-discovery.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-inbox-ignore-view-activity.mjs && node scripts/patch-inbox-skip-view-activity-parse.mjs && node scripts/patch-webmention-sender-content-scope.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-endpoint-posts-search-tags.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-ap-url-lookup-api.mjs && node scripts/patch-ap-inbox-raw-body-digest.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-posts-ai-fields.mjs && node scripts/patch-endpoint-posts-ai-cleanup.mjs && node scripts/patch-endpoint-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.mjs && node scripts/patch-micropub-ai-block-resync.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-endpoint-comments-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-microsub-reader-ap-dispatch.mjs && node scripts/patch-endpoint-blogroll-feeds-alias.mjs && node scripts/patch-endpoint-posts-uid-lookup.mjs && node scripts/patch-endpoint-posts-prefill-url.mjs && node scripts/patch-microsub-feed-discovery.mjs && node scripts/patch-conversations-bluesky-self-filter.mjs && node scripts/patch-conversations-bluesky-cursor-fix.mjs && node scripts/patch-webmention-sender-content-scope.mjs && node scripts/patch-endpoint-micropub-source-filter.mjs && node scripts/patch-endpoint-posts-search-tags.mjs && node scripts/patch-syndicate-force-checked-default.mjs && node scripts/patch-ap-url-lookup-api.mjs && node scripts/patch-ap-inbox-raw-body-digest.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-inbox-raw-body-digest.mjs b/scripts/patch-ap-inbox-raw-body-digest.mjs new file mode 100644 index 00000000..81b6dbb1 --- /dev/null +++ b/scripts/patch-ap-inbox-raw-body-digest.mjs @@ -0,0 +1,123 @@ +/** + * Patch: preserve raw body bytes through the AP inbox buffer guard so that + * Fedify's HTTP Signature Digest verification passes. + * + * Root cause: + * patch-inbox-skip-view-activity-parse.mjs buffers the request body for + * application/activity+json requests (needed to detect PeerTube View + * activities before Fedify parses them). It stores the parsed JSON in + * req.body. fromExpressRequest() then reconstructs the body for Fedify via + * JSON.stringify(req.body). + * + * Fedify 2.x verifies the HTTP Signature "Digest: SHA-256=..." header that + * Mastodon (and most other AP servers) include with every inbox POST. + * The digest is computed over the EXACT original request bytes. Re-encoding + * the body via JSON.stringify() produces different bytes (different key + * ordering, whitespace, Unicode escaping), so the digest check fails and + * Fedify silently rejects every inbound Like, Announce, and Create activity + * from Mastodon. The activity never reaches the inbox handlers and is never + * stored in ap_activities — so conversations/AP shows zero interactions. + * + * Fix (two changes to federation-bridge.js): + * + * 1. In createFedifyMiddleware buffer guard: after the for-await loop, store + * the original Buffer in req._rawBody before JSON-parsing it into req.body. + * + * 2. In fromExpressRequest: when req._rawBody is available, pass it directly + * to new Request() instead of JSON.stringify(req.body). This gives Fedify + * the original bytes so its SHA-256 digest check matches the Digest header. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const candidates = [ + "node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-activitypub/lib/federation-bridge.js", +]; + +const MARKER = "// raw body digest fix"; + +const patchSpecs = [ + // Patch A: store raw bytes in req._rawBody alongside req.body + { + name: "raw-body-store", + oldSnippet: ` try { + req.body = JSON.parse(Buffer.concat(_chunks).toString("utf8")); + } catch { + req.body = {}; + }`, + newSnippet: ` 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 = {}; + }`, + }, + + // Patch B: use req._rawBody in fromExpressRequest when available + { + name: "from-express-request-use-raw-body", + oldSnippet: ` // PeerTube activity+json body fix + if (ct.includes("application/json") || ct.includes("activity+json") || ct.includes("ld+json")) { + body = JSON.stringify(req.body); + }`, + 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 + }`, + }, +]; + +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; + let source = await readFile(filePath, "utf8"); + + if (source.includes(MARKER)) { + continue; + } + + let filePatched = false; + + for (const spec of patchSpecs) { + if (!source.includes(spec.oldSnippet)) { + console.log(`[postinstall] patch-ap-inbox-raw-body-digest: ${spec.name} snippet not found in ${filePath}`); + continue; + } + + source = source.replace(spec.oldSnippet, spec.newSnippet); + filePatched = true; + console.log(`[postinstall] Applied ${spec.name} to ${filePath}`); + } + + if (filePatched) { + await writeFile(filePath, source, "utf8"); + patched += 1; + } +} + +if (checked === 0) { + console.log("[postinstall] patch-ap-inbox-raw-body-digest: no target files found"); +} else if (patched === 0) { + console.log("[postinstall] patch-ap-inbox-raw-body-digest: already up to date"); +} else { + console.log(`[postinstall] patch-ap-inbox-raw-body-digest: patched ${patched}/${checked} file(s)`); +} diff --git a/scripts/patch-ap-url-lookup-api.mjs b/scripts/patch-ap-url-lookup-api.mjs new file mode 100644 index 00000000..a7d3bda0 --- /dev/null +++ b/scripts/patch-ap-url-lookup-api.mjs @@ -0,0 +1,188 @@ +/** + * Patch: add a public GET /api/ap-url endpoint to the ActivityPub endpoint. + * + * Problem: + * The "Also on fediverse" widget on blog post pages passes the blog post URL + * (e.g. https://blog.giersig.eu/replies/bd78a/) to the Mastodon + * authorize_interaction flow: + * https://{instance}/authorize_interaction?uri={blog-post-url} + * + * When the remote instance fetches that URI with Accept: application/activity+json, + * it may hit a static file server (nginx/Caddy) that returns HTML instead of + * AP JSON, causing the interaction to fail with "Could not connect to the given + * address" or a similar error. + * + * Fix: + * Add a public API route to the AP endpoint: + * GET /activitypub/api/ap-url?post={blog-post-url} + * + * This resolves the post in MongoDB, determines its object type (Note or Article), + * and returns the canonical Fedify-served AP object URL: + * { apUrl: "https://blog.giersig.eu/activitypub/objects/note/replies/bd78a/" } + * + * The "Also on fediverse" JS widget can then call this API and use the returned + * apUrl in the authorize_interaction redirect instead of the blog post URL. + * Fedify-served URLs (/activitypub/objects/…) are always proxied to Node.js and + * will reliably return AP JSON with correct content negotiation. + * + * The patch inserts the new route in the `routesPublic` getter of index.js, + * just before the closing `return router` statement. + */ + +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 MARKER = "// AP URL lookup endpoint"; + +const OLD_SNIPPET = ` router.all("/inbox", (req, res) => { + res + .status(405) + .set("Allow", "POST") + .type("application/activity+json") + .json({ + error: "Method Not Allowed", + message: "The shared inbox only accepts POST requests", + }); + }); + + return router; + } + + /** + * Authenticated admin routes — mounted at mountPath, behind IndieAuth. + */`; + +const NEW_SNIPPET = ` router.all("/inbox", (req, res) => { + res + .status(405) + .set("Allow", "POST") + .type("application/activity+json") + .json({ + error: "Method Not Allowed", + message: "The shared inbox only accepts POST requests", + }); + }); + + // AP URL lookup endpoint + // Public API: resolve a blog post URL → its Fedify-served AP object URL. + // GET /api/ap-url?post=https://blog.example.com/notes/foo/ + // Returns { apUrl: "https://blog.example.com/activitypub/objects/note/notes/foo/" } + // + // Use this in "Also on fediverse" widgets so that authorize_interaction + // uses a URL that is always routed to Node.js (never intercepted by a + // static file server), ensuring reliable AP content negotiation. + router.get("/api/ap-url", async (req, res) => { + try { + const postParam = req.query.post; + if (!postParam) { + return res.status(400).json({ error: "post parameter required" }); + } + + const { application } = req.app.locals; + const postsCollection = application.collections?.get("posts"); + + if (!postsCollection) { + return res.status(503).json({ error: "Database unavailable" }); + } + + const publicationUrl = (self._publicationUrl || application.url || "").replace(/\\/$/, ""); + + // Match with or without trailing slash + const postUrl = postParam.replace(/\\/$/, ""); + const post = await postsCollection.findOne({ + "properties.url": { $in: [postUrl, postUrl + "/"] }, + }); + + if (!post) { + return res.status(404).json({ error: "Post not found" }); + } + + // Draft and unlisted posts are not federated + if (post?.properties?.["post-status"] === "draft") { + return res.status(404).json({ error: "Post not found" }); + } + if (post?.properties?.visibility === "unlisted") { + return res.status(404).json({ error: "Post not found" }); + } + + // Determine the AP object type (mirrors jf2-to-as2.js logic) + const postType = post.properties?.["post-type"]; + const isArticle = postType === "article" && !!post.properties?.name; + const objectType = isArticle ? "article" : "note"; + + // Extract the path portion after the publication base URL + const resolvedUrl = (post.properties?.url || "").replace(/\\/$/, ""); + if (!resolvedUrl.startsWith(publicationUrl)) { + return res.status(500).json({ error: "Post URL does not match publication base" }); + } + const postPath = resolvedUrl.slice(publicationUrl.length).replace(/^\\//, ""); + + const mp = (self.options.mountPath || "").replace(/\\/$/, ""); + const apBase = publicationUrl; + const apUrl = \`\${apBase}\${mp}/objects/\${objectType}/\${postPath}\`; + + res.set("Cache-Control", "public, max-age=300"); + res.json({ apUrl }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); + + return router; + } + + /** + * Authenticated admin routes — mounted at mountPath, behind IndieAuth. + */`; + +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)) { + continue; + } + + if (!source.includes(OLD_SNIPPET)) { + console.log(`[postinstall] patch-ap-url-lookup-api: old snippet not found in ${filePath}`); + continue; + } + + const updated = source.replace(OLD_SNIPPET, NEW_SNIPPET); + + if (updated === source) { + continue; + } + + await writeFile(filePath, updated, "utf8"); + patched += 1; + console.log(`[postinstall] Applied patch-ap-url-lookup-api to ${filePath}`); +} + +if (checked === 0) { + console.log("[postinstall] patch-ap-url-lookup-api: no target files found"); +} else if (patched === 0) { + console.log("[postinstall] patch-ap-url-lookup-api: already up to date"); +} else { + console.log(`[postinstall] patch-ap-url-lookup-api: patched ${patched}/${checked} file(s)`); +}