diff --git a/backend/helm/cloudhost-platform/templates/_helpers.tpl b/backend/helm/cloudhost-platform/templates/_helpers.tpl index fda6f09..298453c 100644 --- a/backend/helm/cloudhost-platform/templates/_helpers.tpl +++ b/backend/helm/cloudhost-platform/templates/_helpers.tpl @@ -74,6 +74,21 @@ app.kubernetes.io/instance: {{ .Release.Name }} {{- 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" -}} {{- $scheme := include "cloudhost-platform.urlScheme" . -}} {{- if .Values.ingress.enabled }} diff --git a/backend/helm/cloudhost-platform/templates/backend-deployment.yaml b/backend/helm/cloudhost-platform/templates/backend-deployment.yaml index 82ab5c3..248a207 100644 --- a/backend/helm/cloudhost-platform/templates/backend-deployment.yaml +++ b/backend/helm/cloudhost-platform/templates/backend-deployment.yaml @@ -79,7 +79,7 @@ spec: name: {{ include "cloudhost-platform.secretName" . }} key: jwt-refresh-secret - name: FRONTEND_URL - value: {{ include "cloudhost-platform.frontendPublicUrl" . | quote }} + value: {{ include "cloudhost-platform.corsOrigins" . | quote }} {{- range $key, $val := .Values.backend.env }} - name: {{ $key }} value: {{ $val | quote }} diff --git a/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml b/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml index 3169f89..a5a90b7 100644 --- a/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml +++ b/backend/helm/cloudhost-platform/templates/frontend-deployment.yaml @@ -28,6 +28,14 @@ spec: value: "3000" - name: HOSTNAME 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: httpGet: path: / diff --git a/backend/helm/cloudhost-platform/templates/ingress.yaml b/backend/helm/cloudhost-platform/templates/ingress.yaml index c4ded07..223271d 100644 --- a/backend/helm/cloudhost-platform/templates/ingress.yaml +++ b/backend/helm/cloudhost-platform/templates/ingress.yaml @@ -25,6 +25,9 @@ spec: - {{ .Values.ingress.singleHost.host | quote }} {{- else }} - {{ .Values.ingress.frontend.host | quote }} + {{- if .Values.ingress.panel.host }} + - {{ .Values.ingress.panel.host | quote }} + {{- end }} - {{ .Values.ingress.api.host | quote }} {{- end }} secretName: {{ include "cloudhost-platform.tlsSecretName" . }} @@ -59,6 +62,18 @@ spec: name: {{ include "cloudhost-platform.frontend.fullname" . }} port: 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 }} http: paths: diff --git a/backend/helm/cloudhost-platform/values.yaml b/backend/helm/cloudhost-platform/values.yaml index 920afff..0efdc77 100644 --- a/backend/helm/cloudhost-platform/values.yaml +++ b/backend/helm/cloudhost-platform/values.yaml @@ -78,6 +78,10 @@ ingress: className: nginx frontend: host: platform.cloudhost.local + # Optional dedicated host for the authenticated panel. Empty = disabled + # (single-origin, dev). Production (abrban): panel.abrban.com + panel: + host: "" api: host: api.cloudhost.local singleHost: diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 6affae4..bb09e11 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -14,6 +14,33 @@ function parseAcceptLanguage(header: string | null): string[] { .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 { // 1. Explicit choice via cookie const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value; @@ -33,18 +60,42 @@ function detectLocale(request: NextRequest): Locale { } 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( (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); - 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; + // 2. Host-based separation between the landing site and the panel. + // Disabled entirely unless PANEL_HOST is configured (local dev). + if (PANEL_HOST) { + const host = hostOf(request); + 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 = {