Normalize ActivityPub profile URLs to fix WebFinger invalid URL

This commit is contained in:
svemagie
2026-03-08 11:16:17 +01:00
parent 2e4827be9d
commit 9919b1decc
5 changed files with 199 additions and 4 deletions

View File

@@ -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:<handle>@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.

View File

@@ -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 ||

View File

@@ -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": [],

View File

@@ -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
}
}

View File

@@ -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