From 881976f82162e627d5afacad9eec078dbd0daf39 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:08:39 +0100 Subject: [PATCH] fix: prevent silent webmention-sent data loss on fetch failure Two startup scripts: patch-webmention-sender-retry: patches the package controller so that when a post has no stored content and the live page fetch fails (page not yet deployed), the post is skipped instead of being permanently marked as webmention-sent with zero results. On the next poll the page will be live and webmentions will be sent correctly. patch-webmention-sender-reset-stale: one-time migration (guarded by a migrations collection entry) that resets all posts already incorrectly marked as webmention-sent with all-zero results, so they are retried on the next poll. Co-Authored-By: Claude Sonnet 4.6 --- .../patch-webmention-sender-reset-stale.mjs | 68 +++++++++++ scripts/patch-webmention-sender-retry.mjs | 109 ++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 scripts/patch-webmention-sender-reset-stale.mjs create mode 100644 scripts/patch-webmention-sender-retry.mjs diff --git a/scripts/patch-webmention-sender-reset-stale.mjs b/scripts/patch-webmention-sender-reset-stale.mjs new file mode 100644 index 00000000..7c9babce --- /dev/null +++ b/scripts/patch-webmention-sender-reset-stale.mjs @@ -0,0 +1,68 @@ +/** + * One-time migration: reset posts that were incorrectly marked as webmention-sent + * with empty results because the live page was not yet deployed when the poller fired. + * + * Runs at startup but only executes once (guarded by a migrations collection entry). + * After running, the patch-webmention-sender-retry.mjs code fix prevents recurrence. + */ + +import { MongoClient } from "mongodb"; +import config from "../indiekit.config.mjs"; + +const MIGRATION_ID = "webmention-sender-reset-stale-v1"; + +const mongodbUrl = config.application?.mongodbUrl; +if (!mongodbUrl) { + console.log("[patch] webmention-sender-reset-stale: no MongoDB URL, skipping"); + process.exit(0); +} + +const client = new MongoClient(mongodbUrl, { connectTimeoutMS: 5000 }); + +try { + await client.connect(); + const db = client.db(); + + // Check if this migration has already run + const migrations = db.collection("migrations"); + const alreadyRun = await migrations.findOne({ _id: MIGRATION_ID }); + + if (alreadyRun) { + console.log("[patch] webmention-sender-reset-stale: already run, skipping"); + process.exit(0); + } + + // Find posts marked as webmention-sent with all-zero results. + // These were silently marked by the bug (failed fetch → empty results). + const posts = db.collection("posts"); + const result = await posts.updateMany( + { + "properties.webmention-sent": true, + "properties.webmention-results.sent": 0, + "properties.webmention-results.failed": 0, + "properties.webmention-results.skipped": 0, + }, + { + $unset: { + "properties.webmention-sent": "", + "properties.webmention-results": "", + }, + }, + ); + + console.log( + `[patch] webmention-sender-reset-stale: reset ${result.modifiedCount} post(s) for retry`, + ); + + // Record that this migration has run + await migrations.insertOne({ + _id: MIGRATION_ID, + ranAt: new Date().toISOString(), + modifiedCount: result.modifiedCount, + }); +} catch (error) { + console.error(`[patch] webmention-sender-reset-stale: error — ${error.message}`); + // Non-fatal: don't block startup +} finally { + await client.close().catch(() => {}); +} diff --git a/scripts/patch-webmention-sender-retry.mjs b/scripts/patch-webmention-sender-retry.mjs new file mode 100644 index 00000000..a24991e6 --- /dev/null +++ b/scripts/patch-webmention-sender-retry.mjs @@ -0,0 +1,109 @@ +/** + * Patch @rmdes/indiekit-endpoint-webmention-sender controller to not silently + * mark posts as webmention-sent when the live page fetch fails. + * + * Root cause: when a post has no stored content (likes, bookmarks, reposts), + * the controller tries to fetch the published URL. If the fetch fails (page not + * yet deployed), it marks the post as webmention-sent with empty results — and + * it is never retried. This patch skips those posts instead so they are picked + * up on the next poll once the page is live. + */ + +import { access, readFile, writeFile } from "node:fs/promises"; + +const candidates = [ + "node_modules/@rmdes/indiekit-endpoint-webmention-sender/lib/controllers/webmention-sender.js", +]; + +const marker = "Page not yet available"; + +const oldSnippet = ` // If no content, try fetching the published page + let contentToProcess = postContent; + if (!contentToProcess) { + try { + const pageResponse = await fetch(postUrl); + if (pageResponse.ok) { + contentToProcess = await pageResponse.text(); + } + } catch (error) { + console.log(\`[webmention] Could not fetch \${postUrl}: \${error.message}\`); + } + } + + if (!contentToProcess) { + console.log(\`[webmention] No content to process for \${postUrl}\`); + await markWebmentionsSent(postsCollection, postUrl, { sent: [], failed: [], skipped: [] }); + continue; + }`; + +const newSnippet = ` // If no content, try fetching the published page + let contentToProcess = postContent; + let fetchFailed = false; + if (!contentToProcess) { + try { + const pageResponse = await fetch(postUrl); + if (pageResponse.ok) { + contentToProcess = await pageResponse.text(); + } else { + fetchFailed = true; + } + } catch (error) { + fetchFailed = true; + console.log(\`[webmention] Could not fetch \${postUrl}: \${error.message}\`); + } + } + + if (!contentToProcess) { + if (fetchFailed) { + // Page not yet available — skip and retry on next poll rather than + // permanently marking this post as sent with zero webmentions. + console.log(\`[webmention] Page not yet available for \${postUrl}, will retry next poll\`); + continue; + } + console.log(\`[webmention] No content to process for \${postUrl}\`); + await markWebmentionsSent(postsCollection, postUrl, { sent: [], failed: [], skipped: [] }); + continue; + }`; + +async function exists(filePath) { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +let filesChecked = 0; +let filesPatched = 0; + +for (const filePath of candidates) { + if (!(await exists(filePath))) { + continue; + } + + filesChecked += 1; + + const source = await readFile(filePath, "utf8"); + + if (source.includes(marker)) { + continue; + } + + if (!source.includes(oldSnippet)) { + console.log(`[patch] webmention-sender-retry: target snippet not found in ${filePath} (package updated?)`); + continue; + } + + const updated = source.replace(oldSnippet, newSnippet); + await writeFile(filePath, updated, "utf8"); + filesPatched += 1; +} + +if (filesChecked === 0) { + console.log("[patch] webmention-sender-retry: package file not found"); +} else if (filesPatched === 0) { + console.log("[patch] webmention-sender-retry: already applied"); +} else { + console.log(`[patch] webmention-sender-retry: patched ${filesPatched}/${filesChecked} file(s)`); +}