feat(panel): serve authenticated app on dedicated panel subdomain
Separate the marketing landing site from the authenticated app by host. Next.js middleware reads PANEL_HOST/LANDING_HOST at runtime and redirects authenticated routes (/dashboard, /login, /register) from the landing host to the panel host, and the landing root on the panel host to /dashboard. Disabled (single-origin) when PANEL_HOST is unset, so local dev is unchanged. Helm: add ingress.panel.host with a third ingress rule + TLS host routing to the frontend service, pass LANDING_HOST/PANEL_HOST to the frontend, and append the panel origin to the backend CORS list (frontend URL stays first so PLATFORM_DOMAIN resolution is unaffected). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,21 @@ app.kubernetes.io/instance: {{ .Release.Name }}
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{- /*
|
||||||
|
Comma-separated CORS origins for the backend's FRONTEND_URL.
|
||||||
|
The frontend (landing) URL MUST stay first: configuration.ts derives
|
||||||
|
PLATFORM_DOMAIN / preview domain from the first entry only. The panel host
|
||||||
|
(when configured) is appended as an additional allowed origin.
|
||||||
|
*/ -}}
|
||||||
|
{{- define "cloudhost-platform.corsOrigins" -}}
|
||||||
|
{{- $scheme := include "cloudhost-platform.urlScheme" . -}}
|
||||||
|
{{- $origins := list (include "cloudhost-platform.frontendPublicUrl" .) -}}
|
||||||
|
{{- if and .Values.ingress.enabled .Values.ingress.panel.host -}}
|
||||||
|
{{- $origins = append $origins (printf "%s://%s" $scheme .Values.ingress.panel.host) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- join "," $origins -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
{{- define "cloudhost-platform.apiPublicUrl" -}}
|
{{- define "cloudhost-platform.apiPublicUrl" -}}
|
||||||
{{- $scheme := include "cloudhost-platform.urlScheme" . -}}
|
{{- $scheme := include "cloudhost-platform.urlScheme" . -}}
|
||||||
{{- if .Values.ingress.enabled }}
|
{{- if .Values.ingress.enabled }}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ spec:
|
|||||||
name: {{ include "cloudhost-platform.secretName" . }}
|
name: {{ include "cloudhost-platform.secretName" . }}
|
||||||
key: jwt-refresh-secret
|
key: jwt-refresh-secret
|
||||||
- name: FRONTEND_URL
|
- name: FRONTEND_URL
|
||||||
value: {{ include "cloudhost-platform.frontendPublicUrl" . | quote }}
|
value: {{ include "cloudhost-platform.corsOrigins" . | quote }}
|
||||||
{{- range $key, $val := .Values.backend.env }}
|
{{- range $key, $val := .Values.backend.env }}
|
||||||
- name: {{ $key }}
|
- name: {{ $key }}
|
||||||
value: {{ $val | quote }}
|
value: {{ $val | quote }}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ spec:
|
|||||||
value: "3000"
|
value: "3000"
|
||||||
- name: HOSTNAME
|
- name: HOSTNAME
|
||||||
value: "0.0.0.0"
|
value: "0.0.0.0"
|
||||||
|
{{- if .Values.ingress.panel.host }}
|
||||||
|
# Host-based separation: landing on frontend.host, authenticated
|
||||||
|
# panel on panel.host. Read by Next.js middleware at runtime.
|
||||||
|
- name: LANDING_HOST
|
||||||
|
value: {{ .Values.ingress.frontend.host | quote }}
|
||||||
|
- name: PANEL_HOST
|
||||||
|
value: {{ .Values.ingress.panel.host | quote }}
|
||||||
|
{{- end }}
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /
|
path: /
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ spec:
|
|||||||
- {{ .Values.ingress.singleHost.host | quote }}
|
- {{ .Values.ingress.singleHost.host | quote }}
|
||||||
{{- else }}
|
{{- else }}
|
||||||
- {{ .Values.ingress.frontend.host | quote }}
|
- {{ .Values.ingress.frontend.host | quote }}
|
||||||
|
{{- if .Values.ingress.panel.host }}
|
||||||
|
- {{ .Values.ingress.panel.host | quote }}
|
||||||
|
{{- end }}
|
||||||
- {{ .Values.ingress.api.host | quote }}
|
- {{ .Values.ingress.api.host | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
secretName: {{ include "cloudhost-platform.tlsSecretName" . }}
|
secretName: {{ include "cloudhost-platform.tlsSecretName" . }}
|
||||||
@@ -59,6 +62,18 @@ spec:
|
|||||||
name: {{ include "cloudhost-platform.frontend.fullname" . }}
|
name: {{ include "cloudhost-platform.frontend.fullname" . }}
|
||||||
port:
|
port:
|
||||||
number: 3000
|
number: 3000
|
||||||
|
{{- if .Values.ingress.panel.host }}
|
||||||
|
- host: {{ .Values.ingress.panel.host | quote }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "cloudhost-platform.frontend.fullname" . }}
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
|
{{- end }}
|
||||||
- host: {{ .Values.ingress.api.host | quote }}
|
- host: {{ .Values.ingress.api.host | quote }}
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ ingress:
|
|||||||
className: nginx
|
className: nginx
|
||||||
frontend:
|
frontend:
|
||||||
host: platform.cloudhost.local
|
host: platform.cloudhost.local
|
||||||
|
# Optional dedicated host for the authenticated panel. Empty = disabled
|
||||||
|
# (single-origin, dev). Production (abrban): panel.abrban.com
|
||||||
|
panel:
|
||||||
|
host: ""
|
||||||
api:
|
api:
|
||||||
host: api.cloudhost.local
|
host: api.cloudhost.local
|
||||||
singleHost:
|
singleHost:
|
||||||
|
|||||||
@@ -14,6 +14,33 @@ function parseAcceptLanguage(header: string | null): string[] {
|
|||||||
.map((x) => x.tag);
|
.map((x) => x.tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host-based separation between the marketing landing site and the
|
||||||
|
// authenticated panel. Configured at runtime (server-side) so the same
|
||||||
|
// frontend image can serve both hosts. When PANEL_HOST is unset (local dev),
|
||||||
|
// host routing is disabled and everything is served from a single origin.
|
||||||
|
const PANEL_HOST = process.env.PANEL_HOST?.trim().toLowerCase();
|
||||||
|
const LANDING_HOST = process.env.LANDING_HOST?.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Locale-stripped path prefixes that belong to the authenticated panel.
|
||||||
|
const PANEL_PATH_PREFIXES = ['/dashboard', '/login', '/register'];
|
||||||
|
|
||||||
|
function stripLocale(pathname: string): string {
|
||||||
|
for (const l of locales) {
|
||||||
|
if (pathname === `/${l}`) return '/';
|
||||||
|
if (pathname.startsWith(`/${l}/`)) return pathname.slice(l.length + 1);
|
||||||
|
}
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPanelPath(pathname: string): boolean {
|
||||||
|
const p = stripLocale(pathname);
|
||||||
|
return PANEL_PATH_PREFIXES.some((pre) => p === pre || p.startsWith(`${pre}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostOf(request: NextRequest): string {
|
||||||
|
return (request.headers.get('host') || '').split(':')[0].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function detectLocale(request: NextRequest): Locale {
|
function detectLocale(request: NextRequest): Locale {
|
||||||
// 1. Explicit choice via cookie
|
// 1. Explicit choice via cookie
|
||||||
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
|
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
|
||||||
@@ -33,18 +60,42 @@ function detectLocale(request: NextRequest): Locale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function middleware(request: NextRequest) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname, search } = request.nextUrl;
|
||||||
|
|
||||||
|
// 1. Ensure a locale prefix (e.g. /login -> /fa-IR/login). On the redirect
|
||||||
|
// the host is preserved, so host routing (below) runs on the next request.
|
||||||
const pathnameHasLocale = locales.some(
|
const pathnameHasLocale = locales.some(
|
||||||
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
|
(locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
|
||||||
);
|
);
|
||||||
if (pathnameHasLocale) return;
|
if (!pathnameHasLocale) {
|
||||||
|
const locale = detectLocale(request);
|
||||||
|
request.nextUrl.pathname = `/${locale}${pathname === '/' ? '' : pathname}`;
|
||||||
|
const response = NextResponse.redirect(request.nextUrl);
|
||||||
|
response.cookies.set(LOCALE_COOKIE, locale, { path: '/', maxAge: 60 * 60 * 24 * 365 });
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
const locale = detectLocale(request);
|
// 2. Host-based separation between the landing site and the panel.
|
||||||
request.nextUrl.pathname = `/${locale}${pathname === '/' ? '' : pathname}`;
|
// Disabled entirely unless PANEL_HOST is configured (local dev).
|
||||||
const response = NextResponse.redirect(request.nextUrl);
|
if (PANEL_HOST) {
|
||||||
response.cookies.set(LOCALE_COOKIE, locale, { path: '/', maxAge: 60 * 60 * 24 * 365 });
|
const host = hostOf(request);
|
||||||
return response;
|
const panelPath = isPanelPath(pathname);
|
||||||
|
|
||||||
|
// Authenticated routes requested on the landing host -> move to the panel.
|
||||||
|
if (LANDING_HOST && host === LANDING_HOST && panelPath) {
|
||||||
|
return NextResponse.redirect(`https://${PANEL_HOST}${pathname}${search}`, 308);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marketing pages requested on the panel host -> send to the dashboard.
|
||||||
|
if (host === PANEL_HOST && !panelPath) {
|
||||||
|
const locale = pathname.split('/')[1]; // path is already locale-prefixed
|
||||||
|
const target = request.nextUrl.clone();
|
||||||
|
target.pathname = `/${locale}/dashboard`;
|
||||||
|
return NextResponse.redirect(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
Reference in New Issue
Block a user