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;