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

View File

@@ -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<NextResponse> {
const response = NextResponse.redirect(new URL('/portal/login', env.APP_URL));
export async function POST(req: NextRequest): Promise<NextResponse> {
// 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);

View File

@@ -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 `<img src>` — the authenticated
// `/api/v1/files/<id>/preview` returns JSON, not image bytes.
// Path-only — the admin UI renders this as `<img src>` 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,

View File

@@ -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 `<img src>` 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) {