Compare commits

...
Author SHA1 Message Date
Anthony StirlingandGitHub 0cd1491956 Merge branch 'main' into allowStaticFileOverrides 2025-12-17 01:00:06 +00:00
Anthony Stirling 531e22b2e2 allow static overrides 2025-12-17 00:59:45 +00:00
Reece BrowneandGitHub 93ed05b054 Only allow dual page view when there is more than one page, update to… (#5246) 2025-12-16 16:02:22 +00:00
195b1472e4 Bug/v2/viewer annotations (#5245)
Show uneditable annotations on viewer
show editable annotations layer when in annotation tools (sign, add
image, add text)
Remove draw tool from viewer (this is replaced wholesale in an upcoming
PR so it wasn't worth doing the work to ensure it worked with the new
annotation layer set up_)
refactoring work, mostly renaming variables we can use for all
annotation based tools that had sign specific names.
remove "tools" tooltip

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-16 15:52:47 +00:00
James BruntonandGitHub 340006ceea Add Sign Up functionality to desktop app (#5244)
# Description of Changes
Adds Sign Up with email to desktop app. SSO sign up will come in a
future PR.
2025-12-16 14:55:53 +00:00
Anthony StirlingandGitHub d80e627899 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.
2025-12-15 23:54:25 +00:00
44 changed files with 1316 additions and 471 deletions
@@ -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<String> 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
@@ -94,6 +94,7 @@ public class InitialSetup {
}
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
applicationProperties.getAutomaticallyGenerated().setIsNewServer(isNewServer);
}
public static boolean isNewServer() {
@@ -33,14 +33,35 @@ public class WebMvcConfig implements WebMvcConfigurer {
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
// Check customFiles/static first, then fall back to classpath
registry.addResourceHandler("/assets/**")
.addResourceLocations("classpath:/static/assets/")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath()
+ "assets/",
"classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
// Note: index.html is handled by ReactRoutingController for dynamic processing
registry.addResourceHandler("/index.html")
.addResourceLocations("classpath:/static/")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
// Handle all other static resources (js, css, images, fonts, etc.)
// Check customFiles/static first for user overrides
registry.addResourceHandler("/**")
.addResourceLocations(
"file:"
+ stirling.software.common.configuration.InstallationPathConfig
.getStaticPath(),
"classpath:/static/")
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
}
@Override
@@ -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));
}
}
}
}
@@ -3,9 +3,14 @@ package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@@ -14,6 +19,11 @@ import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
@Controller
public class ReactRoutingController {
@@ -22,24 +32,44 @@ public class ReactRoutingController {
private String cachedIndexHtml;
private boolean indexHtmlExists = false;
private boolean useExternalIndexHtml = false;
@PostConstruct
public void init() {
// Only cache if index.html exists (production builds)
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
// Check for external index.html first (customFiles/static/)
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
log.debug("Checking for custom index.html at: {}", externalIndexPath);
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
log.info("Using custom index.html from: {}", externalIndexPath);
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = true;
return;
} catch (IOException e) {
log.warn("Failed to load custom index.html, falling back to classpath", e);
}
}
// Fall back to classpath index.html
ClassPathResource resource = new ClassPathResource("static/index.html");
if (resource.exists()) {
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
} catch (IOException e) {
// Failed to cache, will process on each request
log.warn("Failed to cache index.html", e);
this.indexHtmlExists = false;
}
}
}
private String processIndexHtml() throws IOException {
ClassPathResource resource = new ClassPathResource("static/index.html");
Resource resource = getIndexHtmlResource();
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
@@ -62,6 +92,17 @@ public class ReactRoutingController {
}
}
private Resource getIndexHtmlResource() throws IOException {
// Check external location first
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
return new FileSystemResource(externalIndexPath.toFile());
}
// Fall back to classpath
return new ClassPathResource("static/index.html");
}
@GetMapping(
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
@@ -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.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<String, PDFont> type3NormalizedFontCache = new ConcurrentHashMap<>();
private final Map<String, Set<Integer>> 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<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 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<PdfJsonConversionProgress> 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<COSBase, FontModelCacheEntry> fontCache = new IdentityHashMap<>();
Map<COSBase, EncodedImage> 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<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)
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<String, PdfJsonFont> fonts; // Font map with UIDs for consistency
private final Map<Integer, Map<PDFont, String>> pageFontResources; // Page font resources
@@ -5318,10 +5510,14 @@ public class PdfJsonConversionService {
public CachedPdfDocument(
byte[] pdfBytes,
TempFile pdfTempFile,
long pdfSize,
PdfJsonDocumentMetadata metadata,
Map<String, PdfJsonFont> fonts,
Map<Integer, Map<PDFont, String>> 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<String, PdfJsonFont> nextFonts) {
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
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);
}
@@ -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<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 {
return buildFallbackFontModel(FALLBACK_FONT_ID);
}
@@ -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(
@@ -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 {
@@ -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<String, Type3FontLibraryEntry> 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);
@@ -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:)
@@ -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)");
}
}
@@ -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();
@@ -334,7 +334,8 @@ public class SecurityConfiguration {
securityProperties.getSaml2(),
userService,
jwtService,
licenseSettingsService))
licenseSettingsService,
applicationProperties))
.failureHandler(
new CustomSaml2AuthenticationFailureHandler())
.authenticationRequestResolver(
@@ -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<String, Object> appMetadata = new HashMap<>();
appMetadata.put("provider", user.getAuthenticationType()); // Default to email provider
appMetadata.put("provider", user.getAuthenticationType());
userMap.put("app_metadata", appMetadata);
return userMap;
@@ -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("/"))
@@ -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);
}
@@ -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() : "<new user>";
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() : "<new user>";
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;
}
/**
@@ -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<LicenseKeyChecker> 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)))
+1 -1
View File
@@ -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'
+93
View File
@@ -589,6 +589,26 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "const-random"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
dependencies = [
"const-random-macro",
]
[[package]]
name = "const-random-macro"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
dependencies = [
"getrandom 0.2.16",
"once_cell",
"tiny-keccak",
]
[[package]]
name = "convert_case"
version = "0.4.0"
@@ -716,6 +736,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.6"
@@ -908,6 +934,15 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
dependencies = [
"const-random",
]
[[package]]
name = "document-features"
version = "0.2.12"
@@ -1626,6 +1661,12 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.16.0"
@@ -2820,6 +2861,16 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-multimap"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
dependencies = [
"dlv-list",
"hashbrown 0.14.5",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@@ -3651,6 +3702,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rust-ini"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
dependencies = [
"cfg-if",
"ordered-multimap",
]
[[package]]
name = "rust_decimal"
version = "1.39.0"
@@ -4256,6 +4317,7 @@ dependencies = [
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-log",
@@ -4615,6 +4677,27 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-deep-link"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
dependencies = [
"dunce",
"plist",
"rust-ini",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.17",
"tracing",
"url",
"windows-registry",
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.4"
@@ -4735,6 +4818,7 @@ dependencies = [
"serde",
"serde_json",
"tauri",
"tauri-plugin-deep-link",
"thiserror 2.0.17",
"tracing",
"windows-sys 0.60.2",
@@ -4954,6 +5038,15 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-keccak"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
dependencies = [
"crunchy",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
+2 -1
View File
@@ -29,9 +29,10 @@ tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4"
tauri-plugin-http = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
tauri-plugin-store = "2.1.0"
tauri-plugin-opener = "2.0.0"
tauri-plugin-deep-link = "2.4.5"
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
tokio = { version = "1.0", features = ["time", "sync"] }
reqwest = { version = "0.11", features = ["json"] }
+3 -1
View File
@@ -19,6 +19,8 @@
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
}
},
"opener:default",
"shell:allow-open"
]
}
+36 -1
View File
@@ -1,4 +1,4 @@
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent};
mod utils;
mod commands;
@@ -28,6 +28,17 @@ use commands::{
};
use state::connection_state::AppConnectionState;
use utils::{add_log, get_tauri_logs};
use tauri_plugin_deep_link::DeepLinkExt;
fn dispatch_deep_link(app: &AppHandle, url: &str) {
add_log(format!("🔗 Dispatching deep link: {}", url));
let _ = app.emit("deep-link", url.to_string());
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
let _ = window.unminimize();
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@@ -42,6 +53,7 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_deep_link::init())
.manage(AppConnectionState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start
@@ -78,6 +90,29 @@ pub fn run() {
}
}
{
let app_handle = app.handle();
// On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux
#[cfg(any(target_os = "linux", target_os = "windows"))]
if let Err(err) = app_handle.deep_link().register_all() {
add_log(format!("⚠️ Failed to register deep link handler: {}", err));
}
if let Ok(Some(urls)) = app_handle.deep_link().get_current() {
let initial_handle = app_handle.clone();
for url in urls {
dispatch_deep_link(&initial_handle, url.as_str());
}
}
let event_app_handle = app_handle.clone();
app_handle.deep_link().on_open_url(move |event| {
for url in event.urls() {
dispatch_deep_link(&event_app_handle, url.as_str());
}
});
}
// Start backend immediately, non-blocking
let app_handle = app.handle().clone();
+7
View File
@@ -77,6 +77,13 @@
},
"fs": {
"requireLiteralLeadingDot": false
},
"deep-link": {
"desktop": {
"schemes": [
"stirlingpdf"
]
}
}
}
}
@@ -1,6 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Tooltip } from '@app/components/shared/Tooltip';
import AppsIcon from '@mui/icons-material/AppsRounded';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
@@ -11,13 +10,11 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess
interface AllToolsNavButtonProps {
activeButton: string;
setActiveButton: (id: string) => void;
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
}
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
activeButton,
setActiveButton,
tooltipPosition = 'right'
}) => {
const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
@@ -55,26 +52,18 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
};
return (
<Tooltip
content={t("quickAccess.allTools", "Tools")}
position={tooltipPosition}
arrow
containerStyle={{ marginTop: "-1rem" }}
maxWidth={200}
>
<div className="mt-4 mb-2">
<QuickAccessButton
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
label={t("quickAccess.allTools", "Tools")}
isActive={isActive}
onClick={handleNavClick}
href={navProps.href}
ariaLabel={t("quickAccess.allTools", "Tools")}
textClassName="all-tools-text"
component="a"
/>
</div>
</Tooltip>
<div className="mt-4 mb-2">
<QuickAccessButton
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
label={t("quickAccess.allTools", "Tools")}
isActive={isActive}
onClick={handleNavClick}
href={navProps.href}
ariaLabel={t("quickAccess.allTools", "Tools")}
textClassName="all-tools-text"
component="a"
/>
</div>
);
};
@@ -1,15 +1,9 @@
import React, { useState, useEffect } from 'react';
import { ActionIcon, Popover } from '@mantine/core';
import React from 'react';
import { ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import { useFileState, useFileContext } from '@app/contexts/FileContext';
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
import { createProcessedFile } from '@app/contexts/file/fileActions';
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
@@ -23,32 +17,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
const { t } = useTranslation();
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
// Viewer context for PDF controls - safely handle when not available
const viewerContext = React.useContext(ViewerContext);
// Signature context for accessing drawing API
const { signatureApiRef, isPlacementMode } = useSignature();
// File state for save functionality
const { state, selectors } = useFileState();
const { actions: fileActions } = useFileContext();
const activeFiles = selectors.getFiles();
// Check if we're in sign mode
const { selectedTool } = useNavigationState();
const isSignMode = selectedTool === 'sign';
// Turn off annotation mode when switching away from viewer
useEffect(() => {
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
viewerContext.setAnnotationMode(false);
}
}, [currentView, viewerContext]);
// Don't show any annotation controls in sign mode
if (isSignMode) {
return null;
@@ -65,7 +41,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
onClick={() => {
viewerContext?.toggleAnnotationsVisibility();
}}
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
disabled={disabled || currentView !== 'viewer'}
>
<LocalIcon
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
@@ -74,164 +50,6 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
/>
</ActionIcon>
</Tooltip>
{/* Annotation Mode Toggle with Drawing Controls */}
{viewerContext?.isAnnotationMode ? (
// When active: Show color picker on hover
<div
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
style={{ display: 'inline-flex' }}
>
<Popover
opened={isHoverColorPickerOpen}
onClose={() => setIsHoverColorPickerOpen(false)}
position="left"
withArrow
shadow="md"
offset={8}
>
<Popover.Target>
<ActionIcon
variant="filled"
color="blue"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
// Deactivate drawing tool when exiting annotation mode
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label="Drawing mode active"
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '8rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
<ColorSwatchButton
color={selectedColor}
size={32}
onClick={() => {
setIsHoverColorPickerOpen(false); // Close hover picker
setIsColorPickerOpen(true); // Open main color picker modal
}}
/>
</div>
</div>
</Popover.Dropdown>
</Popover>
</div>
) : (
// When inactive: Show "Draw" tooltip
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
viewerContext?.toggleAnnotationMode();
// Activate ink drawing tool when entering annotation mode
if (signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.activateDrawMode();
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
} catch (error) {
console.log('Signature API not ready:', error);
}
}
}}
disabled={disabled}
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
>
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
)}
{/* Save PDF with Annotations */}
<Tooltip content={t('rightRail.save', 'Save')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={async () => {
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
try {
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
if (pdfArrayBuffer) {
// Create new File object with flattened annotations
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
// Get the original file name or use a default
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
// Replace the current file in context with the saved version (exact same logic as Sign tool)
if (activeFiles.length > 0) {
// Generate thumbnail and metadata for the saved file
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
// Get current file info
const currentFileIds = state.files.ids;
if (currentFileIds.length > 0) {
const currentFileId = currentFileIds[0];
const currentRecord = selectors.getStirlingFileStub(currentFileId);
if (!currentRecord) {
console.error('No file record found for:', currentFileId);
return;
}
// Create output stub and file (exact same as Sign tool)
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
// Replace the original file with the saved version
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
}
}
}
} catch (error) {
console.error('Error saving PDF:', error);
}
}
}}
disabled={disabled}
>
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
</ActionIcon>
</Tooltip>
{/* Color Picker Modal */}
<ColorPicker
isOpen={isColorPickerOpen}
onClose={() => setIsColorPickerOpen(false)}
selectedColor={selectedColor}
onColorChange={(color) => {
setSelectedColor(color);
// Update drawing tool color if annotation mode is active
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
try {
signatureApiRef.current.updateDrawSettings(color, 2);
} catch (error) {
console.log('Unable to update drawing settings:', error);
}
}
}}
title="Choose Drawing Color"
/>
</>
);
}
@@ -51,6 +51,7 @@ const EmbedPdfViewerContent = ({
getScrollState,
getRotationState,
isAnnotationMode,
setAnnotationMode,
isAnnotationsVisible,
exportActions,
} = useViewer();
@@ -82,15 +83,18 @@ const EmbedPdfViewerContent = ({
// Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
// Check if we're in signature mode OR viewer annotation mode
// Check if we're in an annotation tool
const { selectedTool } = useNavigationState();
// Tools that use the stamp/signature placement system with hover preview
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
// Tools that require the annotation layer (Sign, Add Text, Add Image)
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
// Sync isAnnotationMode in ViewerContext with current tool
useEffect(() => {
setAnnotationMode(isInAnnotationTool);
}, [isInAnnotationTool, setAnnotationMode]);
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
const isPlacementOverlayActive = Boolean(
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
isInAnnotationTool && isPlacementMode && signatureConfig
);
// Track which file tab is active
@@ -333,7 +337,8 @@ const EmbedPdfViewerContent = ({
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
file={effectiveFile.file}
url={effectiveFile.url}
enableAnnotations={shouldEnableAnnotations}
enableAnnotations={isAnnotationMode}
showBakedAnnotations={isAnnotationsVisible}
signatureApiRef={signatureApiRef as React.RefObject<any>}
historyApiRef={historyApiRef as React.RefObject<any>}
onSignatureAdded={() => {
@@ -52,12 +52,13 @@ interface LocalEmbedPDFProps {
file?: File | Blob;
url?: string | null;
enableAnnotations?: boolean;
showBakedAnnotations?: boolean;
onSignatureAdded?: (annotation: any) => void;
signatureApiRef?: React.RefObject<SignatureAPI>;
historyApiRef?: React.RefObject<HistoryAPI>;
}
export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
const { t } = useTranslation();
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
@@ -100,7 +101,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
}),
createPluginRegistration(RenderPluginPackage, {
withForms: true,
withAnnotations: true,
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
}),
// Register interaction manager (required for zoom and selection features)
@@ -166,7 +167,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
// Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage),
];
}, [pdfUrl]);
}, [pdfUrl, enableAnnotations, showBakedAnnotations]);
// Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Button, Paper, Group, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useViewer } from '@app/contexts/ViewerContext';
import { Tooltip } from '@app/components/shared/Tooltip';
import FirstPageIcon from '@mui/icons-material/FirstPage';
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
@@ -209,21 +210,27 @@ export function PdfViewerToolbar({
</Button>
{/* Dual Page Toggle */}
<Button
variant={isDualPageActive ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={handleDualPageToggle}
style={{ minWidth: '2.5rem' }}
title={
<Tooltip
content={
isDualPageActive
? t("viewer.singlePageView", "Single Page View")
: t("viewer.dualPageView", "Dual Page View")
}
position="top"
arrow
>
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
<Button
variant={isDualPageActive ? "filled" : "light"}
color="blue"
size="md"
radius="xl"
onClick={handleDualPageToggle}
disabled={scrollState.totalPages <= 1}
style={{ minWidth: '2.5rem' }}
>
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
</Tooltip>
{/* Zoom Controls */}
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
@@ -95,7 +95,6 @@ interface ViewerContextType {
// Annotation/drawing mode for viewer
isAnnotationMode: boolean;
setAnnotationMode: (enabled: boolean) => void;
toggleAnnotationMode: () => void;
// Active file index for multi-file viewing
activeFileIndex: number;
@@ -230,10 +229,6 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
setIsAnnotationModeState(enabled);
};
const toggleAnnotationMode = () => {
setIsAnnotationModeState(prev => !prev);
};
// State getters - read from bridge refs
const getScrollState = (): ScrollState => {
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
@@ -318,7 +313,6 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
toggleAnnotationsVisibility,
isAnnotationMode,
setAnnotationMode,
toggleAnnotationMode,
// Active file index
activeFileIndex,
@@ -238,6 +238,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const originalImagesRef = useRef<PdfJsonImageElement[][]>([]);
const originalGroupsRef = useRef<TextGroup[][]>([]);
const imagesByPageRef = useRef<PdfJsonImageElement[][]>([]);
const lastLoadedFileRef = useRef<File | null>(null);
const autoLoadKeyRef = useRef<string | null>(null);
const sourceFileIdRef = useRef<string | null>(null);
const loadRequestIdRef = useRef(0);
@@ -251,6 +252,10 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const pagePreviewsRef = useRef<Map<number, string>>(pagePreviews);
const previewScaleRef = useRef<Map<number, number>>(new Map());
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
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
@@ -14,6 +14,7 @@ interface SaaSLoginScreenProps {
onLogin: (username: string, password: string) => Promise<void>;
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
onSelfHostedClick: () => void;
onSwitchToSignup: () => void;
loading: boolean;
error: string | null;
}
@@ -23,6 +24,7 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
onLogin,
onOAuthSuccess,
onSelfHostedClick,
onSwitchToSignup,
loading,
error,
}) => {
@@ -89,6 +91,20 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
submitButtonText={t('setup.login.submit', 'Login')}
/>
<div className="navigation-link-container" style={{ marginTop: '0.5rem', textAlign: 'right' }}>
<button
type="button"
onClick={() => {
setValidationError(null);
onSwitchToSignup();
}}
className="navigation-link-button"
disabled={loading}
>
{t('signup.signUp', 'Sign Up')}
</button>
</div>
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
</>
);
@@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import SignupForm from '@app/routes/signup/SignupForm';
import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation';
import { authService } from '@app/services/authService';
import '@app/routes/authShared/auth.css';
interface SaaSSignupScreenProps {
loading: boolean;
error: string | null;
onLogin: (username: string, password: string) => Promise<void>;
onSwitchToLogin: () => void;
}
export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
loading,
error,
onLogin: _onLogin,
onSwitchToLogin: _onSwitchToLogin,
}) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const [signupFieldErrors, setSignupFieldErrors] = useState<SignupFieldErrors>({});
const [signupSuccessMessage, setSignupSuccessMessage] = useState<string | null>(null);
const [isSignupSubmitting, setIsSignupSubmitting] = useState(false);
const { validateSignupForm } = useSignupFormValidation();
const displayError = error || validationError;
const handleSignupSubmit = async () => {
setValidationError(null);
setSignupSuccessMessage(null);
setSignupFieldErrors({});
const validation = validateSignupForm(email, password, confirmPassword);
if (!validation.isValid) {
setValidationError(validation.error);
setSignupFieldErrors(validation.fieldErrors || {});
return;
}
try {
setIsSignupSubmitting(true);
await authService.signUpSaas(email.trim(), password);
setSignupSuccessMessage(t('signup.checkEmailConfirmation', 'Check your email for a confirmation link to complete your registration.'));
setSignupFieldErrors({});
setValidationError(null);
} catch (err) {
setSignupSuccessMessage(null);
const message = err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' });
setValidationError(message);
} finally {
setIsSignupSubmitting(false);
}
};
return (
<>
<LoginHeader
title={t('signup.title', 'Create an account')}
subtitle={t('signup.subtitle', 'Join Stirling PDF')}
/>
<ErrorMessage error={displayError} />
{signupSuccessMessage && (
<div className="success-message">
<p className="success-message-text">{signupSuccessMessage}</p>
</div>
)}
<SignupForm
email={email}
password={password}
confirmPassword={confirmPassword}
setEmail={(value) => {
setEmail(value);
setValidationError(null);
setSignupFieldErrors({});
}}
setPassword={(value) => {
setPassword(value);
setValidationError(null);
setSignupFieldErrors({});
}}
setConfirmPassword={(value) => {
setConfirmPassword(value);
setValidationError(null);
setSignupFieldErrors({});
}}
onSubmit={handleSignupSubmit}
isSubmitting={loading || isSignupSubmitting}
fieldErrors={signupFieldErrors}
showName={false}
showTerms={false}
/>
</>
);
};
@@ -2,16 +2,20 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import { listen } from '@tauri-apps/api/event';
import { useEffect } from 'react';
import '@app/routes/authShared/auth.css';
enum SetupStep {
SaaSLogin,
SaaSSignup,
ServerSelection,
SelfHostedLogin,
}
@@ -80,6 +84,16 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
setActiveStep(SetupStep.ServerSelection);
};
const handleSwitchToSignup = () => {
setError(null);
setActiveStep(SetupStep.SaaSSignup);
};
const handleSwitchToLogin = () => {
setError(null);
setActiveStep(SetupStep.SaaSLogin);
};
const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config);
setError(null);
@@ -128,6 +142,48 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
}
};
useEffect(() => {
const unsubscribePromise = listen<string>('deep-link', async (event) => {
const url = event.payload;
if (!url) return;
try {
const parsed = new URL(url);
// Supabase sends tokens in the URL hash
const hash = parsed.hash.replace(/^#/, '');
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const type = params.get('type') || parsed.searchParams.get('type');
if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) {
return;
}
if (!accessToken) {
console.error('[SetupWizard] Deep link missing access_token');
return;
}
setLoading(true);
setError(null);
await authService.completeSupabaseSession(accessToken, serverConfig?.url || STIRLING_SAAS_URL);
await connectionModeService.switchToSaaS(serverConfig?.url || STIRLING_SAAS_URL);
tauriBackendService.startBackend().catch(console.error);
onComplete();
} catch (err) {
console.error('[SetupWizard] Failed to handle deep link', err);
setError(err instanceof Error ? err.message : 'Failed to complete signup');
setLoading(false);
}
});
return () => {
void unsubscribePromise.then((unsub) => unsub());
};
}, [onComplete, serverConfig?.url]);
const handleBack = () => {
setError(null);
if (activeStep === SetupStep.SelfHostedLogin) {
@@ -135,6 +191,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
} else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.SaaSLogin);
setServerConfig({ url: STIRLING_SAAS_URL });
} else if (activeStep === SetupStep.SaaSSignup) {
setActiveStep(SetupStep.SaaSLogin);
}
};
@@ -147,11 +205,21 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
onLogin={handleSaaSLogin}
onOAuthSuccess={handleSaaSLoginOAuth}
onSelfHostedClick={handleSelfHostedClick}
onSwitchToSignup={handleSwitchToSignup}
loading={loading}
error={error}
/>
)}
{activeStep === SetupStep.SaaSSignup && (
<SaaSSignupScreen
loading={loading}
error={error}
onLogin={handleSaaSLogin}
onSwitchToLogin={handleSwitchToLogin}
/>
)}
{activeStep === SetupStep.ServerSelection && (
<ServerSelectionScreen
onSelect={handleServerSelection}
@@ -6,6 +6,12 @@
// The SaaS authentication server
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || '';
// SaaS signup URL for creating new cloud accounts
export const STIRLING_SAAS_SIGNUP_URL: string = import.meta.env.VITE_SAAS_SIGNUP_URL || '';
// Supabase publishable key from environment variable
// Used for SaaS authentication
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
// Desktop deep link callback for Supabase email confirmations
export const DESKTOP_DEEP_LINK_CALLBACK = 'stirlingpdf://auth/callback';
+62 -1
View File
@@ -1,6 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import axios from 'axios';
import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
export interface UserInfo {
username: string;
@@ -131,6 +131,67 @@ export class AuthService {
this.notifyListeners();
}
async completeSupabaseSession(accessToken: string, serverUrl: string): Promise<UserInfo> {
if (!accessToken || !accessToken.trim()) {
throw new Error('Invalid access token');
}
if (!SUPABASE_KEY) {
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
await this.saveTokenEverywhere(accessToken);
const userInfo = await this.fetchSupabaseUserInfo(serverUrl, accessToken);
await invoke('save_user_info', {
username: userInfo.username,
email: userInfo.email || null,
});
this.setAuthStatus('authenticated', userInfo);
return userInfo;
}
async signUpSaas(email: string, password: string): Promise<void> {
if (!STIRLING_SAAS_URL) {
throw new Error('VITE_SAAS_SERVER_URL is not configured');
}
if (!SUPABASE_KEY) {
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
const redirectParam = encodeURIComponent(DESKTOP_DEEP_LINK_CALLBACK);
const signupUrl = `${STIRLING_SAAS_URL.replace(/\/$/, '')}/auth/v1/signup?redirect_to=${redirectParam}`;
try {
const response = await axios.post(
signupUrl,
{ email, password, email_redirect_to: DESKTOP_DEEP_LINK_CALLBACK },
{
headers: {
'Content-Type': 'application/json;charset=UTF-8',
apikey: SUPABASE_KEY,
Authorization: `Bearer ${SUPABASE_KEY}`,
},
}
);
if (response.status >= 400) {
throw new Error('Sign up failed');
}
} catch (error) {
if (axios.isAxiosError(error)) {
const message =
error.response?.data?.error_description ||
error.response?.data?.msg ||
error.response?.data?.message ||
error.message;
throw new Error(message || 'Sign up failed');
}
throw error instanceof Error ? error : new Error('Sign up failed');
}
}
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
try {
console.log('Logging in to:', serverUrl);
@@ -343,7 +343,7 @@ describe('SpringAuthClient', () => {
});
const result = await springAuth.signInWithOAuth({
provider: 'github',
provider: '/oauth2/authorization/github',
options: { redirectTo: '/auth/callback' },
});
@@ -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' },
};
}
}
@@ -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' }
});
});
+5 -6
View File
@@ -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);
}
@@ -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
};
});
+12
View File
@@ -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,