diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 93d5ac144..636a34bc0 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -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 diff --git a/.gitignore b/.gitignore index f0e16d5e7..8b9fe5df9 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index a0d7f3610..0dfdfa69e 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -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; + } } diff --git a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java index be437951a..0edb546cc 100644 --- a/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java +++ b/app/common/src/test/java/stirling/software/common/util/RequestUriUtilsTest.java @@ -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", diff --git a/app/core/build.gradle b/app/core/build.gradle index d31125a26..0ff725d3d 100644 --- a/app/core/build.gradle +++ b/app/core/build.gradle @@ -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' diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index 09e40b133..95acd6297 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -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"; } diff --git a/app/core/src/main/resources/application.properties b/app/core/src/main/resources/application.properties index b16e1cc77..60c9baee6 100644 --- a/app/core/src/main/resources/application.properties +++ b/app/core/src/main/resources/application.properties @@ -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 diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 9d1dbc96f..896801ce8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -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; } } diff --git a/build.gradle b/build.gradle index 2451aa45c..859b7e17d 100644 --- a/build.gradle +++ b/build.gradle @@ -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' diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified index cf7a051a2..0ba7cfb3c 100644 --- a/docker/Dockerfile.unified +++ b/docker/Dockerfile.unified @@ -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 \ diff --git a/docker/Dockerfile.unified-lite b/docker/Dockerfile.unified-lite index a83b2544b..2e5a543ff 100644 --- a/docker/Dockerfile.unified-lite +++ b/docker/Dockerfile.unified-lite @@ -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 diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 67fbd488f..f2421fa94 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -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 \ diff --git a/docker/backend/Dockerfile.fat b/docker/backend/Dockerfile.fat index 028350a1c..c54a162da 100644 --- a/docker/backend/Dockerfile.fat +++ b/docker/backend/Dockerfile.fat @@ -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 \ diff --git a/docker/backend/Dockerfile.ultra-lite b/docker/backend/Dockerfile.ultra-lite index 264cad765..ec5ae17c6 100644 --- a/docker/backend/Dockerfile.ultra-lite +++ b/docker/backend/Dockerfile.ultra-lite @@ -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="" \ diff --git a/frontend/src/core/services/apiClientConfig.ts b/frontend/src/core/services/apiClientConfig.ts index a7d01c7cb..9038da5eb 100644 --- a/frontend/src/core/services/apiClientConfig.ts +++ b/frontend/src/core/services/apiClientConfig.ts @@ -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 || '/'; } diff --git a/frontend/src/desktop/services/apiClientConfig.ts b/frontend/src/desktop/services/apiClientConfig.ts index c823f24e2..2245c6e68 100644 --- a/frontend/src/desktop/services/apiClientConfig.ts +++ b/frontend/src/desktop/services/apiClientConfig.ts @@ -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 || '/'; }