diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index e94a5f395..72cfef1a0 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -68,6 +68,7 @@ public class ApplicationProperties { private AutoPipeline autoPipeline = new AutoPipeline(); private ProcessExecutor processExecutor = new ProcessExecutor(); + private PdfEditor pdfEditor = new PdfEditor(); @Bean public PropertySource dynamicYamlPropertySource(ConfigurableEnvironment environment) @@ -100,6 +101,46 @@ public class ApplicationProperties { private String outputFolder; } + @Data + public static class PdfEditor { + private Cache cache = new Cache(); + private FontNormalization fontNormalization = new FontNormalization(); + private CffConverter cffConverter = new CffConverter(); + private Type3 type3 = new Type3(); + private String fallbackFont = "classpath:/static/fonts/NotoSans-Regular.ttf"; + + @Data + public static class Cache { + private long maxBytes = -1; + private int maxPercent = 20; + } + + @Data + public static class FontNormalization { + private boolean enabled = false; + } + + @Data + public static class CffConverter { + private boolean enabled = true; + private String method = "python"; + private String pythonCommand = "/opt/venv/bin/python3"; + private String pythonScript = "/scripts/convert_cff_to_ttf.py"; + private String fontforgeCommand = "fontforge"; + } + + @Data + public static class Type3 { + private Library library = new Library(); + + @Data + public static class Library { + private boolean enabled = true; + private String index = "classpath:/type3/library/index.json"; + } + } + } + @Data public static class Legal { private String termsAndConditions; @@ -368,10 +409,12 @@ public class ApplicationProperties { private TempFileManagement tempFileManagement = new TempFileManagement(); private DatabaseBackup databaseBackup = new DatabaseBackup(); private List corsAllowedOrigins = new ArrayList<>(); - private String - frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set, + private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g. + // 'http://localhost:8080', 'https://api.example.com'). Required for + // SSO. + private String frontendUrl; // Frontend URL for invite email links (e.g. - // falls back to backend URL. + // 'https://app.example.com'). If not set, falls back to backendUrl. public boolean isAnalyticsEnabled() { return this.getEnableAnalytics() != null && this.getEnableAnalytics(); @@ -536,6 +579,7 @@ public class ApplicationProperties { @ToString.Exclude private String key; private String UUID; private String appVersion; + private Boolean isNewServer; } // TODO: Remove post migration diff --git a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java index 88755f950..2cded405f 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/InitialSetup.java @@ -94,6 +94,7 @@ public class InitialSetup { } GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion); applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion); + applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer); } public static boolean isNewServer() { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java new file mode 100644 index 000000000..c82fe19e1 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPdfJsonExceptionHandler.java @@ -0,0 +1,60 @@ +package stirling.software.SPDF.controller.api.converters; + +import java.nio.charset.StandardCharsets; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.exception.CacheUnavailableException; + +@ControllerAdvice(assignableTypes = ConvertPdfJsonController.class) +@Slf4j +@RequiredArgsConstructor +public class ConvertPdfJsonExceptionHandler { + + private final ObjectMapper objectMapper; + + @ExceptionHandler(CacheUnavailableException.class) + @ResponseBody + public ResponseEntity handleCacheUnavailable(CacheUnavailableException ex) { + try { + byte[] body = + objectMapper.writeValueAsBytes( + java.util.Map.of( + "error", "cache_unavailable", + "action", "reupload", + "message", ex.getMessage())); + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.APPLICATION_JSON) + .body(body); + } catch (Exception e) { + log.warn("Failed to serialize cache_unavailable response", e); + var fallbackBody = + java.util.Map.of( + "error", "cache_unavailable", + "action", "reupload", + "message", String.valueOf(ex.getMessage())); + try { + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.APPLICATION_JSON) + .body(objectMapper.writeValueAsBytes(fallbackBody)); + } catch (Exception ignored) { + // Truly last-ditch fallback + return ResponseEntity.status(HttpStatus.GONE) + .contentType(MediaType.APPLICATION_JSON) + .body( + "{\"error\":\"cache_unavailable\",\"action\":\"reupload\",\"message\":\"Cache unavailable\"}" + .getBytes(StandardCharsets.UTF_8)); + } + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java b/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java new file mode 100644 index 000000000..fd5d77677 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/exception/CacheUnavailableException.java @@ -0,0 +1,8 @@ +package stirling.software.SPDF.exception; + +public class CacheUnavailableException extends RuntimeException { + + public CacheUnavailableException(String message) { + super(message); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java index 623b99260..604e9ba38 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonConversionService.java @@ -86,7 +86,6 @@ import org.apache.pdfbox.text.PDFTextStripper; import org.apache.pdfbox.text.TextPosition; import org.apache.pdfbox.util.DateConverter; import org.apache.pdfbox.util.Matrix; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -144,15 +143,23 @@ public class PdfJsonConversionService { private final PdfJsonFontService fontService; private final Type3FontConversionService type3FontConversionService; private final Type3GlyphExtractor type3GlyphExtractor; + private final stirling.software.common.model.ApplicationProperties applicationProperties; private final Map type3NormalizedFontCache = new ConcurrentHashMap<>(); private final Map> type3GlyphCoverageCache = new ConcurrentHashMap<>(); - @Value("${stirling.pdf.json.font-normalization.enabled:true}") private boolean fontNormalizationEnabled; + private long cacheMaxBytes; + private int cacheMaxPercent; /** Cache for storing PDDocuments for lazy page loading. Key is jobId. */ private final Map documentCache = new ConcurrentHashMap<>(); + private final java.util.LinkedHashMap lruCache = + new java.util.LinkedHashMap<>(16, 0.75f, true); + private final Object cacheLock = new Object(); + private volatile long currentCacheBytes = 0L; + private volatile long cacheBudgetBytes = -1L; + private volatile boolean ghostscriptAvailable; private static final float FLOAT_EPSILON = 0.0001f; @@ -161,7 +168,23 @@ public class PdfJsonConversionService { @PostConstruct private void initializeToolAvailability() { + loadConfigurationFromProperties(); initializeGhostscriptAvailability(); + initializeCacheBudget(); + } + + private void loadConfigurationFromProperties() { + stirling.software.common.model.ApplicationProperties.PdfEditor cfg = + applicationProperties.getPdfEditor(); + if (cfg != null) { + fontNormalizationEnabled = cfg.getFontNormalization().isEnabled(); + cacheMaxBytes = cfg.getCache().getMaxBytes(); + cacheMaxPercent = cfg.getCache().getMaxPercent(); + } else { + fontNormalizationEnabled = false; + cacheMaxBytes = -1; + cacheMaxPercent = 20; + } } private void initializeGhostscriptAvailability() { @@ -202,6 +225,25 @@ public class PdfJsonConversionService { } } + private void initializeCacheBudget() { + long effective = -1L; + if (cacheMaxBytes > 0) { + effective = cacheMaxBytes; + } else if (cacheMaxPercent > 0) { + long maxMem = Runtime.getRuntime().maxMemory(); + effective = Math.max(0L, (maxMem * cacheMaxPercent) / 100); + } + cacheBudgetBytes = effective; + if (cacheBudgetBytes > 0) { + log.info( + "PDF JSON cache budget configured: {} bytes (source: {})", + cacheBudgetBytes, + cacheMaxBytes > 0 ? "max-bytes" : "max-percent"); + } else { + log.info("PDF JSON cache budget: unlimited"); + } + } + public byte[] convertPdfToJson(MultipartFile file) throws IOException { return convertPdfToJson(file, null, false); } @@ -236,7 +278,10 @@ public class PdfJsonConversionService { log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId); } else { jobId = contextJobId; - log.debug("Starting PDF to JSON conversion, jobId from context: {}", jobId); + log.info( + "Starting PDF to JSON conversion, jobId from context: {} (lightweight={})", + jobId, + lightweight); } Consumer progress = @@ -318,9 +363,9 @@ public class PdfJsonConversionService { try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) { int totalPages = document.getNumberOfPages(); - // Only use lazy images for real async jobs where client can access the cache - // Synchronous calls with synthetic jobId should do full extraction - boolean useLazyImages = totalPages > 5 && isRealJobId; + // Always enable lazy mode for real async jobs so cache is available regardless of + // page count. Synchronous calls with synthetic jobId still do full extraction. + boolean useLazyImages = isRealJobId; Map fontCache = new IdentityHashMap<>(); Map imageCache = new IdentityHashMap<>(); log.debug( @@ -403,6 +448,11 @@ public class PdfJsonConversionService { // Only cache for real async jobIds, not synthetic synchronous ones if (useLazyImages && isRealJobId) { + log.info( + "Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})", + jobId, + useLazyImages, + isRealJobId); PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata(); docMetadata.setMetadata(pdfJson.getMetadata()); docMetadata.setXmpMetadata(pdfJson.getXmpMetadata()); @@ -435,16 +485,23 @@ public class PdfJsonConversionService { cachedPdfBytes = Files.readAllBytes(workingPath); } CachedPdfDocument cached = - new CachedPdfDocument( - cachedPdfBytes, docMetadata, fonts, pageFontResources); - documentCache.put(jobId, cached); - log.debug( - "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy images, jobId: {}", - cachedPdfBytes.length, + buildCachedDocument( + jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources); + putCachedDocument(jobId, cached); + log.info( + "Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})", + cached.getPdfSize(), totalPages, fonts.size(), - jobId); + jobId, + cached.isDiskBacked()); scheduleDocumentCleanup(jobId); + } else { + log.warn( + "Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}", + useLazyImages, + isRealJobId, + jobId); } if (lightweight) { @@ -2973,6 +3030,139 @@ public class PdfJsonConversionService { } } + // Cache helpers + private CachedPdfDocument buildCachedDocument( + String jobId, + byte[] pdfBytes, + PdfJsonDocumentMetadata metadata, + Map fonts, + Map> pageFontResources) + throws IOException { + if (pdfBytes == null) { + throw new IllegalArgumentException("pdfBytes must not be null"); + } + long budget = cacheBudgetBytes; + // If single document is larger than budget, spill straight to disk + if (budget > 0 && pdfBytes.length > budget) { + TempFile tempFile = new TempFile(tempFileManager, ".pdfjsoncache"); + Files.write(tempFile.getPath(), pdfBytes); + log.debug( + "Cached PDF spilled to disk ({} bytes exceeds budget {}) for jobId {}", + pdfBytes.length, + budget, + jobId); + return new CachedPdfDocument( + null, tempFile, pdfBytes.length, metadata, fonts, pageFontResources); + } + return new CachedPdfDocument( + pdfBytes, null, pdfBytes.length, metadata, fonts, pageFontResources); + } + + private void putCachedDocument(String jobId, CachedPdfDocument cached) { + synchronized (cacheLock) { + CachedPdfDocument existing = documentCache.put(jobId, cached); + if (existing != null) { + lruCache.remove(jobId); + currentCacheBytes = Math.max(0L, currentCacheBytes - existing.getInMemorySize()); + existing.close(); + } + lruCache.put(jobId, cached); + currentCacheBytes += cached.getInMemorySize(); + enforceCacheBudget(); + } + } + + private CachedPdfDocument getCachedDocument(String jobId) { + synchronized (cacheLock) { + CachedPdfDocument cached = documentCache.get(jobId); + if (cached != null) { + lruCache.remove(jobId); + lruCache.put(jobId, cached); + } + return cached; + } + } + + private void enforceCacheBudget() { + if (cacheBudgetBytes <= 0) { + return; + } + // Must be called under cacheLock + java.util.Iterator> it = + lruCache.entrySet().iterator(); + while (currentCacheBytes > cacheBudgetBytes && it.hasNext()) { + java.util.Map.Entry entry = it.next(); + it.remove(); + CachedPdfDocument removed = entry.getValue(); + documentCache.remove(entry.getKey(), removed); + currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize()); + removed.close(); + log.warn( + "Evicted cached PDF for jobId {} to enforce cache budget (budget={} bytes, current={} bytes)", + entry.getKey(), + cacheBudgetBytes, + currentCacheBytes); + } + if (currentCacheBytes > cacheBudgetBytes && !lruCache.isEmpty()) { + // Spill the most recently used large entry to disk + String key = + lruCache.entrySet().stream() + .reduce((first, second) -> second) + .map(java.util.Map.Entry::getKey) + .orElse(null); + if (key != null) { + CachedPdfDocument doc = lruCache.get(key); + if (doc != null && doc.getInMemorySize() > 0) { + try { + CachedPdfDocument diskDoc = + buildCachedDocument( + key, + doc.getPdfBytes(), + doc.getMetadata(), + doc.getFonts(), + doc.getPageFontResources()); + lruCache.put(key, diskDoc); + documentCache.put(key, diskDoc); + currentCacheBytes = + Math.max(0L, currentCacheBytes - doc.getInMemorySize()) + + diskDoc.getInMemorySize(); + doc.close(); + log.debug("Spilled cached PDF for jobId {} to disk to satisfy budget", key); + } catch (IOException ex) { + log.warn( + "Failed to spill cached PDF for jobId {} to disk: {}", + key, + ex.getMessage()); + } + } + } + } + } + + private void removeCachedDocument(String jobId) { + log.warn( + "removeCachedDocument called for jobId: {} [CALLER: {}]", + jobId, + Thread.currentThread().getStackTrace()[2].toString()); + CachedPdfDocument removed = null; + synchronized (cacheLock) { + removed = documentCache.remove(jobId); + if (removed != null) { + lruCache.remove(jobId); + currentCacheBytes = Math.max(0L, currentCacheBytes - removed.getInMemorySize()); + log.warn( + "Removed cached document for jobId: {} (size={} bytes)", + jobId, + removed.getInMemorySize()); + } else { + log.warn("Attempted to remove jobId: {} but it was not in cache", jobId); + } + } + if (removed != null) { + removed.close(); + } + } + private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element) throws IOException { if (element.getCharacterSpacing() != null) { @@ -5311,6 +5501,8 @@ public class PdfJsonConversionService { */ private static class CachedPdfDocument { private final byte[] pdfBytes; + private final TempFile pdfTempFile; + private final long pdfSize; private final PdfJsonDocumentMetadata metadata; private final Map fonts; // Font map with UIDs for consistency private final Map> pageFontResources; // Page font resources @@ -5318,10 +5510,14 @@ public class PdfJsonConversionService { public CachedPdfDocument( byte[] pdfBytes, + TempFile pdfTempFile, + long pdfSize, PdfJsonDocumentMetadata metadata, Map fonts, Map> pageFontResources) { this.pdfBytes = pdfBytes; + this.pdfTempFile = pdfTempFile; + this.pdfSize = pdfSize; this.metadata = metadata; // Create defensive copies to prevent mutation of shared maps this.fonts = @@ -5336,8 +5532,14 @@ public class PdfJsonConversionService { } // Getters return defensive copies to prevent external mutation - public byte[] getPdfBytes() { - return pdfBytes; + public byte[] getPdfBytes() throws IOException { + if (pdfBytes != null) { + return pdfBytes; + } + if (pdfTempFile != null) { + return Files.readAllBytes(pdfTempFile.getPath()); + } + throw new IOException("Cached PDF backing missing"); } public PdfJsonDocumentMetadata getMetadata() { @@ -5352,6 +5554,18 @@ public class PdfJsonConversionService { return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources); } + public long getPdfSize() { + return pdfSize; + } + + public long getInMemorySize() { + return pdfBytes != null ? pdfBytes.length : 0L; + } + + public boolean isDiskBacked() { + return pdfBytes == null && pdfTempFile != null; + } + public long getTimestamp() { return timestamp; } @@ -5363,7 +5577,19 @@ public class PdfJsonConversionService { public CachedPdfDocument withUpdatedFonts( byte[] nextBytes, Map nextFonts) { Map fontsToUse = nextFonts != null ? nextFonts : this.fonts; - return new CachedPdfDocument(nextBytes, metadata, fontsToUse, pageFontResources); + return new CachedPdfDocument( + nextBytes, + null, + nextBytes != null ? nextBytes.length : 0, + metadata, + fontsToUse, + pageFontResources); + } + + public void close() { + if (pdfTempFile != null) { + pdfTempFile.close(); + } } } @@ -5444,14 +5670,15 @@ public class PdfJsonConversionService { // Cache PDF bytes, metadata, and fonts for lazy page loading if (jobId != null) { CachedPdfDocument cached = - new CachedPdfDocument(pdfBytes, docMetadata, fonts, pageFontResources); - documentCache.put(jobId, cached); + buildCachedDocument(jobId, pdfBytes, docMetadata, fonts, pageFontResources); + putCachedDocument(jobId, cached); log.debug( - "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}", - pdfBytes.length, + "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {} (diskBacked={})", + cached.getPdfSize(), totalPages, fonts.size(), - jobId); + jobId, + cached.isDiskBacked()); // Schedule cleanup after 30 minutes scheduleDocumentCleanup(jobId); @@ -5466,9 +5693,10 @@ public class PdfJsonConversionService { /** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */ public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException { - CachedPdfDocument cached = documentCache.get(jobId); + CachedPdfDocument cached = getCachedDocument(jobId); if (cached == null) { - throw new IllegalArgumentException("No cached document found for jobId: " + jobId); + throw new stirling.software.SPDF.exception.CacheUnavailableException( + "No cached document found for jobId: " + jobId); } int pageIndex = pageNumber - 1; @@ -5480,8 +5708,8 @@ public class PdfJsonConversionService { } log.debug( - "Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})", - cached.getPdfBytes().length, + "Loading PDF from {} to extract page {} (jobId: {})", + cached.isDiskBacked() ? "disk cache" : "memory cache", pageNumber, jobId); @@ -5627,10 +5855,21 @@ public class PdfJsonConversionService { if (jobId == null || jobId.isBlank()) { throw new IllegalArgumentException("jobId is required for incremental export"); } - CachedPdfDocument cached = documentCache.get(jobId); + log.info("Looking up cache for jobId: {}", jobId); + CachedPdfDocument cached = getCachedDocument(jobId); if (cached == null) { - throw new IllegalArgumentException("No cached document available for jobId: " + jobId); + log.error( + "Cache not found for jobId: {}. Available cache keys: {}", + jobId, + documentCache.keySet()); + throw new stirling.software.SPDF.exception.CacheUnavailableException( + "No cached document available for jobId: " + jobId); } + log.info( + "Found cached document for jobId: {} (size={}, diskBacked={})", + jobId, + cached.getPdfSize(), + cached.isDiskBacked()); if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) { log.debug( "Incremental export requested with no page updates; returning cached PDF for jobId {}", @@ -5709,7 +5948,14 @@ public class PdfJsonConversionService { document.save(baos); byte[] updatedBytes = baos.toByteArray(); - documentCache.put(jobId, cached.withUpdatedFonts(updatedBytes, mergedFonts)); + CachedPdfDocument updated = + buildCachedDocument( + jobId, + updatedBytes, + cached.getMetadata(), + mergedFonts, + cached.getPageFontResources()); + putCachedDocument(jobId, updated); // Clear Type3 cache entries for this incremental update clearType3CacheEntriesForJob(updateJobId); @@ -5724,11 +5970,13 @@ public class PdfJsonConversionService { /** Clears a cached document. */ public void clearCachedDocument(String jobId) { - CachedPdfDocument cached = documentCache.remove(jobId); + CachedPdfDocument cached = getCachedDocument(jobId); + removeCachedDocument(jobId); if (cached != null) { log.debug( - "Removed cached PDF bytes ({} bytes) for jobId: {}", - cached.getPdfBytes().length, + "Removed cached PDF ({} bytes, diskBacked={}) for jobId: {}", + cached.getPdfSize(), + cached.isDiskBacked(), jobId); } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java index 107abbe2b..e4baee055 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/PdfJsonFallbackFontService.java @@ -312,12 +312,29 @@ public class PdfJsonFallbackFontService { "ttf"))); private final ResourceLoader resourceLoader; + private final stirling.software.common.model.ApplicationProperties applicationProperties; @Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}") + private String legacyFallbackFontLocation; + private String fallbackFontLocation; private final Map fallbackFontCache = new ConcurrentHashMap<>(); + @jakarta.annotation.PostConstruct + private void loadConfig() { + String configured = null; + if (applicationProperties.getPdfEditor() != null) { + configured = applicationProperties.getPdfEditor().getFallbackFont(); + } + if (configured != null && !configured.isBlank()) { + fallbackFontLocation = configured; + } else { + fallbackFontLocation = legacyFallbackFontLocation; + } + log.info("Using fallback font location: {}", fallbackFontLocation); + } + public PdfJsonFont buildFallbackFontModel() throws IOException { return buildFallbackFontModel(FALLBACK_FONT_ID); } diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java index 1a9f7f698..6a56bad09 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/PdfJsonFontService.java @@ -5,7 +5,6 @@ import java.nio.file.Files; import java.util.Base64; import java.util.Locale; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import jakarta.annotation.PostConstruct; @@ -25,22 +24,16 @@ import stirling.software.common.util.TempFileManager; public class PdfJsonFontService { private final TempFileManager tempFileManager; + private final stirling.software.common.model.ApplicationProperties applicationProperties; - @Getter - @Value("${stirling.pdf.json.cff-converter.enabled:true}") - private boolean cffConversionEnabled; + @Getter private boolean cffConversionEnabled; - @Getter - @Value("${stirling.pdf.json.cff-converter.method:python}") - private String cffConverterMethod; + @Getter private String cffConverterMethod; - @Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}") private String pythonCommand; - @Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}") private String pythonScript; - @Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}") private String fontforgeCommand; private volatile boolean pythonCffConverterAvailable; @@ -48,6 +41,7 @@ public class PdfJsonFontService { @PostConstruct private void initialiseCffConverterAvailability() { + loadConfiguration(); if (!cffConversionEnabled) { log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration"); pythonCffConverterAvailable = false; @@ -77,6 +71,22 @@ public class PdfJsonFontService { log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod); } + private void loadConfiguration() { + if (applicationProperties.getPdfEditor() != null + && applicationProperties.getPdfEditor().getCffConverter() != null) { + var cfg = applicationProperties.getPdfEditor().getCffConverter(); + this.cffConversionEnabled = cfg.isEnabled(); + this.cffConverterMethod = cfg.getMethod(); + this.pythonCommand = cfg.getPythonCommand(); + this.pythonScript = cfg.getPythonScript(); + this.fontforgeCommand = cfg.getFontforgeCommand(); + } else { + // Use defaults when config is not available + this.cffConversionEnabled = false; + log.warn("[FONT-DEBUG] PdfEditor configuration not available, CFF conversion disabled"); + } + } + public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) { if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) { log.warn( diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java index 4385e5725..b4e8f9d95 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/Type3LibraryStrategy.java @@ -2,7 +2,6 @@ package stirling.software.SPDF.service.pdfjson.type3; import java.io.IOException; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; @@ -23,8 +22,8 @@ import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayl public class Type3LibraryStrategy implements Type3ConversionStrategy { private final Type3FontLibrary fontLibrary; + private final stirling.software.common.model.ApplicationProperties applicationProperties; - @Value("${stirling.pdf.json.type3.library.enabled:true}") private boolean enabled; @Override @@ -42,6 +41,19 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy { return enabled && fontLibrary != null && fontLibrary.isLoaded(); } + @jakarta.annotation.PostConstruct + private void loadConfiguration() { + if (applicationProperties.getPdfEditor() != null + && applicationProperties.getPdfEditor().getType3() != null + && applicationProperties.getPdfEditor().getType3().getLibrary() != null) { + var cfg = applicationProperties.getPdfEditor().getType3().getLibrary(); + this.enabled = cfg.isEnabled(); + } else { + this.enabled = false; + log.warn("PdfEditor Type3 library configuration not available, disabled"); + } + } + @Override public PdfJsonFontConversionCandidate convert( Type3ConversionRequest request, Type3GlyphContext context) throws IOException { diff --git a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java index 32a6abec2..f00c729a2 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/pdfjson/type3/library/Type3FontLibrary.java @@ -14,7 +14,6 @@ import java.util.stream.Collectors; import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.font.PDType3Font; -import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; import org.springframework.stereotype.Component; @@ -34,8 +33,8 @@ public class Type3FontLibrary { private final ObjectMapper objectMapper; private final ResourceLoader resourceLoader; + private final stirling.software.common.model.ApplicationProperties applicationProperties; - @Value("${stirling.pdf.json.type3.library.index:classpath:/type3/library/index.json}") private String indexLocation; private final Map signatureIndex = new ConcurrentHashMap<>(); @@ -44,6 +43,17 @@ public class Type3FontLibrary { @jakarta.annotation.PostConstruct void initialise() { + if (applicationProperties.getPdfEditor() != null + && applicationProperties.getPdfEditor().getType3() != null + && applicationProperties.getPdfEditor().getType3().getLibrary() != null) { + this.indexLocation = + applicationProperties.getPdfEditor().getType3().getLibrary().getIndex(); + } else { + log.warn( + "[TYPE3] PdfEditor Type3 library configuration not available; Type3 library disabled"); + entries = List.of(); + return; + } Resource resource = resourceLoader.getResource(indexLocation); if (!resource.exists()) { log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index a272c54cc..dffebe1bb 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -58,6 +58,8 @@ security: idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair + # IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId} + # Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration. jwt: # This feature is currently under development and not yet fully supported. Do not use in production. persistence: true # Set to 'true' to enable JWT key store enableKeyRotation: true # Set to 'true' to enable key pair rotation @@ -132,8 +134,9 @@ system: enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) maxDPI: 500 # Maximum allowed DPI for PDF to image conversion - corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. - frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL. + corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173' + backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development. + frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails. serverCertificate: enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option organizationName: Stirling-PDF # Organization name for generated certificates @@ -179,23 +182,6 @@ system: databaseBackup: cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight -stirling: - pdf: - fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font - json: - font-normalization: - enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts. - cff-converter: - enabled: true # Wrap CFF/Type1C fonts as OpenType-CFF for browser compatibility - method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts) - python-command: /opt/venv/bin/python3 # Python interpreter path - python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script - fontforge-command: fontforge # Override if FontForge is installed under a different name/path - type3: - library: - enabled: true # Match common Type3 fonts against the built-in library of converted programs - index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:) - ui: appNameNavbar: '' # name displayed on the navigation bar logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo) @@ -239,3 +225,21 @@ processExecutor: qpdfTimeoutMinutes: 30 ghostscriptTimeoutMinutes: 30 ocrMyPdfTimeoutMinutes: 30 + +pdfEditor: + fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font + cache: + max-bytes: -1 # Max in-memory cache size in bytes; -1 disables byte cap + max-percent: 20 # Max in-memory cache as % of JVM max; used when max-bytes <= 0 + font-normalization: + enabled: false # IMPORTANT: Disable to preserve ToUnicode CMaps for correct font rendering. Ghostscript strips Unicode mappings from CID fonts. + cff-converter: + enabled: true # Wrap CFF/Type1CFF fonts as OpenType-CFF for browser compatibility + method: python # Converter method: 'python' (fontTools, recommended - wraps as OTF), 'fontforge' (legacy - converts to TTF, may hang on CID fonts) + python-command: /opt/venv/bin/python3 # Python interpreter path + python-script: /scripts/convert_cff_to_ttf.py # Path to font wrapping script + fontforge-command: fontforge # Override if FontForge is installed under a different name/path + type3: + library: + enabled: true # Match common Type3 fonts against the built-in library of converted programs + index: classpath:/type3/library/index.json # Override to point at a custom index.json (supports http:, file:, classpath:) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 88fddb7ea..e13d807da 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -94,6 +94,22 @@ public class ProprietaryUIDataController { this.auditRepository = auditRepository; } + /** + * Get the backend base URL for SAML/OAuth redirects. Uses system.backendUrl from config if set, + * otherwise defaults to http://localhost:8080 + */ + private String getBackendBaseUrl() { + String backendUrl = applicationProperties.getSystem().getBackendUrl(); + + // If backendUrl is configured, use it + if (backendUrl != null && !backendUrl.trim().isEmpty()) { + return backendUrl.trim(); + } + + // For development, default to localhost:8080 (backend port) + return "http://localhost:8080"; + } + @GetMapping("/audit-dashboard") @PreAuthorize("hasRole('ADMIN')") @EnterpriseEndpoint @@ -185,14 +201,17 @@ public class ProprietaryUIDataController { } SAML2 saml2 = securityProps.getSaml2(); - if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality() - && applicationProperties.getPremium().isEnabled()) { + if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); + // For SAML, we need to use the backend URL directly, not a relative path + // This ensures Spring Security generates the correct ACS URL + String backendUrl = getBackendBaseUrl(); + String fullSamlPath = backendUrl + saml2AuthenticationPath; + if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) { - providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); + providerList.put(fullSamlPath, samlIdp + " (SAML 2)"); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java index d035dbc58..17857fc85 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java @@ -120,9 +120,7 @@ public class AccountWebController { SAML2 saml2 = securityProps.getSaml2(); - if (securityProps.isSaml2Active() - && applicationProperties.getSystem().getEnableAlphaFunctionality() - && applicationProperties.getPremium().isEnabled()) { + if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) { String samlIdp = saml2.getProvider(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 257d243ea..1226237c8 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -334,7 +334,8 @@ public class SecurityConfiguration { securityProperties.getSaml2(), userService, jwtService, - licenseSettingsService)) + licenseSettingsService, + applicationProperties)) .failureHandler( new CustomSaml2AuthenticationFailureHandler()) .authenticationRequestResolver( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index de6428554..c3e11c3ab 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -244,10 +244,13 @@ public class AuthController { userMap.put("username", user.getUsername()); userMap.put("role", user.getRolesAsString()); userMap.put("enabled", user.isEnabled()); + userMap.put( + "authenticationType", + user.getAuthenticationType()); // Expose authentication type for SSO detection // Add metadata for OAuth compatibility Map appMetadata = new HashMap<>(); - appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider + appMetadata.put("provider", user.getAuthenticationType()); userMap.put("app_metadata", appMetadata); return userMap; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java index e8bce579a..3c63f1bf4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2AuthenticationSuccessHandler.java @@ -51,6 +51,7 @@ public class CustomSaml2AuthenticationSuccessHandler private final JwtServiceInterface jwtService; private final stirling.software.proprietary.service.UserLicenseSettingsService licenseSettingsService; + private final ApplicationProperties applicationProperties; @Override @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @@ -77,8 +78,8 @@ public class CustomSaml2AuthenticationSuccessHandler log.warn( "SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)", username); - response.sendRedirect( - request.getContextPath() + "/logout?saml2RequiresLicense=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); return; } } else if (!licenseSettingsService.isSamlEligible(null)) { @@ -86,8 +87,8 @@ public class CustomSaml2AuthenticationSuccessHandler log.warn( "SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)", username); - response.sendRedirect( - request.getContextPath() + "/logout?saml2RequiresLicense=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?saml2RequiresLicense=true"); return; } @@ -144,20 +145,28 @@ public class CustomSaml2AuthenticationSuccessHandler log.debug( "User {} exists with password but is not SSO user, redirecting to logout", username); - response.sendRedirect( - contextPath + "/logout?oAuth2AuthenticationErrorWeb=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true"); return; } try { - if (!userExists || saml2Properties.getBlockRegistration()) { - log.debug("Registration blocked for new user: {}", username); - response.sendRedirect( - contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); + // Block new users only if: blockRegistration is true OR autoCreateUser is false + if (!userExists + && (saml2Properties.getBlockRegistration() + || !saml2Properties.getAutoCreateUser())) { + log.debug( + "Registration blocked for new user '{}' (blockRegistration: {}, autoCreateUser: {})", + username, + saml2Properties.getBlockRegistration(), + saml2Properties.getAutoCreateUser()); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/login?errorOAuth=oAuth2AdminBlockedUser"); return; } if (!userExists && licenseSettingsService.wouldExceedLimit(1)) { - response.sendRedirect(contextPath + "/logout?maxUsersReached=true"); + String origin = resolveOrigin(request); + response.sendRedirect(origin + "/logout?maxUsersReached=true"); return; } @@ -222,16 +231,30 @@ public class CustomSaml2AuthenticationSuccessHandler String contextPath, String jwt) { String redirectPath = resolveRedirectPath(request, contextPath); - String origin = - resolveForwardedOrigin(request) - .orElseGet( - () -> - resolveOriginFromReferer(request) - .orElseGet(() -> buildOriginFromRequest(request))); + String origin = resolveOrigin(request); clearRedirectCookie(response); return origin + redirectPath + "#access_token=" + jwt; } + /** + * Resolve the origin (frontend URL) for redirects. First checks system.frontendUrl from config, + * then falls back to detecting from request headers. + */ + private String resolveOrigin(HttpServletRequest request) { + // First check if frontendUrl is configured + String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl(); + if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) { + return configuredFrontendUrl.trim(); + } + + // Fall back to auto-detection from request headers + return resolveForwardedOrigin(request) + .orElseGet( + () -> + resolveOriginFromReferer(request) + .orElseGet(() -> buildOriginFromRequest(request))); + } + private String resolveRedirectPath(HttpServletRequest request, String contextPath) { return extractRedirectPathFromCookie(request) .filter(path -> path.startsWith("/")) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java index 9d21f88a3..99be4b5b0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/Saml2Configuration.java @@ -41,22 +41,74 @@ public class Saml2Configuration { @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); - X509Certificate idpCert = CertificateUtils.readCertificate(samlConf.getIdpCert()); + + log.info( + "Initializing SAML2 configuration with registration ID: {}", + samlConf.getRegistrationId()); + + // Load IdP certificate + X509Certificate idpCert; + try { + Resource idpCertResource = samlConf.getIdpCert(); + log.info("Loading IdP certificate from: {}", idpCertResource.getDescription()); + if (!idpCertResource.exists()) { + log.error( + "SAML2 IdP certificate not found at: {}", idpCertResource.getDescription()); + throw new IllegalStateException( + "SAML2 IdP certificate file does not exist: " + + idpCertResource.getDescription()); + } + idpCert = CertificateUtils.readCertificate(idpCertResource); + log.info( + "Successfully loaded IdP certificate. Subject: {}", + idpCert.getSubjectX500Principal().getName()); + } catch (Exception e) { + log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e); + throw new IllegalStateException("Failed to load SAML2 IdP certificate", e); + } + Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert); + + // Load SP private key and certificate Resource privateKeyResource = samlConf.getPrivateKey(); Resource certificateResource = samlConf.getSpCert(); - Saml2X509Credential signingCredential = - new Saml2X509Credential( - CertificateUtils.readPrivateKey(privateKeyResource), - CertificateUtils.readCertificate(certificateResource), - Saml2X509CredentialType.SIGNING); + + log.info("Loading SP private key from: {}", privateKeyResource.getDescription()); + if (!privateKeyResource.exists()) { + log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription()); + throw new IllegalStateException( + "SAML2 SP private key file does not exist: " + + privateKeyResource.getDescription()); + } + + log.info("Loading SP certificate from: {}", certificateResource.getDescription()); + if (!certificateResource.exists()) { + log.error( + "SAML2 SP certificate not found at: {}", certificateResource.getDescription()); + throw new IllegalStateException( + "SAML2 SP certificate file does not exist: " + + certificateResource.getDescription()); + } + + Saml2X509Credential signingCredential; + try { + signingCredential = + new Saml2X509Credential( + CertificateUtils.readPrivateKey(privateKeyResource), + CertificateUtils.readCertificate(certificateResource), + Saml2X509CredentialType.SIGNING); + log.info("Successfully loaded SP credentials"); + } catch (Exception e) { + log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e); + throw new IllegalStateException("Failed to load SAML2 SP credentials", e); + } RelyingPartyRegistration rp = RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) .signingX509Credentials(c -> c.add(signingCredential)) .entityId(samlConf.getIdpIssuer()) .singleLogoutServiceBinding(Saml2MessageBinding.POST) .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) - .singleLogoutServiceResponseLocation("http://localhost:8080/login") + .singleLogoutServiceResponseLocation("{baseUrl}/login") .assertionConsumerServiceBinding(Saml2MessageBinding.POST) .assertionConsumerServiceLocation( "{baseUrl}/login/saml2/sso/{registrationId}") @@ -75,9 +127,14 @@ public class Saml2Configuration { .singleLogoutServiceLocation( samlConf.getIdpSingleLogoutUrl()) .singleLogoutServiceResponseLocation( - "http://localhost:8080/login") + "{baseUrl}/login") .wantAuthnRequestsSigned(true)) .build(); + + log.info( + "SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}", + samlConf.getRegistrationId(), + samlConf.getIdpIssuer()); return new InMemoryRelyingPartyRegistrationRepository(rp); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java index aa794e699..54660a1cc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -177,6 +177,13 @@ public class UserLicenseSettingsService { */ @Transactional public void grandfatherExistingOAuthUsers() { + // Only grandfather users if this is a V1→V2 upgrade, not a fresh V2 install + Boolean isNewServer = applicationProperties.getAutomaticallyGenerated().getIsNewServer(); + if (Boolean.TRUE.equals(isNewServer)) { + log.info("Fresh V2 installation detected - skipping OAuth user grandfathering"); + return; + } + UserLicenseSettings settings = getOrCreateSettings(); // Check if we've already run this migration @@ -348,30 +355,22 @@ public class UserLicenseSettingsService { String username = (user != null) ? user.getUsername() : ""; log.info("OAuth eligibility check for user: {}", username); - // Grandfathered users always have OAuth access - if (user != null && user.isOauthGrandfathered()) { - log.debug("User {} is grandfathered for OAuth", user.getUsername()); + // Check license first - if paying, they're eligible (no need to check grandfathering) + boolean hasPaid = hasPaidLicense(); + if (hasPaid) { + log.debug("User {} eligible for OAuth via paid license", username); return true; } - // todo: remove - if (user != null) { - log.info( - "User {} is NOT grandfathered (isOauthGrandfathered={})", - username, - user.isOauthGrandfathered()); - } else { - log.info("New user attempting OAuth login - checking license requirement"); + // No license - check if grandfathered (fallback for V1 users) + if (user != null && user.isOauthGrandfathered()) { + log.info("User {} eligible for OAuth via grandfathering (no paid license)", username); + return true; } - // Users can use OAuth with SERVER or ENTERPRISE license - boolean hasPaid = hasPaidLicense(); - log.info( - "OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}", - hasPaid, - username, - hasPaid); - return hasPaid; + // Not grandfathered and no license + log.info("User {} NOT eligible for OAuth: no paid license and not grandfathered", username); + return false; } /** @@ -391,29 +390,26 @@ public class UserLicenseSettingsService { String username = (user != null) ? user.getUsername() : ""; log.info("SAML2 eligibility check for user: {}", username); - // Grandfathered users always have SAML access - if (user != null && user.isOauthGrandfathered()) { - log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username); + // Check license first - if paying, they're eligible (no need to check grandfathering) + boolean hasEnterprise = hasEnterpriseLicense(); + if (hasEnterprise) { + log.debug("User {} eligible for SAML2 via ENTERPRISE license", username); return true; } - if (user != null) { + // No license - check if grandfathered (fallback for V1 users) + if (user != null && user.isOauthGrandfathered()) { log.info( - "User {} is NOT grandfathered (isOauthGrandfathered={})", - username, - user.isOauthGrandfathered()); - } else { - log.info("New user attempting SAML2 login - checking license requirement"); + "User {} eligible for SAML2 via grandfathering (no ENTERPRISE license)", + username); + return true; } - // Users can use SAML only with ENTERPRISE license - boolean hasEnterprise = hasEnterpriseLicense(); + // Not grandfathered and no license log.info( - "SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}", - hasEnterprise, - username, - hasEnterprise); - return hasEnterprise; + "User {} NOT eligible for SAML2: no ENTERPRISE license and not grandfathered", + username); + return false; } /** diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java index 7f9445ad7..a7f8042f6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/UserLicenseSettingsServiceTest.java @@ -33,6 +33,7 @@ class UserLicenseSettingsServiceTest { @Mock private UserService userService; @Mock private ApplicationProperties applicationProperties; @Mock private ApplicationProperties.Premium premium; + @Mock private ApplicationProperties.AutomaticallyGenerated automaticallyGenerated; @Mock private LicenseKeyChecker licenseKeyChecker; @Mock private ObjectProvider licenseKeyCheckerProvider; @@ -49,6 +50,8 @@ class UserLicenseSettingsServiceTest { mockSettings.setGrandfatheredUserSignature("80:test-signature"); when(applicationProperties.getPremium()).thenReturn(premium); + when(applicationProperties.getAutomaticallyGenerated()).thenReturn(automaticallyGenerated); + when(automaticallyGenerated.getIsNewServer()).thenReturn(false); // Default: not a new server when(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings)); when(userService.getTotalUsersCount()).thenReturn(80L); when(settingsRepository.save(any(UserLicenseSettings.class))) diff --git a/build.gradle b/build.gradle index 90fde88e5..17bcdb878 100644 --- a/build.gradle +++ b/build.gradle @@ -59,7 +59,7 @@ repositories { allprojects { group = 'stirling.software' - version = '2.1.3' + version = '2.1.4' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' diff --git a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx index 533dc644b..422b06603 100644 --- a/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx +++ b/frontend/src/core/tools/pdfTextEditor/PdfTextEditor.tsx @@ -238,6 +238,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const originalImagesRef = useRef([]); const originalGroupsRef = useRef([]); const imagesByPageRef = useRef([]); + const lastLoadedFileRef = useRef(null); const autoLoadKeyRef = useRef(null); const sourceFileIdRef = useRef(null); const loadRequestIdRef = useRef(0); @@ -251,6 +252,10 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const pagePreviewsRef = useRef>(pagePreviews); const previewScaleRef = useRef>(new Map()); const cachedJobIdRef = useRef(null); + const previousCachedJobIdRef = useRef(null); + const cacheRecoveryInProgressRef = useRef(false); + const cacheRecoveryAttemptsRef = useRef(0); + const recoverCacheAndReloadRef = useRef<() => Promise>(async () => false); // Keep ref in sync with state for access in async callbacks useEffect(() => { @@ -279,6 +284,13 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }; }, []); + const isCacheUnavailableError = useCallback((error: any): boolean => { + const status = error?.response?.status; + // Treat any 410 as cache unavailable, since responseType: 'blob' makes + // it impossible to reliably check the JSON body + return status === 410; + }, []); + const dirtyPages = useMemo( () => getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current), [groupsByPage, imagesByPage], @@ -316,6 +328,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { loadedImagePagesRef.current = new Set(); loadingImagePagesRef.current = new Set(); setSelectedPage(0); + setIsLazyMode(false); + setCachedJobId(null); + cachedJobIdRef.current = null; return; } const cloned = deepCloneDocument(document); @@ -365,11 +380,14 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }, []); useEffect(() => { - const previousJobId = cachedJobIdRef.current; + // Clear old cached job when job ID changes + const previousJobId = previousCachedJobIdRef.current; if (previousJobId && previousJobId !== cachedJobId) { + console.log(`[PdfTextEditor] Clearing old cache for jobId: ${previousJobId}, new jobId: ${cachedJobId}`); clearCachedJob(previousJobId); } - cachedJobIdRef.current = cachedJobId; + // Update the previous jobId ref for next time + previousCachedJobIdRef.current = cachedJobId; }, [cachedJobId, clearCachedJob]); const initializePdfPreview = useCallback( @@ -489,6 +507,11 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { ); } catch (error) { console.error(`[loadImagesForPage] Failed to load images for page ${pageNumber}:`, error); + if (isCacheUnavailableError(error)) { + console.log('[loadImagesForPage] Cache expired, triggering automatic recovery...'); + // Automatically recover by reloading the file + void recoverCacheAndReloadRef.current(); + } } finally { loadingImagePagesRef.current.delete(pageIndex); setLoadingImagePages((prev) => { @@ -498,7 +521,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }); } }, - [isLazyMode, cachedJobId], + [isLazyMode, cachedJobId, isCacheUnavailableError], ); const handleLoadFile = useCallback( @@ -507,6 +530,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { return; } + lastLoadedFileRef.current = file; const requestId = loadRequestIdRef.current + 1; loadRequestIdRef.current = requestId; @@ -555,59 +579,35 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { message: 'Starting conversion...', }); - let jobComplete = false; - let attempts = 0; - const maxAttempts = 600; + let jobComplete = false; + let attempts = 0; + const maxAttempts = 600; + let pollDelay = 500; - while (!jobComplete && attempts < maxAttempts) { - await new Promise((resolve) => setTimeout(resolve, 1000)); - attempts += 1; + while (!jobComplete && attempts < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, pollDelay)); + attempts += 1; + if (pollDelay < 10000) { + pollDelay = Math.min(10000, Math.floor(pollDelay * 1.5)); + } try { const statusResponse = await apiClient.get(`/api/v1/general/job/${jobId}`); const jobStatus = statusResponse.data; console.log(`Job status (attempt ${attempts}):`, jobStatus); - if (jobStatus.notes && jobStatus.notes.length > 0) { - const lastNote = jobStatus.notes[jobStatus.notes.length - 1]; - console.log('Latest note:', lastNote); - const matchWithCount = lastNote.match( - /\[(\d+)%\]\s+(\w+):\s+(.+?)\s+\((\d+)\/(\d+)\)/, - ); - if (matchWithCount) { - const percent = parseInt(matchWithCount[1], 10); - const stage = matchWithCount[2]; - const message = matchWithCount[3]; - const current = parseInt(matchWithCount[4], 10); - const total = parseInt(matchWithCount[5], 10); - setConversionProgress({ - percent, - stage, - message, - current, - total, - }); - } else { - const match = lastNote.match(/\[(\d+)%\]\s+(\w+):\s+(.+)/); - if (match) { - const percent = parseInt(match[1], 10); - const stage = match[2]; - const message = match[3]; - setConversionProgress({ - percent, - stage, - message, - }); - } - } - } else if (jobStatus.progress !== undefined) { - const percent = Math.min(Math.max(jobStatus.progress, 0), 100); - setConversionProgress({ - percent, - stage: jobStatus.stage || 'processing', - message: jobStatus.note || 'Converting PDF to JSON...', - }); - } + const percent = Math.min(Math.max(jobStatus.progress ?? 0, 0), 100); + const stage = jobStatus.stage || 'processing'; + const message = jobStatus.note || 'Converting PDF to JSON...'; + const current = jobStatus.current ?? undefined; + const total = jobStatus.total ?? undefined; + setConversionProgress({ + percent, + stage, + message, + current, + total, + }); if (jobStatus.complete) { if (jobStatus.error) { @@ -701,7 +701,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { setLoadedDocument(parsed); resetToDocument(parsed, groupingMode); setIsLazyMode(shouldUseLazyMode); - setCachedJobId(shouldUseLazyMode ? pendingJobId : null); + const newJobId = shouldUseLazyMode ? pendingJobId : null; + setCachedJobId(newJobId); + cachedJobIdRef.current = newJobId; setFileName(file.name); setErrorMessage(null); } catch (error: any) { @@ -719,6 +721,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { setLoadedDocument(null); resetToDocument(null, groupingMode); clearPdfPreview(); + setIsLazyMode(false); + setCachedJobId(null); + cachedJobIdRef.current = null; if (isPdf) { const errorMsg = @@ -743,6 +748,38 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { [groupingMode, resetToDocument, t], ); + const recoverCacheAndReload = useCallback(async () => { + if (cacheRecoveryInProgressRef.current) { + return false; + } + if (cacheRecoveryAttemptsRef.current >= 2) { + console.warn('[PdfTextEditor] Cache recovery limit reached'); + return false; + } + cacheRecoveryAttemptsRef.current += 1; + const file = lastLoadedFileRef.current; + if (!file) { + console.warn('[PdfTextEditor] No file available for cache recovery'); + return false; + } + cacheRecoveryInProgressRef.current = true; + try { + console.log('[PdfTextEditor] Automatically reloading file due to cache expiration...'); + await handleLoadFile(file); + console.log('[PdfTextEditor] Cache recovery successful'); + return true; + } catch (error) { + console.error('[PdfTextEditor] Cache recovery failed', error); + return false; + } finally { + cacheRecoveryInProgressRef.current = false; + } + }, [handleLoadFile]); + + useEffect(() => { + recoverCacheAndReloadRef.current = recoverCacheAndReload; + }, [recoverCacheAndReload]); + // Wrapper for loading files from the dropzone - adds to workbench first const handleLoadFileFromDropzone = useCallback( async (file: File) => { @@ -1057,7 +1094,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { try { const payload = buildPayload(); if (!payload) { - return; + throw new Error('Failed to build payload'); } const { document, filename } = payload; @@ -1076,7 +1113,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ''); const expectedName = `${baseName || 'document'}.pdf`; const response = await apiClient.post( - `/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`, + `/api/v1/convert/pdf/text-editor/partial/${cachedJobIdRef.current}?filename=${encodeURIComponent(expectedName)}`, partialDocument, { responseType: 'blob', @@ -1100,6 +1137,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { '[handleGeneratePdf] Incremental export failed, falling back to full export', incrementalError, ); + // Fall through to full export below } } @@ -1636,6 +1674,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }, 0); }, [navigationActions, navigationState.selectedTool]); + // Register workbench view (re-runs when dependencies change) useEffect(() => { registerCustomWorkbenchView({ id: WORKBENCH_VIEW_ID, @@ -1646,24 +1685,30 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { }); setLeftPanelView('hidden'); setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, latestViewDataRef.current); - - return () => { - // Clear backend cache if we were using lazy loading - clearCachedJob(cachedJobIdRef.current); - clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID); - unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID); - setLeftPanelView('toolPicker'); - }; }, [ - clearCachedJob, - clearCustomWorkbenchViewData, registerCustomWorkbenchView, setCustomWorkbenchViewData, setLeftPanelView, viewLabel, - unregisterCustomWorkbenchView, ]); + // Cleanup ONLY on component unmount (not on re-renders) + useEffect(() => { + return () => { + // Clear backend cache when leaving the tool + const jobId = cachedJobIdRef.current; + if (jobId) { + console.log(`[PdfTextEditor] Cleaning up cached document on unmount: ${jobId}`); + apiClient.post(`/api/v1/convert/pdf/text-editor/clear-cache/${jobId}`).catch((error) => { + console.warn('[PdfTextEditor] Failed to clear cache on unmount:', error); + }); + } + clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID); + unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID); + setLeftPanelView('toolPicker'); + }; + }, []); // Empty deps = cleanup only on unmount + // Note: Compare tool doesn't auto-force workbench, and neither should we // The workbench should be set when the tool is selected via proper channels // (tool registry, tool picker, etc.) - not forced here diff --git a/frontend/src/proprietary/auth/springAuthClient.test.ts b/frontend/src/proprietary/auth/springAuthClient.test.ts index 5c6af7a75..e3db60338 100644 --- a/frontend/src/proprietary/auth/springAuthClient.test.ts +++ b/frontend/src/proprietary/auth/springAuthClient.test.ts @@ -343,7 +343,7 @@ describe('SpringAuthClient', () => { }); const result = await springAuth.signInWithOAuth({ - provider: 'github', + provider: '/oauth2/authorization/github', options: { redirectTo: '/auth/callback' }, }); diff --git a/frontend/src/proprietary/auth/springAuthClient.ts b/frontend/src/proprietary/auth/springAuthClient.ts index be404ffb4..f77e34cb6 100644 --- a/frontend/src/proprietary/auth/springAuthClient.ts +++ b/frontend/src/proprietary/auth/springAuthClient.ts @@ -250,11 +250,11 @@ class SpringAuthClient { } /** - * Sign in with OAuth provider (GitHub, Google, Authentik, etc.) - * This redirects to the Spring OAuth2 authorization endpoint + * Sign in with OAuth/SAML provider (GitHub, Google, Authentik, etc.) + * This redirects to the Spring OAuth2/SAML2 authorization endpoint * - * @param params.provider - OAuth provider ID (e.g., 'github', 'google', 'authentik', 'mycompany') - * Can be any known provider or custom string - the backend determines available providers + * @param params.provider - Full auth path from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling') + * The backend provides the complete path including the auth type and provider ID */ async signInWithOAuth(params: { provider: OAuthProvider; @@ -264,15 +264,16 @@ class SpringAuthClient { const redirectPath = normalizeRedirectPath(params.options?.redirectTo); persistRedirectPath(redirectPath); - // Redirect to Spring OAuth2 endpoint (Vite will proxy to backend) - const redirectUrl = `/oauth2/authorization/${params.provider}`; - // console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl); + // Use the full path provided by the backend + // This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...) + const redirectUrl = params.provider; + // console.log('[SpringAuth] Redirecting to SSO:', redirectUrl); // Use window.location.assign for full page navigation window.location.assign(redirectUrl); return { error: null }; } catch (error) { return { - error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' }, + error: { message: error instanceof Error ? error.message : 'SSO redirect failed' }, }; } } diff --git a/frontend/src/proprietary/routes/Login.test.tsx b/frontend/src/proprietary/routes/Login.test.tsx index 218eb7efb..dc4e76419 100644 --- a/frontend/src/proprietary/routes/Login.test.tsx +++ b/frontend/src/proprietary/routes/Login.test.tsx @@ -295,9 +295,9 @@ describe('Login', () => { await user.click(oauthButton); await waitFor(() => { - // Should use 'authentik' directly, NOT map to 'oidc' + // Should use full path directly, NOT map to 'oidc' expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ - provider: 'authentik', + provider: '/oauth2/authorization/authentik', options: { redirectTo: '/auth/callback' } }); }); @@ -338,10 +338,10 @@ describe('Login', () => { await user.click(oauthButton); await waitFor(() => { - // Should use 'mycompany' directly - this is the critical fix + // Should use full path directly - this is the critical fix // Previously it would map unknown providers to 'oidc' expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ - provider: 'mycompany', + provider: '/oauth2/authorization/mycompany', options: { redirectTo: '/auth/callback' } }); }); @@ -382,9 +382,9 @@ describe('Login', () => { await user.click(oauthButton); await waitFor(() => { - // Should use 'oidc' when explicitly configured + // Should use full path when explicitly configured expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ - provider: 'oidc', + provider: '/oauth2/authorization/oidc', options: { redirectTo: '/auth/callback' } }); }); diff --git a/frontend/src/proprietary/routes/Login.tsx b/frontend/src/proprietary/routes/Login.tsx index 4bca34d43..d25b70c58 100644 --- a/frontend/src/proprietary/routes/Login.tsx +++ b/frontend/src/proprietary/routes/Login.tsx @@ -109,13 +109,12 @@ export default function Login() { updateSupportedLanguages(data.languages, data.defaultLocale); } - // Extract provider IDs from the providerList map - // The keys are like "/oauth2/authorization/google" - extract the last part - const providerIds = Object.keys(data.providerList || {}) - .map(key => key.split('/').pop()) - .filter((id): id is string => id !== undefined); + // Use the full paths from providerList as provider identifiers + // The backend provides paths like "/oauth2/authorization/google" or "/saml2/authenticate/stirling" + // We'll use these full paths so the auth client knows where to redirect + const providerPaths = Object.keys(data.providerList || {}); - setEnabledProviders(providerIds); + setEnabledProviders(providerPaths); } catch (err) { console.error('[Login] Failed to fetch enabled providers:', err); } diff --git a/frontend/src/proprietary/routes/login/OAuthButtons.tsx b/frontend/src/proprietary/routes/login/OAuthButtons.tsx index d62edfdc1..4a9cc3cc3 100644 --- a/frontend/src/proprietary/routes/login/OAuthButtons.tsx +++ b/frontend/src/proprietary/routes/login/OAuthButtons.tsx @@ -26,7 +26,7 @@ interface OAuthButtonsProps { onProviderClick: (provider: OAuthProvider) => void isSubmitting: boolean layout?: 'vertical' | 'grid' | 'icons' - enabledProviders?: OAuthProvider[] // List of enabled provider IDs from backend + enabledProviders?: OAuthProvider[] // List of full auth paths from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling') } export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) { @@ -37,19 +37,24 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = ' ? Object.keys(oauthProviderConfig) : enabledProviders; - // Build provider list - use provider ID to determine icon and label - const providers = providersToShow.map(id => { - if (id in oauthProviderConfig) { + // Build provider list - extract provider ID from full path for display + const providers = providersToShow.map(pathOrId => { + // Extract provider ID from full path (e.g., '/saml2/authenticate/stirling' -> 'stirling') + const providerId = pathOrId.split('/').pop() || pathOrId; + + if (providerId in oauthProviderConfig) { // Known provider - use predefined icon and label return { - id, - ...oauthProviderConfig[id] + id: pathOrId, // Keep full path for redirect + providerId, // Store extracted ID for display lookup + ...oauthProviderConfig[providerId] }; } // Unknown provider - use generic icon and capitalize ID for label return { - id, - label: id.charAt(0).toUpperCase() + id.slice(1), + id: pathOrId, // Keep full path for redirect + providerId, // Store extracted ID for display lookup + label: providerId.charAt(0).toUpperCase() + providerId.slice(1), file: GENERIC_PROVIDER_ICON }; }); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f8d52908d..56de53e37 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -55,12 +55,24 @@ export default defineConfig(({ mode }) => { secure: false, xfwd: true, }, + '/saml2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, '/login/oauth2': { target: 'http://localhost:8080', changeOrigin: true, secure: false, xfwd: true, }, + '/login/saml2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, '/swagger-ui': { target: 'http://localhost:8080', changeOrigin: true,