Cache fix issues V2 (#5237)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
Anthony Stirling
2025-12-15 23:54:25 +00:00
committed by GitHub
parent 336ec34125
commit d80e627899
26 changed files with 805 additions and 229 deletions
@@ -68,6 +68,7 @@ public class ApplicationProperties {
private AutoPipeline autoPipeline = new AutoPipeline(); private AutoPipeline autoPipeline = new AutoPipeline();
private ProcessExecutor processExecutor = new ProcessExecutor(); private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
@Bean @Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment) public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -100,6 +101,46 @@ public class ApplicationProperties {
private String outputFolder; 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 @Data
public static class Legal { public static class Legal {
private String termsAndConditions; private String termsAndConditions;
@@ -368,10 +409,12 @@ public class ApplicationProperties {
private TempFileManagement tempFileManagement = new TempFileManagement(); private TempFileManagement tempFileManagement = new TempFileManagement();
private DatabaseBackup databaseBackup = new DatabaseBackup(); private DatabaseBackup databaseBackup = new DatabaseBackup();
private List<String> corsAllowedOrigins = new ArrayList<>(); private List<String> corsAllowedOrigins = new ArrayList<>();
private String private String backendUrl; // Backend base URL for SAML/OAuth/API callbacks (e.g.
frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set, // '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() { public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics(); return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -536,6 +579,7 @@ public class ApplicationProperties {
@ToString.Exclude private String key; @ToString.Exclude private String key;
private String UUID; private String UUID;
private String appVersion; private String appVersion;
private Boolean isNewServer;
} }
// TODO: Remove post migration // TODO: Remove post migration
@@ -94,6 +94,7 @@ public class InitialSetup {
} }
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion); GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion); applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer);
} }
public static boolean isNewServer() { public static boolean isNewServer() {
@@ -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<byte[]> 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));
}
}
}
}
@@ -0,0 +1,8 @@
package stirling.software.SPDF.exception;
public class CacheUnavailableException extends RuntimeException {
public CacheUnavailableException(String message) {
super(message);
}
}
@@ -86,7 +86,6 @@ import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.text.TextPosition; import org.apache.pdfbox.text.TextPosition;
import org.apache.pdfbox.util.DateConverter; import org.apache.pdfbox.util.DateConverter;
import org.apache.pdfbox.util.Matrix; import org.apache.pdfbox.util.Matrix;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -144,15 +143,23 @@ public class PdfJsonConversionService {
private final PdfJsonFontService fontService; private final PdfJsonFontService fontService;
private final Type3FontConversionService type3FontConversionService; private final Type3FontConversionService type3FontConversionService;
private final Type3GlyphExtractor type3GlyphExtractor; private final Type3GlyphExtractor type3GlyphExtractor;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
private final Map<String, PDFont> type3NormalizedFontCache = new ConcurrentHashMap<>(); private final Map<String, PDFont> type3NormalizedFontCache = new ConcurrentHashMap<>();
private final Map<String, Set<Integer>> type3GlyphCoverageCache = new ConcurrentHashMap<>(); private final Map<String, Set<Integer>> type3GlyphCoverageCache = new ConcurrentHashMap<>();
@Value("${stirling.pdf.json.font-normalization.enabled:true}")
private boolean fontNormalizationEnabled; private boolean fontNormalizationEnabled;
private long cacheMaxBytes;
private int cacheMaxPercent;
/** Cache for storing PDDocuments for lazy page loading. Key is jobId. */ /** Cache for storing PDDocuments for lazy page loading. Key is jobId. */
private final Map<String, CachedPdfDocument> documentCache = new ConcurrentHashMap<>(); private final Map<String, CachedPdfDocument> documentCache = new ConcurrentHashMap<>();
private final java.util.LinkedHashMap<String, CachedPdfDocument> 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 volatile boolean ghostscriptAvailable;
private static final float FLOAT_EPSILON = 0.0001f; private static final float FLOAT_EPSILON = 0.0001f;
@@ -161,7 +168,23 @@ public class PdfJsonConversionService {
@PostConstruct @PostConstruct
private void initializeToolAvailability() { private void initializeToolAvailability() {
loadConfigurationFromProperties();
initializeGhostscriptAvailability(); 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() { 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 { public byte[] convertPdfToJson(MultipartFile file) throws IOException {
return convertPdfToJson(file, null, false); return convertPdfToJson(file, null, false);
} }
@@ -236,7 +278,10 @@ public class PdfJsonConversionService {
log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId); log.debug("Generated synthetic jobId for synchronous conversion: {}", jobId);
} else { } else {
jobId = contextJobId; 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<PdfJsonConversionProgress> progress = Consumer<PdfJsonConversionProgress> progress =
@@ -318,9 +363,9 @@ public class PdfJsonConversionService {
try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) { try (PDDocument document = pdfDocumentFactory.load(workingPath, true)) {
int totalPages = document.getNumberOfPages(); int totalPages = document.getNumberOfPages();
// Only use lazy images for real async jobs where client can access the cache // Always enable lazy mode for real async jobs so cache is available regardless of
// Synchronous calls with synthetic jobId should do full extraction // page count. Synchronous calls with synthetic jobId still do full extraction.
boolean useLazyImages = totalPages > 5 && isRealJobId; boolean useLazyImages = isRealJobId;
Map<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>(); Map<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>();
Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>(); Map<COSBase, EncodedImage> imageCache = new IdentityHashMap<>();
log.debug( log.debug(
@@ -403,6 +448,11 @@ public class PdfJsonConversionService {
// Only cache for real async jobIds, not synthetic synchronous ones // Only cache for real async jobIds, not synthetic synchronous ones
if (useLazyImages && isRealJobId) { if (useLazyImages && isRealJobId) {
log.info(
"Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
jobId,
useLazyImages,
isRealJobId);
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata(); PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(pdfJson.getMetadata()); docMetadata.setMetadata(pdfJson.getMetadata());
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata()); docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
@@ -435,16 +485,23 @@ public class PdfJsonConversionService {
cachedPdfBytes = Files.readAllBytes(workingPath); cachedPdfBytes = Files.readAllBytes(workingPath);
} }
CachedPdfDocument cached = CachedPdfDocument cached =
new CachedPdfDocument( buildCachedDocument(
cachedPdfBytes, docMetadata, fonts, pageFontResources); jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources);
documentCache.put(jobId, cached); putCachedDocument(jobId, cached);
log.debug( log.info(
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy images, jobId: {}", "Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})",
cachedPdfBytes.length, cached.getPdfSize(),
totalPages, totalPages,
fonts.size(), fonts.size(),
jobId); jobId,
cached.isDiskBacked());
scheduleDocumentCleanup(jobId); scheduleDocumentCleanup(jobId);
} else {
log.warn(
"Skipping cache creation: useLazyImages={}, isRealJobId={}, jobId={}",
useLazyImages,
isRealJobId,
jobId);
} }
if (lightweight) { if (lightweight) {
@@ -2973,6 +3030,139 @@ public class PdfJsonConversionService {
} }
} }
// Cache helpers
private CachedPdfDocument buildCachedDocument(
String jobId,
byte[] pdfBytes,
PdfJsonDocumentMetadata metadata,
Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> 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<java.util.Map.Entry<String, CachedPdfDocument>> it =
lruCache.entrySet().iterator();
while (currentCacheBytes > cacheBudgetBytes && it.hasNext()) {
java.util.Map.Entry<String, CachedPdfDocument> 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) private void applyTextState(PDPageContentStream contentStream, PdfJsonTextElement element)
throws IOException { throws IOException {
if (element.getCharacterSpacing() != null) { if (element.getCharacterSpacing() != null) {
@@ -5311,6 +5501,8 @@ public class PdfJsonConversionService {
*/ */
private static class CachedPdfDocument { private static class CachedPdfDocument {
private final byte[] pdfBytes; private final byte[] pdfBytes;
private final TempFile pdfTempFile;
private final long pdfSize;
private final PdfJsonDocumentMetadata metadata; private final PdfJsonDocumentMetadata metadata;
private final Map<String, PdfJsonFont> fonts; // Font map with UIDs for consistency private final Map<String, PdfJsonFont> fonts; // Font map with UIDs for consistency
private final Map<Integer, Map<PDFont, String>> pageFontResources; // Page font resources private final Map<Integer, Map<PDFont, String>> pageFontResources; // Page font resources
@@ -5318,10 +5510,14 @@ public class PdfJsonConversionService {
public CachedPdfDocument( public CachedPdfDocument(
byte[] pdfBytes, byte[] pdfBytes,
TempFile pdfTempFile,
long pdfSize,
PdfJsonDocumentMetadata metadata, PdfJsonDocumentMetadata metadata,
Map<String, PdfJsonFont> fonts, Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> pageFontResources) { Map<Integer, Map<PDFont, String>> pageFontResources) {
this.pdfBytes = pdfBytes; this.pdfBytes = pdfBytes;
this.pdfTempFile = pdfTempFile;
this.pdfSize = pdfSize;
this.metadata = metadata; this.metadata = metadata;
// Create defensive copies to prevent mutation of shared maps // Create defensive copies to prevent mutation of shared maps
this.fonts = this.fonts =
@@ -5336,9 +5532,15 @@ public class PdfJsonConversionService {
} }
// Getters return defensive copies to prevent external mutation // Getters return defensive copies to prevent external mutation
public byte[] getPdfBytes() { public byte[] getPdfBytes() throws IOException {
if (pdfBytes != null) {
return pdfBytes; return pdfBytes;
} }
if (pdfTempFile != null) {
return Files.readAllBytes(pdfTempFile.getPath());
}
throw new IOException("Cached PDF backing missing");
}
public PdfJsonDocumentMetadata getMetadata() { public PdfJsonDocumentMetadata getMetadata() {
return metadata; return metadata;
@@ -5352,6 +5554,18 @@ public class PdfJsonConversionService {
return new java.util.concurrent.ConcurrentHashMap<>(pageFontResources); 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() { public long getTimestamp() {
return timestamp; return timestamp;
} }
@@ -5363,7 +5577,19 @@ public class PdfJsonConversionService {
public CachedPdfDocument withUpdatedFonts( public CachedPdfDocument withUpdatedFonts(
byte[] nextBytes, Map<String, PdfJsonFont> nextFonts) { byte[] nextBytes, Map<String, PdfJsonFont> nextFonts) {
Map<String, PdfJsonFont> fontsToUse = nextFonts != null ? nextFonts : this.fonts; Map<String, PdfJsonFont> 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 // Cache PDF bytes, metadata, and fonts for lazy page loading
if (jobId != null) { if (jobId != null) {
CachedPdfDocument cached = CachedPdfDocument cached =
new CachedPdfDocument(pdfBytes, docMetadata, fonts, pageFontResources); buildCachedDocument(jobId, pdfBytes, docMetadata, fonts, pageFontResources);
documentCache.put(jobId, cached); putCachedDocument(jobId, cached);
log.debug( log.debug(
"Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {}", "Cached PDF bytes ({} bytes, {} pages, {} fonts) for lazy loading, jobId: {} (diskBacked={})",
pdfBytes.length, cached.getPdfSize(),
totalPages, totalPages,
fonts.size(), fonts.size(),
jobId); jobId,
cached.isDiskBacked());
// Schedule cleanup after 30 minutes // Schedule cleanup after 30 minutes
scheduleDocumentCleanup(jobId); scheduleDocumentCleanup(jobId);
@@ -5466,9 +5693,10 @@ public class PdfJsonConversionService {
/** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */ /** Extracts a single page from cached PDF bytes. Re-loads the PDF for each request. */
public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException { public byte[] extractSinglePage(String jobId, int pageNumber) throws IOException {
CachedPdfDocument cached = documentCache.get(jobId); CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) { 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; int pageIndex = pageNumber - 1;
@@ -5480,8 +5708,8 @@ public class PdfJsonConversionService {
} }
log.debug( log.debug(
"Loading PDF from bytes ({} bytes) to extract page {} (jobId: {})", "Loading PDF from {} to extract page {} (jobId: {})",
cached.getPdfBytes().length, cached.isDiskBacked() ? "disk cache" : "memory cache",
pageNumber, pageNumber,
jobId); jobId);
@@ -5627,10 +5855,21 @@ public class PdfJsonConversionService {
if (jobId == null || jobId.isBlank()) { if (jobId == null || jobId.isBlank()) {
throw new IllegalArgumentException("jobId is required for incremental export"); 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) { 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()) { if (updates == null || updates.getPages() == null || updates.getPages().isEmpty()) {
log.debug( log.debug(
"Incremental export requested with no page updates; returning cached PDF for jobId {}", "Incremental export requested with no page updates; returning cached PDF for jobId {}",
@@ -5709,7 +5948,14 @@ public class PdfJsonConversionService {
document.save(baos); document.save(baos);
byte[] updatedBytes = baos.toByteArray(); 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 // Clear Type3 cache entries for this incremental update
clearType3CacheEntriesForJob(updateJobId); clearType3CacheEntriesForJob(updateJobId);
@@ -5724,11 +5970,13 @@ public class PdfJsonConversionService {
/** Clears a cached document. */ /** Clears a cached document. */
public void clearCachedDocument(String jobId) { public void clearCachedDocument(String jobId) {
CachedPdfDocument cached = documentCache.remove(jobId); CachedPdfDocument cached = getCachedDocument(jobId);
removeCachedDocument(jobId);
if (cached != null) { if (cached != null) {
log.debug( log.debug(
"Removed cached PDF bytes ({} bytes) for jobId: {}", "Removed cached PDF ({} bytes, diskBacked={}) for jobId: {}",
cached.getPdfBytes().length, cached.getPdfSize(),
cached.isDiskBacked(),
jobId); jobId);
} }
@@ -312,12 +312,29 @@ public class PdfJsonFallbackFontService {
"ttf"))); "ttf")));
private final ResourceLoader resourceLoader; private final ResourceLoader resourceLoader;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}") @Value("${stirling.pdf.fallback-font:" + DEFAULT_FALLBACK_FONT_LOCATION + "}")
private String legacyFallbackFontLocation;
private String fallbackFontLocation; private String fallbackFontLocation;
private final Map<String, byte[]> fallbackFontCache = new ConcurrentHashMap<>(); private final Map<String, byte[]> 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 { public PdfJsonFont buildFallbackFontModel() throws IOException {
return buildFallbackFontModel(FALLBACK_FONT_ID); return buildFallbackFontModel(FALLBACK_FONT_ID);
} }
@@ -5,7 +5,6 @@ import java.nio.file.Files;
import java.util.Base64; import java.util.Base64;
import java.util.Locale; import java.util.Locale;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
@@ -25,22 +24,16 @@ import stirling.software.common.util.TempFileManager;
public class PdfJsonFontService { public class PdfJsonFontService {
private final TempFileManager tempFileManager; private final TempFileManager tempFileManager;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Getter @Getter private boolean cffConversionEnabled;
@Value("${stirling.pdf.json.cff-converter.enabled:true}")
private boolean cffConversionEnabled;
@Getter @Getter private String cffConverterMethod;
@Value("${stirling.pdf.json.cff-converter.method:python}")
private String cffConverterMethod;
@Value("${stirling.pdf.json.cff-converter.python-command:/opt/venv/bin/python3}")
private String pythonCommand; private String pythonCommand;
@Value("${stirling.pdf.json.cff-converter.python-script:/scripts/convert_cff_to_ttf.py}")
private String pythonScript; private String pythonScript;
@Value("${stirling.pdf.json.cff-converter.fontforge-command:fontforge}")
private String fontforgeCommand; private String fontforgeCommand;
private volatile boolean pythonCffConverterAvailable; private volatile boolean pythonCffConverterAvailable;
@@ -48,6 +41,7 @@ public class PdfJsonFontService {
@PostConstruct @PostConstruct
private void initialiseCffConverterAvailability() { private void initialiseCffConverterAvailability() {
loadConfiguration();
if (!cffConversionEnabled) { if (!cffConversionEnabled) {
log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration"); log.warn("[FONT-DEBUG] CFF conversion is DISABLED in configuration");
pythonCffConverterAvailable = false; pythonCffConverterAvailable = false;
@@ -77,6 +71,22 @@ public class PdfJsonFontService {
log.info("[FONT-DEBUG] Selected CFF converter method: {}", cffConverterMethod); 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) { public byte[] convertCffProgramToTrueType(byte[] fontBytes, String toUnicode) {
if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) { if (!cffConversionEnabled || fontBytes == null || fontBytes.length == 0) {
log.warn( log.warn(
@@ -2,7 +2,6 @@ package stirling.software.SPDF.service.pdfjson.type3;
import java.io.IOException; import java.io.IOException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order; import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -23,8 +22,8 @@ import stirling.software.SPDF.service.pdfjson.type3.library.Type3FontLibraryPayl
public class Type3LibraryStrategy implements Type3ConversionStrategy { public class Type3LibraryStrategy implements Type3ConversionStrategy {
private final Type3FontLibrary fontLibrary; private final Type3FontLibrary fontLibrary;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@Value("${stirling.pdf.json.type3.library.enabled:true}")
private boolean enabled; private boolean enabled;
@Override @Override
@@ -42,6 +41,19 @@ public class Type3LibraryStrategy implements Type3ConversionStrategy {
return enabled && fontLibrary != null && fontLibrary.isLoaded(); 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 @Override
public PdfJsonFontConversionCandidate convert( public PdfJsonFontConversionCandidate convert(
Type3ConversionRequest request, Type3GlyphContext context) throws IOException { Type3ConversionRequest request, Type3GlyphContext context) throws IOException {
@@ -14,7 +14,6 @@ import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.font.PDType3Font; 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.Resource;
import org.springframework.core.io.ResourceLoader; import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -34,8 +33,8 @@ public class Type3FontLibrary {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ResourceLoader resourceLoader; 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 String indexLocation;
private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>(); private final Map<String, Type3FontLibraryEntry> signatureIndex = new ConcurrentHashMap<>();
@@ -44,6 +43,17 @@ public class Type3FontLibrary {
@jakarta.annotation.PostConstruct @jakarta.annotation.PostConstruct
void initialise() { 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); Resource resource = resourceLoader.getResource(indexLocation);
if (!resource.exists()) { if (!resource.exists()) {
log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation); log.info("[TYPE3] Library index {} not found; Type3 library disabled", indexLocation);
@@ -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 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 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 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. 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 persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation 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 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) 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 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. 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'
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. 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: serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates organizationName: Stirling-PDF # Organization name for generated certificates
@@ -179,23 +182,6 @@ system:
databaseBackup: databaseBackup:
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight 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: ui:
appNameNavbar: '' # name displayed on the navigation bar appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo) logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
@@ -239,3 +225,21 @@ processExecutor:
qpdfTimeoutMinutes: 30 qpdfTimeoutMinutes: 30
ghostscriptTimeoutMinutes: 30 ghostscriptTimeoutMinutes: 30
ocrMyPdfTimeoutMinutes: 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:)
@@ -94,6 +94,22 @@ public class ProprietaryUIDataController {
this.auditRepository = auditRepository; 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") @GetMapping("/audit-dashboard")
@PreAuthorize("hasRole('ADMIN')") @PreAuthorize("hasRole('ADMIN')")
@EnterpriseEndpoint @EnterpriseEndpoint
@@ -185,14 +201,17 @@ public class ProprietaryUIDataController {
} }
SAML2 saml2 = securityProps.getSaml2(); SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active() if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider(); String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); 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()) { if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
providerList.put(saml2AuthenticationPath, samlIdp + " (SAML 2)"); providerList.put(fullSamlPath, samlIdp + " (SAML 2)");
} }
} }
@@ -120,9 +120,7 @@ public class AccountWebController {
SAML2 saml2 = securityProps.getSaml2(); SAML2 saml2 = securityProps.getSaml2();
if (securityProps.isSaml2Active() if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
&& applicationProperties.getSystem().getEnableAlphaFunctionality()
&& applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider(); String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId(); String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
@@ -334,7 +334,8 @@ public class SecurityConfiguration {
securityProperties.getSaml2(), securityProperties.getSaml2(),
userService, userService,
jwtService, jwtService,
licenseSettingsService)) licenseSettingsService,
applicationProperties))
.failureHandler( .failureHandler(
new CustomSaml2AuthenticationFailureHandler()) new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver( .authenticationRequestResolver(
@@ -244,10 +244,13 @@ public class AuthController {
userMap.put("username", user.getUsername()); userMap.put("username", user.getUsername());
userMap.put("role", user.getRolesAsString()); userMap.put("role", user.getRolesAsString());
userMap.put("enabled", user.isEnabled()); userMap.put("enabled", user.isEnabled());
userMap.put(
"authenticationType",
user.getAuthenticationType()); // Expose authentication type for SSO detection
// Add metadata for OAuth compatibility // Add metadata for OAuth compatibility
Map<String, Object> appMetadata = new HashMap<>(); Map<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider appMetadata.put("provider", user.getAuthenticationType());
userMap.put("app_metadata", appMetadata); userMap.put("app_metadata", appMetadata);
return userMap; return userMap;
@@ -51,6 +51,7 @@ public class CustomSaml2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService; private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService; licenseSettingsService;
private final ApplicationProperties applicationProperties;
@Override @Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC) @Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -77,8 +78,8 @@ public class CustomSaml2AuthenticationSuccessHandler
log.warn( log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)", "SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username); username);
response.sendRedirect( String origin = resolveOrigin(request);
request.getContextPath() + "/logout?saml2RequiresLicense=true"); response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return; return;
} }
} else if (!licenseSettingsService.isSamlEligible(null)) { } else if (!licenseSettingsService.isSamlEligible(null)) {
@@ -86,8 +87,8 @@ public class CustomSaml2AuthenticationSuccessHandler
log.warn( log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)", "SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username); username);
response.sendRedirect( String origin = resolveOrigin(request);
request.getContextPath() + "/logout?saml2RequiresLicense=true"); response.sendRedirect(origin + "/logout?saml2RequiresLicense=true");
return; return;
} }
@@ -144,20 +145,28 @@ public class CustomSaml2AuthenticationSuccessHandler
log.debug( log.debug(
"User {} exists with password but is not SSO user, redirecting to logout", "User {} exists with password but is not SSO user, redirecting to logout",
username); username);
response.sendRedirect( String origin = resolveOrigin(request);
contextPath + "/logout?oAuth2AuthenticationErrorWeb=true"); response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
return; return;
} }
try { try {
if (!userExists || saml2Properties.getBlockRegistration()) { // Block new users only if: blockRegistration is true OR autoCreateUser is false
log.debug("Registration blocked for new user: {}", username); if (!userExists
response.sendRedirect( && (saml2Properties.getBlockRegistration()
contextPath + "/login?errorOAuth=oAuth2AdminBlockedUser"); || !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; return;
} }
if (!userExists && licenseSettingsService.wouldExceedLimit(1)) { if (!userExists && licenseSettingsService.wouldExceedLimit(1)) {
response.sendRedirect(contextPath + "/logout?maxUsersReached=true"); String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?maxUsersReached=true");
return; return;
} }
@@ -222,14 +231,28 @@ public class CustomSaml2AuthenticationSuccessHandler
String contextPath, String contextPath,
String jwt) { String jwt) {
String redirectPath = resolveRedirectPath(request, contextPath); String redirectPath = resolveRedirectPath(request, contextPath);
String origin = String origin = resolveOrigin(request);
resolveForwardedOrigin(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( .orElseGet(
() -> () ->
resolveOriginFromReferer(request) resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request))); .orElseGet(() -> buildOriginFromRequest(request)));
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
} }
private String resolveRedirectPath(HttpServletRequest request, String contextPath) { private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
@@ -41,22 +41,74 @@ public class Saml2Configuration {
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true") @ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception { public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2(); 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); Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
// Load SP private key and certificate
Resource privateKeyResource = samlConf.getPrivateKey(); Resource privateKeyResource = samlConf.getPrivateKey();
Resource certificateResource = samlConf.getSpCert(); Resource certificateResource = samlConf.getSpCert();
Saml2X509Credential signingCredential =
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( new Saml2X509Credential(
CertificateUtils.readPrivateKey(privateKeyResource), CertificateUtils.readPrivateKey(privateKeyResource),
CertificateUtils.readCertificate(certificateResource), CertificateUtils.readCertificate(certificateResource),
Saml2X509CredentialType.SIGNING); 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 rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId()) RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential)) .signingX509Credentials(c -> c.add(signingCredential))
.entityId(samlConf.getIdpIssuer()) .entityId(samlConf.getIdpIssuer())
.singleLogoutServiceBinding(Saml2MessageBinding.POST) .singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl()) .singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation("http://localhost:8080/login") .singleLogoutServiceResponseLocation("{baseUrl}/login")
.assertionConsumerServiceBinding(Saml2MessageBinding.POST) .assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation( .assertionConsumerServiceLocation(
"{baseUrl}/login/saml2/sso/{registrationId}") "{baseUrl}/login/saml2/sso/{registrationId}")
@@ -75,9 +127,14 @@ public class Saml2Configuration {
.singleLogoutServiceLocation( .singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl()) samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation( .singleLogoutServiceResponseLocation(
"http://localhost:8080/login") "{baseUrl}/login")
.wantAuthnRequestsSigned(true)) .wantAuthnRequestsSigned(true))
.build(); .build();
log.info(
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
samlConf.getRegistrationId(),
samlConf.getIdpIssuer());
return new InMemoryRelyingPartyRegistrationRepository(rp); return new InMemoryRelyingPartyRegistrationRepository(rp);
} }
@@ -177,6 +177,13 @@ public class UserLicenseSettingsService {
*/ */
@Transactional @Transactional
public void grandfatherExistingOAuthUsers() { public void grandfatherExistingOAuthUsers() {
// Only grandfather users if this is a V1V2 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(); UserLicenseSettings settings = getOrCreateSettings();
// Check if we've already run this migration // Check if we've already run this migration
@@ -348,30 +355,22 @@ public class UserLicenseSettingsService {
String username = (user != null) ? user.getUsername() : "<new user>"; String username = (user != null) ? user.getUsername() : "<new user>";
log.info("OAuth eligibility check for user: {}", username); log.info("OAuth eligibility check for user: {}", username);
// Grandfathered users always have OAuth access // Check license first - if paying, they're eligible (no need to check grandfathering)
if (user != null && user.isOauthGrandfathered()) { boolean hasPaid = hasPaidLicense();
log.debug("User {} is grandfathered for OAuth", user.getUsername()); if (hasPaid) {
log.debug("User {} eligible for OAuth via paid license", username);
return true; return true;
} }
// todo: remove // No license - check if grandfathered (fallback for V1 users)
if (user != null) { if (user != null && user.isOauthGrandfathered()) {
log.info( log.info("User {} eligible for OAuth via grandfathering (no paid license)", username);
"User {} is NOT grandfathered (isOauthGrandfathered={})", return true;
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting OAuth login - checking license requirement");
} }
// Users can use OAuth with SERVER or ENTERPRISE license // Not grandfathered and no license
boolean hasPaid = hasPaidLicense(); log.info("User {} NOT eligible for OAuth: no paid license and not grandfathered", username);
log.info( return false;
"OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
hasPaid,
username,
hasPaid);
return hasPaid;
} }
/** /**
@@ -391,29 +390,26 @@ public class UserLicenseSettingsService {
String username = (user != null) ? user.getUsername() : "<new user>"; String username = (user != null) ? user.getUsername() : "<new user>";
log.info("SAML2 eligibility check for user: {}", username); log.info("SAML2 eligibility check for user: {}", username);
// Grandfathered users always have SAML access // Check license first - if paying, they're eligible (no need to check grandfathering)
if (user != null && user.isOauthGrandfathered()) { boolean hasEnterprise = hasEnterpriseLicense();
log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username); if (hasEnterprise) {
log.debug("User {} eligible for SAML2 via ENTERPRISE license", username);
return true; return true;
} }
if (user != null) { // No license - check if grandfathered (fallback for V1 users)
if (user != null && user.isOauthGrandfathered()) {
log.info( log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})", "User {} eligible for SAML2 via grandfathering (no ENTERPRISE license)",
username, username);
user.isOauthGrandfathered()); return true;
} else {
log.info("New user attempting SAML2 login - checking license requirement");
} }
// Users can use SAML only with ENTERPRISE license // Not grandfathered and no license
boolean hasEnterprise = hasEnterpriseLicense();
log.info( log.info(
"SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}", "User {} NOT eligible for SAML2: no ENTERPRISE license and not grandfathered",
hasEnterprise, username);
username, return false;
hasEnterprise);
return hasEnterprise;
} }
/** /**
@@ -33,6 +33,7 @@ class UserLicenseSettingsServiceTest {
@Mock private UserService userService; @Mock private UserService userService;
@Mock private ApplicationProperties applicationProperties; @Mock private ApplicationProperties applicationProperties;
@Mock private ApplicationProperties.Premium premium; @Mock private ApplicationProperties.Premium premium;
@Mock private ApplicationProperties.AutomaticallyGenerated automaticallyGenerated;
@Mock private LicenseKeyChecker licenseKeyChecker; @Mock private LicenseKeyChecker licenseKeyChecker;
@Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider; @Mock private ObjectProvider<LicenseKeyChecker> licenseKeyCheckerProvider;
@@ -49,6 +50,8 @@ class UserLicenseSettingsServiceTest {
mockSettings.setGrandfatheredUserSignature("80:test-signature"); mockSettings.setGrandfatheredUserSignature("80:test-signature");
when(applicationProperties.getPremium()).thenReturn(premium); 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(settingsRepository.findSettings()).thenReturn(Optional.of(mockSettings));
when(userService.getTotalUsersCount()).thenReturn(80L); when(userService.getTotalUsersCount()).thenReturn(80L);
when(settingsRepository.save(any(UserLicenseSettings.class))) when(settingsRepository.save(any(UserLicenseSettings.class)))
+1 -1
View File
@@ -59,7 +59,7 @@ repositories {
allprojects { allprojects {
group = 'stirling.software' group = 'stirling.software'
version = '2.1.3' version = '2.1.4'
configurations.configureEach { configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging' exclude group: 'commons-logging', module: 'commons-logging'
@@ -238,6 +238,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const originalImagesRef = useRef<PdfJsonImageElement[][]>([]); const originalImagesRef = useRef<PdfJsonImageElement[][]>([]);
const originalGroupsRef = useRef<TextGroup[][]>([]); const originalGroupsRef = useRef<TextGroup[][]>([]);
const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]); const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]);
const lastLoadedFileRef = useRef<File | null>(null);
const autoLoadKeyRef = useRef<string | null>(null); const autoLoadKeyRef = useRef<string | null>(null);
const sourceFileIdRef = useRef<string | null>(null); const sourceFileIdRef = useRef<string | null>(null);
const loadRequestIdRef = useRef(0); const loadRequestIdRef = useRef(0);
@@ -251,6 +252,10 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const pagePreviewsRef = useRef<Map<number, string>>(pagePreviews); const pagePreviewsRef = useRef<Map<number, string>>(pagePreviews);
const previewScaleRef = useRef<Map<number, number>>(new Map()); const previewScaleRef = useRef<Map<number, number>>(new Map());
const cachedJobIdRef = useRef<string | null>(null); const cachedJobIdRef = useRef<string | null>(null);
const previousCachedJobIdRef = useRef<string | null>(null);
const cacheRecoveryInProgressRef = useRef(false);
const cacheRecoveryAttemptsRef = useRef(0);
const recoverCacheAndReloadRef = useRef<() => Promise<boolean>>(async () => false);
// Keep ref in sync with state for access in async callbacks // Keep ref in sync with state for access in async callbacks
useEffect(() => { 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( const dirtyPages = useMemo(
() => getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current), () => getDirtyPages(groupsByPage, imagesByPage, originalGroupsRef.current, originalImagesRef.current),
[groupsByPage, imagesByPage], [groupsByPage, imagesByPage],
@@ -316,6 +328,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
loadedImagePagesRef.current = new Set(); loadedImagePagesRef.current = new Set();
loadingImagePagesRef.current = new Set(); loadingImagePagesRef.current = new Set();
setSelectedPage(0); setSelectedPage(0);
setIsLazyMode(false);
setCachedJobId(null);
cachedJobIdRef.current = null;
return; return;
} }
const cloned = deepCloneDocument(document); const cloned = deepCloneDocument(document);
@@ -365,11 +380,14 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
}, []); }, []);
useEffect(() => { useEffect(() => {
const previousJobId = cachedJobIdRef.current; // Clear old cached job when job ID changes
const previousJobId = previousCachedJobIdRef.current;
if (previousJobId && previousJobId !== cachedJobId) { if (previousJobId && previousJobId !== cachedJobId) {
console.log(`[PdfTextEditor] Clearing old cache for jobId: ${previousJobId}, new jobId: ${cachedJobId}`);
clearCachedJob(previousJobId); clearCachedJob(previousJobId);
} }
cachedJobIdRef.current = cachedJobId; // Update the previous jobId ref for next time
previousCachedJobIdRef.current = cachedJobId;
}, [cachedJobId, clearCachedJob]); }, [cachedJobId, clearCachedJob]);
const initializePdfPreview = useCallback( const initializePdfPreview = useCallback(
@@ -489,6 +507,11 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
); );
} catch (error) { } catch (error) {
console.error(`[loadImagesForPage] Failed to load images for page ${pageNumber}:`, 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 { } finally {
loadingImagePagesRef.current.delete(pageIndex); loadingImagePagesRef.current.delete(pageIndex);
setLoadingImagePages((prev) => { setLoadingImagePages((prev) => {
@@ -498,7 +521,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
}); });
} }
}, },
[isLazyMode, cachedJobId], [isLazyMode, cachedJobId, isCacheUnavailableError],
); );
const handleLoadFile = useCallback( const handleLoadFile = useCallback(
@@ -507,6 +530,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
return; return;
} }
lastLoadedFileRef.current = file;
const requestId = loadRequestIdRef.current + 1; const requestId = loadRequestIdRef.current + 1;
loadRequestIdRef.current = requestId; loadRequestIdRef.current = requestId;
@@ -558,28 +582,25 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
let jobComplete = false; let jobComplete = false;
let attempts = 0; let attempts = 0;
const maxAttempts = 600; const maxAttempts = 600;
let pollDelay = 500;
while (!jobComplete && attempts < maxAttempts) { while (!jobComplete && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, pollDelay));
attempts += 1; attempts += 1;
if (pollDelay < 10000) {
pollDelay = Math.min(10000, Math.floor(pollDelay * 1.5));
}
try { try {
const statusResponse = await apiClient.get(`/api/v1/general/job/${jobId}`); const statusResponse = await apiClient.get(`/api/v1/general/job/${jobId}`);
const jobStatus = statusResponse.data; const jobStatus = statusResponse.data;
console.log(`Job status (attempt ${attempts}):`, jobStatus); console.log(`Job status (attempt ${attempts}):`, jobStatus);
if (jobStatus.notes && jobStatus.notes.length > 0) { const percent = Math.min(Math.max(jobStatus.progress ?? 0, 0), 100);
const lastNote = jobStatus.notes[jobStatus.notes.length - 1]; const stage = jobStatus.stage || 'processing';
console.log('Latest note:', lastNote); const message = jobStatus.note || 'Converting PDF to JSON...';
const matchWithCount = lastNote.match( const current = jobStatus.current ?? undefined;
/\[(\d+)%\]\s+(\w+):\s+(.+?)\s+\((\d+)\/(\d+)\)/, const total = jobStatus.total ?? undefined;
);
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({ setConversionProgress({
percent, percent,
stage, stage,
@@ -587,27 +608,6 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
current, current,
total, 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...',
});
}
if (jobStatus.complete) { if (jobStatus.complete) {
if (jobStatus.error) { if (jobStatus.error) {
@@ -701,7 +701,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
setLoadedDocument(parsed); setLoadedDocument(parsed);
resetToDocument(parsed, groupingMode); resetToDocument(parsed, groupingMode);
setIsLazyMode(shouldUseLazyMode); setIsLazyMode(shouldUseLazyMode);
setCachedJobId(shouldUseLazyMode ? pendingJobId : null); const newJobId = shouldUseLazyMode ? pendingJobId : null;
setCachedJobId(newJobId);
cachedJobIdRef.current = newJobId;
setFileName(file.name); setFileName(file.name);
setErrorMessage(null); setErrorMessage(null);
} catch (error: any) { } catch (error: any) {
@@ -719,6 +721,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
setLoadedDocument(null); setLoadedDocument(null);
resetToDocument(null, groupingMode); resetToDocument(null, groupingMode);
clearPdfPreview(); clearPdfPreview();
setIsLazyMode(false);
setCachedJobId(null);
cachedJobIdRef.current = null;
if (isPdf) { if (isPdf) {
const errorMsg = const errorMsg =
@@ -743,6 +748,38 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
[groupingMode, resetToDocument, t], [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 // Wrapper for loading files from the dropzone - adds to workbench first
const handleLoadFileFromDropzone = useCallback( const handleLoadFileFromDropzone = useCallback(
async (file: File) => { async (file: File) => {
@@ -1057,7 +1094,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
try { try {
const payload = buildPayload(); const payload = buildPayload();
if (!payload) { if (!payload) {
return; throw new Error('Failed to build payload');
} }
const { document, filename } = payload; const { document, filename } = payload;
@@ -1076,7 +1113,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ''); const baseName = sanitizeBaseName(filename).replace(/-edited$/u, '');
const expectedName = `${baseName || 'document'}.pdf`; const expectedName = `${baseName || 'document'}.pdf`;
const response = await apiClient.post( 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, partialDocument,
{ {
responseType: 'blob', responseType: 'blob',
@@ -1100,6 +1137,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
'[handleGeneratePdf] Incremental export failed, falling back to full export', '[handleGeneratePdf] Incremental export failed, falling back to full export',
incrementalError, incrementalError,
); );
// Fall through to full export below
} }
} }
@@ -1636,6 +1674,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
}, 0); }, 0);
}, [navigationActions, navigationState.selectedTool]); }, [navigationActions, navigationState.selectedTool]);
// Register workbench view (re-runs when dependencies change)
useEffect(() => { useEffect(() => {
registerCustomWorkbenchView({ registerCustomWorkbenchView({
id: WORKBENCH_VIEW_ID, id: WORKBENCH_VIEW_ID,
@@ -1646,24 +1685,30 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
}); });
setLeftPanelView('hidden'); setLeftPanelView('hidden');
setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, latestViewDataRef.current); 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, registerCustomWorkbenchView,
setCustomWorkbenchViewData, setCustomWorkbenchViewData,
setLeftPanelView, setLeftPanelView,
viewLabel, 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 // 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 // The workbench should be set when the tool is selected via proper channels
// (tool registry, tool picker, etc.) - not forced here // (tool registry, tool picker, etc.) - not forced here
@@ -343,7 +343,7 @@ describe('SpringAuthClient', () => {
}); });
const result = await springAuth.signInWithOAuth({ const result = await springAuth.signInWithOAuth({
provider: 'github', provider: '/oauth2/authorization/github',
options: { redirectTo: '/auth/callback' }, options: { redirectTo: '/auth/callback' },
}); });
@@ -250,11 +250,11 @@ class SpringAuthClient {
} }
/** /**
* Sign in with OAuth provider (GitHub, Google, Authentik, etc.) * Sign in with OAuth/SAML provider (GitHub, Google, Authentik, etc.)
* This redirects to the Spring OAuth2 authorization endpoint * This redirects to the Spring OAuth2/SAML2 authorization endpoint
* *
* @param params.provider - OAuth provider ID (e.g., 'github', 'google', 'authentik', 'mycompany') * @param params.provider - Full auth path from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling')
* Can be any known provider or custom string - the backend determines available providers * The backend provides the complete path including the auth type and provider ID
*/ */
async signInWithOAuth(params: { async signInWithOAuth(params: {
provider: OAuthProvider; provider: OAuthProvider;
@@ -264,15 +264,16 @@ class SpringAuthClient {
const redirectPath = normalizeRedirectPath(params.options?.redirectTo); const redirectPath = normalizeRedirectPath(params.options?.redirectTo);
persistRedirectPath(redirectPath); persistRedirectPath(redirectPath);
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend) // Use the full path provided by the backend
const redirectUrl = `/oauth2/authorization/${params.provider}`; // This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
// console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl); const redirectUrl = params.provider;
// console.log('[SpringAuth] Redirecting to SSO:', redirectUrl);
// Use window.location.assign for full page navigation // Use window.location.assign for full page navigation
window.location.assign(redirectUrl); window.location.assign(redirectUrl);
return { error: null }; return { error: null };
} catch (error) { } catch (error) {
return { return {
error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' }, error: { message: error instanceof Error ? error.message : 'SSO redirect failed' },
}; };
} }
} }
@@ -295,9 +295,9 @@ describe('Login', () => {
await user.click(oauthButton); await user.click(oauthButton);
await waitFor(() => { await waitFor(() => {
// Should use 'authentik' directly, NOT map to 'oidc' // Should use full path directly, NOT map to 'oidc'
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: 'authentik', provider: '/oauth2/authorization/authentik',
options: { redirectTo: '/auth/callback' } options: { redirectTo: '/auth/callback' }
}); });
}); });
@@ -338,10 +338,10 @@ describe('Login', () => {
await user.click(oauthButton); await user.click(oauthButton);
await waitFor(() => { 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' // Previously it would map unknown providers to 'oidc'
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: 'mycompany', provider: '/oauth2/authorization/mycompany',
options: { redirectTo: '/auth/callback' } options: { redirectTo: '/auth/callback' }
}); });
}); });
@@ -382,9 +382,9 @@ describe('Login', () => {
await user.click(oauthButton); await user.click(oauthButton);
await waitFor(() => { await waitFor(() => {
// Should use 'oidc' when explicitly configured // Should use full path when explicitly configured
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({ expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: 'oidc', provider: '/oauth2/authorization/oidc',
options: { redirectTo: '/auth/callback' } options: { redirectTo: '/auth/callback' }
}); });
}); });
+5 -6
View File
@@ -109,13 +109,12 @@ export default function Login() {
updateSupportedLanguages(data.languages, data.defaultLocale); updateSupportedLanguages(data.languages, data.defaultLocale);
} }
// Extract provider IDs from the providerList map // Use the full paths from providerList as provider identifiers
// The keys are like "/oauth2/authorization/google" - extract the last part // The backend provides paths like "/oauth2/authorization/google" or "/saml2/authenticate/stirling"
const providerIds = Object.keys(data.providerList || {}) // We'll use these full paths so the auth client knows where to redirect
.map(key => key.split('/').pop()) const providerPaths = Object.keys(data.providerList || {});
.filter((id): id is string => id !== undefined);
setEnabledProviders(providerIds); setEnabledProviders(providerPaths);
} catch (err) { } catch (err) {
console.error('[Login] Failed to fetch enabled providers:', err); console.error('[Login] Failed to fetch enabled providers:', err);
} }
@@ -26,7 +26,7 @@ interface OAuthButtonsProps {
onProviderClick: (provider: OAuthProvider) => void onProviderClick: (provider: OAuthProvider) => void
isSubmitting: boolean isSubmitting: boolean
layout?: 'vertical' | 'grid' | 'icons' 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) { export default function OAuthButtons({ onProviderClick, isSubmitting, layout = 'vertical', enabledProviders = [] }: OAuthButtonsProps) {
@@ -37,19 +37,24 @@ export default function OAuthButtons({ onProviderClick, isSubmitting, layout = '
? Object.keys(oauthProviderConfig) ? Object.keys(oauthProviderConfig)
: enabledProviders; : enabledProviders;
// Build provider list - use provider ID to determine icon and label // Build provider list - extract provider ID from full path for display
const providers = providersToShow.map(id => { const providers = providersToShow.map(pathOrId => {
if (id in oauthProviderConfig) { // 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 // Known provider - use predefined icon and label
return { return {
id, id: pathOrId, // Keep full path for redirect
...oauthProviderConfig[id] providerId, // Store extracted ID for display lookup
...oauthProviderConfig[providerId]
}; };
} }
// Unknown provider - use generic icon and capitalize ID for label // Unknown provider - use generic icon and capitalize ID for label
return { return {
id, id: pathOrId, // Keep full path for redirect
label: id.charAt(0).toUpperCase() + id.slice(1), providerId, // Store extracted ID for display lookup
label: providerId.charAt(0).toUpperCase() + providerId.slice(1),
file: GENERIC_PROVIDER_ICON file: GENERIC_PROVIDER_ICON
}; };
}); });
+12
View File
@@ -55,12 +55,24 @@ export default defineConfig(({ mode }) => {
secure: false, secure: false,
xfwd: true, xfwd: true,
}, },
'/saml2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/login/oauth2': { '/login/oauth2': {
target: 'http://localhost:8080', target: 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
xfwd: true, xfwd: true,
}, },
'/login/saml2': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
xfwd: true,
},
'/swagger-ui': { '/swagger-ui': {
target: 'http://localhost:8080', target: 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,