From ddae0276c705297db1d27fd6440bf66af8944750 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Sun, 8 Mar 2026 17:33:47 +0100 Subject: [PATCH] chore(patches): add mastodon disconnect flow and runtime guards --- README.md | 2 +- package.json | 4 +- ...atch-conversations-mastodon-disconnect.mjs | 325 ++++++++++++++++++ ...atch-listening-endpoint-runtime-guards.mjs | 28 ++ 4 files changed, 356 insertions(+), 3 deletions(-) create mode 100644 scripts/patch-conversations-mastodon-disconnect.mjs diff --git a/README.md b/README.md index 79ea580f..69ce73ea 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# indiekit-blog +# Indieweb/kit Blog Server ## Admin login diff --git a/package.json b/package.json index c69ace13..a6d8e489 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "postinstall": "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-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-frontend-serviceworker-file.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", - "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-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-frontend-serviceworker-file.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 node_modules/@indiekit/indiekit/bin/cli.js serve --config indiekit.config.mjs", + "postinstall": "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-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.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", + "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-federation-unlisted-guards.mjs && node scripts/patch-endpoint-micropub-where-note-visibility.mjs && node scripts/patch-frontend-serviceworker-file.mjs && node scripts/patch-conversations-collection-guards.mjs && node scripts/patch-conversations-mastodon-disconnect.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 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-conversations-mastodon-disconnect.mjs b/scripts/patch-conversations-mastodon-disconnect.mjs new file mode 100644 index 00000000..9353487e --- /dev/null +++ b/scripts/patch-conversations-mastodon-disconnect.mjs @@ -0,0 +1,325 @@ +import { access, readFile, writeFile } from "node:fs/promises"; + +const conversationsIndexCandidates = [ + "node_modules/@rmdes/indiekit-endpoint-conversations/index.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-conversations/index.js", +]; + +const conversationsControllerCandidates = [ + "node_modules/@rmdes/indiekit-endpoint-conversations/lib/controllers/conversations.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-conversations/lib/controllers/conversations.js", +]; + +const conversationsSchedulerCandidates = [ + "node_modules/@rmdes/indiekit-endpoint-conversations/lib/polling/scheduler.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-conversations/lib/polling/scheduler.js", +]; + +const conversationsViewCandidates = [ + "node_modules/@rmdes/indiekit-endpoint-conversations/views/conversations.njk", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-conversations/views/conversations.njk", +]; + +const patchSpecs = [ + { + name: "conversations-index-mastodon-disconnect-routes", + candidates: conversationsIndexCandidates, + oldSnippet: ` // Manual poll trigger (admin only) + router.post("/poll", conversationsController.triggerPoll); + + return router;`, + newSnippet: ` // Manual poll trigger (admin only) + router.post("/poll", conversationsController.triggerPoll); + router.post("/mastodon/logout", conversationsController.logoutMastodon); + router.post("/mastodon/reconnect", conversationsController.reconnectMastodon); + + return router;`, + }, + { + name: "conversations-dashboard-connection-state", + candidates: conversationsControllerCandidates, + marker: "const connectionState = {", + oldSnippet: ` // Get stats + const totalItems = await getConversationCount(application);`, + newSnippet: ` const connectionState = { + mastodonEnabled: + !!config.mastodonEnabled && !pollState?.mastodon_disabled, + blueskyEnabled: !!config.blueskyEnabled, + activitypubEnabled: !!config.activitypubEnabled, + }; + + // Get stats + const totalItems = await getConversationCount(application);`, + }, + { + name: "conversations-dashboard-render-connection-state", + candidates: conversationsControllerCandidates, + oldSnippet: ` config, + pollState, + totalItems,`, + newSnippet: ` config, + pollState, + connectionState, + totalItems,`, + }, + { + name: "conversations-dashboard-error-render-connection-state", + candidates: conversationsControllerCandidates, + oldSnippet: ` config: {}, + totalItems: 0,`, + newSnippet: ` config: {}, + connectionState: { + mastodonEnabled: false, + blueskyEnabled: false, + activitypubEnabled: false, + }, + totalItems: 0,`, + }, + { + name: "conversations-status-mastodon-disabled-flag", + candidates: conversationsControllerCandidates, + oldSnippet: ` const totalItems = await getConversationCount(application); + + response.json({ + status: "ok", + mastodon: { + enabled: !!config.mastodonEnabled,`, + newSnippet: ` const totalItems = await getConversationCount(application); + const mastodonEnabled = + !!config.mastodonEnabled && !pollState?.mastodon_disabled; + + response.json({ + status: "ok", + mastodon: { + enabled: mastodonEnabled, + disabledByAdmin: !!pollState?.mastodon_disabled,`, + }, + { + name: "conversations-controller-mastodon-disconnect-handlers", + candidates: conversationsControllerCandidates, + marker: "async function logoutMastodon(request, response)", + oldSnippet: `/** + * Ingest a webmention`, + newSnippet: `/** + * Disable Mastodon polling (dashboard logout/disconnect action) + * POST /conversations/mastodon/logout + */ +async function logoutMastodon(request, response) { + const { application } = request.app.locals; + const config = application?.conversations || {}; + const stateCollection = application?.collections?.get("conversation_state"); + + try { + if (stateCollection) { + await stateCollection.findOneAndUpdate( + { _id: "poll_cursors" }, + { + $set: { + mastodon_disabled: true, + mastodon_last_error: null, + mastodon_last_poll: new Date().toISOString(), + mastodon_last_disabled_at: new Date().toISOString(), + }, + $unset: { + mastodon_since_id: "", + }, + }, + { upsert: true }, + ); + } + + response.redirect((config.mountPath || "/conversations") + "?mastodon=logged_out"); + } catch (error) { + console.error("[Conversations] Mastodon logout error:", error.message); + response.redirect( + (config.mountPath || "/conversations") + "?error=mastodon_logout_failed", + ); + } +} + +/** + * Re-enable Mastodon polling after dashboard disconnect + * POST /conversations/mastodon/reconnect + */ +async function reconnectMastodon(request, response) { + const { application } = request.app.locals; + const config = application?.conversations || {}; + const stateCollection = application?.collections?.get("conversation_state"); + + try { + if (stateCollection) { + await stateCollection.findOneAndUpdate( + { _id: "poll_cursors" }, + { + $unset: { + mastodon_disabled: "", + }, + $set: { + mastodon_last_error: null, + mastodon_last_poll: new Date().toISOString(), + }, + }, + { upsert: true }, + ); + } + + response.redirect((config.mountPath || "/conversations") + "?mastodon=reconnected"); + } catch (error) { + console.error("[Conversations] Mastodon reconnect error:", error.message); + response.redirect( + (config.mountPath || "/conversations") + "?error=mastodon_reconnect_failed", + ); + } +} + +/** + * Ingest a webmention`, + }, + { + name: "conversations-controller-export-disconnect-handlers", + candidates: conversationsControllerCandidates, + oldSnippet: ` triggerPoll, + ingest,`, + newSnippet: ` triggerPoll, + logoutMastodon, + reconnectMastodon, + ingest,`, + }, + { + name: "conversations-scheduler-mastodon-disabled-check", + candidates: conversationsSchedulerCandidates, + oldSnippet: ` const mastodonToken = process.env.MASTODON_ACCESS_TOKEN; + const hasMastodon = mastodonUrl && mastodonToken;`, + newSnippet: ` const mastodonToken = process.env.MASTODON_ACCESS_TOKEN; + const mastodonDisabled = state.mastodon_disabled === true; + const hasMastodon = !mastodonDisabled && mastodonUrl && mastodonToken;`, + }, + { + name: "conversations-scheduler-mastodon-403-backoff", + candidates: conversationsSchedulerCandidates, + oldSnippet: ` if (error.status === 429 || error.status === 401) {`, + newSnippet: ` if (error.status === 429 || error.status === 401 || error.status === 403) {`, + }, + { + name: "conversations-view-mastodon-connection-state", + candidates: conversationsViewCandidates, + oldSnippet: "config.mastodonEnabled", + newSnippet: "connectionState.mastodonEnabled", + replaceAll: true, + }, + { + name: "conversations-view-bluesky-connection-state", + candidates: conversationsViewCandidates, + oldSnippet: "config.blueskyEnabled", + newSnippet: "connectionState.blueskyEnabled", + replaceAll: true, + }, + { + name: "conversations-view-activitypub-connection-state", + candidates: conversationsViewCandidates, + oldSnippet: "config.activitypubEnabled", + newSnippet: "connectionState.activitypubEnabled", + replaceAll: true, + }, + { + name: "conversations-view-mastodon-logout-button", + candidates: conversationsViewCandidates, + marker: "action=\"{{ baseUrl }}/mastodon/logout\"", + oldSnippet: `

+ {{ platformCounts.mastodon or 0 }} {{ __("conversations.dashboard.itemsCollected") }} +

`, + newSnippet: `

+ {{ platformCounts.mastodon or 0 }} {{ __("conversations.dashboard.itemsCollected") }} +

+
+ +
`, + }, + { + name: "conversations-view-mastodon-disabled-state", + candidates: conversationsViewCandidates, + marker: "Mastodon polling is disconnected for this dashboard.", + oldSnippet: ` {% else %} +

+ {{ __("conversations.dashboard.mastodonHint") }} +

+ {% endif %}`, + newSnippet: ` {% else %} + {% if pollState and pollState.mastodon_disabled %} +

+ Mastodon polling is disconnected for this dashboard. +

+
+ +
+ {% else %} +

+ {{ __("conversations.dashboard.mastodonHint") }} +

+ {% endif %} + {% endif %}`, + }, +]; + +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) { + let foundAnyTarget = false; + + for (const filePath of spec.candidates) { + if (!(await exists(filePath))) { + continue; + } + + foundAnyTarget = true; + checkedFiles.add(filePath); + + const source = await readFile(filePath, "utf8"); + + if (spec.marker && source.includes(spec.marker)) { + continue; + } + + if (!source.includes(spec.oldSnippet)) { + continue; + } + + let updated; + if (spec.replaceAll) { + updated = source.split(spec.oldSnippet).join(spec.newSnippet); + } else { + updated = source.replace(spec.oldSnippet, spec.newSnippet); + } + + if (updated === source) { + continue; + } + + await writeFile(filePath, updated, "utf8"); + patchedFiles.add(filePath); + } + + if (!foundAnyTarget) { + console.log(`[postinstall] ${spec.name}: no target files found`); + } +} + +if (checkedFiles.size === 0) { + console.log("[postinstall] No conversations mastodon disconnect files found"); +} else if (patchedFiles.size === 0) { + console.log("[postinstall] conversations mastodon disconnect patches already applied"); +} else { + console.log( + `[postinstall] Patched conversations mastodon disconnect in ${patchedFiles.size}/${checkedFiles.size} file(s)`, + ); +} diff --git a/scripts/patch-listening-endpoint-runtime-guards.mjs b/scripts/patch-listening-endpoint-runtime-guards.mjs index 6aec6f96..aecbe1c0 100644 --- a/scripts/patch-listening-endpoint-runtime-guards.mjs +++ b/scripts/patch-listening-endpoint-runtime-guards.mjs @@ -46,6 +46,34 @@ const patchSpecs = [ "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-funkwhale/lib/sync.js", ], }, + { + name: "funkwhale-latest-date-coercion", + marker: "Invalid listenedAt in latest record; falling back to full sync", + oldSnippet: ` const latest = await collection.findOne({}, { sort: { listenedAt: -1 } }); + const latestDate = latest?.listenedAt || new Date(0); + + console.log( + \`[Funkwhale] Syncing listenings since: \${latestDate.toISOString()}\` + );`, + newSnippet: ` const latest = await collection.findOne({}, { sort: { listenedAt: -1 } }); + const latestRawDate = latest?.listenedAt; + let latestDate = latestRawDate ? new Date(latestRawDate) : new Date(0); + + if (Number.isNaN(latestDate.getTime())) { + console.warn( + "[Funkwhale] Invalid listenedAt in latest record; falling back to full sync" + ); + latestDate = new Date(0); + } + + console.log( + \`[Funkwhale] Syncing listenings since: \${latestDate.toISOString()}\` + );`, + candidates: [ + "node_modules/@rmdes/indiekit-endpoint-funkwhale/lib/sync.js", + "node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-funkwhale/lib/sync.js", + ], + }, { name: "funkwhale-now-playing-fallback", marker: "degrade to empty now-playing response when upstream endpoint is missing",