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:
52
src/lib/branding/url.ts
Normal file
52
src/lib/branding/url.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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 ?? '';
|
||||
|
||||
|
||||
@@ -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<PortBrandin
|
||||
]);
|
||||
|
||||
return {
|
||||
logoUrl: logoUrl ?? DEFAULT_BRANDING.logoUrl,
|
||||
emailBackgroundUrl: emailBackgroundUrl ?? DEFAULT_BRANDING.emailBackgroundUrl,
|
||||
// Branding URLs that bake a localhost/LAN host (uploaded while running
|
||||
// on the dev's Mac) don't resolve from any other device. Normalize
|
||||
// here so in-app consumers get a path-only URL the browser resolves
|
||||
// against the current origin. Email surfaces re-absolutize via
|
||||
// `absolutizeBrandingUrl()` because mail clients have no app origin.
|
||||
logoUrl: normalizeBrandingUrl(logoUrl) ?? DEFAULT_BRANDING.logoUrl,
|
||||
emailBackgroundUrl:
|
||||
normalizeBrandingUrl(emailBackgroundUrl) ?? DEFAULT_BRANDING.emailBackgroundUrl,
|
||||
primaryColor: primaryColor ?? DEFAULT_BRANDING.primaryColor,
|
||||
appName: appName ?? DEFAULT_BRANDING.appName,
|
||||
emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml,
|
||||
|
||||
@@ -15,6 +15,27 @@ import type { ServerToClientEvents, ClientToServerEvents } from './events';
|
||||
|
||||
let io: Server<ClientToServerEvents, ServerToClientEvents> | 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 },
|
||||
|
||||
Reference in New Issue
Block a user