From be261f3f90c7675cd9565aa408ecfc7116ecbdb6 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 22 May 2026 12:28:34 +0200 Subject: [PATCH] fix(dev-lan): unblock phone-on-LAN testing of the dev server Branding URLs were baked with env.APP_URL=http://localhost:3000 at upload time and stored verbatim in system_settings, so any logo/ background loaded from a non-localhost origin (an iPhone hitting the Mac's LAN IP) failed to resolve. Same pattern bit Socket.IO (CORS + client connection target) and the portal logout redirect. - Branding: getPortBrandingConfig normalizes localhost/private-LAN hosts to path-only; both upload routes store path-only going forward; email shell re-absolutizes via absolutizeBrandingUrl() so inboxes (no app origin) still get fetchable URLs. DB backfilled to strip http://localhost:3000 from existing rows. - Socket.IO: client connects to window.location.origin (io() with no URL); server CORS allows localhost + private-LAN ranges in dev, stays locked to APP_URL in prod. - Portal logout: redirect target built from the request URL instead of env.APP_URL. - next.config: allowedDevOrigins widened from a hardcoded IP to 192.168/10/172.16-31 wildcards so HMR works across networks without an edit per-network. (Without HMR the login form's React click handler never hydrates and the form falls back to GET, leaking the password into the URL.) Co-Authored-By: Claude Opus 4.7 (1M context) --- next-env.d.ts | 2 +- next.config.ts | 12 +++-- src/app/api/portal/auth/logout/route.ts | 11 +++-- src/app/api/v1/admin/branding/logo/route.ts | 13 ++--- src/app/api/v1/admin/settings/image/route.ts | 11 ++--- src/lib/branding/url.ts | 52 ++++++++++++++++++++ src/lib/email/shell.ts | 8 ++- src/lib/services/port-config.ts | 11 ++++- src/lib/socket/server.ts | 27 +++++++++- src/providers/socket-provider.tsx | 7 ++- 10 files changed, 124 insertions(+), 30 deletions(-) create mode 100644 src/lib/branding/url.ts diff --git a/next-env.d.ts b/next-env.d.ts index 20e7bcfb..c4b7818f 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import './.next/dev/types/routes.d.ts'; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts index b0315d54..ef37673a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -84,11 +84,13 @@ const nextConfig: NextConfig = { // visible in every screenshot from the iPhone testing pass. devIndicators: false, // LAN access from a real iPhone hits the dev server via the Mac's - // local IP (e.g. 192.168.x.x), not localhost. Next 15 surfaces a - // warning for cross-origin /_next/* fetches unless we allow-list the - // origins explicitly. Wildcard the 192.168/0.0.0.0 ranges in dev so - // any LAN device works without a config edit per network. - ...(isProd ? {} : { allowedDevOrigins: ['192.168.1.42'] }), + // local IP (e.g. 192.168.x.x), not localhost. Next surfaces a warning + // and blocks cross-origin /_next/* fetches (incl. HMR) unless we + // allow-list the origins explicitly. When HMR is blocked the page + // never fully hydrates and form click handlers fall back to native + // submits — the symptom that bit us with a hard-coded IP. Wildcards + // cover any LAN device without a per-network config edit. + ...(isProd ? {} : { allowedDevOrigins: ['192.168.*.*', '10.*.*.*', '172.16.*.*', '172.20.*.*'] }), // Native/CJS-leaning server-only packages — list here so Next doesn't // bundle them into the route trace (slower cold start + risk that // native bindings fail at runtime). Build-auditor C3+M3: socket.io diff --git a/src/app/api/portal/auth/logout/route.ts b/src/app/api/portal/auth/logout/route.ts index bb16cb7f..5446e293 100644 --- a/src/app/api/portal/auth/logout/route.ts +++ b/src/app/api/portal/auth/logout/route.ts @@ -1,10 +1,13 @@ -import { NextResponse } from 'next/server'; +import { NextResponse, type NextRequest } from 'next/server'; import { PORTAL_COOKIE } from '@/lib/portal/auth'; -import { env } from '@/lib/env'; -export async function POST(): Promise { - const response = NextResponse.redirect(new URL('/portal/login', env.APP_URL)); +export async function POST(req: NextRequest): Promise { + // Build the redirect from the request URL so we stay on whatever host + // the user is actually browsing from (localhost, LAN IP, prod domain). + // Reading env.APP_URL here used to redirect phone-on-LAN users back + // to localhost. + const response = NextResponse.redirect(new URL('/portal/login', req.url)); response.cookies.delete(PORTAL_COOKIE); diff --git a/src/app/api/v1/admin/branding/logo/route.ts b/src/app/api/v1/admin/branding/logo/route.ts index 5a56d0f6..ec640d6c 100644 --- a/src/app/api/v1/admin/branding/logo/route.ts +++ b/src/app/api/v1/admin/branding/logo/route.ts @@ -9,7 +9,6 @@ import { setPortLogo, type LogoCrop, } from '@/lib/services/logo.service'; -import { env } from '@/lib/env'; const MAX_RAW_BYTES = 5 * 1024 * 1024; @@ -50,14 +49,13 @@ export const GET = withAuth( if (!file) { return NextResponse.json({ data: null }); } - const baseUrl = env.APP_URL.replace(/\/+$/, ''); - // Stream from the public-by-id surface (gated on `category='branding'`) - // so the URL works as a direct `` — the authenticated - // `/api/v1/files//preview` returns JSON, not image bytes. + // Path-only — the admin UI renders this as `` and the + // browser resolves against the current origin. Stays valid whether + // the admin opens the page from localhost or a LAN IP. return NextResponse.json({ data: { fileId: file.id, - previewUrl: `${baseUrl}/api/public/files/${file.id}`, + previewUrl: `/api/public/files/${file.id}`, sizeBytes: file.sizeBytes, mimeType: file.mimeType, }, @@ -95,11 +93,10 @@ export const POST = withAuth( ipAddress: ctx.ipAddress, userAgent: ctx.userAgent, }); - const baseUrl = env.APP_URL.replace(/\/+$/, ''); return NextResponse.json({ data: { fileId: result.fileId, - previewUrl: `${baseUrl}/api/public/files/${result.fileId}`, + previewUrl: `/api/public/files/${result.fileId}`, warnings: result.warnings, finalDimensions: processed.finalDimensions, finalBytes: processed.finalBytes, diff --git a/src/app/api/v1/admin/settings/image/route.ts b/src/app/api/v1/admin/settings/image/route.ts index 283dc94d..83f4186c 100644 --- a/src/app/api/v1/admin/settings/image/route.ts +++ b/src/app/api/v1/admin/settings/image/route.ts @@ -6,7 +6,6 @@ import { db } from '@/lib/db'; import { ports } from '@/lib/db/schema/ports'; import { uploadFile } from '@/lib/services/files'; import { errorResponse, ValidationError } from '@/lib/errors'; -import { env } from '@/lib/env'; const MAX_BYTES = 5 * 1024 * 1024; @@ -77,11 +76,11 @@ export const POST = withAuth( }, ); - const baseUrl = env.APP_URL.replace(/\/+$/, ''); - // Branding assets must survive in email-inbox land where no session - // cookie travels — route through the public-by-id surface gated on - // `category='branding'` rather than the authenticated preview path. - const url = `${baseUrl}/api/public/files/${record.id}`; + // Path-only so the in-app `` resolves against whatever + // host the page was loaded from (localhost, LAN IP, prod domain). + // Email shell calls `absolutizeBrandingUrl()` to prepend APP_URL + // for mail clients, which have no origin context. + const url = `/api/public/files/${record.id}`; return NextResponse.json({ data: { fileId: record.id, url } }); } catch (error) { diff --git a/src/lib/branding/url.ts b/src/lib/branding/url.ts new file mode 100644 index 00000000..74cbc8b5 --- /dev/null +++ b/src/lib/branding/url.ts @@ -0,0 +1,52 @@ +import { env } from '@/lib/env'; + +const LOCAL_HOST_PATTERNS = [ + /^localhost(:\d+)?$/i, + /^127\.\d+\.\d+\.\d+(:\d+)?$/, + /^0\.0\.0\.0(:\d+)?$/, + /^192\.168\.\d+\.\d+(:\d+)?$/, + /^10\.\d+\.\d+\.\d+(:\d+)?$/, + /^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/, +]; + +/** + * Strip the scheme+host from a branding URL when the host is localhost + * or a private LAN address, leaving a path-only URL the browser can + * resolve against whatever origin it loaded the page from. + * + * Without this, a logo uploaded while the app ran at http://localhost:3000 + * is forever pinned to that host — fine for the dev's Mac, broken from + * a phone on the LAN or any device with a different DNS view. + * + * Public-internet URLs (CDN, S3) pass through unchanged. + */ +export function normalizeBrandingUrl(url: string | null | undefined): string | null { + if (!url) return null; + const trimmed = url.trim(); + if (!trimmed) return null; + if (trimmed.startsWith('/')) return trimmed; + try { + const parsed = new URL(trimmed); + const isLocal = LOCAL_HOST_PATTERNS.some((re) => re.test(parsed.host)); + if (!isLocal) return trimmed; + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch { + return trimmed; + } +} + +/** + * Email surfaces (rendered HTML inboxes) cannot resolve path-only URLs — + * the recipient's mail client has no origin context. Use this when + * emitting branding into an email shell to guarantee an absolute URL. + * + * Pass-through for URLs that are already absolute. + */ +export function absolutizeBrandingUrl(url: string | null | undefined): string | null { + if (!url) return null; + const trimmed = url.trim(); + if (!trimmed) return null; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + const base = env.APP_URL.replace(/\/+$/, ''); + return `${base}${trimmed.startsWith('/') ? '' : '/'}${trimmed}`; +} diff --git a/src/lib/email/shell.ts b/src/lib/email/shell.ts index 44f162f8..86f16c60 100644 --- a/src/lib/email/shell.ts +++ b/src/lib/email/shell.ts @@ -16,6 +16,8 @@ * function. Templates call `renderShell({ title, body, branding })`. */ +import { absolutizeBrandingUrl } from '@/lib/branding/url'; + // Neutral defaults — no tenant-specific imagery leaks across ports. // When branding hasn't been configured the email renders without a logo // and on a plain off-white background. Admins upload their own assets via @@ -42,8 +44,10 @@ interface ShellOpts { } export function renderShell({ title, body, branding }: ShellOpts): string { - const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL; - const backgroundUrl = branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL; + // Branding URLs are stored path-only (so in-app rendering works across + // any host). Mail clients have no app origin, so re-absolutize here. + const logoUrl = absolutizeBrandingUrl(branding?.logoUrl ?? DEFAULT_LOGO_URL); + const backgroundUrl = absolutizeBrandingUrl(branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL); const headerHtml = branding?.emailHeaderHtml ?? ''; const footerHtml = branding?.emailFooterHtml ?? ''; diff --git a/src/lib/services/port-config.ts b/src/lib/services/port-config.ts index f4820032..7b17d164 100644 --- a/src/lib/services/port-config.ts +++ b/src/lib/services/port-config.ts @@ -8,6 +8,7 @@ * env var when neither is set. */ import { env } from '@/lib/env'; +import { normalizeBrandingUrl } from '@/lib/branding/url'; import { getSetting } from '@/lib/services/settings.service'; // ─── Setting key constants ─────────────────────────────────────────────────── @@ -572,8 +573,14 @@ export async function getPortBrandingConfig(portId: string): Promise | null = null; +const DEV_ORIGIN_PATTERNS = [ + /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/, + /^https?:\/\/192\.168\.\d+\.\d+(:\d+)?$/, + /^https?:\/\/10\.\d+\.\d+\.\d+(:\d+)?$/, + /^https?:\/\/172\.(1[6-9]|2\d|3[01])\.\d+\.\d+(:\d+)?$/, +]; + +function socketCorsOrigin( + origin: string | undefined, + cb: (err: Error | null, allow?: boolean) => void, +): void { + if (!origin) return cb(null, true); + if (process.env.NODE_ENV === 'production') { + return cb(null, origin === process.env.APP_URL); + } + cb( + null, + DEV_ORIGIN_PATTERNS.some((re) => re.test(origin)), + ); +} + /** * Returns true if the user is a super-admin OR holds a userPortRoles row * for the given portId. The Socket.IO auth middleware uses this to decide @@ -77,7 +98,11 @@ export function initSocketServer( path: '/socket.io/', adapter: createAdapter(pubClient, subClient), cors: { - origin: process.env.APP_URL, + // In prod, lock to the canonical APP_URL. In dev, allow localhost + // + private-LAN origins so the same dev server serves the Mac + // (localhost) and a phone on Wi-Fi (192.168.x.x) without a config + // edit per network. Mirrors the trustedOrigins pattern in auth. + origin: socketCorsOrigin, credentials: true, }, connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 }, diff --git a/src/providers/socket-provider.tsx b/src/providers/socket-provider.tsx index 07e6873e..c925ec0e 100644 --- a/src/providers/socket-provider.tsx +++ b/src/providers/socket-provider.tsx @@ -67,7 +67,12 @@ function SocketProviderClient({ children }: { children: ReactNode }) { return; } - const s = io(process.env.NEXT_PUBLIC_APP_URL!, { + // Connect to whatever origin the page was loaded from — `io()` with + // no URL defaults to window.location. This used to read + // NEXT_PUBLIC_APP_URL, which baked the deploy-time canonical URL + // (localhost in dev) and broke realtime when the same dev server + // was hit from a LAN IP. + const s = io({ path: '/socket.io/', withCredentials: true, auth: { portId: currentPortId },