Skip to content

Commit 0692058

Browse files
authored
Embed admin portal as its own app in the jar behind buildWithPortal (Stirling-Tools#6911)
## 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.
1 parent 8150d16 commit 0692058

10 files changed

Lines changed: 87 additions & 16 deletions

File tree

.github/workflows/PR-Auto-Deploy-V2.yml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ jobs:
116116
env:
117117
USE_DEPOT: ${{ needs.pick.outputs.is_fork != 'true' }}
118118
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
119+
# Single source of truth for whether this preview embeds the admin portal:
120+
# drives the image build-arg and the deployment comment.
121+
BUILD_PORTAL: "true"
119122

120123
steps:
121124
- name: Harden Runner
@@ -246,7 +249,9 @@ jobs:
246249
file: ./docker/embedded/Dockerfile
247250
push: true
248251
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
249-
build-args: VERSION_TAG=v2-alpha
252+
build-args: |
253+
VERSION_TAG=v2-alpha
254+
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
250255
platforms: linux/amd64
251256

252257
- name: Build and push V2 image (Docker fork fallback)
@@ -259,7 +264,9 @@ jobs:
259264
cache-from: type=gha,scope=stirling-pdf-latest
260265
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
261266
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:v2-${{ steps.commit-hash.outputs.app_short }}
262-
build-args: VERSION_TAG=v2-alpha
267+
build-args: |
268+
VERSION_TAG=v2-alpha
269+
BUILD_PORTAL=${{ env.BUILD_PORTAL }}
263270
platforms: linux/amd64
264271

265272
- name: Set up SSH
@@ -290,6 +297,8 @@ jobs:
290297
- /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/storage:/storage:rw
291298
environment:
292299
DISABLE_ADDITIONAL_FEATURES: "false"
300+
POLICIES_ENABLED: "true"
301+
STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true"
293302
SECURITY_ENABLELOGIN: "true"
294303
SECURITY_INITIALLOGIN_USERNAME: "${{ secrets.TEST_LOGIN_USERNAME }}"
295304
SECURITY_INITIALLOGIN_PASSWORD: "${{ secrets.TEST_LOGIN_PASSWORD }}"
@@ -359,12 +368,19 @@ jobs:
359368
}
360369
361370
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
362-
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
371+
372+
// Only mention the portal when this image actually embeds it.
373+
// Use the direct IP URL - the SSL hostname isn't supported yet.
374+
const withPortal = "${{ env.BUILD_PORTAL }}" === "true";
375+
const portalNote = withPortal
376+
? `🧩 **Admin portal** included - try it at [${deploymentUrl}/portal](${deploymentUrl}/portal).\n\n`
377+
: ``;
363378
364379
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
365380
`Your V2 PR with embedded architecture has been deployed!\n\n` +
366381
`🔗 **Direct Test URL (non-SSL)** [${deploymentUrl}](${deploymentUrl})\n\n` +
367-
`🔐 **Secure HTTPS URL**: [${httpsUrl}](${httpsUrl})\n\n` +
382+
`🔐 **Secure HTTPS URL**: unsupported currently\n\n` +
383+
portalNote +
368384
`_This deployment will be automatically cleaned up when the PR is closed._\n\n` +
369385
`🔄 **Auto-deployed** for approved V2 contributors.`;
370386

app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ public static boolean isStaticResource(String contextPath, String requestURI) {
5757
return true;
5858
}
5959

60+
// Admin portal SPA shell. Served publicly like the editor root so a direct
61+
// nav / refresh to /portal loads the app (the JWT lives in localStorage, not
62+
// a cookie, so the server can't authenticate the navigation itself). The
63+
// portal gates access via its own auth gate + RequirePortalAccess, and its
64+
// data APIs stay protected, so serving the shell pre-auth is safe.
65+
if (normalizedUri.equals("/portal") || normalizedUri.startsWith("/portal/")) {
66+
return true;
67+
}
68+
6069
// Treat common static file extensions as static resources
6170
return normalizedUri.endsWith(".svg")
6271
|| normalizedUri.endsWith(".png")

app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ void testIsStaticResource_mobileScannerPath() {
7373
assertTrue(RequestUriUtils.isStaticResource("/mobile-scanner"));
7474
}
7575

76+
@Test
77+
void testIsStaticResource_portalShell() {
78+
// The admin portal SPA shell is served pre-auth so it's directly navigable.
79+
assertTrue(RequestUriUtils.isStaticResource("/portal"));
80+
assertTrue(RequestUriUtils.isStaticResource("/portal/users"));
81+
assertTrue(RequestUriUtils.isStaticResource("/app", "/app/portal"));
82+
}
83+
7684
// --- isFrontendRoute tests ---
7785

7886
@Test

app/core/build.gradle

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,14 @@ springBoot {
175175
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
176176
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
177177
def buildPrototypes = project.hasProperty('prototypesMode') && project.property('prototypesMode') == 'true'
178+
// The admin portal ships as a lazy route inside the editor bundle (see
179+
// proprietary/routes/adminRouteExtensions). -PbuildWithPortal=true includes that
180+
// chunk via VITE_INCLUDE_PORTAL on the editor build; the deploy GHA sets it when
181+
// the portal or AI layers change. Building the portal implies building the editor.
182+
def buildWithPortal = project.hasProperty('buildWithPortal') && project.property('buildWithPortal') == 'true'
183+
if (buildWithPortal) {
184+
buildWithFrontend = true
185+
}
178186
// Workspace root holds package.json and node_modules (shared across editor /
179187
// future portal). Editor-specific paths (src, public, dist, tauri) live one
180188
// level deeper under frontend/editor/.
@@ -297,9 +305,11 @@ tasks.register('npmBuild', Exec) {
297305
// Override VITE_API_BASE_URL to use relative paths for production builds
298306
// This ensures JARs work regardless of how they're deployed (direct, proxied, etc.)
299307
environment 'VITE_API_BASE_URL', '/'
308+
// Include the admin portal's lazy route/chunk in the editor build when requested.
309+
environment 'VITE_INCLUDE_PORTAL', (buildWithPortal ? 'true' : 'false')
300310

301311
doFirst {
302-
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/)"
312+
println "Building editor frontend application for production (mode=${frontendMode}, VITE_API_BASE_URL=/, portal=${buildWithPortal})"
303313
}
304314
}
305315

docker/embedded/Dockerfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,17 @@ COPY . .
4242
ARG PROTOTYPES_BUILD=false
4343
ARG STIRLING_FLAVOR=proprietary
4444
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
45+
# Embed the admin portal app at /portal. Set true by the deploy workflow when the
46+
# portal or AI layers change; defaults false so normal builds skip the extra app.
47+
ARG BUILD_PORTAL=false
4548

4649
# Bundle only the JPDFium native for this image's target arch.
4750
ARG TARGETARCH
4851
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
4952
STIRLING_FLAVOR=${STIRLING_FLAVOR} \
5053
gradle clean build \
5154
-PbuildWithFrontend=true \
55+
-PbuildWithPortal=${BUILD_PORTAL} \
5256
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
5357
-PprototypesMode=${PROTOTYPES_BUILD} \
5458
-x spotlessApply -x spotlessCheck -x test -x sonarqube \

docker/embedded/Dockerfile.fat

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,15 @@ RUN gradle dependencies --no-daemon || true
4040

4141
COPY . .
4242

43+
# Embed the admin portal app at /portal when the deploy workflow flags it.
44+
ARG BUILD_PORTAL=false
4345
# Bundle only the JPDFium native for this image's target arch.
4446
ARG TARGETARCH
4547
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
4648
DISABLE_ADDITIONAL_FEATURES=false \
4749
gradle clean build \
4850
-PbuildWithFrontend=true \
51+
-PbuildWithPortal=${BUILD_PORTAL} \
4952
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
5053
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
5154
--no-daemon

docker/embedded/Dockerfile.ultra-lite

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,15 @@ RUN ./gradlew dependencies --no-daemon || true
4040
COPY . .
4141

4242
# Build ultra-lite JAR with embedded frontend (minimal features).
43+
# Embed the admin portal app at /portal when the deploy workflow flags it.
44+
ARG BUILD_PORTAL=false
4345
# Bundle only the JPDFium native for this image's target arch.
4446
ARG TARGETARCH
4547
RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \
4648
DISABLE_ADDITIONAL_FEATURES=true \
4749
./gradlew clean build \
4850
-PbuildWithFrontend=true \
51+
-PbuildWithPortal=${BUILD_PORTAL} \
4952
-PjpdfiumPlatforms="$JPDFIUM_PLATFORM" \
5053
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
5154
--no-daemon

frontend/editor/.env

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
# API base URL — use / for same-origin (default for web builds)
88
VITE_API_BASE_URL=/
99

10+
# Include the admin portal's lazy route/chunk in the build (set true by
11+
# -PbuildWithPortal in the JAR). Off by default; always on in dev.
12+
VITE_INCLUDE_PORTAL=false
13+
1014
# Google Drive integration
1115
VITE_GOOGLE_DRIVE_CLIENT_ID=
1216
VITE_GOOGLE_DRIVE_API_KEY=

frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,34 @@ import { lazy } from "react";
22
import type { ReactElement } from "react";
33
import { Route } from "react-router-dom";
44

5-
// Lazy so the portal is its own chunk, never in the editor's initial bundle;
6-
// only fetched when an admin navigates to /portal. Mocks start first so the
7-
// worker is ready before the portal's first fetch.
8-
const PortalApp = lazy(async () => {
9-
const { startPortalMocksIfEnabled } =
10-
await import("@portal/mocks/startIfEnabled");
11-
await startPortalMocksIfEnabled();
12-
const m = await import("@portal/PortalApp");
13-
return { default: m.PortalApp };
14-
});
5+
// The portal ships as a lazy chunk of the editor. It's included in dev (so it's
6+
// always available to work on) and in production builds made with
7+
// VITE_INCLUDE_PORTAL=true (set by -PbuildWithPortal in the JAR, and by the deploy
8+
// GHA when the portal or AI layers change). Vite replaces the env with a literal at
9+
// build time, so when it's off the dynamic import below is tree-shaken out and the
10+
// portal chunk isn't emitted. PortalApp stays module-level so it isn't recreated on
11+
// each render. Mocks start first so the worker is ready before the portal's first
12+
// fetch.
13+
const includePortal =
14+
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
15+
16+
const PortalApp = includePortal
17+
? lazy(async () => {
18+
const { startPortalMocksIfEnabled } =
19+
await import("@portal/mocks/startIfEnabled");
20+
await startPortalMocksIfEnabled();
21+
const m = await import("@portal/PortalApp");
22+
return { default: m.PortalApp };
23+
})
24+
: null;
1525

1626
/**
1727
* The portal mounts as an admin-only route-set at /portal/*. Access is gated
1828
* inside PortalApp (its own AuthProvider + AuthGate, plus server enforcement),
19-
* so this just wires the lazy route into the editor's router.
29+
* so this just wires the lazy route into the editor's router when the portal is
30+
* included in this build.
2031
*/
2132
export function getAdminRouteExtensions(): ReactElement[] {
33+
if (!PortalApp) return [];
2234
return [<Route key="portal" path="/portal/*" element={<PortalApp />} />];
2335
}

frontend/editor/vite-env.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
interface ImportMetaEnv {
44
// Used by all builds (.env)
55
readonly VITE_API_BASE_URL: string;
6+
/** "true" includes the admin portal's lazy route/chunk in the editor build. */
7+
readonly VITE_INCLUDE_PORTAL: string;
68
readonly VITE_GOOGLE_DRIVE_CLIENT_ID: string;
79
readonly VITE_GOOGLE_DRIVE_API_KEY: string;
810
readonly VITE_GOOGLE_DRIVE_APP_ID: string;

0 commit comments

Comments
 (0)