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
16 changed files with 406 additions and 87 deletions

View File

@@ -90,12 +90,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
disable_security: [true, false] variant:
include: - name: "default"
- disable_security: false disable_security: true
file_suffix: "-with-login" build_frontend: true
- disable_security: true
file_suffix: "" 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: steps:
- name: Harden Runner - name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1 uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@@ -114,10 +121,18 @@ jobs:
with: with:
gradle-version: 8.14 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 - 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: env:
DISABLE_ADDITIONAL_FEATURES: ${{ matrix.disable_security }} DISABLE_ADDITIONAL_FEATURES: ${{ matrix.variant.disable_security }}
STIRLING_PDF_DESKTOP_UI: false STIRLING_PDF_DESKTOP_UI: false
- name: Rename JAR - name: Rename JAR
@@ -126,12 +141,12 @@ jobs:
echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar" echo "Looking for: app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar"
ls -la app/core/build/libs/ ls -la app/core/build/libs/
mkdir -p ./jar-dist 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 - name: Upload JAR artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with: with:
name: jar${{ matrix.file_suffix }} name: jar${{ matrix.variant.file_suffix }}
path: ./jar-dist/*.jar path: ./jar-dist/*.jar
retention-days: 1 retention-days: 1
@@ -524,7 +539,7 @@ jobs:
pattern: Stirling-PDF-* pattern: Stirling-PDF-*
path: ./artifacts/tauri path: ./artifacts/tauri
- name: Download JAR artifact (no login) - name: Download JAR artifact (default)
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with: with:
name: jar name: jar
@@ -536,6 +551,12 @@ jobs:
name: jar-with-login name: jar-with-login
path: ./artifacts/jars 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 - name: Display structure of downloaded files
run: ls -R ./artifacts run: ls -R ./artifacts

18
.gitignore vendored
View File

@@ -17,8 +17,8 @@ local.properties
version.properties version.properties
#### Stirling-PDF Files ### #### Stirling-PDF Files ###
pipeline/watchedFolders/ pipeline/
pipeline/finishedFolders/ !pipeline/.gitkeep
customFiles/ customFiles/
configs/ configs/
watchedFolders/ watchedFolders/
@@ -31,6 +31,20 @@ exampleYmlFiles/stirling/
/testing/file_snapshots /testing/file_snapshots
SwaggerDoc.json 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 .gradle
.gradle-home .gradle-home

View File

@@ -7,23 +7,103 @@ public class RequestUriUtils {
} }
public static boolean isStaticResource(String contextPath, String requestURI) { public static boolean isStaticResource(String contextPath, String requestURI) {
return requestURI.startsWith(contextPath + "/css/") if (requestURI == null) {
|| requestURI.startsWith(contextPath + "/fonts/") return false;
|| requestURI.startsWith(contextPath + "/js/") }
|| requestURI.endsWith(contextPath + "robots.txt")
|| requestURI.startsWith(contextPath + "/images/") String normalizedUri = stripContextPath(contextPath, requestURI);
|| requestURI.startsWith(contextPath + "/public/")
|| requestURI.startsWith(contextPath + "/pdfjs/") // API routes are never static except for the public status endpoint
|| requestURI.startsWith(contextPath + "/pdfjs-legacy/") if (normalizedUri.startsWith("/api/")) {
|| requestURI.startsWith(contextPath + "/login") return normalizedUri.startsWith("/api/v1/info/status");
|| requestURI.startsWith(contextPath + "/error") }
|| requestURI.startsWith(contextPath + "/favicon")
|| requestURI.endsWith(".svg") // Well-known static asset directories (backend + React build artifacts)
|| requestURI.endsWith(".png") if (normalizedUri.startsWith("/css/")
|| requestURI.endsWith(".ico") || normalizedUri.startsWith("/fonts/")
|| requestURI.endsWith(".txt") || normalizedUri.startsWith("/js/")
|| requestURI.endsWith(".webmanifest") || normalizedUri.startsWith("/images/")
|| requestURI.startsWith(contextPath + "/api/v1/info/status"); || 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) { public static boolean isTrackableResource(String requestURI) {
@@ -43,6 +123,7 @@ public class RequestUriUtils {
|| requestURI.endsWith(".svg") || requestURI.endsWith(".svg")
|| requestURI.endsWith("popularity.txt") || requestURI.endsWith("popularity.txt")
|| requestURI.endsWith(".js") || requestURI.endsWith(".js")
|| requestURI.endsWith(".toml")
|| requestURI.contains("swagger") || requestURI.contains("swagger")
|| requestURI.startsWith("/api/v1/info") || requestURI.startsWith("/api/v1/info")
|| requestURI.startsWith("/site.webmanifest") || requestURI.startsWith("/site.webmanifest")
@@ -83,4 +164,11 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/api/v1/invite/accept") || trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.contains("/v1/api-docs"); || 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"); "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 @Test
void testIsStaticResourceWithContextPath() { void testIsStaticResourceWithContextPath() {
String contextPath = "/myapp"; String contextPath = "/myapp";
@@ -83,6 +103,7 @@ public class RequestUriUtilsTest {
"/favicon.ico", "/favicon.ico",
"/icon.svg", "/icon.svg",
"/image.png", "/image.png",
"/locales/en/translation.toml",
"/site.webmanifest", "/site.webmanifest",
"/app/logo.svg", "/app/logo.svg",
"/downloads/document.png", "/downloads/document.png",

View File

@@ -1,5 +1,7 @@
apply plugin: 'org.springframework.boot' apply plugin: 'org.springframework.boot'
import org.apache.tools.ant.taskdefs.condition.Os
repositories { repositories {
maven { url = 'https://build.shibboleth.net/maven/releases' } maven { url = 'https://build.shibboleth.net/maven/releases' }
maven { url = 'https://maven.pkg.github.com/jcefmaven/jcefmaven' } maven { url = 'https://maven.pkg.github.com/jcefmaven/jcefmaven' }
@@ -15,6 +17,7 @@ configurations {
spotless { spotless {
java { java {
target 'src/**/java/**/*.java' target 'src/**/java/**/*.java'
targetExclude 'src/main/resources/static/**'
googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false) googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false)
importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling") importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling")
@@ -25,12 +28,14 @@ spotless {
} }
yaml { yaml {
target '**/*.yml', '**/*.yaml' target '**/*.yml', '**/*.yaml'
targetExclude 'src/main/resources/static/**'
trimTrailingWhitespace() trimTrailingWhitespace()
leadingTabsToSpaces() leadingTabsToSpaces()
endWithNewline() endWithNewline()
} }
format 'gradle', { format 'gradle', {
target '**/gradle/*.gradle', '**/*.gradle' target '**/gradle/*.gradle', '**/*.gradle'
targetExclude 'src/main/resources/static/**'
trimTrailingWhitespace() trimTrailingWhitespace()
leadingTabsToSpaces() leadingTabsToSpaces()
endWithNewline() endWithNewline()
@@ -157,5 +162,125 @@ springBoot {
mainClass = 'stirling.software.SPDF.SPDFApplication' 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 ':common:jar'
bootJar.dependsOn ':proprietary:jar' bootJar.dependsOn ':proprietary:jar'

View File

@@ -1,16 +1,17 @@
package stirling.software.SPDF.controller.web; package stirling.software.SPDF.controller.web;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI @Controller
public class ReactRoutingController { 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() { public String forwardRootPaths() {
return "forward:/index.html"; 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() { public String forwardNestedPaths() {
return "forward:/index.html"; return "forward:/index.html";
} }

View File

@@ -52,7 +52,7 @@ server.servlet.session.timeout:30m
springdoc.api-docs.path=/v1/api-docs springdoc.api-docs.path=/v1/api-docs
# Set the URL of the OpenAPI JSON for the Swagger UI # Set the URL of the OpenAPI JSON for the Swagger UI
springdoc.swagger-ui.url=/v1/api-docs 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 # Force OpenAPI 3.0 specification version
springdoc.api-docs.version=OPENAPI_3_0 springdoc.api-docs.version=OPENAPI_3_0
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq 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;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2; import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2; 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.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; 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 // If we still don't have any authentication, check if it's a public endpoint. If not, deny
// the request // the request
if (authentication == null || !authentication.isAuthenticated()) { if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
String contextPath = request.getContextPath(); String contextPath = request.getContextPath();
// Allow public auth endpoints to pass through without authentication // Allow public auth endpoints to pass through without authentication
@@ -119,18 +119,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
return; return;
} }
if ("GET".equalsIgnoreCase(method) && !requestURI.startsWith(contextPath + "/login")) { // For API requests, return 401 with JSON response (no redirects)
response.sendRedirect(contextPath + "/login"); // redirect to the login page response.setStatus(HttpStatus.UNAUTHORIZED.value());
} else { response.setContentType("application/json");
response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter()
response.getWriter() .write(
.write( """
""" {
Authentication required. Please provide a X-API-KEY in request header. "error": "Unauthorized",
This is found in Settings -> Account Settings -> API Key "message": "Authentication required. Please provide valid credentials or X-API-KEY header.",
Alternatively you can disable authentication if this is unexpected. "status": 401
"""); }
} """);
return; return;
} }
@@ -179,8 +179,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
// Block user registration if not allowed by configuration // Block user registration if not allowed by configuration
if (blockRegistration && !isUserExists) { if (blockRegistration && !isUserExists) {
log.warn("Blocked registration for OAuth2/SAML user: {}", username); log.warn("Blocked registration for OAuth2/SAML user: {}", username);
response.sendRedirect( SecurityContextHolder.clearContext();
request.getContextPath() + "/logout?oAuth2AdminBlockedUser=true"); response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType("application/json");
response.getWriter()
.write(
"""
{
"error": "Forbidden",
"message": "User registration is blocked by administrator",
"status": 403
}
""");
return; 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) { 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; return;
} }
if (isUserDisabled) { 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; return;
} }
} }
@@ -250,33 +282,28 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
protected boolean shouldNotFilter(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI(); String uri = request.getRequestURI();
String contextPath = request.getContextPath(); String contextPath = request.getContextPath();
String[] permitAllPatterns = {
contextPath + "/login", // Allow unauthenticated access to static resources and SPA routes (GET/HEAD only)
contextPath + "/register", if ("GET".equalsIgnoreCase(request.getMethod())
contextPath + "/invite", || "HEAD".equalsIgnoreCase(request.getMethod())) {
contextPath + "/error", if (RequestUriUtils.isStaticResource(contextPath, uri)
contextPath + "/images/", || RequestUriUtils.isFrontendRoute(contextPath, uri)) {
contextPath + "/public/", return true;
contextPath + "/css/", }
contextPath + "/fonts/", }
contextPath + "/js/",
contextPath + "/pdfjs/", // For API routes, only skip filter for these public endpoints
contextPath + "/pdfjs-legacy/", String[] publicApiPatterns = {
contextPath + "/api/v1/info/status", contextPath + "/api/v1/info/status",
contextPath + "/api/v1/auth/login", contextPath + "/api/v1/auth/login",
contextPath + "/api/v1/auth/refresh", contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me", contextPath + "/api/v1/auth/me",
contextPath + "/api/v1/invite/validate", contextPath + "/api/v1/invite/validate",
contextPath + "/api/v1/invite/accept", contextPath + "/api/v1/invite/accept"
contextPath + "/site.webmanifest"
}; };
for (String pattern : permitAllPatterns) { for (String pattern : publicApiPatterns) {
if (uri.startsWith(pattern) if (uri.startsWith(pattern)) {
|| uri.endsWith(".svg")
|| uri.endsWith(".mjs")
|| uri.endsWith(".png")
|| uri.endsWith(".ico")) {
return true; return true;
} }
} }

View File

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

View File

@@ -10,9 +10,11 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
COPY frontend . 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 FROM gradle:8.14-jdk21 AS backend-build
COPY build.gradle . COPY build.gradle .
@@ -27,6 +29,7 @@ RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || re
WORKDIR /app WORKDIR /app
COPY . . COPY . .
# Build server-only JAR (no frontend, includes security features controlled by DOCKER_ENABLE_SECURITY at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \ RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \ STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube ./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 COPY docker/unified/entrypoint.sh /entrypoint.sh
# Environment Variables # Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=false \ ENV VERSION_TAG=$VERSION_TAG \
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_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \ JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \ HOME=/home/stirlingpdfuser \

View File

@@ -10,7 +10,9 @@ COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci RUN npm ci
COPY frontend . 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 # Stage 2: Build Backend
FROM gradle:8.14-jdk21 AS backend-build FROM gradle:8.14-jdk21 AS backend-build
@@ -76,7 +78,6 @@ ENV DISABLE_ADDITIONAL_FEATURES=false \
TMP=/tmp/stirling-pdf \ TMP=/tmp/stirling-pdf \
MODE=BOTH \ MODE=BOTH \
BACKEND_INTERNAL_PORT=8081 \ BACKEND_INTERNAL_PORT=8081 \
VITE_API_BASE_URL=http://localhost:8080 \
ENDPOINTS_GROUPS_TO_REMOVE=CLI ENDPOINTS_GROUPS_TO_REMOVE=CLI
# Install minimal dependencies # Install minimal dependencies

View File

@@ -17,7 +17,7 @@ WORKDIR /app
# Copy the entire project to the working directory # Copy the entire project to the working directory
COPY . . 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 \ RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \ STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube ./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" LABEL org.opencontainers.image.keywords="PDF, manipulation, backend, API, Spring Boot"
# Set Environment Variables # Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=false \ ENV VERSION_TAG=$VERSION_TAG \
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_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \ JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \ HOME=/home/stirlingpdfuser \

View File

@@ -17,7 +17,7 @@ WORKDIR /app
# Copy the entire project to the working directory # Copy the entire project to the working directory
COPY . . 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 \ RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \ STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube ./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 ARG VERSION_TAG
# Set Environment Variables # Set Environment Variables
ENV DISABLE_ADDITIONAL_FEATURES=true \ ENV VERSION_TAG=$VERSION_TAG \
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_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \
JAVA_CUSTOM_OPTS="" \ JAVA_CUSTOM_OPTS="" \
HOME=/home/stirlingpdfuser \ HOME=/home/stirlingpdfuser \

View File

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

View File

@@ -1,7 +1,19 @@
/** /**
* Get the base URL for API requests. * 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 { 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 || '/'; 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 * 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 * Note: In Tauri mode, the actual URL is determined dynamically by operationRouter
* based on connection mode and backend port. This initial baseURL is overridden * based on connection mode and backend port. This initial baseURL is overridden
* by request interceptors in apiClientSetup.ts. * by request interceptors in apiClientSetup.ts.
*/ */
export function getApiBaseUrl(): string { export function getApiBaseUrl(): string {
if (!isTauri()) { 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 || '/'; return import.meta.env.VITE_API_BASE_URL || '/';
} }