From 9919b1deccd708327abdb5c8912f81b7134fa125 Mon Sep 17 00:00:00 2001 From: svemagie <869694+svemagie@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:16:17 +0100 Subject: [PATCH] Normalize ActivityPub profile URLs to fix WebFinger invalid URL --- README.md | 5 +- indiekit.config.mjs | 18 +- package.json | 2 +- .../preflight-activitypub-profile-urls.mjs | 175 ++++++++++++++++++ start.example.sh | 3 + 5 files changed, 199 insertions(+), 4 deletions(-) create mode 100644 scripts/preflight-activitypub-profile-urls.mjs diff --git a/README.md b/README.md index be8b59bf..be16210c 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,14 @@ - ActivityPub federation is enabled via `@rmdes/indiekit-endpoint-activitypub`. - Actor handle resolution order is: `AP_HANDLE`, then `ACTIVITYPUB_HANDLE`, then `GITHUB_USERNAME`, then publication hostname first label. - Actor profile seed values come from `AUTHOR_NAME`, `AUTHOR_BIO`, `AUTHOR_AVATAR`, and `SITE_DESCRIPTION`. +- `AUTHOR_AVATAR` can be absolute (`https://...`) or slash-relative (`/images/avatar.jpg`); startup normalizes it to an absolute URL. - Optional ActivityPub variables: - `AP_ALSO_KNOWN_AS` (Mastodon migration alias URL) - `AP_LOG_LEVEL` (`debug|info|warning|error|fatal`, default `info`) - `AP_DEBUG` (`1` or `true` enables debug dashboard) - `AP_DEBUG_PASSWORD` (required when debug dashboard is enabled) - `REDIS_URL` (recommended for production delivery queue durability) +- Startup preflight `scripts/preflight-activitypub-profile-urls.mjs` normalizes existing ActivityPub profile URL fields in MongoDB (`url`, `icon`, `image`, `alsoKnownAs`) so WebFinger/actor responses do not fail on invalid URL values. - The ActivityPub locale patch creates/repairs `locales/de.json` from `locales/en.json` so backend UI keys do not render as raw `activitypub.*` translation strings when `SITE_LOCALE=de`. - Quick verification commands: - `curl -s "https://blog.giersig.eu/.well-known/webfinger?resource=acct:@blog.giersig.eu" | jq .` @@ -104,10 +106,11 @@ - `start.sh` is intentionally ignored by Git (`.gitignore`) so server secrets are not committed. - Use `start.example.sh` as the tracked template and keep real credentials in environment variables (or `.env` on the server). - Startup scripts parse `.env` with the `dotenv` parser (not shell `source`), so values containing spaces are handled safely. -- Startup scripts run preflight + patch helpers before boot (`scripts/preflight-production-security.mjs`, `scripts/preflight-mongo-connection.mjs`, `scripts/patch-lightningcss.mjs`, `scripts/patch-endpoint-media-scope.mjs`, `scripts/patch-endpoint-media-sharp-runtime.mjs`, `scripts/patch-frontend-sharp-runtime.mjs`, `scripts/patch-endpoint-files-upload-route.mjs`, `scripts/patch-endpoint-files-upload-locales.mjs`, `scripts/patch-endpoint-activitypub-locales.mjs`, `scripts/patch-frontend-serviceworker-file.mjs`, `scripts/patch-conversations-collection-guards.mjs`, `scripts/patch-indiekit-routes-rate-limits.mjs`, `scripts/patch-indiekit-error-production-stack.mjs`, `scripts/patch-indieauth-devmode-guard.mjs`, `scripts/patch-listening-endpoint-runtime-guards.mjs`). +- Startup scripts run preflight + patch helpers before boot (`scripts/preflight-production-security.mjs`, `scripts/preflight-mongo-connection.mjs`, `scripts/preflight-activitypub-profile-urls.mjs`, `scripts/patch-lightningcss.mjs`, `scripts/patch-endpoint-media-scope.mjs`, `scripts/patch-endpoint-media-sharp-runtime.mjs`, `scripts/patch-frontend-sharp-runtime.mjs`, `scripts/patch-endpoint-files-upload-route.mjs`, `scripts/patch-endpoint-files-upload-locales.mjs`, `scripts/patch-endpoint-activitypub-locales.mjs`, `scripts/patch-frontend-serviceworker-file.mjs`, `scripts/patch-conversations-collection-guards.mjs`, `scripts/patch-indiekit-routes-rate-limits.mjs`, `scripts/patch-indiekit-error-production-stack.mjs`, `scripts/patch-indieauth-devmode-guard.mjs`, `scripts/patch-listening-endpoint-runtime-guards.mjs`). - The production security preflight blocks startup on insecure auth/session configuration and catches empty-password bcrypt hashes. - One-time recovery mode is available with `INDIEKIT_ALLOW_PASSWORD_SETUP=1` to bootstrap/reset `PASSWORD_SECRET` when locked out. Remove this flag after setting a valid hash. - The media scope patch fixes a known upstream issue where file uploads can fail if the token scope is `create update delete` without explicit `media`. +- The ActivityPub profile URL preflight repairs invalid URL fields in the `ap_profile` document (for example relative `icon` paths), preventing `/.well-known/webfinger` and actor responses from failing with `TypeError: Invalid URL`. - The media sharp runtime patch makes image transformation resilient on FreeBSD: if `sharp` cannot load, uploads continue without resize/rotation instead of crashing the server process. - The frontend sharp runtime patch makes icon generation non-fatal on FreeBSD when `sharp` cannot load, preventing startup crashes in asset controller imports. - The files upload route patch fixes browser multi-upload by posting to `/files/upload` (session-authenticated) instead of direct `/media` calls without bearer token. diff --git a/indiekit.config.mjs b/indiekit.config.mjs index 40b14906..f3b6fe13 100644 --- a/indiekit.config.mjs +++ b/indiekit.config.mjs @@ -37,7 +37,9 @@ const funkwhaleToken = process.env.FUNKWHALE_TOKEN; const lastfmApiKey = process.env.LASTFM_API_KEY; const lastfmUsername = process.env.LASTFM_USERNAME; const publicationBaseUrl = ( - process.env.PUBLICATION_URL || "https://blog.giersig.eu" + process.env.PUBLICATION_URL || + process.env.SITE_URL || + "https://blog.giersig.eu" ).replace(/\/+$/, ""); const publicationHostname = (() => { try { @@ -51,7 +53,19 @@ const debugEnabled = process.env.INDIEKIT_DEBUG === "1" || nodeEnv !== "producti const siteName = process.env.SITE_NAME || "Indiekit"; const authorName = process.env.AUTHOR_NAME || ""; const authorBio = process.env.AUTHOR_BIO || ""; -const authorAvatar = process.env.AUTHOR_AVATAR || ""; +const authorAvatar = (() => { + const avatar = (process.env.AUTHOR_AVATAR || "").trim(); + + if (!avatar) { + return ""; + } + + try { + return new URL(avatar, publicationBaseUrl).href; + } catch { + return ""; + } +})(); const activityPubHandle = ( process.env.AP_HANDLE || process.env.ACTIVITYPUB_HANDLE || diff --git a/package.json b/package.json index 4b0debe4..1bbe3c40 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "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-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/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-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", + "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-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-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", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/scripts/preflight-activitypub-profile-urls.mjs b/scripts/preflight-activitypub-profile-urls.mjs new file mode 100644 index 00000000..64f38455 --- /dev/null +++ b/scripts/preflight-activitypub-profile-urls.mjs @@ -0,0 +1,175 @@ +import { MongoClient } from "mongodb"; + +import config from "../indiekit.config.mjs"; + +const strictMode = process.env.REQUIRE_MONGO !== "0"; +const mongodbUrl = config.application?.mongodbUrl; +const publicationBaseUrl = (() => { + const candidate = + config.publication?.me || + process.env.PUBLICATION_URL || + process.env.SITE_URL || + "https://blog.giersig.eu"; + + try { + return new URL(candidate).href; + } catch { + return "https://blog.giersig.eu/"; + } +})(); + +function toHttpUrl(value, { baseUrl, allowRelative = false } = {}) { + if (typeof value !== "string") { + return ""; + } + + const trimmed = value.trim(); + + if (!trimmed) { + return ""; + } + + try { + const absolute = new URL(trimmed); + + if (absolute.protocol === "http:" || absolute.protocol === "https:") { + return absolute.href; + } + + return ""; + } catch { + if (!allowRelative) { + return ""; + } + + try { + const resolved = new URL(trimmed, baseUrl); + + if (resolved.protocol === "http:" || resolved.protocol === "https:") { + return resolved.href; + } + + return ""; + } catch { + return ""; + } + } +} + +function readAliases(value) { + if (Array.isArray(value)) { + return value + .filter((entry) => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + + return []; +} + +function normalizeAliases(value, baseUrl) { + const aliases = []; + + for (const entry of readAliases(value)) { + // Only resolve slash-relative aliases. Non-URL handles like @user@host are dropped. + const normalized = toHttpUrl(entry, { + baseUrl, + allowRelative: entry.startsWith("/"), + }); + + if (normalized && !aliases.includes(normalized)) { + aliases.push(normalized); + } + } + + return aliases; +} + +if (!mongodbUrl) { + console.warn( + "[preflight] ActivityPub profile URL sync skipped: MongoDB URL is not configured.", + ); + process.exit(0); +} + +const client = new MongoClient(mongodbUrl, { connectTimeoutMS: 5000 }); + +try { + await client.connect(); + + const apProfile = client.db().collection("ap_profile"); + const profile = await apProfile.findOne({}); + + if (!profile) { + console.log( + "[preflight] ActivityPub profile URL sync skipped: no profile document found.", + ); + process.exit(0); + } + + const updates = {}; + const normalizedProfileUrl = + toHttpUrl(profile.url, { baseUrl: publicationBaseUrl, allowRelative: true }) || + publicationBaseUrl; + + if ((profile.url || "") !== normalizedProfileUrl) { + updates.url = normalizedProfileUrl; + } + + const normalizedIcon = toHttpUrl(profile.icon, { + baseUrl: publicationBaseUrl, + allowRelative: true, + }); + + if ((profile.icon || "") !== normalizedIcon) { + updates.icon = normalizedIcon; + } + + const normalizedImage = toHttpUrl(profile.image, { + baseUrl: publicationBaseUrl, + allowRelative: true, + }); + + if ((profile.image || "") !== normalizedImage) { + updates.image = normalizedImage; + } + + const originalAliases = readAliases(profile.alsoKnownAs); + const normalizedAliases = normalizeAliases(profile.alsoKnownAs, publicationBaseUrl); + + if (JSON.stringify(originalAliases) !== JSON.stringify(normalizedAliases)) { + updates.alsoKnownAs = normalizedAliases; + } + + const fields = Object.keys(updates); + + if (fields.length === 0) { + console.log("[preflight] ActivityPub profile URL fields already valid"); + process.exit(0); + } + + await apProfile.updateOne({ _id: profile._id }, { $set: updates }); + console.log( + `[preflight] ActivityPub profile URL fields normalized: ${fields.join(", ")}`, + ); +} catch (error) { + const message = `[preflight] ActivityPub profile URL sync failed: ${error.message}`; + + if (strictMode) { + console.error(message); + process.exit(1); + } + + console.warn(`${message} Continuing because strict mode is disabled.`); +} finally { + try { + await client.close(); + } catch { + // no-op + } +} diff --git a/start.example.sh b/start.example.sh index 8ceff648..cf76273f 100644 --- a/start.example.sh +++ b/start.example.sh @@ -45,6 +45,9 @@ unset DEBUG # Verify MongoDB credentials/connectivity before launching server. /usr/local/bin/node scripts/preflight-mongo-connection.mjs +# Normalize ActivityPub profile URL fields (icon/image/aliases) in MongoDB. +/usr/local/bin/node scripts/preflight-activitypub-profile-urls.mjs + # Ensure runtime dependency patches are applied even if node_modules already exists. /usr/local/bin/node scripts/patch-lightningcss.mjs /usr/local/bin/node scripts/patch-endpoint-media-scope.mjs