Add Podroll OPML file upload support

This commit is contained in:
svemagie
2026-03-09 16:13:27 +01:00
parent c2a24b9162
commit 5836d05d86
2 changed files with 484 additions and 2 deletions

View File

@@ -4,8 +4,8 @@
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"scripts": { "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-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-homepage-locales.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-preset-eleventy-ai-frontmatter.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", "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-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-homepage-locales.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-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.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-endpoint-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-homepage-locales.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-preset-eleventy-ai-frontmatter.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", "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-activitypub-docloader-loglevel.mjs && node scripts/patch-endpoint-homepage-locales.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-podroll-opml-upload.mjs && node scripts/patch-preset-eleventy-ai-frontmatter.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" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],

View File

@@ -0,0 +1,482 @@
import { access, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const endpointCandidates = [
"node_modules/@rmdes/indiekit-endpoint-podroll",
"node_modules/@indiekit/indiekit/node_modules/@rmdes/indiekit-endpoint-podroll",
];
const dashboardFormOld =
'<form method="post" action="{{ mountPath }}/settings" class="podroll-form">';
const dashboardFormNew =
'<form method="post" action="{{ mountPath }}/settings" class="podroll-form" enctype="multipart/form-data">';
const dashboardOpmlFieldOld = [
' <div class="podroll-field">',
' <label class="label" for="opmlUrl">{{ __("podroll.opmlUrl") }}</label>',
' <span class="hint" id="opmlUrl-hint">{{ __("podroll.opmlUrlHelp") }}</span>',
' <input class="input" type="url" id="opmlUrl" name="opmlUrl" value="{{ config.opmlUrl }}" aria-describedby="opmlUrl-hint" placeholder="https://...">',
' </div>',
].join("\n");
const dashboardOpmlFieldNew = [
' <div class="podroll-field">',
' <label class="label" for="opmlUrl">{{ __("podroll.opmlUrl") }}</label>',
' <span class="hint" id="opmlUrl-hint">{{ __("podroll.opmlUrlHelp") }}</span>',
' <input class="input" type="url" id="opmlUrl" name="opmlUrl" value="{{ config.opmlUrl }}" aria-describedby="opmlUrl-hint" placeholder="https://...">',
' </div>',
' <div class="podroll-field">',
' <label class="label" for="opmlFile">{{ __("podroll.opmlFile") }}</label>',
' <span class="hint" id="opmlFile-hint">{{ __("podroll.opmlFileHelp") }}</span>',
' <input class="input" type="file" id="opmlFile" name="opmlFile" accept=".opml,.xml,text/xml,application/xml" aria-describedby="opmlFile-hint">',
' {% if config.hasOpmlUpload %}',
' <p class="hint">{{ __("podroll.opmlFileActive") }}</p>',
' {% endif %}',
' </div>',
' <div class="podroll-field">',
' <label class="checkbox" for="clearOpmlFile">',
' <input type="checkbox" id="clearOpmlFile" name="clearOpmlFile" value="1">',
' {{ __("podroll.clearOpmlFile") }}',
' </label>',
' </div>',
].join("\n");
const controllerGetEffectiveOld = [
'/**',
' * Get effective URLs: DB-stored settings override env var defaults',
' * @param {object} db - MongoDB database instance',
' * @param {object} podrollConfig - Plugin config from env vars',
' * @returns {Promise<object>} Effective episodesUrl and opmlUrl',
' */',
'async function getEffectiveUrls(db, podrollConfig) {',
' let episodesUrl = podrollConfig?.episodesUrl || "";',
' let opmlUrl = podrollConfig?.opmlUrl || "";',
'',
' if (db) {',
' const settings = await db',
' .collection("podrollMeta")',
' .findOne({ key: "settings" });',
' if (settings) {',
' if (settings.episodesUrl) episodesUrl = settings.episodesUrl;',
' if (settings.opmlUrl) opmlUrl = settings.opmlUrl;',
' }',
' }',
'',
' return { episodesUrl, opmlUrl };',
'}',
].join("\n");
const controllerGetEffectiveNew = [
'/**',
' * Get effective podroll configuration: DB-stored settings override env var defaults',
' * @param {object} db - MongoDB database instance',
' * @param {object} podrollConfig - Plugin config from env vars',
' * @returns {Promise<object>} Effective episodesUrl, opmlUrl and opmlUpload',
' */',
'async function getEffectiveUrls(db, podrollConfig) {',
' let episodesUrl = podrollConfig?.episodesUrl || "";',
' let opmlUrl = podrollConfig?.opmlUrl || "";',
' let opmlUpload = podrollConfig?.opmlUpload || "";',
'',
' if (db) {',
' const settings = await db',
' .collection("podrollMeta")',
' .findOne({ key: "settings" });',
' if (settings) {',
' if (settings.episodesUrl) episodesUrl = settings.episodesUrl;',
' if (settings.opmlUrl) opmlUrl = settings.opmlUrl;',
' if (settings.opmlUpload) opmlUpload = settings.opmlUpload;',
' }',
' }',
'',
' return { episodesUrl, opmlUrl, opmlUpload };',
'}',
].join("\n");
const controllerConfigOld = [
' config: {',
' episodesUrl: urls.episodesUrl,',
' opmlUrl: urls.opmlUrl,',
' syncInterval: application.podrollConfig?.syncInterval || 900000,',
' },',
].join("\n");
const controllerConfigNew = [
' config: {',
' episodesUrl: urls.episodesUrl,',
' opmlUrl: urls.opmlUrl,',
' hasOpmlUpload: Boolean(urls.opmlUpload),',
' syncInterval: application.podrollConfig?.syncInterval || 900000,',
' },',
].join("\n");
const controllerSaveOld = [
' const { episodesUrl, opmlUrl } = request.body;',
'',
' await db.collection("podrollMeta").updateOne(',
' { key: "settings" },',
' {',
' $set: {',
' key: "settings",',
' episodesUrl: episodesUrl || "",',
' opmlUrl: opmlUrl || "",',
' updatedAt: new Date().toISOString(),',
' },',
' },',
' { upsert: true },',
' );',
].join("\n");
const controllerSaveNew = [
' const { episodesUrl, opmlUrl, clearOpmlFile } = request.body;',
'',
' const existingSettings = await db',
' .collection("podrollMeta")',
' .findOne({ key: "settings" });',
'',
' let opmlUpload = existingSettings?.opmlUpload || "";',
'',
' if (clearOpmlFile === "1") {',
' opmlUpload = "";',
' }',
'',
' const uploadedFileRaw = request.files?.opmlFile;',
' const uploadedFile = Array.isArray(uploadedFileRaw)',
' ? uploadedFileRaw[0]',
' : uploadedFileRaw;',
'',
' if (uploadedFile) {',
' const uploadedName = String(uploadedFile.name || "");',
' const uploadedType = String(uploadedFile.mimetype || "").toLowerCase();',
' const isXmlName = /\\.(opml|xml)$/i.test(uploadedName);',
' const isXmlType = uploadedType.includes("xml");',
'',
' if (!isXmlName && !isXmlType) {',
' throw new Error(request.__("podroll.opmlFileInvalidType"));',
' }',
'',
' if (uploadedFile.size > 1_048_576) {',
' throw new Error(request.__("podroll.opmlFileTooLarge"));',
' }',
'',
' const opmlText = Buffer.from(uploadedFile.data).toString("utf8").trim();',
'',
' if (!opmlText || !opmlText.toLowerCase().includes("<opml")) {',
' throw new Error(request.__("podroll.opmlFileInvalidContent"));',
' }',
'',
' opmlUpload = opmlText;',
' }',
'',
' await db.collection("podrollMeta").updateOne(',
' { key: "settings" },',
' {',
' $set: {',
' key: "settings",',
' episodesUrl: episodesUrl || "",',
' opmlUrl: opmlUrl || "",',
' opmlUpload,',
' updatedAt: new Date().toISOString(),',
' },',
' },',
' { upsert: true },',
' );',
].join("\n");
const controllerSyncBlockOld = [
' const syncOptions = {',
' ...application.podrollConfig,',
' episodesUrl: urls.episodesUrl,',
' opmlUrl: urls.opmlUrl,',
' };',
].join("\n");
const controllerSyncBlockNew = [
' const syncOptions = {',
' ...application.podrollConfig,',
' episodesUrl: urls.episodesUrl,',
' opmlUrl: urls.opmlUrl,',
' opmlUpload: urls.opmlUpload,',
' };',
].join("\n");
const syncSourcesHeadOld = [
'async function syncSources(db, options) {',
' const { opmlUrl, fetchTimeout } = options;',
'',
' if (!opmlUrl) {',
' return { success: false, error: "No opmlUrl configured" };',
' }',
'',
' try {',
' console.log("[Podroll] Fetching OPML sources...");',
' const sources = await fetchOpmlSources(opmlUrl, fetchTimeout);',
].join("\n");
const syncSourcesHeadNew = [
'async function syncSources(db, options) {',
' const { opmlUrl, opmlUpload, fetchTimeout } = options;',
'',
' const hasUploadedOpml = Boolean(opmlUpload && opmlUpload.trim());',
' const hasRemoteOpml = Boolean(opmlUrl);',
'',
' if (!hasUploadedOpml && !hasRemoteOpml) {',
' return { success: false, error: "No opml source configured" };',
' }',
'',
' try {',
' const opmlSource = hasUploadedOpml',
' ? `data:text/xml;charset=utf-8,${encodeURIComponent(opmlUpload)}`',
' : opmlUrl;',
'',
' if (hasUploadedOpml) {',
' console.log("[Podroll] Parsing uploaded OPML sources...");',
' } else {',
' console.log("[Podroll] Fetching OPML sources...");',
' }',
'',
' const sources = await fetchOpmlSources(opmlSource, fetchTimeout);',
].join("\n");
const runSyncOld =
' options.opmlUrl ? syncSources(db, options) : { success: true, skipped: true },';
const runSyncNew =
' options.opmlUrl || options.opmlUpload ? syncSources(db, options) : { success: true, skipped: true },';
const effectiveOptionsOld = [
' return {',
' ...options,',
' episodesUrl: settings.episodesUrl || options.episodesUrl,',
' opmlUrl: settings.opmlUrl || options.opmlUrl,',
' };',
].join("\n");
const effectiveOptionsNew = [
' return {',
' ...options,',
' episodesUrl: settings.episodesUrl || options.episodesUrl,',
' opmlUrl: settings.opmlUrl || options.opmlUrl,',
' opmlUpload: settings.opmlUpload || options.opmlUpload,',
' };',
].join("\n");
const indexDefaultsOld = ' opmlUrl: "",';
const indexDefaultsNew = ' opmlUrl: "",\n opmlUpload: "",';
const localeDefaults = {
opmlFile: "Upload OPML file",
opmlFileHelp:
"Upload an OPML file to sync podcast subscriptions without a remote OPML URL",
opmlFileActive:
"An uploaded OPML file is currently saved and will be used during source sync",
clearOpmlFile: "Remove saved uploaded OPML file",
opmlFileInvalidType: "Please upload an .opml or .xml file",
opmlFileInvalidContent: "Uploaded file is not valid OPML content",
opmlFileTooLarge: "Uploaded OPML file is too large (max 1 MB)",
};
const deLocaleOverrides = {
opmlFile: "OPML-Datei hochladen",
opmlFileHelp:
"Laden Sie eine OPML-Datei hoch, um Podcast-Abonnements ohne externe OPML-URL zu synchronisieren",
opmlFileActive:
"Eine hochgeladene OPML-Datei ist gespeichert und wird bei der Quellen-Synchronisierung verwendet",
clearOpmlFile: "Gespeicherte hochgeladene OPML-Datei entfernen",
opmlFileInvalidType: "Bitte eine .opml- oder .xml-Datei hochladen",
opmlFileInvalidContent: "Die hochgeladene Datei enthaelt keinen gueltigen OPML-Inhalt",
opmlFileTooLarge: "Die hochgeladene OPML-Datei ist zu gross (max. 1 MB)",
};
async function exists(filePath) {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
function replaceOnce(source, from, to) {
if (source.includes(to)) {
return { updated: source, changed: false, status: "already" };
}
if (!source.includes(from)) {
return { updated: source, changed: false, status: "missing" };
}
return {
updated: source.replace(from, to),
changed: true,
status: "patched",
};
}
function patchLocale(source, overrides = {}) {
const parsed = JSON.parse(source);
const podroll =
parsed && parsed.podroll && typeof parsed.podroll === "object"
? parsed.podroll
: {};
let changed = false;
for (const [key, value] of Object.entries(localeDefaults)) {
if (!(key in podroll)) {
podroll[key] = value;
changed = true;
}
}
for (const [key, value] of Object.entries(overrides)) {
if (podroll[key] !== value) {
podroll[key] = value;
changed = true;
}
}
if (!changed) {
return { updated: source, changed: false };
}
parsed.podroll = podroll;
return { updated: `${JSON.stringify(parsed, null, 2)}\n`, changed: true };
}
let endpointsChecked = 0;
let filesPatched = 0;
for (const endpointPath of endpointCandidates) {
if (!(await exists(endpointPath))) {
continue;
}
endpointsChecked += 1;
const dashboardPath = path.join(endpointPath, "views", "dashboard.njk");
const controllerPath = path.join(
endpointPath,
"lib",
"controllers",
"dashboard.js",
);
const syncPath = path.join(endpointPath, "lib", "sync.js");
const indexPath = path.join(endpointPath, "index.js");
const enLocalePath = path.join(endpointPath, "locales", "en.json");
const deLocalePath = path.join(endpointPath, "locales", "de.json");
if (await exists(dashboardPath)) {
let source = await readFile(dashboardPath, "utf8");
let changed = false;
const formResult = replaceOnce(source, dashboardFormOld, dashboardFormNew);
source = formResult.updated;
changed = changed || formResult.changed;
const fieldResult = replaceOnce(source, dashboardOpmlFieldOld, dashboardOpmlFieldNew);
source = fieldResult.updated;
changed = changed || fieldResult.changed;
if (changed) {
await writeFile(dashboardPath, source, "utf8");
filesPatched += 1;
}
}
if (await exists(controllerPath)) {
let source = await readFile(controllerPath, "utf8");
let changed = false;
const effectiveResult = replaceOnce(
source,
controllerGetEffectiveOld,
controllerGetEffectiveNew,
);
source = effectiveResult.updated;
changed = changed || effectiveResult.changed;
const configResult = replaceOnce(source, controllerConfigOld, controllerConfigNew);
source = configResult.updated;
changed = changed || configResult.changed;
const saveResult = replaceOnce(source, controllerSaveOld, controllerSaveNew);
source = saveResult.updated;
changed = changed || saveResult.changed;
if (source.includes(controllerSyncBlockOld)) {
source = source.replaceAll(controllerSyncBlockOld, controllerSyncBlockNew);
changed = true;
}
if (changed) {
await writeFile(controllerPath, source, "utf8");
filesPatched += 1;
}
}
if (await exists(syncPath)) {
let source = await readFile(syncPath, "utf8");
let changed = false;
const syncSourcesResult = replaceOnce(
source,
syncSourcesHeadOld,
syncSourcesHeadNew,
);
source = syncSourcesResult.updated;
changed = changed || syncSourcesResult.changed;
const runSyncResult = replaceOnce(source, runSyncOld, runSyncNew);
source = runSyncResult.updated;
changed = changed || runSyncResult.changed;
const effectiveOptsResult = replaceOnce(
source,
effectiveOptionsOld,
effectiveOptionsNew,
);
source = effectiveOptsResult.updated;
changed = changed || effectiveOptsResult.changed;
if (changed) {
await writeFile(syncPath, source, "utf8");
filesPatched += 1;
}
}
if (await exists(indexPath)) {
const source = await readFile(indexPath, "utf8");
const result = replaceOnce(source, indexDefaultsOld, indexDefaultsNew);
if (result.changed) {
await writeFile(indexPath, result.updated, "utf8");
filesPatched += 1;
}
}
if (await exists(enLocalePath)) {
const source = await readFile(enLocalePath, "utf8");
const result = patchLocale(source);
if (result.changed) {
await writeFile(enLocalePath, result.updated, "utf8");
filesPatched += 1;
}
}
if (await exists(deLocalePath)) {
const source = await readFile(deLocalePath, "utf8");
const result = patchLocale(source, deLocaleOverrides);
if (result.changed) {
await writeFile(deLocalePath, result.updated, "utf8");
filesPatched += 1;
}
}
}
if (endpointsChecked === 0) {
console.log("[postinstall] No endpoint-podroll directories found");
} else if (filesPatched === 0) {
console.log("[postinstall] endpoint-podroll OPML upload patch already applied");
} else {
console.log(
`[postinstall] Patched endpoint-podroll OPML upload in ${filesPatched} file(s)`,
);
}