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:
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import './.next/dev/types/routes.d.ts';
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ const nextConfig: NextConfig = {
|
|||||||
// visible in every screenshot from the iPhone testing pass.
|
// visible in every screenshot from the iPhone testing pass.
|
||||||
devIndicators: false,
|
devIndicators: false,
|
||||||
// LAN access from a real iPhone hits the dev server via the Mac's
|
// 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
|
// local IP (e.g. 192.168.x.x), not localhost. Next surfaces a warning
|
||||||
// warning for cross-origin /_next/* fetches unless we allow-list the
|
// and blocks cross-origin /_next/* fetches (incl. HMR) unless we
|
||||||
// origins explicitly. Wildcard the 192.168/0.0.0.0 ranges in dev so
|
// allow-list the origins explicitly. When HMR is blocked the page
|
||||||
// any LAN device works without a config edit per network.
|
// never fully hydrates and form click handlers fall back to native
|
||||||
...(isProd ? {} : { allowedDevOrigins: ['192.168.1.42'] }),
|
// 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
|
// Native/CJS-leaning server-only packages — list here so Next doesn't
|
||||||
// bundle them into the route trace (slower cold start + risk that
|
// bundle them into the route trace (slower cold start + risk that
|
||||||
// native bindings fail at runtime). Build-auditor C3+M3: socket.io
|
// native bindings fail at runtime). Build-auditor C3+M3: socket.io
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse, type NextRequest } from 'next/server';
|
||||||
|
|
||||||
import { PORTAL_COOKIE } from '@/lib/portal/auth';
|
import { PORTAL_COOKIE } from '@/lib/portal/auth';
|
||||||
import { env } from '@/lib/env';
|
|
||||||
|
|
||||||
export async function POST(): Promise<NextResponse> {
|
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||||
const response = NextResponse.redirect(new URL('/portal/login', env.APP_URL));
|
// 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);
|
response.cookies.delete(PORTAL_COOKIE);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
setPortLogo,
|
setPortLogo,
|
||||||
type LogoCrop,
|
type LogoCrop,
|
||||||
} from '@/lib/services/logo.service';
|
} from '@/lib/services/logo.service';
|
||||||
import { env } from '@/lib/env';
|
|
||||||
|
|
||||||
const MAX_RAW_BYTES = 5 * 1024 * 1024;
|
const MAX_RAW_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -50,14 +49,13 @@ export const GET = withAuth(
|
|||||||
if (!file) {
|
if (!file) {
|
||||||
return NextResponse.json({ data: null });
|
return NextResponse.json({ data: null });
|
||||||
}
|
}
|
||||||
const baseUrl = env.APP_URL.replace(/\/+$/, '');
|
// Path-only — the admin UI renders this as `<img src>` and the
|
||||||
// Stream from the public-by-id surface (gated on `category='branding'`)
|
// browser resolves against the current origin. Stays valid whether
|
||||||
// so the URL works as a direct `<img src>` — the authenticated
|
// the admin opens the page from localhost or a LAN IP.
|
||||||
// `/api/v1/files/<id>/preview` returns JSON, not image bytes.
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
fileId: file.id,
|
fileId: file.id,
|
||||||
previewUrl: `${baseUrl}/api/public/files/${file.id}`,
|
previewUrl: `/api/public/files/${file.id}`,
|
||||||
sizeBytes: file.sizeBytes,
|
sizeBytes: file.sizeBytes,
|
||||||
mimeType: file.mimeType,
|
mimeType: file.mimeType,
|
||||||
},
|
},
|
||||||
@@ -95,11 +93,10 @@ export const POST = withAuth(
|
|||||||
ipAddress: ctx.ipAddress,
|
ipAddress: ctx.ipAddress,
|
||||||
userAgent: ctx.userAgent,
|
userAgent: ctx.userAgent,
|
||||||
});
|
});
|
||||||
const baseUrl = env.APP_URL.replace(/\/+$/, '');
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
data: {
|
data: {
|
||||||
fileId: result.fileId,
|
fileId: result.fileId,
|
||||||
previewUrl: `${baseUrl}/api/public/files/${result.fileId}`,
|
previewUrl: `/api/public/files/${result.fileId}`,
|
||||||
warnings: result.warnings,
|
warnings: result.warnings,
|
||||||
finalDimensions: processed.finalDimensions,
|
finalDimensions: processed.finalDimensions,
|
||||||
finalBytes: processed.finalBytes,
|
finalBytes: processed.finalBytes,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { db } from '@/lib/db';
|
|||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { uploadFile } from '@/lib/services/files';
|
import { uploadFile } from '@/lib/services/files';
|
||||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||||
import { env } from '@/lib/env';
|
|
||||||
|
|
||||||
const MAX_BYTES = 5 * 1024 * 1024;
|
const MAX_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -77,11 +76,11 @@ export const POST = withAuth(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const baseUrl = env.APP_URL.replace(/\/+$/, '');
|
// Path-only so the in-app `<img src>` resolves against whatever
|
||||||
// Branding assets must survive in email-inbox land where no session
|
// host the page was loaded from (localhost, LAN IP, prod domain).
|
||||||
// cookie travels — route through the public-by-id surface gated on
|
// Email shell calls `absolutizeBrandingUrl()` to prepend APP_URL
|
||||||
// `category='branding'` rather than the authenticated preview path.
|
// for mail clients, which have no origin context.
|
||||||
const url = `${baseUrl}/api/public/files/${record.id}`;
|
const url = `/api/public/files/${record.id}`;
|
||||||
|
|
||||||
return NextResponse.json({ data: { fileId: record.id, url } });
|
return NextResponse.json({ data: { fileId: record.id, url } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
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 })`.
|
* function. Templates call `renderShell({ title, body, branding })`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { absolutizeBrandingUrl } from '@/lib/branding/url';
|
||||||
|
|
||||||
// Neutral defaults — no tenant-specific imagery leaks across ports.
|
// Neutral defaults — no tenant-specific imagery leaks across ports.
|
||||||
// When branding hasn't been configured the email renders without a logo
|
// 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
|
// 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 {
|
export function renderShell({ title, body, branding }: ShellOpts): string {
|
||||||
const logoUrl = branding?.logoUrl ?? DEFAULT_LOGO_URL;
|
// Branding URLs are stored path-only (so in-app rendering works across
|
||||||
const backgroundUrl = branding?.backgroundUrl ?? DEFAULT_BACKGROUND_URL;
|
// 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 headerHtml = branding?.emailHeaderHtml ?? '';
|
||||||
const footerHtml = branding?.emailFooterHtml ?? '';
|
const footerHtml = branding?.emailFooterHtml ?? '';
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* env var when neither is set.
|
* env var when neither is set.
|
||||||
*/
|
*/
|
||||||
import { env } from '@/lib/env';
|
import { env } from '@/lib/env';
|
||||||
|
import { normalizeBrandingUrl } from '@/lib/branding/url';
|
||||||
import { getSetting } from '@/lib/services/settings.service';
|
import { getSetting } from '@/lib/services/settings.service';
|
||||||
|
|
||||||
// ─── Setting key constants ───────────────────────────────────────────────────
|
// ─── Setting key constants ───────────────────────────────────────────────────
|
||||||
@@ -572,8 +573,14 @@ export async function getPortBrandingConfig(portId: string): Promise<PortBrandin
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
logoUrl: logoUrl ?? DEFAULT_BRANDING.logoUrl,
|
// Branding URLs that bake a localhost/LAN host (uploaded while running
|
||||||
emailBackgroundUrl: emailBackgroundUrl ?? DEFAULT_BRANDING.emailBackgroundUrl,
|
// 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,
|
primaryColor: primaryColor ?? DEFAULT_BRANDING.primaryColor,
|
||||||
appName: appName ?? DEFAULT_BRANDING.appName,
|
appName: appName ?? DEFAULT_BRANDING.appName,
|
||||||
emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml,
|
emailHeaderHtml: emailHeaderHtml ?? DEFAULT_BRANDING.emailHeaderHtml,
|
||||||
|
|||||||
@@ -15,6 +15,27 @@ import type { ServerToClientEvents, ClientToServerEvents } from './events';
|
|||||||
|
|
||||||
let io: Server<ClientToServerEvents, ServerToClientEvents> | null = null;
|
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
|
* 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
|
* for the given portId. The Socket.IO auth middleware uses this to decide
|
||||||
@@ -77,7 +98,11 @@ export function initSocketServer(
|
|||||||
path: '/socket.io/',
|
path: '/socket.io/',
|
||||||
adapter: createAdapter(pubClient, subClient),
|
adapter: createAdapter(pubClient, subClient),
|
||||||
cors: {
|
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,
|
credentials: true,
|
||||||
},
|
},
|
||||||
connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },
|
connectionStateRecovery: { maxDisconnectionDuration: 2 * 60 * 1000 },
|
||||||
|
|||||||
@@ -67,7 +67,12 @@ function SocketProviderClient({ children }: { children: ReactNode }) {
|
|||||||
return;
|
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/',
|
path: '/socket.io/',
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
auth: { portId: currentPortId },
|
auth: { portId: currentPortId },
|
||||||
|
|||||||
Reference in New Issue
Block a user