chore(patches): add mastodon disconnect flow and runtime guards

This commit is contained in:
svemagie
2026-03-08 17:33:47 +01:00
parent 1dedd763a6
commit ddae0276c7
4 changed files with 356 additions and 3 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# indiekit-blog
# Indieweb/kit Blog Server
## Admin login
+2 -2
View File
@@ -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": [],
@@ -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: ` <p style="font-size: 0.85em; margin: 0.25rem 0">
{{ platformCounts.mastodon or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
</p>`,
newSnippet: ` <p style="font-size: 0.85em; margin: 0.25rem 0">
{{ platformCounts.mastodon or 0 }} {{ __("conversations.dashboard.itemsCollected") }}
</p>
<form method="post" action="{{ baseUrl }}/mastodon/logout" style="margin-top: 0.75rem">
<button type="submit" class="button">Logout Mastodon</button>
</form>`,
},
{
name: "conversations-view-mastodon-disabled-state",
candidates: conversationsViewCandidates,
marker: "Mastodon polling is disconnected for this dashboard.",
oldSnippet: ` {% else %}
<p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
{{ __("conversations.dashboard.mastodonHint") }}
</p>
{% endif %}`,
newSnippet: ` {% else %}
{% if pollState and pollState.mastodon_disabled %}
<p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
Mastodon polling is disconnected for this dashboard.
</p>
<form method="post" action="{{ baseUrl }}/mastodon/reconnect" style="margin-top: 0.75rem">
<button type="submit" class="button">Reconnect Mastodon</button>
</form>
{% else %}
<p style="font-size: 0.85em; color: #6b7280; margin: 0.25rem 0">
{{ __("conversations.dashboard.mastodonHint") }}
</p>
{% 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)`,
);
}
@@ -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",