From 0692058602b0df3597ef93239bd518eaee29b113 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:55:22 +0100 Subject: [PATCH] Embed admin portal as its own app in the jar behind buildWithPortal (#6911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Lets the admin portal ("Stirling Processor") ship **inside the JAR**, gated by a build flag. On `main` the portal already exists as a lazy `/portal/*` route in the editor but isn't included in production builds and isn't reachable in a login-enabled server. This PR makes it a **flag-gated, directly-navigable** part of the editor bundle, and wires it into the PR preview deployment so it can be tried live. It keeps the exact architecture `main` uses (portal = a lazy chunk of the editor, not a separate app), so it inherits all the editor's global providers/styles and there's no second build to maintain. ## How **Frontend - gate the existing lazy route** ([`adminRouteExtensions.tsx`](frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx)) ```ts const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; const PortalApp = includePortal ? lazy(() => import("@portal/PortalApp")) : null; ``` Vite bakes the env to a literal, so when off the dynamic import is **tree-shaken out entirely** (no `PortalApp` chunk emitted). Always on in dev. `VITE_INCLUDE_PORTAL` is typed in `vite-env.d.ts` and declared (default `false`) in `editor/.env`. **Gradle** ([`build.gradle`](app/core/build.gradle)) - `-PbuildWithPortal=true` forces `buildWithFrontend=true` and sets `VITE_INCLUDE_PORTAL=true` on the editor build. Process-env takes priority over `.env`, so the flag wins for JAR builds while plain `vite build` / Cloudflare Pages default to off. **Backend - make the shell reachable** ([`RequestUriUtils`](app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java)) - permits `/portal` + `/portal/*` as public SPA routes. The editor keeps its JWT in localStorage (not a cookie), so a direct nav/refresh to `/portal` isn't authenticated at the server and would otherwise redirect to `/login` and never load. Serving the shell pre-auth (like the editor root already is) lets it load; **access control is unchanged** - the portal has its own auth gate + `RequirePortalAccess`, and its data APIs stay protected. **Docker** - the embedded Dockerfiles take `ARG BUILD_PORTAL=false` → `-PbuildWithPortal=${BUILD_PORTAL}`. Default off, so official `push-docker` images do **not** bundle the portal. **CI - scoped to the PR preview deploy only** ([`PR-Auto-Deploy-V2.yml`](.github/workflows/PR-Auto-Deploy-V2.yml)) - the one job that builds the JAR and comments owns all portal wiring: passes `BUILD_PORTAL=true`, enables the portal's backend features (`POLICIES_ENABLED`, `STIRLING_BILLING_ACCOUNT_LINK_ENABLED`), and adds an "Admin portal included" line (linking `/portal` via the direct IP) to the deployment comment. `push-docker`, `build.yml`, `test-build-docker`, and the shared paths-filter are untouched. ## Validation (real, in the JAR) Built and booted the JAR with `-PbuildWithPortal=true` and login enabled: - `/portal` and `/portal/users` load via direct nav and render **fully themed** (dark surfaces, gradients, filled buttons). - Editor-only build (`-PbuildWithFrontend=true`, no portal flag) → editor ships, **0 portal chunks** (tree-shaken). - `-PbuildWithPortal=true` → `PortalApp` chunk present. Green: `frontend:check:all` (typecheck all variants, lint, format, build, tests incl. the `VITE_*` env guard), backend compile, `RequestUriUtilsTest`, spotless. ## Notes - **Official images never bundle the portal** (Dockerfile default off); only the PR preview does. Flip `BUILD_PORTAL` / `-PbuildWithPortal` to include it elsewhere. - The `/portal` shell being public is the one deviation from `main`, and it's required for the route to be reachable at all in a login-enabled server; data access is still fully gated. --- .github/workflows/PR-Auto-Deploy-V2.yml | 24 ++++++++++--- .../software/common/util/RequestUriUtils.java | 9 +++++ .../common/util/RequestUriUtilsTest.java | 8 +++++ app/core/build.gradle | 12 ++++++- docker/embedded/Dockerfile | 4 +++ docker/embedded/Dockerfile.fat | 3 ++ docker/embedded/Dockerfile.ultra-lite | 3 ++ frontend/editor/.env | 4 +++ .../routes/adminRouteExtensions.tsx | 34 +++++++++++++------ frontend/editor/vite-env.d.ts | 2 ++ 10 files changed, 87 insertions(+), 16 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index b6ab8d34c8..31135cfb4b 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -116,6 +116,9 @@ jobs: env: USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }} DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }} + # Single source of truth for whether this preview embeds the admin portal: + # drives the image build-arg and the deployment comment. + BUILD_PORTAL: "true" steps: - name: Harden Runner @@ -246,7 +249,9 @@ jobs: file: ./docker/embedded/Dockerfile push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} - build-args: VERSION_TAG=v2-alpha + build-args: | + VERSION_TAG=v2-alpha + BUILD_PORTAL=${{ env.BUILD_PORTAL }} platforms: linux/amd64 - name: Build and push V2 image (Docker fork fallback) @@ -259,7 +264,9 @@ jobs: cache-from: type=gha,scope=stirling-pdf-latest cache-to: type=gha,mode=max,scope=stirling-pdf-latest tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }} - build-args: VERSION_TAG=v2-alpha + build-args: | + VERSION_TAG=v2-alpha + BUILD_PORTAL=${{ env.BUILD_PORTAL }} platforms: linux/amd64 - name: Set up SSH @@ -290,6 +297,8 @@ jobs: - /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw environment: DISABLE_ADDITIONAL_FEATURES: "false" + POLICIES_ENABLED: "true" + STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true" SECURITY_ENABLELOGIN: "true" SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}" SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}" @@ -359,12 +368,19 @@ jobs: } const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`; - const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`; + + // Only mention the portal when this image actually embeds it. + // Use the direct IP URL - the SSL hostname isn't supported yet. + const withPortal = "${{ env.BUILD_PORTAL }}" === "true"; + const portalNote = withPortal + ? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n` + : ``; const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` + `Your V2 PR with embedded architecture has been deployed!\n\n` + `🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` + - `🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` + + `🔐 **Secure HTTPS URL**: unsupported currently\n\n` + + portalNote + `_This deployment will be automatically cleaned up when the PR is closed._\n\n` + `🔄 **Auto-deployed** for approved V2 contributors.`; diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 74c713b6db..faf71c2f97 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -57,6 +57,15 @@ public class RequestUriUtils { return true; } + // Admin portal SPA shell. Served publicly like the editor root so a direct + // nav / refresh to /portal loads the app (the JWT lives in localStorage, not + // a cookie, so the server can't authenticate the navigation itself). The + // portal gates access via its own auth gate + RequirePortalAccess, and its + // data APIs stay protected, so serving the shell pre-auth is safe. + if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) { + return true; + } + // Treat common static file extensions as static resources return normalizedUri.endsWith(".svg") || normalizedUri.endsWith(".png") diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index 4f0f3e372a..a13aeac82b 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -73,6 +73,14 @@ class RequestUriUtilsTest { assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner")); } + @Test + void testIsStaticResource_portalShell() { + // The admin portal SPA shell is served pre-auth so it's directly navigable. + assertTrue(RequestUriUtils.isStaticResource("/portal")); + assertTrue(RequestUriUtils.isStaticResource("/portal/users")); + assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal")); + } + // --- isFrontendRoute tests --- @Test diff --git a/app/core/build.gradle b/app/core/build.gradle index d77fbdd438..6a55d89304 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -175,6 +175,14 @@ springBoot { // Frontend build tasks - only enabled with -PbuildWithFrontend=true def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true' def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true' +// The admin portal ships as a lazy route inside the editor bundle (see +// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that +// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when +// the portal or AI layers change. Building the portal implies building the editor. +def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true' +if (buildWithPortal) { + buildWithFrontend = true +} // Workspace root holds package.json and node_modules (shared across editor / // future portal). Editor-specific paths (src, public, dist, tauri) live one // level deeper under frontend/editor/. @@ -297,9 +305,11 @@ tasks.register('npmBuild', Exec) { // Override VITE_API_BASE_URL to use relative paths for production builds // This ensures JARs work regardless of how they're deployed (direct, proxied, etc.) environment 'VITE_API_BASE_URL', '/' + // Include the admin portal's lazy route/chunk in the editor build when requested. + environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false') doFirst { - println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)" + println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})" } } diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 7ea8201f59..760b728f34 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -42,6 +42,9 @@ COPY . . ARG PROTOTYPES_BUILD=false ARG STIRLING_FLAVOR=proprietary ENV STIRLING_FLAVOR=${STIRLING_FLAVOR} +# Embed the admin portal app at /portal. Set true by the deploy workflow when the +# portal or AI layers change; defaults false so normal builds skip the extra app. +ARG BUILD_PORTAL=false # Bundle only the JPDFium native for this image's target arch. ARG TARGETARCH @@ -49,6 +52,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li STIRLING_FLAVOR=${STIRLING_FLAVOR} \ gradle clean build \ -PbuildWithFrontend=true \ + -PbuildWithPortal=${BUILD_PORTAL} \ -PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \ -PprototypesMode=${PROTOTYPES_BUILD} \ -x spotlessApply -x spotlessCheck -x test -x sonarqube \ diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 71f2a12414..32219b6bd3 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -40,12 +40,15 @@ RUN gradle dependencies --no-daemon || true COPY . . +# Embed the admin portal app at /portal when the deploy workflow flags it. +ARG BUILD_PORTAL=false # Bundle only the JPDFium native for this image's target arch. ARG TARGETARCH RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \ DISABLE_ADDITIONAL_FEATURES=false \ gradle clean build \ -PbuildWithFrontend=true \ + -PbuildWithPortal=${BUILD_PORTAL} \ -PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \ -x spotlessApply -x spotlessCheck -x test -x sonarqube \ --no-daemon diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index 01048e864d..bdaa788785 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -40,12 +40,15 @@ RUN ./gradlew dependencies --no-daemon || true COPY . . # Build ultra-lite JAR with embedded frontend (minimal features). +# Embed the admin portal app at /portal when the deploy workflow flags it. +ARG BUILD_PORTAL=false # Bundle only the JPDFium native for this image's target arch. ARG TARGETARCH RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \ DISABLE_ADDITIONAL_FEATURES=true \ ./gradlew clean build \ -PbuildWithFrontend=true \ + -PbuildWithPortal=${BUILD_PORTAL} \ -PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \ -x spotlessApply -x spotlessCheck -x test -x sonarqube \ --no-daemon diff --git a/frontend/editor/.env b/frontend/editor/.env index f8f3c2e308..961da5d840 100644 --- a/frontend/editor/.env +++ b/frontend/editor/.env @@ -7,6 +7,10 @@ # API base URL — use / for same-origin (default for web builds) VITE_API_BASE_URL=/ +# Include the admin portal's lazy route/chunk in the build (set true by +# -PbuildWithPortal in the JAR). Off by default; always on in dev. +VITE_INCLUDE_PORTAL=false + # Google Drive integration VITE_GOOGLE_DRIVE_CLIENT_ID= VITE_GOOGLE_DRIVE_API_KEY= diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index c0216e377f..89676d2d29 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -2,22 +2,34 @@ import { lazy } from "react"; import type { ReactElement } from "react"; import { Route } from "react-router-dom"; -// Lazy so the portal is its own chunk, never in the editor's initial bundle; -// only fetched when an admin navigates to /portal. Mocks start first so the -// worker is ready before the portal's first fetch. -const PortalApp = lazy(async () => { - const { startPortalMocksIfEnabled } = - await import("@portal/mocks/startIfEnabled"); - await startPortalMocksIfEnabled(); - const m = await import("@portal/PortalApp"); - return { default: m.PortalApp }; -}); +// The portal ships as a lazy chunk of the editor. It's included in dev (so it's +// always available to work on) and in production builds made with +// VITE_INCLUDE_PORTAL=true (set by -PbuildWithPortal in the JAR, and by the deploy +// GHA when the portal or AI layers change). Vite replaces the env with a literal at +// build time, so when it's off the dynamic import below is tree-shaken out and the +// portal chunk isn't emitted. PortalApp stays module-level so it isn't recreated on +// each render. Mocks start first so the worker is ready before the portal's first +// fetch. +const includePortal = + import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; + +const PortalApp = includePortal + ? lazy(async () => { + const { startPortalMocksIfEnabled } = + await import("@portal/mocks/startIfEnabled"); + await startPortalMocksIfEnabled(); + const m = await import("@portal/PortalApp"); + return { default: m.PortalApp }; + }) + : null; /** * The portal mounts as an admin-only route-set at /portal/*. Access is gated * inside PortalApp (its own AuthProvider + AuthGate, plus server enforcement), - * so this just wires the lazy route into the editor's router. + * so this just wires the lazy route into the editor's router when the portal is + * included in this build. */ export function getAdminRouteExtensions(): ReactElement[] { + if (!PortalApp) return []; return [} />]; } diff --git a/frontend/editor/vite-env.d.ts b/frontend/editor/vite-env.d.ts index d26c3bceb9..f690cf1b3e 100644 --- a/frontend/editor/vite-env.d.ts +++ b/frontend/editor/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { // Used by all builds (.env) readonly VITE_API_BASE_URL: string; + /** "true" includes the admin portal's lazy route/chunk in the editor build. */ + readonly VITE_INCLUDE_PORTAL: string; readonly VITE_GOOGLE_DRIVE_CLIENT_ID: string; readonly VITE_GOOGLE_DRIVE_API_KEY: string; readonly VITE_GOOGLE_DRIVE_APP_ID: string;