mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
## 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.
383 lines
15 KiB
Groovy
383 lines
15 KiB
Groovy
apply plugin: 'org.springframework.boot'
|
|
|
|
import org.apache.tools.ant.taskdefs.condition.Os
|
|
|
|
configurations {
|
|
developmentOnly
|
|
runtimeClasspath {
|
|
extendsFrom developmentOnly
|
|
}
|
|
}
|
|
|
|
spotless {
|
|
java {
|
|
target 'src/**/java/**/*.java'
|
|
targetExclude 'src/main/resources/static/**', 'src/main/java/org/apache/**'
|
|
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
|
|
// google-java-format 1.28.0 bundles Guava 32.x which crashes Spotless lint on JDK 24/25
|
|
suppressLintsFor { setStep('google-java-format') }
|
|
|
|
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
|
|
trimTrailingWhitespace()
|
|
leadingTabsToSpaces()
|
|
endWithNewline()
|
|
}
|
|
yaml {
|
|
target '**/*.yml', '**/*.yaml'
|
|
targetExclude 'src/main/resources/static/**'
|
|
trimTrailingWhitespace()
|
|
leadingTabsToSpaces()
|
|
endWithNewline()
|
|
}
|
|
format 'gradle', {
|
|
target '**/gradle/*.gradle', '**/*.gradle'
|
|
targetExclude 'src/main/resources/static/**'
|
|
trimTrailingWhitespace()
|
|
leadingTabsToSpaces()
|
|
endWithNewline()
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
if (!gradle.ext.disableAdditional) {
|
|
implementation project(':proprietary')
|
|
}
|
|
|
|
if (gradle.ext.enableSaas) {
|
|
implementation project(':saas')
|
|
}
|
|
|
|
implementation project(':common')
|
|
implementation 'org.springframework.boot:spring-boot-starter-jetty'
|
|
implementation 'org.eclipse.jetty.http2:jetty-http2-server'
|
|
implementation 'org.eclipse.jetty:jetty-alpn-java-server'
|
|
implementation ('org.telegram:telegrambots:6.9.7.1') {
|
|
// Grizzly server + Jersey JAX-RS stack: only used for webhook mode;
|
|
// Stirling-PDF uses long-polling mode so these are dead weight (~3 MB)
|
|
exclude group: 'org.glassfish.jersey.inject'
|
|
exclude group: 'org.glassfish.jersey.media'
|
|
exclude group: 'org.glassfish.jersey.containers'
|
|
exclude group: 'org.glassfish.jersey.core'
|
|
exclude group: 'org.glassfish.jersey.ext'
|
|
exclude group: 'org.glassfish.grizzly'
|
|
exclude group: 'org.glassfish.hk2'
|
|
exclude group: 'org.glassfish.hk2.external'
|
|
exclude group: 'org.javassist', module: 'javassist'
|
|
// Old javax JAX-RS Jackson bindings (not needed, project uses jakarta)
|
|
exclude group: 'com.fasterxml.jackson.jaxrs'
|
|
exclude group: 'com.fasterxml.jackson.module', module: 'jackson-module-jaxb-annotations'
|
|
}
|
|
implementation "commons-io:commons-io:$commonsIoVersion"
|
|
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
|
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
|
implementation 'io.micrometer:micrometer-core'
|
|
implementation 'com.google.zxing:core:3.5.4'
|
|
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
|
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
|
|
|
// General PDF dependencies
|
|
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
|
|
implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion"
|
|
|
|
implementation 'org.verapdf:validation-model:1.28.2'
|
|
// CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
|
|
implementation "org.mozilla:rhino:${rhinoVersion}"
|
|
|
|
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
|
|
implementation 'javax.xml.bind:jaxb-api:2.3.1'
|
|
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
|
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
|
|
|
|
// CVE-2022-25647: Explicit gson to prevent unsafe deserialization (tabula would pull 2.8.7)
|
|
implementation "com.google.code.gson:gson:${gsonVersion}"
|
|
implementation 'org.apache.pdfbox:jbig2-imageio:3.0.4'
|
|
implementation 'com.opencsv:opencsv:5.12.0' // https://mvnrepository.com/artifact/com.opencsv/opencsv
|
|
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
|
|
|
// Batik only bridge module needed (transitively pulls anim, gvt, util, css, dom, svg-dom)
|
|
// Replaces batik-all which included unused codec, svggen, transcoder, script modules
|
|
implementation "org.apache.xmlgraphics:batik-bridge:${batikVersion}"
|
|
// Required by TwelveMonkeys imageio-batik SPI (SVGImageReaderSpi) during ImageIO init
|
|
runtimeOnly "org.apache.xmlgraphics:batik-transcoder:${batikVersion}"
|
|
|
|
// PDFBox Graphics2D bridge for Batik SVG to PDF conversion
|
|
implementation 'de.rototor.pdfbox:graphics2d:3.0.5'
|
|
|
|
// TwelveMonkeys
|
|
runtimeOnly "com.twelvemonkeys.imageio:imageio-batik:$imageioVersion"
|
|
runtimeOnly "com.twelvemonkeys.imageio:imageio-bmp:$imageioVersion"
|
|
runtimeOnly "com.twelvemonkeys.imageio:imageio-jpeg:$imageioVersion"
|
|
runtimeOnly "com.twelvemonkeys.imageio:imageio-tiff:$imageioVersion"
|
|
runtimeOnly "com.twelvemonkeys.imageio:imageio-webp:$imageioVersion"
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-hdr:$imageioVersion"
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-icns:$imageioVersion"
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-iff:$imageioVersion"
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-pcx:$imageioVersion@
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-pict:$imageioVersion"
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-pnm:$imageioVersion"
|
|
runtimeOnly "com.twelvemonkeys.imageio:imageio-psd:$imageioVersion"
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-sgi:$imageioVersion"
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-tga:$imageioVersion"
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-thumbsdb:$imageioVersion"
|
|
// runtimeOnly "com.twelvemonkeys.imageio:imageio-xwd:$imageioVersion"
|
|
|
|
developmentOnly 'org.springframework.boot:spring-boot-devtools'
|
|
}
|
|
|
|
sourceSets {
|
|
main {
|
|
resources {
|
|
srcDirs += ['../configs']
|
|
}
|
|
}
|
|
test {
|
|
}
|
|
|
|
}
|
|
|
|
|
|
// Disable regular jar
|
|
jar {
|
|
enabled = false
|
|
}
|
|
|
|
// Configure and enable bootJar for this project
|
|
bootJar {
|
|
enabled = true
|
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
|
zip64 = true
|
|
|
|
// Don't include all dependencies directly like the old jar task did
|
|
// from {
|
|
// configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
|
|
// }
|
|
|
|
// Exclude signature files to prevent "Invalid signature file digest" errors
|
|
exclude 'META-INF/*.SF'
|
|
exclude 'META-INF/*.DSA'
|
|
exclude 'META-INF/*.RSA'
|
|
exclude 'META-INF/*.EC'
|
|
|
|
manifest {
|
|
attributes(
|
|
'Implementation-Title': 'Stirling-PDF',
|
|
'Implementation-Version': project.version,
|
|
'Enable-Native-Access': 'ALL-UNNAMED'
|
|
)
|
|
}
|
|
}
|
|
|
|
// Configure main class for Spring Boot
|
|
springBoot {
|
|
mainClass = 'stirling.software.SPDF.SPDFApplication'
|
|
}
|
|
|
|
// 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/.
|
|
|
|
// Vite mode: -PprototypesMode > -PfrontendMode > enableSaas > disableAdditional > proprietary.
|
|
def frontendModeOverride = project.findProperty('frontendMode')?.toString()?.toLowerCase()
|
|
def frontendMode
|
|
if (buildPrototypes) {
|
|
frontendMode = 'prototypes'
|
|
} else if (frontendModeOverride) {
|
|
frontendMode = frontendModeOverride
|
|
} else if (gradle.ext.enableSaas) {
|
|
frontendMode = 'saas'
|
|
} else if (gradle.ext.disableAdditional) {
|
|
frontendMode = 'core'
|
|
} else {
|
|
frontendMode = 'proprietary'
|
|
}
|
|
def frontendBuildTask = "frontend:build:${frontendMode}"
|
|
|
|
// 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/. When the portal lands as an embedded
|
|
// app, add a sibling frontendPortalDir / frontendPortalDistDir alongside.
|
|
def frontendDir = file('../../frontend')
|
|
def frontendEditorDir = file('../../frontend/editor')
|
|
def frontendEditorDistDir = file('../../frontend/editor/dist')
|
|
def resourcesStaticDir = file('src/main/resources/static')
|
|
def generatedFrontendPaths = [
|
|
'assets',
|
|
'index.html',
|
|
'index.html.gz',
|
|
'index.html.br',
|
|
'sw.js',
|
|
'sw.js.gz',
|
|
'sw.js.br',
|
|
'manifest.json.gz',
|
|
'manifest.json.br',
|
|
'site.webmanifest.gz',
|
|
'site.webmanifest.br',
|
|
'browserconfig.xml.gz',
|
|
'browserconfig.xml.br',
|
|
'manifest-classic.json',
|
|
'manifest-classic.json.gz',
|
|
'manifest-classic.json.br',
|
|
'locales',
|
|
'Login',
|
|
'classic-logo',
|
|
'modern-logo',
|
|
'og_images',
|
|
'samples',
|
|
'pdfium',
|
|
'vendor',
|
|
'pdfjs'
|
|
]
|
|
|
|
tasks.register('npmInstall', Exec) {
|
|
doNotTrackState("node_modules contains symlinks that Gradle cannot snapshot on Windows/WSL")
|
|
enabled = buildWithFrontend
|
|
group = 'frontend'
|
|
description = 'Install frontend dependencies'
|
|
workingDir frontendDir
|
|
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'ci', '--prefer-offline'] : ['npm', 'ci', '--prefer-offline']
|
|
inputs.file(new File(frontendDir, 'package.json'))
|
|
inputs.file(new File(frontendDir, 'package-lock.json'))
|
|
outputs.dir(new File(frontendDir, 'node_modules'))
|
|
|
|
// Show live output
|
|
standardOutput = System.out
|
|
errorOutput = System.err
|
|
|
|
// Skip if node_modules exists and is up-to-date
|
|
onlyIf {
|
|
def nodeModules = new File(frontendDir, 'node_modules')
|
|
if (!nodeModules.exists()) {
|
|
println "node_modules not found, will install..."
|
|
return true
|
|
}
|
|
|
|
// if required devDependency is missing, reinstall
|
|
def iconifyPkg = new File(frontendDir, 'node_modules/@iconify-json/material-symbols/package.json')
|
|
if (!iconifyPkg.exists()) {
|
|
println "@iconify-json/material-symbols missing, will reinstall..."
|
|
return true
|
|
}
|
|
|
|
def packageJson = new File(frontendDir, 'package.json')
|
|
def packageLock = new File(frontendDir, 'package-lock.json')
|
|
def isOutdated = nodeModules.lastModified() < packageJson.lastModified() ||
|
|
nodeModules.lastModified() < packageLock.lastModified()
|
|
if (isOutdated) {
|
|
println "package.json or package-lock.json changed, will reinstall..."
|
|
} else {
|
|
println "node_modules is up-to-date, skipping npm install"
|
|
}
|
|
return isOutdated
|
|
}
|
|
|
|
doFirst {
|
|
println "Installing npm dependencies in ${frontendDir}..."
|
|
}
|
|
}
|
|
|
|
tasks.register('npmBuild', Exec) {
|
|
doNotTrackState("Frontend build depends on untracked npmInstall task")
|
|
enabled = buildWithFrontend
|
|
group = 'frontend'
|
|
description = 'Build editor frontend application'
|
|
workingDir file('../..')
|
|
commandLine = ['task', frontendBuildTask]
|
|
inputs.dir(new File(frontendEditorDir, 'src'))
|
|
inputs.dir(new File(frontendEditorDir, 'public'))
|
|
inputs.file(new File(frontendDir, 'package.json'))
|
|
outputs.dir(frontendEditorDistDir)
|
|
|
|
// Show live output
|
|
standardOutput = System.out
|
|
errorOutput = System.err
|
|
|
|
// 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=/, portal=${buildWithPortal})"
|
|
}
|
|
}
|
|
|
|
tasks.register('copyFrontendAssets', Copy) {
|
|
enabled = buildWithFrontend
|
|
group = 'frontend'
|
|
description = 'Copy editor frontend build to static resources'
|
|
dependsOn npmBuild
|
|
dependsOn cleanFrontendAssets
|
|
from(frontendEditorDistDir) {
|
|
// Exclude files that conflict with backend static resources
|
|
exclude 'robots.txt' // Backend already has this
|
|
exclude 'favicon.ico' // Backend already has this
|
|
}
|
|
into resourcesStaticDir
|
|
duplicatesStrategy = DuplicatesStrategy.INCLUDE // Let frontend overwrite when needed
|
|
doFirst {
|
|
println "Copying frontend build from ${frontendEditorDistDir} to ${resourcesStaticDir}..."
|
|
println "Backend static resources will be preserved"
|
|
}
|
|
doLast {
|
|
println "Frontend assets copied successfully!"
|
|
}
|
|
}
|
|
|
|
tasks.register('cleanFrontendAssets', Delete) {
|
|
group = 'frontend'
|
|
description = 'Remove previously generated frontend assets from static resources'
|
|
delete generatedFrontendPaths.collect { new File(resourcesStaticDir, it) }
|
|
// Prerendered per-route SPA pages (e.g. compress.html) carry per-tool OG tags and are
|
|
// copied from the frontend build. Remove stale ones so renamed/removed tools don't linger.
|
|
// api-landing.html and mobile-upload.html are real backend source files, not generated artifacts.
|
|
delete fileTree(dir: resourcesStaticDir, includes: ['*.html'], excludes: ['api-landing.html', 'mobile-upload.html'])
|
|
// Nested prerendered route pages (e.g. settings/people.html)
|
|
delete new File(resourcesStaticDir, 'settings')
|
|
}
|
|
|
|
tasks.register('copyApiLandingPage', Copy) {
|
|
group = 'frontend'
|
|
description = 'Copy API landing page to index.html for backend-only mode'
|
|
from(new File(resourcesStaticDir, 'api-landing.html'))
|
|
into(resourcesStaticDir)
|
|
rename('api-landing.html', 'index.html')
|
|
dependsOn cleanFrontendAssets
|
|
doFirst {
|
|
println "Copying API landing page to index.html for backend-only mode..."
|
|
}
|
|
}
|
|
|
|
// Ensure copyFrontendAssets runs after spotless tasks
|
|
tasks.named('copyFrontendAssets').configure {
|
|
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
|
|
}
|
|
|
|
if (buildWithFrontend) {
|
|
println "Editor frontend build enabled - JAR will include React frontend (mode=${frontendMode})"
|
|
processResources.dependsOn copyFrontendAssets
|
|
} else {
|
|
println "Frontend build disabled - JAR will be backend-only with API landing page"
|
|
// When not building the UI, ensure any stale frontend assets are removed and use API landing page
|
|
processResources.dependsOn copyApiLandingPage
|
|
}
|
|
|
|
bootJar.dependsOn ':common:jar'
|
|
if (!gradle.ext.disableAdditional) {
|
|
bootJar.dependsOn ':proprietary:jar'
|
|
}
|
|
if (gradle.ext.enableSaas) {
|
|
bootJar.dependsOn ':saas:jar'
|
|
}
|