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) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 12:28:34 +02:00
parent 6aaccb6d33
commit be261f3f90
10 changed files with 124 additions and 30 deletions

52
src/lib/branding/url.ts Normal file
View File

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