From ea4839f0de33320bc8fbce54acc800d274cd3708 Mon Sep 17 00:00:00 2001 From: Eric Boothe Date: Sat, 16 May 2026 16:25:31 -0600 Subject: [PATCH 1/2] feat(admin): add Site URL editor for emdash:site_url Adds a dedicated GET/POST endpoint at /_emdash/api/settings/site-url and a corresponding "Email Site URL" section in admin General Settings. The endpoint updates the emdash:site_url option, which governs the base URL used in transactional emails (magic-link, invitation, password-reset) via getSiteBaseUrl(). Previously this value was written once during the setup wizard via setIfAbsent() and was not editable from the admin UI, so deployments behind reverse proxies that captured a bogus first-request origin had no way to fix it. Fixes #989. The write is gated on settings:manage. Submitted URLs are validated to http(s) origins only (no path/query/fragment, no XSS-prone schemes), and trailing slashes are stripped. The existing site:url field in Site Identity (used for canonical links and sitemaps) is unchanged. --- .changeset/fix-site-url-admin-editor.md | 8 + .../components/settings/GeneralSettings.tsx | 100 ++++++++- packages/admin/src/lib/api/index.ts | 9 +- packages/admin/src/lib/api/settings.ts | 33 +++ packages/core/src/astro/integration/routes.ts | 7 + .../src/astro/routes/api/settings/site-url.ts | 141 ++++++++++++ .../astro/settings-site-url.test.ts | 202 ++++++++++++++++++ 7 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-site-url-admin-editor.md create mode 100644 packages/core/src/astro/routes/api/settings/site-url.ts create mode 100644 packages/core/tests/integration/astro/settings-site-url.test.ts diff --git a/.changeset/fix-site-url-admin-editor.md b/.changeset/fix-site-url-admin-editor.md new file mode 100644 index 000000000..afe6623f3 --- /dev/null +++ b/.changeset/fix-site-url-admin-editor.md @@ -0,0 +1,8 @@ +--- +"emdash": patch +"@emdash-cms/admin": patch +--- + +Adds a Site URL field to admin General Settings that updates the `emdash:site_url` option, making the URL used for transactional emails (magic-link, invites, password resets) editable after the setup wizard. Fixes #989. + +A new endpoint, `GET/POST /_emdash/api/settings/site-url`, gates the write on `settings:manage` and normalizes the submitted value to a bare origin (rejecting non-http(s) schemes and any value carrying a path, query string, or fragment). The pre-existing `site:url` setting in the Site Identity section (used for canonical links and sitemaps) is unchanged. diff --git a/packages/admin/src/components/settings/GeneralSettings.tsx b/packages/admin/src/components/settings/GeneralSettings.tsx index 601c9e034..db7ff91e2 100644 --- a/packages/admin/src/components/settings/GeneralSettings.tsx +++ b/packages/admin/src/components/settings/GeneralSettings.tsx @@ -11,7 +11,14 @@ import { FloppyDisk, CheckCircle, WarningCircle, Upload, X } from "@phosphor-ico import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; -import { fetchSettings, updateSettings, type SiteSettings, type MediaItem } from "../../lib/api"; +import { + fetchSettings, + fetchSiteUrl, + updateSettings, + updateSiteUrl, + type SiteSettings, + type MediaItem, +} from "../../lib/api"; import { EditorHeader } from "../EditorHeader"; import { MediaPickerModal } from "../MediaPickerModal"; import { BackToSettingsLink } from "./BackToSettingsLink.js"; @@ -26,7 +33,21 @@ export function GeneralSettings() { staleTime: Infinity, }); + // `emdash:site_url` lives outside the `site:*` settings namespace and is + // served by a dedicated endpoint -- keep it on a separate query so the + // main settings cache doesn't have to know about a sibling concept. + const { data: siteUrlSetting } = useQuery({ + queryKey: ["settings", "site-url"], + queryFn: fetchSiteUrl, + staleTime: Infinity, + }); + const [formData, setFormData] = React.useState>({}); + const [siteUrlInput, setSiteUrlInput] = React.useState(""); + const [siteUrlStatus, setSiteUrlStatus] = React.useState<{ + type: "success" | "error"; + message: string; + } | null>(null); const [saveStatus, setSaveStatus] = React.useState<{ type: "success" | "error"; message: string; @@ -39,6 +60,10 @@ export function GeneralSettings() { if (settings) setFormData(settings); }, [settings]); + React.useEffect(() => { + if (siteUrlSetting) setSiteUrlInput(siteUrlSetting.siteUrl ?? ""); + }, [siteUrlSetting]); + React.useEffect(() => { if (saveStatus) { const timer = setTimeout(setSaveStatus, 3000, null); @@ -46,6 +71,13 @@ export function GeneralSettings() { } }, [saveStatus]); + React.useEffect(() => { + if (siteUrlStatus) { + const timer = setTimeout(setSiteUrlStatus, 3000, null); + return () => clearTimeout(timer); + } + }, [siteUrlStatus]); + const saveMutation = useMutation({ mutationFn: (data: Partial) => updateSettings(data), onSuccess: () => { @@ -60,6 +92,21 @@ export function GeneralSettings() { }, }); + const siteUrlMutation = useMutation({ + mutationFn: (value: string) => updateSiteUrl(value), + onSuccess: (data) => { + void queryClient.invalidateQueries({ queryKey: ["settings", "site-url"] }); + if (data.siteUrl) setSiteUrlInput(data.siteUrl); + setSiteUrlStatus({ type: "success", message: t`Site URL saved` }); + }, + onError: (error) => { + setSiteUrlStatus({ + type: "error", + message: error instanceof Error ? error.message : t`Failed to save site URL`, + }); + }, + }); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); saveMutation.mutate(formData); @@ -287,6 +334,57 @@ export function GeneralSettings() { + {/* Email Site URL -- + A separate form section because `emdash:site_url` is updated via + its own endpoint (`/_emdash/api/settings/site-url`) and lives + outside the `site:*` settings namespace edited by the form above. + See packages/core/src/api/site-url.ts and upstream issue #989. */} +
+

{t`Email Site URL`}

+

+ {t`Sets the base URL used for magic-link, invitation, and password-reset emails. Changes do not affect URLs in emails that have already been sent.`} +

+ {siteUrlStatus && ( +
+ {siteUrlStatus.type === "success" ? ( + + ) : ( + + )} + {siteUrlStatus.message} +
+ )} +
+ setSiteUrlInput(e.target.value)} + description={t`Must be an absolute http or https origin, e.g. https://example.com. Set during initial setup; edit here to update.`} + placeholder="https://example.com" + /> +
+ +
+
+
+ {/* Reading Settings */}

{t`Reading`}

diff --git a/packages/admin/src/lib/api/index.ts b/packages/admin/src/lib/api/index.ts index 5ccba56ef..c381e5f61 100644 --- a/packages/admin/src/lib/api/index.ts +++ b/packages/admin/src/lib/api/index.ts @@ -103,7 +103,14 @@ export { } from "./plugins.js"; // Settings -export { type SiteSettings, fetchSettings, updateSettings } from "./settings.js"; +export { + type SiteSettings, + type SiteUrlSetting, + fetchSettings, + updateSettings, + fetchSiteUrl, + updateSiteUrl, +} from "./settings.js"; // Users, passkeys, allowed domains export { diff --git a/packages/admin/src/lib/api/settings.ts b/packages/admin/src/lib/api/settings.ts index 67822ee4c..ae225a793 100644 --- a/packages/admin/src/lib/api/settings.ts +++ b/packages/admin/src/lib/api/settings.ts @@ -60,3 +60,36 @@ export async function updateSettings( }); return parseApiResponse>(response, "Failed to update settings"); } + +// --------------------------------------------------------------------------- +// `emdash:site_url` -- the internal origin used to build links in +// magic-link / invitation / password-reset emails. Separate from +// `SiteSettings.url` (presentation-layer URL used for canonical links). +// See `packages/core/src/astro/routes/api/settings/site-url.ts` and +// upstream issue #989 for why these are distinct. +// --------------------------------------------------------------------------- + +export interface SiteUrlSetting { + siteUrl: string | null; +} + +/** + * Fetch the current `emdash:site_url` option. + */ +export async function fetchSiteUrl(): Promise { + const response = await apiFetch(`${API_BASE}/settings/site-url`); + return parseApiResponse(response, "Failed to fetch site URL"); +} + +/** + * Update the `emdash:site_url` option. The value is normalized server-side + * to a bare origin (e.g. `https://example.com`) before persistence. + */ +export async function updateSiteUrl(siteUrl: string): Promise { + const response = await apiFetch(`${API_BASE}/settings/site-url`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ siteUrl }), + }); + return parseApiResponse(response, "Failed to update site URL"); +} diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 14010a756..1e5887964 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -291,6 +291,13 @@ export function injectCoreRoutes(injectRoute: InjectRoute): void { entrypoint: resolveRoute("api/settings/email.ts"), }); + // Site URL settings route -- governs `emdash:site_url`, the origin used + // to build links in magic-link / invitation / password-reset emails. + injectRoute({ + pattern: "/_emdash/api/settings/site-url", + entrypoint: resolveRoute("api/settings/site-url.ts"), + }); + // Snapshot route (for DO preview database population) injectRoute({ pattern: "/_emdash/api/snapshot", diff --git a/packages/core/src/astro/routes/api/settings/site-url.ts b/packages/core/src/astro/routes/api/settings/site-url.ts new file mode 100644 index 000000000..2eb6ee28c --- /dev/null +++ b/packages/core/src/astro/routes/api/settings/site-url.ts @@ -0,0 +1,141 @@ +/** + * Site URL Settings API endpoint + * + * GET /_emdash/api/settings/site-url — current `emdash:site_url` value + * POST /_emdash/api/settings/site-url — update `emdash:site_url` + * + * Why a dedicated endpoint? + * The `emdash:site_url` option governs the base URL used to build links in + * magic-link, invitation, and password-reset emails (see + * `src/api/site-url.ts` -> `getSiteBaseUrl`). It is written once during the + * setup wizard via `setIfAbsent()` and was previously not editable from + * the admin UI. The regular Site Settings form edits `site:url`, which is + * a separate, presentation-layer URL (used for canonical links/sitemaps) + * and is not consulted by the auth email path. See issue #989. + * + * Keeping this as its own endpoint (rather than rolling it into + * `POST /_emdash/api/settings`) preserves the existing `site:*` namespace + * and makes it explicit that this write changes the security-sensitive + * origin used in transactional emails. + */ + +import type { APIRoute } from "astro"; +import { z } from "zod"; + +import { requirePerm } from "#api/authorize.js"; +import { apiError, apiSuccess, handleError } from "#api/error.js"; +import { isParseError, parseBody } from "#api/parse.js"; +import { OptionsRepository } from "#db/repositories/options.js"; + +export const prerender = false; + +const SITE_URL_OPTION = "emdash:site_url"; + +/** + * Accept any non-empty string -- shape validation (http/https scheme, + * parseability) is done after parsing with `URL` so we can produce a + * single, normalized origin (no path, no trailing slash) before write. + * + * Matching `getPublicOrigin()` semantics: the stored value is always an + * origin like `https://example.com`, never with a path or trailing slash, + * so subsequent reads in `getSiteBaseUrl()` can append `/_emdash` cleanly. + */ +const siteUrlBody = z.object({ + siteUrl: z.string().min(1).max(2048), +}); + +/** + * GET /_emdash/api/settings/site-url + * + * Returns `{ siteUrl: string | null }`. `null` means the option has never + * been set (only possible in the rare case the setup wizard skipped this + * write -- the wizard writes via `setIfAbsent` on every completion). + */ +export const GET: APIRoute = async ({ locals }) => { + const { emdash, user } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "settings:read"); + if (denied) return denied; + + try { + const options = new OptionsRepository(emdash.db); + const siteUrl = (await options.get(SITE_URL_OPTION)) ?? null; + return apiSuccess({ siteUrl }); + } catch (error) { + return handleError(error, "Failed to read site URL", "SITE_URL_READ_ERROR"); + } +}; + +/** + * POST /_emdash/api/settings/site-url + * + * Updates `emdash:site_url`. Accepts `{ siteUrl: string }` and normalizes + * to a bare origin (`https://host[:port]`) before persisting. + * + * Rejects values that: + * - fail `new URL()` parsing, + * - use a scheme other than `http:` or `https:` (XSS-prone schemes like + * `javascript:` and `data:` must not be writable here -- this value is + * interpolated into outgoing email content), + * - carry a path, query, or fragment (we want an origin only, to match + * how `getSiteBaseUrl` appends `/_emdash`). + */ +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash, user } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + const denied = requirePerm(user, "settings:manage"); + if (denied) return denied; + + try { + const body = await parseBody(request, siteUrlBody); + if (isParseError(body)) return body; + + let parsed: URL; + try { + parsed = new URL(body.siteUrl.trim()); + } catch { + return apiError( + "INVALID_SITE_URL", + "Site URL must be a valid absolute URL (e.g. https://example.com)", + 400, + ); + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return apiError( + "INVALID_SITE_URL", + "Site URL must use the http or https scheme", + 400, + ); + } + + // Allow trailing-slash-only path so users pasting `https://example.com/` + // from a browser address bar don't get rejected. Anything beyond that + // (a real path, query string, or fragment) is rejected -- we store an + // origin, not a deep link. + if ((parsed.pathname !== "" && parsed.pathname !== "/") || parsed.search || parsed.hash) { + return apiError( + "INVALID_SITE_URL", + "Site URL must be an origin only (no path, query string, or fragment)", + 400, + ); + } + + const normalized = parsed.origin; + + const options = new OptionsRepository(emdash.db); + await options.set(SITE_URL_OPTION, normalized); + + return apiSuccess({ siteUrl: normalized }); + } catch (error) { + return handleError(error, "Failed to update site URL", "SITE_URL_UPDATE_ERROR"); + } +}; diff --git a/packages/core/tests/integration/astro/settings-site-url.test.ts b/packages/core/tests/integration/astro/settings-site-url.test.ts new file mode 100644 index 000000000..5d7418da4 --- /dev/null +++ b/packages/core/tests/integration/astro/settings-site-url.test.ts @@ -0,0 +1,202 @@ +/** + * GET/POST /_emdash/api/settings/site-url + * + * Exercises the dedicated `emdash:site_url` editor: validates input + * normalization, scheme/origin restrictions, RBAC, and round-trip + * persistence. See `packages/core/src/astro/routes/api/settings/site-url.ts` + * and upstream issue #989 for why the `emdash:site_url` key is edited + * via its own endpoint rather than through `POST /_emdash/api/settings`. + */ + +import type { APIContext } from "astro"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + GET as getSiteUrl, + POST as postSiteUrl, +} from "../../../src/astro/routes/api/settings/site-url.js"; +import { OptionsRepository } from "../../../src/database/repositories/options.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +const ADMIN_USER = { id: "user_admin", role: 50 as const }; +const EDITOR_USER = { id: "user_editor", role: 40 as const }; + +function buildRequest(body: unknown): Request { + return new Request("http://localhost/_emdash/api/settings/site-url", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function buildContext( + db: Kysely, + request: Request | null, + user: { id: string; role: 10 | 20 | 30 | 40 | 50 } | null, +): APIContext { + return { + params: {}, + url: new URL(request?.url ?? "http://localhost/_emdash/api/settings/site-url"), + request: request ?? new Request("http://localhost/_emdash/api/settings/site-url"), + locals: { + emdash: { + db, + config: {}, + storage: undefined, + }, + user, + }, + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- minimal stub + } as unknown as APIContext; +} + +describe("GET /_emdash/api/settings/site-url", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("returns the stored site_url", async () => { + const options = new OptionsRepository(db); + await options.set("emdash:site_url", "https://stored.example"); + + const res = await getSiteUrl(buildContext(db, null, ADMIN_USER)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { siteUrl: string | null } }; + expect(body.data.siteUrl).toBe("https://stored.example"); + }); + + it("returns null when the option has never been set", async () => { + const res = await getSiteUrl(buildContext(db, null, ADMIN_USER)); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { siteUrl: string | null } }; + expect(body.data.siteUrl).toBeNull(); + }); + + it("requires an authenticated user", async () => { + const res = await getSiteUrl(buildContext(db, null, null)); + expect(res.status).toBe(401); + }); +}); + +describe("POST /_emdash/api/settings/site-url", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("stores a normalized origin", async () => { + const res = await postSiteUrl( + buildContext(db, buildRequest({ siteUrl: "https://new.example" }), ADMIN_USER), + ); + expect(res.status).toBe(200); + + const options = new OptionsRepository(db); + expect(await options.get("emdash:site_url")).toBe("https://new.example"); + }); + + it("strips a trailing slash before persisting", async () => { + // Address-bar paste commonly includes a trailing slash. The URL + // constructor preserves the path; we explicitly normalize to `origin`. + const res = await postSiteUrl( + buildContext(db, buildRequest({ siteUrl: "https://new.example/" }), ADMIN_USER), + ); + expect(res.status).toBe(200); + + const options = new OptionsRepository(db); + expect(await options.get("emdash:site_url")).toBe("https://new.example"); + }); + + it("overwrites a previously-stored value", async () => { + // The setup wizard uses setIfAbsent() which would refuse to overwrite. + // The admin endpoint must use plain set() so post-setup edits succeed. + const options = new OptionsRepository(db); + await options.set("emdash:site_url", "https://old.example"); + + const res = await postSiteUrl( + buildContext(db, buildRequest({ siteUrl: "https://new.example" }), ADMIN_USER), + ); + expect(res.status).toBe(200); + + expect(await options.get("emdash:site_url")).toBe("https://new.example"); + }); + + it("rejects non-http(s) schemes", async () => { + // XSS-vector schemes must never be writable here -- this value gets + // interpolated into outgoing email content. + const res = await postSiteUrl( + buildContext( + db, + buildRequest({ siteUrl: "javascript:alert(1)" }), + ADMIN_USER, + ), + ); + expect(res.status).toBe(400); + + const options = new OptionsRepository(db); + expect(await options.get("emdash:site_url")).toBeNull(); + }); + + it("rejects values that include a path component", async () => { + // The stored value is an origin only -- `getSiteBaseUrl()` appends + // `/_emdash` on read. A pre-pended path would produce double-pathed + // links in transactional emails. + const res = await postSiteUrl( + buildContext( + db, + buildRequest({ siteUrl: "https://example.com/admin" }), + ADMIN_USER, + ), + ); + expect(res.status).toBe(400); + }); + + it("rejects values that include a query string", async () => { + const res = await postSiteUrl( + buildContext( + db, + buildRequest({ siteUrl: "https://example.com?x=1" }), + ADMIN_USER, + ), + ); + expect(res.status).toBe(400); + }); + + it("rejects unparseable URLs", async () => { + const res = await postSiteUrl( + buildContext(db, buildRequest({ siteUrl: "not a url" }), ADMIN_USER), + ); + expect(res.status).toBe(400); + }); + + it("rejects callers without settings:manage", async () => { + // Editors have settings:read but not settings:manage. + const res = await postSiteUrl( + buildContext( + db, + buildRequest({ siteUrl: "https://new.example" }), + EDITOR_USER, + ), + ); + expect(res.status).toBe(403); + }); + + it("requires an authenticated user", async () => { + const res = await postSiteUrl( + buildContext(db, buildRequest({ siteUrl: "https://new.example" }), null), + ); + expect(res.status).toBe(401); + }); +}); From f5a6e92dba7683970d593e1f9bf3bb95a8d0cc10 Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Sat, 16 May 2026 22:26:58 +0000 Subject: [PATCH 2/2] style: format --- .../src/astro/routes/api/settings/site-url.ts | 6 +---- .../astro/settings-site-url.test.ts | 24 ++++--------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/packages/core/src/astro/routes/api/settings/site-url.ts b/packages/core/src/astro/routes/api/settings/site-url.ts index 2eb6ee28c..1e5648b1f 100644 --- a/packages/core/src/astro/routes/api/settings/site-url.ts +++ b/packages/core/src/astro/routes/api/settings/site-url.ts @@ -110,11 +110,7 @@ export const POST: APIRoute = async ({ request, locals }) => { } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return apiError( - "INVALID_SITE_URL", - "Site URL must use the http or https scheme", - 400, - ); + return apiError("INVALID_SITE_URL", "Site URL must use the http or https scheme", 400); } // Allow trailing-slash-only path so users pasting `https://example.com/` diff --git a/packages/core/tests/integration/astro/settings-site-url.test.ts b/packages/core/tests/integration/astro/settings-site-url.test.ts index 5d7418da4..1be7e1d27 100644 --- a/packages/core/tests/integration/astro/settings-site-url.test.ts +++ b/packages/core/tests/integration/astro/settings-site-url.test.ts @@ -137,11 +137,7 @@ describe("POST /_emdash/api/settings/site-url", () => { // XSS-vector schemes must never be writable here -- this value gets // interpolated into outgoing email content. const res = await postSiteUrl( - buildContext( - db, - buildRequest({ siteUrl: "javascript:alert(1)" }), - ADMIN_USER, - ), + buildContext(db, buildRequest({ siteUrl: "javascript:alert(1)" }), ADMIN_USER), ); expect(res.status).toBe(400); @@ -154,22 +150,14 @@ describe("POST /_emdash/api/settings/site-url", () => { // `/_emdash` on read. A pre-pended path would produce double-pathed // links in transactional emails. const res = await postSiteUrl( - buildContext( - db, - buildRequest({ siteUrl: "https://example.com/admin" }), - ADMIN_USER, - ), + buildContext(db, buildRequest({ siteUrl: "https://example.com/admin" }), ADMIN_USER), ); expect(res.status).toBe(400); }); it("rejects values that include a query string", async () => { const res = await postSiteUrl( - buildContext( - db, - buildRequest({ siteUrl: "https://example.com?x=1" }), - ADMIN_USER, - ), + buildContext(db, buildRequest({ siteUrl: "https://example.com?x=1" }), ADMIN_USER), ); expect(res.status).toBe(400); }); @@ -184,11 +172,7 @@ describe("POST /_emdash/api/settings/site-url", () => { it("rejects callers without settings:manage", async () => { // Editors have settings:read but not settings:manage. const res = await postSiteUrl( - buildContext( - db, - buildRequest({ siteUrl: "https://new.example" }), - EDITOR_USER, - ), + buildContext(db, buildRequest({ siteUrl: "https://new.example" }), EDITOR_USER), ); expect(res.status).toBe(403); });