From b84d80c3a97b4534de03eafbe25ade77d94fe615 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Sat, 23 May 2026 18:19:45 -0600 Subject: [PATCH] Fix magic link routes --- CloudronManifest.json | 2 +- app/api/auth/magic-link/request/route.ts | 3 ++- .../[stakeholderId]/resend/route.ts | 3 ++- app/api/rules/[id]/stakeholders/route.ts | 3 ++- app/api/rules/route.ts | 3 ++- app/api/user/email-change/request/route.ts | 3 ++- lib/server/publicOrigin.ts | 25 +++++++++++++++++++ 7 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 lib/server/publicOrigin.ts diff --git a/CloudronManifest.json b/CloudronManifest.json index fd8c539..7812579 100644 --- a/CloudronManifest.json +++ b/CloudronManifest.json @@ -4,7 +4,7 @@ "title": "Community Rule", "author": "MEDLab", "description": "Community governance and rule-building app", - "version": "0.1.5", + "version": "0.1.6", "httpPort": 3000, "healthCheckPath": "/api/health", "memoryLimit": 805306368, diff --git a/app/api/auth/magic-link/request/route.ts b/app/api/auth/magic-link/request/route.ts index 64ccc16..b644c82 100644 --- a/app/api/auth/magic-link/request/route.ts +++ b/app/api/auth/magic-link/request/route.ts @@ -20,6 +20,7 @@ import { import { logRouteError } from "../../../../../lib/server/requestId"; import { apiRoute } from "../../../../../lib/server/apiRoute"; import { safeInternalPath } from "../../../../../lib/safeInternalPath"; +import { getPublicOrigin } from "../../../../../lib/server/publicOrigin"; import { magicLinkRequestBodySchema } from "../../../../../lib/server/validation/createFlowSchemas"; import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp"; @@ -99,7 +100,7 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request }, }); - const origin = request.nextUrl.origin; + const origin = getPublicOrigin(request); const verifyUrl = `${origin}/api/auth/magic-link/verify?token=${encodeURIComponent(token)}`; try { diff --git a/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts index 87fcc3e..222c170 100644 --- a/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts +++ b/app/api/rules/[id]/stakeholders/[stakeholderId]/resend/route.ts @@ -21,6 +21,7 @@ import { } from "../../../../../../../lib/server/responses"; import { getSessionUser } from "../../../../../../../lib/server/session"; import { rateLimitKey } from "../../../../../../../lib/server/rateLimit"; +import { getPublicOrigin } from "../../../../../../../lib/server/publicOrigin"; type RouteContext = { params: Promise<{ id: string; stakeholderId: string }> }; @@ -92,7 +93,7 @@ export const POST = apiRoute( }, }); - const verifyUrl = stakeholderInviteVerifyUrl(request.nextUrl.origin, token); + const verifyUrl = stakeholderInviteVerifyUrl(getPublicOrigin(request), token); try { await sendRuleStakeholderInviteEmail(row.email, verifyUrl, row.rule.title); } catch (err) { diff --git a/app/api/rules/[id]/stakeholders/route.ts b/app/api/rules/[id]/stakeholders/route.ts index f169cb2..02fe87b 100644 --- a/app/api/rules/[id]/stakeholders/route.ts +++ b/app/api/rules/[id]/stakeholders/route.ts @@ -18,6 +18,7 @@ import { unauthorized, } from "../../../../../lib/server/responses"; import { getSessionUser } from "../../../../../lib/server/session"; +import { getPublicOrigin } from "../../../../../lib/server/publicOrigin"; import { MAX_STAKEHOLDER_EMAILS, postRuleStakeholderBodySchema, @@ -151,7 +152,7 @@ export const POST = apiRoute( return serverMisconfigured(); } - const origin = request.nextUrl.origin; + const origin = getPublicOrigin(request); const sent = await createRuleStakeholderInviteAndSendMail({ scope: "rules.stakeholders.add", requestId, diff --git a/app/api/rules/route.ts b/app/api/rules/route.ts index 2b0983b..839be10 100644 --- a/app/api/rules/route.ts +++ b/app/api/rules/route.ts @@ -20,6 +20,7 @@ import { stakeholderInviteVerifyUrl } from "../../../lib/server/ruleStakeholderI import { STAKEHOLDER_INVITE_TTL_MS } from "../../../lib/server/ruleStakeholders"; import { getSessionUser } from "../../../lib/server/session"; import { apiRoute } from "../../../lib/server/apiRoute"; +import { getPublicOrigin } from "../../../lib/server/publicOrigin"; import { publishRuleBodySchema, uniqueStakeholderEmailsForPublish, @@ -148,7 +149,7 @@ export const POST = apiRoute( return { rule: created, invites: toSend }; }); - const origin = request.nextUrl.origin; + const origin = getPublicOrigin(request); try { for (const inv of invites) { const verifyUrl = stakeholderInviteVerifyUrl(origin, inv.token); diff --git a/app/api/user/email-change/request/route.ts b/app/api/user/email-change/request/route.ts index f27cf7b..d632d38 100644 --- a/app/api/user/email-change/request/route.ts +++ b/app/api/user/email-change/request/route.ts @@ -20,6 +20,7 @@ import { unauthorized, } from "../../../../../lib/server/responses"; import { getSessionUser } from "../../../../../lib/server/session"; +import { getPublicOrigin } from "../../../../../lib/server/publicOrigin"; import { readLimitedJson } from "../../../../../lib/server/validation/requestBody"; import { emailChangeRequestBodySchema } from "../../../../../lib/server/validation/userEmailChangeSchemas"; import { jsonFromZodError } from "../../../../../lib/server/validation/zodHttp"; @@ -115,7 +116,7 @@ export const POST = apiRoute(SCOPE, async (request: NextRequest, _ctx, { request }, }); - const origin = request.nextUrl.origin; + const origin = getPublicOrigin(request); const verifyUrl = `${origin}/api/user/email-change/verify?token=${encodeURIComponent(token)}`; try { diff --git a/lib/server/publicOrigin.ts b/lib/server/publicOrigin.ts new file mode 100644 index 0000000..9a011b1 --- /dev/null +++ b/lib/server/publicOrigin.ts @@ -0,0 +1,25 @@ +import type { NextRequest } from "next/server"; + +/** + * Resolve the public origin of the request, preferring proxy-forwarded headers. + * + * Next.js standalone behind Cloudron's reverse proxy sees `Host: 0.0.0.0:3000` + * (the bind address from `ENV HOSTNAME` in the Dockerfile), so + * `request.nextUrl.origin` returns an internal address unsuitable for + * outbound URLs (magic-link emails, stakeholder invites, etc.). + * + * Trusts `X-Forwarded-Host` + `X-Forwarded-Proto` when present. Falls back + * to `request.nextUrl.origin` for local dev where no proxy is in front. + */ +export function getPublicOrigin(request: NextRequest): string { + const forwardedHost = request.headers.get("x-forwarded-host"); + const forwardedProto = request.headers.get("x-forwarded-proto"); + + if (forwardedHost) { + const proto = forwardedProto?.split(",")[0]?.trim() || "https"; + const host = forwardedHost.split(",")[0]?.trim(); + if (host) return `${proto}://${host}`; + } + + return request.nextUrl.origin; +}