Builds custom Jar (#5029)

# Description of Changes

Change jar files to contain frontend if provided with param, else
doesnt... add release artifact -server version which wont have frontend

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling 2025-11-26 17:21:42 +00:00 committed by GitHub
parent a62c8b54cf
commit e47ed13be8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 406 additions and 87 deletions

View File

@ -90,12 +90,19 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
disable_security: [true, false]
include:
- disable_security: false
file_suffix: "-with-login"
- disable_security: true
variant:
- name: "default"
disable_security: true
build_frontend: true
file_suffix: ""
- name: "with-login"
disable_security: false
build_frontend: true
file_suffix: "-with-login"
- name: "server-only"
disable_security: true
build_frontend: false
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@ -114,10 +121,18 @@ jobs:
with:
gradle-version: 8.14
- name: Setup Node.js
if: matrix.variant.build_frontend == true
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Build JAR
run: ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
run: ./gradlew clean build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube
env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }}
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
STIRLING_PDF_DESKTOP_UI: false
- name: Rename JAR
@ -126,12 +141,12 @@ jobs:
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
ls -la app/core/build/libs/
mkdir -p ./jar-dist
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.file_suffix }}.jar
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
- name: Upload JAR artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: jar${{ matrix.file_suffix }}
name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar
retention-days: 1
@ -524,7 +539,7 @@ jobs:
pattern: Stirling-PDF-*
path: ./artifacts/tauri
- name: Download JAR artifact (no login)
- name: Download JAR artifact (default)
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar
@ -536,6 +551,12 @@ jobs:
name: jar-with-login
path: ./artifacts/jars
- name: Download JAR artifact (server only)
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: jar-server
path: ./artifacts/jars
- name: Display structure of downloaded files
run: ls -R ./artifacts

18
.gitignore vendored
View File

@ -17,8 +17,8 @@ local.properties
version.properties
#### Stirling-PDF Files ###
pipeline/watchedFolders/
pipeline/finishedFolders/
pipeline/
!pipeline/.gitkeep
customFiles/
configs/
watchedFolders/
@ -31,6 +31,20 @@ exampleYmlFiles/stirling/
/testing/file_snapshots
SwaggerDoc.json
# Frontend build artifacts copied to backend static resources
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
app/core/src/main/resources/static/index.html
app/core/src/main/resources/static/locales/
app/core/src/main/resources/static/Login/
app/core/src/main/resources/static/classic-logo/
app/core/src/main/resources/static/modern-logo/
app/core/src/main/resources/static/og_images/
app/core/src/main/resources/static/samples/
app/core/src/main/resources/static/manifest-classic.json
app/core/src/main/resources/static/robots.txt
# Note: Keep backend-managed files like fonts/, css/, js/, pdfjs/, etc.
# Gradle
.gradle
.gradle-home

View File

@ -7,23 +7,103 @@ public class RequestUriUtils {
}
public static boolean isStaticResource(String contextPath, String requestURI) {
return requestURI.startsWith(contextPath + "/css/")
|| requestURI.startsWith(contextPath + "/fonts/")
|| requestURI.startsWith(contextPath + "/js/")
|| requestURI.endsWith(contextPath + "robots.txt")
|| requestURI.startsWith(contextPath + "/images/")
|| requestURI.startsWith(contextPath + "/public/")
|| requestURI.startsWith(contextPath + "/pdfjs/")
|| requestURI.startsWith(contextPath + "/pdfjs-legacy/")
|| requestURI.startsWith(contextPath + "/login")
|| requestURI.startsWith(contextPath + "/error")
|| requestURI.startsWith(contextPath + "/favicon")
|| requestURI.endsWith(".svg")
|| requestURI.endsWith(".png")
|| requestURI.endsWith(".ico")
|| requestURI.endsWith(".txt")
|| requestURI.endsWith(".webmanifest")
|| requestURI.startsWith(contextPath + "/api/v1/info/status");
if (requestURI == null) {
return false;
}
String normalizedUri = stripContextPath(contextPath, requestURI);
// API routes are never static except for the public status endpoint
if (normalizedUri.startsWith("/api/")) {
return normalizedUri.startsWith("/api/v1/info/status");
}
// Well-known static asset directories (backend + React build artifacts)
if (normalizedUri.startsWith("/css/")
|| normalizedUri.startsWith("/fonts/")
|| normalizedUri.startsWith("/js/")
|| normalizedUri.startsWith("/images/")
|| normalizedUri.startsWith("/public/")
|| normalizedUri.startsWith("/pdfjs/")
|| normalizedUri.startsWith("/pdfjs-legacy/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
|| normalizedUri.startsWith("/samples/")
|| normalizedUri.startsWith("/classic-logo/")
|| normalizedUri.startsWith("/modern-logo/")
|| normalizedUri.startsWith("/og_images/")) {
return true;
}
// Specific static files bundled with the frontend
if (normalizedUri.equals("/robots.txt")
|| normalizedUri.equals("/favicon.ico")
|| normalizedUri.equals("/site.webmanifest")
|| normalizedUri.equals("/manifest-classic.json")
|| normalizedUri.equals("/index.html")) {
return true;
}
// Login/error pages remain public
if (normalizedUri.startsWith("/login") || normalizedUri.startsWith("/error")) {
return true;
}
// Treat common static file extensions as static resources
return normalizedUri.endsWith(".svg")
|| normalizedUri.endsWith(".png")
|| normalizedUri.endsWith(".ico")
|| normalizedUri.endsWith(".txt")
|| normalizedUri.endsWith(".webmanifest")
|| normalizedUri.endsWith(".js")
|| normalizedUri.endsWith(".css")
|| normalizedUri.endsWith(".mjs")
|| normalizedUri.endsWith(".html")
|| normalizedUri.endsWith(".toml");
}
public static boolean isFrontendRoute(String contextPath, String requestURI) {
if (requestURI == null) {
return false;
}
String normalizedUri = stripContextPath(contextPath, requestURI);
// APIs are never treated as frontend routes
if (normalizedUri.startsWith("/api/")) {
return false;
}
// Blocklist of backend/non-frontend paths that should still go through filters
String[] backendOnlyPrefixes = {
"/register",
"/invite",
"/pipeline",
"/pdfjs",
"/pdfjs-legacy",
"/fonts",
"/images",
"/files",
"/css",
"/js",
"/swagger",
"/v1/api-docs",
"/actuator"
};
for (String prefix : backendOnlyPrefixes) {
if (normalizedUri.equals(prefix) || normalizedUri.startsWith(prefix + "/")) {
return false;
}
}
if (normalizedUri.isBlank()) {
return false;
}
// Allow root and any extensionless path (React Router will handle these)
return !normalizedUri.contains(".");
}
public static boolean isTrackableResource(String requestURI) {
@ -43,6 +123,7 @@ public class RequestUriUtils {
|| requestURI.endsWith(".svg")
|| requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js")
|| requestURI.endsWith(".toml")
|| requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest")
@ -83,4 +164,11 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.contains("/v1/api-docs");
}
private static String stripContextPath(String contextPath, String requestURI) {
if (contextPath != null && !contextPath.isBlank() && requestURI.startsWith(contextPath)) {
return requestURI.substring(contextPath.length());
}
return requestURI;
}
}

View File

@ -49,6 +49,26 @@ public class RequestUriUtilsTest {
"API products should not be static");
}
@Test
void testIsFrontendRoute() {
assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
"React routes without extensions should be frontend routes");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/api/v1/users"),
"API routes should not be frontend routes");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/register"),
"Register should not be treated as a frontend route");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/pipeline/jobs"),
"Pipeline should not be treated as a frontend route");
assertFalse(
RequestUriUtils.isFrontendRoute("", "/files/download"),
"Files path should not be treated as a frontend route");
}
@Test
void testIsStaticResourceWithContextPath() {
String contextPath = "/myapp";
@ -83,6 +103,7 @@ public class RequestUriUtilsTest {
"/favicon.ico",
"/icon.svg",
"/image.png",
"/locales/en/translation.toml",
"/site.webmanifest",
"/app/logo.svg",
"/downloads/document.png",

View File

@ -1,5 +1,7 @@
apply plugin: 'org.springframework.boot'
import org.apache.tools.ant.taskdefs.condition.Os
repositories {
maven { url = 'https://build.shibboleth.net/maven/releases' }
maven { url = 'https://maven.pkg.github.com/jcefmaven/jcefmaven' }
@ -15,6 +17,7 @@ configurations {
spotless {
java {
target 'src/**/java/**/*.java'
targetExclude 'src/main/resources/static/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
@ -25,12 +28,14 @@ spotless {
}
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()
@ -157,5 +162,125 @@ springBoot {
mainClass = 'stirling.software.SPDF.SPDFApplication'
}
// Frontend build tasks - only enabled with -PbuildWithFrontend=true
def buildWithFrontend = project.hasProperty('buildWithFrontend') && project.property('buildWithFrontend') == 'true'
def frontendDir = file('../../frontend')
def frontendDistDir = file('../../frontend/dist')
def resourcesStaticDir = file('src/main/resources/static')
def generatedFrontendPaths = [
'assets',
'index.html',
'locales',
'Login',
'classic-logo',
'modern-logo',
'og_images',
'samples',
'manifest-classic.json'
]
tasks.register('npmInstall', Exec) {
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
}
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) {
enabled = buildWithFrontend
group = 'frontend'
description = 'Build frontend application'
workingDir frontendDir
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'run', 'build'] : ['npm', 'run', 'build']
dependsOn npmInstall
inputs.dir(new File(frontendDir, 'src'))
inputs.file(new File(frontendDir, 'package.json'))
outputs.dir(frontendDistDir)
// 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', '/'
doFirst {
println "Building frontend application for production (VITE_API_BASE_URL=/)"
}
}
tasks.register('copyFrontendAssets', Copy) {
enabled = buildWithFrontend
group = 'frontend'
description = 'Copy frontend build to static resources'
dependsOn npmBuild
from(frontendDistDir) {
// 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 ${frontendDistDir} 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) }
}
// Ensure copyFrontendAssets runs after spotless tasks
tasks.named('copyFrontendAssets').configure {
mustRunAfter tasks.matching { it.name.startsWith('spotless') }
}
if (buildWithFrontend) {
println "Frontend build enabled - JAR will include React frontend"
processResources.dependsOn copyFrontendAssets
} else {
println "Frontend build disabled - JAR will be backend-only"
// When not building the UI, ensure any stale frontend assets are removed
processResources.dependsOn cleanFrontendAssets
}
bootJar.dependsOn ':common:jar'
bootJar.dependsOn ':proprietary:jar'

View File

@ -1,16 +1,17 @@
package stirling.software.SPDF.controller.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@Controller
public class ReactRoutingController {
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico)[^\\.]*$}")
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
}
@GetMapping("/{path:^(?!api|static)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
@GetMapping("/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
}

View File

@ -52,7 +52,7 @@ server.servlet.session.timeout:30m
springdoc.api-docs.path=/v1/api-docs
# Set the URL of the OpenAPI JSON for the Swagger UI
springdoc.swagger-ui.url=/v1/api-docs
springdoc.swagger-ui.path=/index.html
springdoc.swagger-ui.path=/swagger-ui.html
# Force OpenAPI 3.0 specification version
springdoc.api-docs.version=OPENAPI_3_0
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq

View File

@ -28,6 +28,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
@ -110,7 +111,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
// If we still don't have any authentication, check if it's a public endpoint. If not, deny
// the request
if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
String contextPath = request.getContextPath();
// Allow public auth endpoints to pass through without authentication
@ -119,18 +119,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
return;
}
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) {
response.sendRedirect(contextPath + "/login"); // redirect to the login page
} else {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.getWriter()
.write(
"""
Authentication required. Please provide a X-API-KEY in request header.
This is found in Settings -> Account Settings -> API Key
Alternatively you can disable authentication if this is unexpected.
""");
}
// For API requests, return 401 with JSON response (no redirects)
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json");
response.getWriter()
.write(
"""
{
"error": "Unauthorized",
"message": "Authentication required. Please provide valid credentials or X-API-KEY header.",
"status": 401
}
""");
return;
}
@ -179,8 +179,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
// Block user registration if not allowed by configuration
if (blockRegistration && !isUserExists) {
log.warn("Blocked registration for OAuth2/SAML user: {}", username);
response.sendRedirect(
request.getContextPath() + "/logout?oAuth2AdminBlockedUser=true");
SecurityContextHolder.clearContext();
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/json");
response.getWriter()
.write(
"""
{
"error": "Forbidden",
"message": "User registration is blocked by administrator",
"status": 403
}
""");
return;
}
@ -194,13 +204,35 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
}
// Redirect to logout if credentials are invalid
// Return 401 if credentials are invalid (no redirects)
if (!isUserExists && notSsoLogin) {
response.sendRedirect(request.getContextPath() + "/logout?badCredentials=true");
SecurityContextHolder.clearContext();
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType("application/json");
response.getWriter()
.write(
"""
{
"error": "Unauthorized",
"message": "Invalid credentials",
"status": 401
}
""");
return;
}
if (isUserDisabled) {
response.sendRedirect(request.getContextPath() + "/logout?userIsDisabled=true");
SecurityContextHolder.clearContext();
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/json");
response.getWriter()
.write(
"""
{
"error": "Forbidden",
"message": "User account is disabled",
"status": 403
}
""");
return;
}
}
@ -250,33 +282,28 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
String[] permitAllPatterns = {
contextPath + "/login",
contextPath + "/register",
contextPath + "/invite",
contextPath + "/error",
contextPath + "/images/",
contextPath + "/public/",
contextPath + "/css/",
contextPath + "/fonts/",
contextPath + "/js/",
contextPath + "/pdfjs/",
contextPath + "/pdfjs-legacy/",
// Allow unauthenticated access to static resources and SPA routes (GET/HEAD only)
if ("GET".equalsIgnoreCase(request.getMethod())
|| "HEAD".equalsIgnoreCase(request.getMethod())) {
if (RequestUriUtils.isStaticResource(contextPath, uri)
|| RequestUriUtils.isFrontendRoute(contextPath, uri)) {
return true;
}
}
// For API routes, only skip filter for these public endpoints
String[] publicApiPatterns = {
contextPath + "/api/v1/info/status",
contextPath + "/api/v1/auth/login",
contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me",
contextPath + "/api/v1/invite/validate",
contextPath + "/api/v1/invite/accept",
contextPath + "/site.webmanifest"
contextPath + "/api/v1/invite/accept"
};
for (String pattern : permitAllPatterns) {
if (uri.startsWith(pattern)
|| uri.endsWith(".svg")
|| uri.endsWith(".mjs")
|| uri.endsWith(".png")
|| uri.endsWith(".ico")) {
for (String pattern : publicApiPatterns) {
if (uri.startsWith(pattern)) {
return true;
}
}

View File

@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.0.0'
version = '2.0.1'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'

View File

@ -10,9 +10,11 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend .
RUN DISABLE_ADDITIONAL_FEATURES=false npm run build
# Override VITE_API_BASE_URL to use relative paths for production
# This ensures frontend works with nginx proxy setup
RUN DISABLE_ADDITIONAL_FEATURES=false VITE_API_BASE_URL=/ npm run build
# Stage 2: Build Backend
# Stage 2: Build Backend (server-only JAR - no UI)
FROM gradle:8.14-jdk21 AS backend-build
COPY build.gradle .
@ -27,6 +29,7 @@ RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || re
WORKDIR /app
COPY . .
# Build server-only JAR (no frontend, includes security features controlled by DOCKER_ENABLE_SECURITY at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
@ -62,8 +65,7 @@ COPY docker/unified/nginx.conf /etc/nginx/nginx.conf
COPY docker/unified/entrypoint.sh /entrypoint.sh
# Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=false \
VERSION_TAG=$VERSION_TAG \
ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \

View File

@ -10,7 +10,9 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend .
RUN DISABLE_ADDITIONAL_FEATURES=true npm run build
# Override VITE_API_BASE_URL to use relative paths for production
# This ensures frontend works with nginx proxy setup
RUN DISABLE_ADDITIONAL_FEATURES=true VITE_API_BASE_URL=/ npm run build
# Stage 2: Build Backend
FROM gradle:8.14-jdk21 AS backend-build
@ -76,7 +78,6 @@ ENV DISABLE_ADDITIONAL_FEATURES=false \
TMP=/tmp/stirling-pdf \
MODE=BOTH \
BACKEND_INTERNAL_PORT=8081 \
VITE_API_BASE_URL=http://localhost:8080 \
ENDPOINTS_GROUPS_TO_REMOVE=CLI
# Install minimal dependencies

View File

@ -17,7 +17,7 @@ WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
@ -44,8 +44,7 @@ LABEL org.opencontainers.image.version="${VERSION_TAG}"
LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=false \
VERSION_TAG=$VERSION_TAG \
ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \

View File

@ -17,7 +17,7 @@ WORKDIR /app
# Copy the entire project to the working directory
COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=false
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
@ -35,8 +35,7 @@ COPY --from=build /app/build/libs/restart-helper.jar restart-helper.jar
ARG VERSION_TAG
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \
VERSION_TAG=$VERSION_TAG \
ENV VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \

View File

@ -28,8 +28,7 @@ FROM alpine:3.22.1@sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8
ARG VERSION_TAG
# Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \
HOME=/home/stirlingpdfuser \
ENV HOME=/home/stirlingpdfuser \
VERSION_TAG=$VERSION_TAG \
JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \

View File

@ -1,7 +1,19 @@
/**
* Get the base URL for API requests.
* Core version uses simple environment variable.
*
* Priority:
* 1. window.STIRLING_PDF_API_BASE_URL (runtime override - fixes hardcoded localhost issues)
* 2. import.meta.env.VITE_API_BASE_URL (build-time env var)
* 3. '/' (relative path - works for same-origin deployments)
*
* Note: Runtime override is needed because VITE_API_BASE_URL gets baked into the build.
* If someone builds with VITE_API_BASE_URL=http://localhost:8080, it breaks production deployments.
*/
export function getApiBaseUrl(): string {
// Runtime override to fix hardcoded localhost in builds
if (typeof window !== 'undefined' && (window as any).STIRLING_PDF_API_BASE_URL) {
return (window as any).STIRLING_PDF_API_BASE_URL;
}
return import.meta.env.VITE_API_BASE_URL || '/';
}

View File

@ -3,12 +3,22 @@ import { isTauri } from '@tauri-apps/api/core';
/**
* Desktop override: Determine base URL depending on Tauri environment
*
* Priority (non-Tauri mode):
* 1. window.STIRLING_PDF_API_BASE_URL (runtime override - fixes hardcoded localhost issues)
* 2. import.meta.env.VITE_API_BASE_URL (build-time env var)
* 3. '/' (relative path - works for same-origin deployments)
*
* Note: In Tauri mode, the actual URL is determined dynamically by operationRouter
* based on connection mode and backend port. This initial baseURL is overridden
* by request interceptors in apiClientSetup.ts.
*/
export function getApiBaseUrl(): string {
if (!isTauri()) {
// Runtime override to fix hardcoded localhost in builds
if (typeof window !== 'undefined' && (window as any).STIRLING_PDF_API_BASE_URL) {
return (window as any).STIRLING_PDF_API_BASE_URL;
}
return import.meta.env.VITE_API_BASE_URL || '/';
}