Merge branch 'main' into mfa_20260129

This commit is contained in:
Ludy
2026-02-12 18:43:19 +01:00
committed by GitHub
79 changed files with 261845 additions and 261157 deletions
@@ -29,14 +29,14 @@ import org.springframework.stereotype.Component;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.PostConstruct;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import jakarta.annotation.PostConstruct;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.YamlPropertySourceFactory;
import stirling.software.common.model.exception.UnsupportedProviderException;
@@ -101,8 +101,8 @@ public class ApplicationProperties {
}
/**
* Initialize fileUploadLimit from environment variables if not set in settings.yml.
* Supports SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
* SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
*/
@PostConstruct
public void initializeFileUploadLimitFromEnv() {
@@ -124,12 +124,18 @@ public class ApplicationProperties {
long sizeInMB = Long.parseLong(systemMaxFileSize.trim());
if (sizeInMB > 0 && sizeInMB <= 999) {
fileUploadLimit = sizeInMB + "MB";
log.info("Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB", sizeInMB);
log.info(
"Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB",
sizeInMB);
} else {
log.warn("SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring", sizeInMB);
log.warn(
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
sizeInMB);
}
} catch (NumberFormatException e) {
log.warn("SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring", systemMaxFileSize);
log.warn(
"SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring",
systemMaxFileSize);
}
}
}
@@ -0,0 +1,29 @@
package stirling.software.common.service;
/**
* Interface for checking license status dynamically. Implementation provided by proprietary module
* when available.
*/
public interface LicenseServiceInterface {
/**
* Get the license type as a string.
*
* @return "NORMAL", "SERVER", or "ENTERPRISE"
*/
String getLicenseTypeName();
/**
* Check if running Pro or higher (SERVER or ENTERPRISE license).
*
* @return true if SERVER or ENTERPRISE license is active
*/
boolean isRunningProOrHigher();
/**
* Check if running Enterprise edition.
*
* @return true if ENTERPRISE license is active
*/
boolean isRunningEE();
}
@@ -107,56 +107,65 @@ public class PDFToFile {
File[] outputFiles =
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
List<File> markdownFiles = new ArrayList<>();
List<File> imageFiles = new ArrayList<>();
// Convert HTML files to Markdown
// Convert HTML files to Markdown and collect image files
for (File outputFile : outputFiles) {
if (outputFile.getName().endsWith(".html")) {
String html = Files.readString(outputFile.toPath());
String markdown = htmlToMarkdownConverter.convert(html);
// Update image references to point to images/ folder
markdown = updateImageReferences(markdown);
String mdFileName = outputFile.getName().replace(".html", ".md");
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
Files.writeString(mdFile.toPath(), markdown);
markdownFiles.add(mdFile);
} else if (!outputFile.getName().endsWith(".md")) {
// Collect non-HTML, non-MD files as images/assets
imageFiles.add(outputFile);
}
}
// If there's only one markdown file, return it directly
if (markdownFiles.size() == 1) {
fileName = pdfBaseName + ".md";
fileBytes = Files.readAllBytes(markdownFiles.get(0).toPath());
} else {
// Multiple files - create a zip
fileName = pdfBaseName + "ToMarkdown.zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// Always create a ZIP file
fileName = pdfBaseName + "ToMarkdown.zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
// Add markdown files
for (File mdFile : markdownFiles) {
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
zipOutputStream.putNextEntry(mdEntry);
Files.copy(mdFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
// Add images and other assets
for (File file : outputFiles) {
if (!file.getName().endsWith(".html") && !file.getName().endsWith(".md")) {
ZipEntry assetEntry = new ZipEntry(file.getName());
zipOutputStream.putNextEntry(assetEntry);
Files.copy(file.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
}
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
// Add markdown files to root of ZIP
for (File mdFile : markdownFiles) {
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
zipOutputStream.putNextEntry(mdEntry);
Files.copy(mdFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
fileBytes = byteArrayOutputStream.toByteArray();
// Add images and other assets to images/ folder
for (File imageFile : imageFiles) {
ZipEntry assetEntry = new ZipEntry("images/" + imageFile.getName());
zipOutputStream.putNextEntry(assetEntry);
Files.copy(imageFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
}
fileBytes = byteArrayOutputStream.toByteArray();
}
return WebResponseUtils.bytesToWebResponse(
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
/**
* Updates image references in markdown to point to the images/ folder. Matches patterns like
* ![alt](filename.png) and converts to ![alt](images/filename.png)
*/
private String updateImageReferences(String markdown) {
// Match markdown image syntax: ![alt text](image.png)
// Only update if the path doesn't already start with images/
return markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
}
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
throws IOException, InterruptedException {
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
@@ -574,34 +574,39 @@ public class PdfUtils {
boolean everyPage)
throws IOException {
PDDocument document = pdfDocumentFactory.load(pdfBytes);
// Get the first page of the PDF
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
// Create an image object from the image bytes
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "");
// Draw the image onto the page at the specified x and y coordinates
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF");
if (!everyPage && i == 0) {
break;
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
// Get the first page of the PDF
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
// Create an image object from the image bytes
PDImageXObject image =
PDImageXObject.createFromByteArray(document, imageBytes, "");
// Draw the image onto the page at the specified x and y coordinates
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF");
if (!everyPage && i == 0) {
break;
}
} catch (IOException e) {
// Log an error message if there is an issue overlaying the image onto the PDF
log.error("Error overlaying image onto PDF", e);
throw e;
}
} catch (IOException e) {
// Log an error message if there is an issue overlaying the image onto the PDF
log.error("Error overlaying image onto PDF", e);
throw e;
}
// Create a ByteArrayOutputStream to save the PDF to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
log.info("PDF successfully saved to byte array");
return baos.toByteArray();
}
// Create a ByteArrayOutputStream to save the PDF to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
log.info("PDF successfully saved to byte array");
return baos.toByteArray();
}
public boolean containsTextInFile(PDDocument pdfDocument, String text, String pagesToCheck)
@@ -153,11 +153,12 @@ class PDFToFileTest {
MediaType.APPLICATION_PDF_VALUE,
"Fake PDF content".getBytes());
// Create a mock HTML output file
// Create a mock HTML output file with image references
Path htmlOutputFile = tempDir.resolve("test.html");
Files.write(
htmlOutputFile,
"<html><body><h1>Test</h1><p>This is a test.</p></body></html>".getBytes());
"<html><body><h1>Test</h1><p>This is a test.</p><img src=\"image1.png\" /></body></html>"
.getBytes());
// Setup ProcessExecutor mock
mockedStaticProcessExecutor
@@ -174,18 +175,61 @@ class PDFToFileTest {
Files.copy(
htmlOutputFile, Path.of(outputDir.getPath(), "test.html"));
// Create a mock image file
Files.write(
Path.of(outputDir.getPath(), "image1.png"),
"Fake image data".getBytes());
return mockExecutorResult;
});
// Execute the method
ResponseEntity<byte[]> response = pdfToFile.processPdfToMarkdown(pdfFile);
// Verify
// Verify - should now return a ZIP file instead of plain markdown
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertTrue(response.getBody().length > 0);
// Verify content disposition indicates a ZIP file
assertTrue(
response.getHeaders().getContentDisposition().toString().contains("test.md"));
response.getHeaders()
.getContentDisposition()
.toString()
.contains("ToMarkdown.zip"));
// Verify the content by unzipping it
try (ZipInputStream zipStream =
ZipSecurity.createHardenedInputStream(
new java.io.ByteArrayInputStream(response.getBody()))) {
ZipEntry entry;
boolean foundMdFile = false;
boolean foundImageInFolder = false;
String markdownContent = null;
while ((entry = zipStream.getNextEntry()) != null) {
if (entry.getName().endsWith(".md")) {
foundMdFile = true;
// Read markdown content to verify image references
markdownContent =
new String(
zipStream.readAllBytes(),
java.nio.charset.StandardCharsets.UTF_8);
} else if (entry.getName().startsWith("images/")
&& entry.getName().endsWith(".png")) {
foundImageInFolder = true;
}
zipStream.closeEntry();
}
assertTrue(foundMdFile, "ZIP should contain Markdown file");
assertTrue(foundImageInFolder, "ZIP should contain image in images/ folder");
assertNotNull(markdownContent, "Markdown content should be present");
// Verify markdown references images with images/ prefix
assertTrue(
markdownContent.contains("images/"),
"Markdown should reference images with images/ prefix");
}
}
}
@@ -256,14 +300,15 @@ class PDFToFileTest {
while ((entry = zipStream.getNextEntry()) != null) {
if (entry.getName().endsWith(".md")) {
foundMdFiles = true;
} else if (entry.getName().endsWith(".png")) {
} else if (entry.getName().startsWith("images/")
&& entry.getName().endsWith(".png")) {
foundImage = true;
}
zipStream.closeEntry();
}
assertTrue(foundMdFiles, "ZIP should contain Markdown files");
assertTrue(foundImage, "ZIP should contain image files");
assertTrue(foundImage, "ZIP should contain image files in images/ folder");
}
}
}
+2
View File
@@ -168,6 +168,7 @@ def generatedFrontendPaths = [
]
tasks.register('npmInstall', Exec) {
doNotTrackState("node_modules contains symlinks that Gradle cannot snapshot on Windows/WSL")
enabled = buildWithFrontend
group = 'frontend'
description = 'Install frontend dependencies'
@@ -214,6 +215,7 @@ tasks.register('npmInstall', Exec) {
}
tasks.register('npmBuild', Exec) {
doNotTrackState("Frontend build depends on untracked npmInstall task")
enabled = buildWithFrontend
group = 'frontend'
description = 'Build frontend application'
@@ -14,9 +14,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.controller.web.UploadLimitService;
/**
* Configuration for Spring multipart file upload settings.
* Synchronizes multipart limits with fileUploadLimit from settings.yml or environment variables
* (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE).
* Configuration for Spring multipart file upload settings. Synchronizes multipart limits with
* fileUploadLimit from settings.yml or environment variables (SYSTEMFILEUPLOADLIMIT or
* SYSTEM_MAXFILESIZE).
*/
@Configuration
@Slf4j
@@ -25,9 +25,9 @@ public class MultipartConfiguration {
@Autowired private UploadLimitService uploadLimitService;
/**
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml
* or environment variables (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE).
* Depends on ApplicationProperties being initialized so @PostConstruct has run.
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml or environment
* variables (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE). Depends on ApplicationProperties
* being initialized so @PostConstruct has run.
*/
@Bean
@DependsOn("applicationProperties")
@@ -35,7 +35,8 @@ public class MultipartConfiguration {
MultipartConfigFactory factory = new MultipartConfigFactory();
// First check if SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE is explicitly set
String springMaxFileSize = java.lang.System.getenv("SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE");
String springMaxFileSize =
java.lang.System.getenv("SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE");
long uploadLimitBytes = 0;
if (springMaxFileSize != null && !springMaxFileSize.trim().isEmpty()) {
@@ -45,7 +46,10 @@ public class MultipartConfiguration {
uploadLimitBytes = dataSize.toBytes();
log.info("Using SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}", springMaxFileSize);
} catch (Exception e) {
log.warn("Failed to parse SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}", springMaxFileSize, e);
log.warn(
"Failed to parse SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}",
springMaxFileSize,
e);
}
}
@@ -73,4 +77,3 @@ public class MultipartConfiguration {
return factory.createMultipartConfig();
}
}
@@ -101,66 +101,68 @@ public class SplitPdfBySectionsController {
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
}
} else {
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
int pageNum = pageIndex + 1;
if (pagesToSplit.contains(pageIndex)) {
for (int i = 0; i < horiz; i++) {
for (int j = 0; j < verti; j++) {
try (PDDocument subDoc =
pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addSingleSectionToTarget(
sourceDocument,
pageIndex,
subDoc,
subLayerUtility,
i,
j,
horiz,
verti);
int sectionNum = i * verti + j + 1;
String entryName =
filename
+ "_"
+ pageNum
+ "_"
+ sectionNum
+ ".pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error(
"Error creating section {} for page {}",
(i * verti + j + 1),
pageNum,
e);
throw e;
try (TempFile zipTempFile = new TempFile(tempFileManager, ".zip")) {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
int pageNum = pageIndex + 1;
if (pagesToSplit.contains(pageIndex)) {
for (int i = 0; i < horiz; i++) {
for (int j = 0; j < verti; j++) {
try (PDDocument subDoc =
pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addSingleSectionToTarget(
sourceDocument,
pageIndex,
subDoc,
subLayerUtility,
i,
j,
horiz,
verti);
int sectionNum = i * verti + j + 1;
String entryName =
filename
+ "_"
+ pageNum
+ "_"
+ sectionNum
+ ".pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error(
"Error creating section {} for page {}",
(i * verti + j + 1),
pageNum,
e);
throw e;
}
}
}
}
} else {
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addPageToTarget(sourceDocument, pageIndex, subDoc, subLayerUtility);
String entryName = filename + "_" + pageNum + "_1.pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error("Error processing unsplit page {}", pageNum, e);
throw e;
} else {
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addPageToTarget(
sourceDocument, pageIndex, subDoc, subLayerUtility);
String entryName = filename + "_" + pageNum + "_1.pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error("Error processing unsplit page {}", pageNum, e);
throw e;
}
}
}
} catch (IOException e) {
log.error("Error creating ZIP file with split PDF sections", e);
throw e;
}
} catch (IOException e) {
log.error("Error creating ZIP file with split PDF sections", e);
throw e;
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
}
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
}
} catch (Exception e) {
log.error("Error splitting PDF file: {}", file.getOriginalFilename(), e);
@@ -44,7 +44,7 @@ public class ConvertMarkdownToPdf {
@Operation(
summary = "Convert a Markdown file to PDF",
description =
"This endpoint takes a Markdown file input, converts it to HTML, and then to"
"This endpoint takes a Markdown file or ZIP (containing Markdown + images) input, converts it to HTML, and then to"
+ " PDF format. Input:MARKDOWN Output:PDF Type:SISO")
public ResponseEntity<byte[]> markdownToPdf(@ModelAttribute GeneralFile generalFile)
throws Exception {
@@ -52,40 +52,181 @@ public class ConvertMarkdownToPdf {
if (fileInput == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "Markdown");
"error.fileFormatRequired", "File must be in {0} format", "Markdown or ZIP");
}
String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
if (originalFilename == null || !originalFilename.endsWith(".md")) {
if (originalFilename == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", ".md");
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
}
// Convert Markdown to HTML using CommonMark
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
boolean isZip = originalFilename.toLowerCase().endsWith(".zip");
boolean isMarkdown = originalFilename.toLowerCase().endsWith(".md");
Node document = parser.parse(new String(fileInput.getBytes()));
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
if (!isZip && !isMarkdown) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
}
String htmlContent = renderer.render(document);
byte[] pdfBytes;
String outputFilename;
if (isZip) {
// Handle ZIP file containing markdown + images
try (TempDirectory tempDir = new TempDirectory(tempFileManager)) {
// Extract ZIP to temp directory
java.nio.file.Path tempDirPath = tempDir.getPath();
try (java.util.zip.ZipInputStream zipIn =
io.github.pixee.security.ZipSecurity.createHardenedInputStream(
new java.io.ByteArrayInputStream(fileInput.getBytes()))) {
java.util.zip.ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
java.nio.file.Path filePath = tempDirPath.resolve(entry.getName());
java.nio.file.Files.createDirectories(filePath.getParent());
java.nio.file.Files.copy(zipIn, filePath);
}
zipIn.closeEntry();
}
}
// Find the markdown file (look for .md files, prefer index.md or first one)
java.io.File markdownFile = findMarkdownFile(tempDirPath.toFile());
if (markdownFile == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired",
"ZIP must contain at least one {0} file",
".md");
}
// Read and convert markdown to HTML
String markdownContent = java.nio.file.Files.readString(markdownFile.toPath());
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
Node document = parser.parse(markdownContent);
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
String htmlContent = renderer.render(document);
// Create a new ZIP with HTML + images for WeasyPrint
byte[] htmlZipBytes = createHtmlZip(htmlContent, tempDirPath.toFile());
// Use FileToPdf which already supports ZIP files with images
pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlZipBytes,
"package.zip",
tempFileManager,
customHtmlSanitizer);
outputFilename =
GeneralUtils.generateFilename(
originalFilename.substring(0, originalFilename.lastIndexOf('.')),
".pdf");
}
} else {
// Handle plain markdown file (no images)
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
Node document = parser.parse(new String(fileInput.getBytes()));
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
String htmlContent = renderer.render(document);
pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlContent.getBytes(),
"converted.html",
tempFileManager,
customHtmlSanitizer);
outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
}
byte[] pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlContent.getBytes(),
"converted.html",
tempFileManager,
customHtmlSanitizer);
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
String outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
/**
* Finds a markdown file in the directory. Prefers index.md, otherwise returns the first .md
* file found.
*/
private java.io.File findMarkdownFile(java.io.File directory) throws java.io.IOException {
java.io.File indexMd = new java.io.File(directory, "index.md");
if (indexMd.exists()) {
return indexMd;
}
// Search for any .md file
try (java.util.stream.Stream<java.nio.file.Path> paths =
java.nio.file.Files.walk(directory.toPath())) {
return paths.filter(p -> p.toString().toLowerCase().endsWith(".md"))
.findFirst()
.map(java.nio.file.Path::toFile)
.orElse(null);
}
}
/**
* Creates a ZIP file containing the HTML content and all other files (images) from the
* directory.
*/
private byte[] createHtmlZip(String htmlContent, java.io.File sourceDir)
throws java.io.IOException {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) {
// Add HTML file to root
java.util.zip.ZipEntry htmlEntry = new java.util.zip.ZipEntry("index.html");
zos.putNextEntry(htmlEntry);
zos.write(htmlContent.getBytes(java.nio.charset.StandardCharsets.UTF_8));
zos.closeEntry();
// Add all other files (images, etc.)
addDirectoryToZip(zos, sourceDir.toPath(), sourceDir.toPath());
}
return baos.toByteArray();
}
/** Recursively adds files from a directory to a ZIP, excluding .md files. */
private void addDirectoryToZip(
java.util.zip.ZipOutputStream zos,
java.nio.file.Path sourceDir,
java.nio.file.Path rootDir)
throws java.io.IOException {
try (java.util.stream.Stream<java.nio.file.Path> paths =
java.nio.file.Files.walk(sourceDir, 1)) {
for (java.nio.file.Path path : paths.toList()) {
if (java.nio.file.Files.isDirectory(path)) {
if (!path.equals(sourceDir)) {
addDirectoryToZip(zos, path, rootDir);
}
} else if (!path.toString().toLowerCase().endsWith(".md")) {
// Add file to ZIP, maintaining relative path structure
java.nio.file.Path relativePath = rootDir.relativize(path);
java.util.zip.ZipEntry entry =
new java.util.zip.ZipEntry(relativePath.toString());
zos.putNextEntry(entry);
java.nio.file.Files.copy(path, zos);
zos.closeEntry();
}
}
}
}
}
class TableAttributeProvider implements AttributeProvider {
@@ -35,6 +35,7 @@ public class ConfigController {
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
public ConfigController(
@@ -45,15 +46,66 @@ public class ConfigController {
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
}
/**
* Get current license type dynamically instead of from cached bean. This ensures the frontend
* sees updated license status after admin changes the license key.
*/
private String getCurrentLicenseType() {
// Use LicenseService for fresh license status if available
if (licenseService != null) {
return licenseService.getLicenseTypeName();
}
// Fallback to cached bean if service not available
if (applicationContext.containsBean("license")) {
return applicationContext.getBean("license", String.class);
}
return null;
}
/** Check if running Pro or higher (SERVER or ENTERPRISE license) dynamically. */
private Boolean isRunningProOrHigher() {
// Use LicenseService for fresh license status if available
if (licenseService != null) {
return licenseService.isRunningProOrHigher();
}
// Fallback to cached bean
if (applicationContext.containsBean("runningProOrHigher")) {
return applicationContext.getBean("runningProOrHigher", Boolean.class);
}
return null;
}
/** Check if running Enterprise edition dynamically. */
private Boolean isRunningEE() {
// Use LicenseService for fresh license status if available
if (licenseService != null) {
return licenseService.isRunningEE();
}
// Fallback to cached bean
if (applicationContext.containsBean("runningEE")) {
return applicationContext.getBean("runningEE", Boolean.class);
}
return null;
}
@GetMapping("/app-config")
public ResponseEntity<Map<String, Object>> getAppConfig() {
Map<String, Object> configData = new HashMap<>();
@@ -185,19 +237,23 @@ public class ConfigController {
applicationProperties.getLegal().getAccessibilityStatement());
// Try to get EEAppConfig values if available
// Get these dynamically to reflect current license status (not cached at startup)
try {
if (applicationContext.containsBean("runningProOrHigher")) {
configData.put(
"runningProOrHigher",
applicationContext.getBean("runningProOrHigher", Boolean.class));
Boolean runningProOrHigher = isRunningProOrHigher();
if (runningProOrHigher != null) {
configData.put("runningProOrHigher", runningProOrHigher);
}
if (applicationContext.containsBean("runningEE")) {
configData.put(
"runningEE", applicationContext.getBean("runningEE", Boolean.class));
Boolean runningEE = isRunningEE();
if (runningEE != null) {
configData.put("runningEE", runningEE);
}
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
String licenseType = getCurrentLicenseType();
if (licenseType != null) {
configData.put("license", licenseType);
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
@@ -55,45 +55,45 @@ public class OverlayImageController {
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
PDDocument document = pdfDocumentFactory.load(pdfBytes);
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
if (isSvg) {
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
} else {
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
PDImageXObject image =
PDImageXObject.createFromByteArray(document, imageBytes, "");
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF page {}", i);
}
}
if (isSvg) {
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
} else {
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
PDImageXObject image =
PDImageXObject.createFromByteArray(document, imageBytes, "");
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF page {}", i);
if (!everyPage && i == 0) {
break;
}
}
if (!everyPage && i == 0) {
break;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
byte[] result = baos.toByteArray();
log.info("PDF with overlaid image successfully created");
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(
pdfFile.getOriginalFilename(), "_overlayed.pdf"));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
byte[] result = baos.toByteArray();
log.info("PDF with overlaid image successfully created");
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_overlayed.pdf"));
} catch (IOException e) {
log.error("Failed to add image to PDF", e);
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
@@ -0,0 +1,51 @@
package stirling.software.proprietary.security.configuration.ee;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.service.LicenseServiceInterface;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
/**
* Service that provides dynamic license checking instead of cached beans. This ensures that when
* admins update the license key, the changes are immediately reflected in the UI and config
* endpoints without requiring a restart.
*
* <p>Note: Some components (EnterpriseEndpointAspect, PremiumEndpointAspect, filters) still inject
* cached beans at startup for performance. These will require a restart to reflect license changes.
* This is acceptable because: 1. Most deployments add licenses during initial setup 2. License
* changes in production typically warrant a restart anyway 3. UI reflects changes immediately
* (banner disappears, license status updates)
*/
@Service
@RequiredArgsConstructor
public class DynamicLicenseService implements LicenseServiceInterface {
private final LicenseKeyChecker licenseKeyChecker;
/**
* Get the current license type dynamically (not cached).
*
* @return Current license: NORMAL, SERVER, or ENTERPRISE
*/
public License getCurrentLicense() {
return licenseKeyChecker.getPremiumLicenseEnabledResult();
}
@Override
public boolean isRunningProOrHigher() {
License license = getCurrentLicense();
return license == License.SERVER || license == License.ENTERPRISE;
}
@Override
public boolean isRunningEE() {
return getCurrentLicense() == License.ENTERPRISE;
}
@Override
public String getLicenseTypeName() {
return getCurrentLicense().name();
}
}
@@ -69,27 +69,28 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (!apiKeyExists(request, response)) {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Allow auth endpoints to pass through without JWT
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
if (requestURI.startsWith(contextPath + "/api/")
|| (acceptHeader != null
&& acceptHeader.contains("application/json"))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Authentication required\"}");
return;
}
// Check if this is a public endpoint BEFORE validating JWT
// This allows public endpoints to work even with expired tokens in the request
if (isPublicAuthEndpoint(requestURI, contextPath)) {
// For public auth endpoints, skip JWT validation and continue
filterChain.doFilter(request, response);
return;
}
// For HTML requests (SPA routes), let React Router handle it (serve
// index.html)
filterChain.doFilter(request, response);
if (jwtToken == null) {
// No JWT token and not a public endpoint
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
if (requestURI.startsWith(contextPath + "/api/")
|| (acceptHeader != null && acceptHeader.contains("application/json"))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Authentication required\"}");
return;
}
// For public auth endpoints without JWT, continue to the endpoint
// For HTML requests (SPA routes), let React Router handle it (serve
// index.html)
filterChain.doFilter(request, response);
return;
}
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
@Slf4j
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
@@ -33,9 +34,33 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica
if (exception instanceof Saml2AuthenticationException) {
Saml2Error error = ((Saml2AuthenticationException) exception).getSaml2Error();
if (TauriSamlUtils.isTauriRelayState(request)) {
String redirectUrl =
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", error.getErrorCode());
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
return;
}
getRedirectStrategy()
.sendRedirect(request, response, "/login?errorOAuth=" + error.getErrorCode());
} else if (exception instanceof ProviderNotFoundException) {
if (TauriSamlUtils.isTauriRelayState(request)) {
String redirectUrl =
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
redirectUrl =
appendQueryParam(
redirectUrl, "errorOAuth", "not_authentication_provider_found");
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
return;
}
getRedirectStrategy()
.sendRedirect(
request,
@@ -43,4 +68,19 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica
"/login?errorOAuth=not_authentication_provider_found");
}
}
private String appendQueryParam(String path, String key, String value) {
if (path == null || path.isBlank()) {
return path;
}
String separator = path.contains("?") ? "&" : "?";
String encodedKey =
java.net.URLEncoder.encode(key, java.nio.charset.StandardCharsets.UTF_8);
String encodedValue =
value == null
? ""
: java.net.URLEncoder.encode(
value, java.nio.charset.StandardCharsets.UTF_8);
return path + separator + encodedKey + "=" + encodedValue;
}
}
@@ -33,6 +33,7 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@@ -233,7 +234,16 @@ public class CustomSaml2AuthenticationSuccessHandler
String redirectPath = resolveRedirectPath(request, contextPath);
String origin = resolveOrigin(request);
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
String url = origin + redirectPath + "#access_token=" + jwt;
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
if (nonce != null) {
url +=
"&nonce="
+ java.net.URLEncoder.encode(
nonce, java.nio.charset.StandardCharsets.UTF_8);
}
return url;
}
/**
@@ -256,6 +266,9 @@ public class CustomSaml2AuthenticationSuccessHandler
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
if (TauriSamlUtils.isTauriRelayState(request)) {
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
}
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
.orElseGet(() -> defaultCallbackPath(contextPath));
@@ -156,6 +156,16 @@ public class Saml2Configuration {
OpenSaml4AuthenticationRequestResolver resolver =
new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository);
resolver.setRelayStateResolver(
request -> {
String tauriParam = request.getParameter("tauri");
if (!"1".equals(tauriParam)) {
return null;
}
String nonce = request.getParameter("nonce");
return TauriSamlUtils.buildRelayState(nonce);
});
resolver.setAuthnRequestCustomizer(
customizer -> {
HttpServletRequest request = customizer.getRequest();
@@ -0,0 +1,42 @@
package stirling.software.proprietary.security.saml2;
import jakarta.servlet.http.HttpServletRequest;
/** Utility helpers for the Tauri desktop SAML flow. */
public final class TauriSamlUtils {
public static final String TAURI_RELAY_STATE_PREFIX = "tauri:";
private TauriSamlUtils() {
// Utility class - prevent instantiation
}
public static boolean isTauriRelayState(HttpServletRequest request) {
String relayState = request.getParameter("RelayState");
return relayState != null
&& (relayState.equals("tauri") || relayState.startsWith(TAURI_RELAY_STATE_PREFIX));
}
public static String extractNonceFromRelayState(String relayState) {
if (relayState == null || !relayState.startsWith(TAURI_RELAY_STATE_PREFIX)) {
return null;
}
String[] parts = relayState.split(":");
if (parts.length >= 2) {
String nonce = parts[parts.length - 1];
return nonce.isBlank() ? null : nonce;
}
return null;
}
public static String extractNonceFromRequest(HttpServletRequest request) {
return extractNonceFromRelayState(request.getParameter("RelayState"));
}
public static String buildRelayState(String nonce) {
if (nonce == null || nonce.isBlank()) {
return "tauri";
}
return TAURI_RELAY_STATE_PREFIX + nonce;
}
}
+2 -2
View File
@@ -5,7 +5,7 @@ plugins {
id "org.springframework.boot" version "3.5.9"
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "com.diffplug.spotless" version "8.1.0"
id "com.diffplug.spotless" version "8.2.1"
id "com.github.jk1.dependency-license-report" version "3.0.1"
//id "nebula.lint" version "19.0.3"
id "org.sonarqube" version "7.2.2.6593"
@@ -67,7 +67,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.4.5'
version = '2.4.6'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.4.5",
"version": "2.4.6",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -37,7 +37,6 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
console.error('Current search:', window.location.search);
console.error('Timestamp:', new Date().toISOString());
console.error('User agent:', navigator.userAgent);
// Check for React error codes
if (error.message.includes('Minified React error')) {
const errorCodeMatch = error.message.match(/#(\d+)/);
@@ -108,4 +107,4 @@ export default class ErrorBoundary extends React.Component<ErrorBoundaryProps, E
return this.props.children;
}
}
}
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Menu, Button, ActionIcon } from '@mantine/core';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '@app/i18n';
import { supportedLanguages, setUserLanguage } from '@app/i18n';
import LocalIcon from '@app/components/shared/LocalIcon';
import styles from '@app/components/shared/LanguageSelector.module.css';
import { Z_INDEX_CONFIG_MODAL } from '@app/styles/zIndex';
@@ -206,7 +206,8 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
// Simulate processing time for smooth transition
setTimeout(() => {
i18n.changeLanguage(value);
// Use setUserLanguage to properly set priority (ensures user choice persists across sessions)
setUserLanguage(value);
setTimeout(() => {
setPendingLanguage(null);
@@ -122,6 +122,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
}
setError(null);
const startTime = performance.now();
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const testConfig = getSimulatedAppConfig();
@@ -142,6 +143,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
// apiClient automatically adds JWT header if available via interceptors
// Always suppress error toast - we handle 401 errors locally
console.debug('[AppConfig] Fetching app config', { attempt, force, path: window.location.pathname });
const response = await apiClient.get<AppConfig>(
'/api/v1/config/app-config',
{
@@ -152,6 +154,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
const data = response.data;
console.debug('[AppConfig] Config fetched successfully:', data);
console.debug('[AppConfig] Fetch duration ms:', (performance.now() - startTime).toFixed(2));
setConfig(data);
setHasResolvedConfig(true);
setLoading(false);
@@ -163,6 +166,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
// This allows the app to work even without authentication
if (status === 401) {
console.debug('[AppConfig] 401 error - using default config (login enabled)');
console.debug('[AppConfig] Fetch duration ms:', (performance.now() - startTime).toFixed(2));
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
@@ -181,6 +185,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
const errorMessage = err?.response?.data?.message || err?.message || 'Unknown error occurred';
setError(errorMessage);
console.error(`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err);
console.debug('[AppConfig] Fetch duration ms:', (performance.now() - startTime).toFixed(2));
// Preserve existing config (initial default or previous fetch). If nothing is set, assume login enabled.
setConfig((current) => current ?? { enableLogin: true });
setHasResolvedConfig(true);
@@ -203,7 +208,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
// On auth pages, always skip the config fetch
// The config will be fetched after authentication via jwt-available event
if (isAuthPage) {
console.debug('[AppConfig] On auth page - using default config, skipping fetch');
console.debug('[AppConfig] On auth page - using default config, skipping fetch', { path: currentPath });
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
@@ -30,6 +30,7 @@ export function useEndpointEnabled(endpoint: string): {
try {
setLoading(true);
setError(null);
console.debug('[useEndpointConfig] Fetch endpoint status', { endpoint });
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
const isEnabled = response.data;
@@ -102,6 +103,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
try {
setLoading(true);
setError(null);
console.debug('[useEndpointConfig] Fetching endpoint statuses', { count: endpoints.length, force });
// Check which endpoints we haven't fetched yet
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
+73 -10
View File
@@ -50,6 +50,23 @@ export const supportedLanguages = {
// RTL languages (based on your existing language.direction property)
export const rtlLanguages = ['ar-AR', 'fa-IR'];
// LocalStorage keys for i18next
export const I18N_STORAGE_KEYS = {
LANGUAGE: 'i18nextLng',
LANGUAGE_SOURCE: 'i18nextLng-source',
} as const;
/**
* Language selection priority levels
* Higher number = higher priority (cannot be overridden by lower priority)
*/
export enum LanguageSource {
Fallback = 0,
Browser = 1,
ServerDefault = 2,
User = 3,
}
i18n
.use(TomlBackend)
.use(LanguageDetector)
@@ -79,7 +96,7 @@ i18n
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
caches: ['localStorage'],
caches: [], // Don't cache auto-detected language - only cache when user manually selects
convertDetectedLanguage: (lng: string) => {
// Map en and en-US to en-GB
if (lng === 'en' || lng === 'en-US') return 'en-GB';
@@ -105,6 +122,18 @@ i18n.on('languageChanged', (lng) => {
document.documentElement.lang = lng;
});
// Track browser-detected language on first initialization
i18n.on('initialized', () => {
// If no source is set yet, mark current language as browser-detected
if (!localStorage.getItem(I18N_STORAGE_KEYS.LANGUAGE_SOURCE)) {
const detectedLang = i18n.language;
if (detectedLang) {
localStorage.setItem(I18N_STORAGE_KEYS.LANGUAGE, detectedLang);
localStorage.setItem(I18N_STORAGE_KEYS.LANGUAGE_SOURCE, String(LanguageSource.Browser));
}
}
});
export function normalizeLanguageCode(languageCode: string): string {
// Replace underscores with hyphens to align with i18next/translation file naming
const hyphenated = languageCode.replace(/_/g, '-');
@@ -133,6 +162,42 @@ export function toUnderscoreLanguages(languages: string[]): string[] {
return languages.map(toUnderscoreFormat);
}
/**
* Get the current language source priority
*/
function getCurrentSourcePriority(): LanguageSource {
const sourceStr = localStorage.getItem(I18N_STORAGE_KEYS.LANGUAGE_SOURCE);
const sourceNum = sourceStr ? parseInt(sourceStr, 10) : null;
return (sourceNum !== null && !isNaN(sourceNum)) ? sourceNum as LanguageSource : LanguageSource.Fallback;
}
/**
* Set language with priority tracking
* Only updates if new source has equal or higher priority than current
*/
function setLanguageWithPriority(language: string, source: LanguageSource): boolean {
const currentPriority = getCurrentSourcePriority();
const newPriority = source;
// Only apply if new source has higher priority
if (newPriority >= currentPriority) {
i18n.changeLanguage(language);
localStorage.setItem(I18N_STORAGE_KEYS.LANGUAGE, language);
localStorage.setItem(I18N_STORAGE_KEYS.LANGUAGE_SOURCE, String(source));
return true;
}
return false;
}
/**
* Set user-selected language (highest priority)
* Call this from the UI language selector
*/
export function setUserLanguage(language: string): void {
setLanguageWithPriority(language, LanguageSource.User);
}
/**
* Updates the supported languages list dynamically based on config
* If configLanguages is null/empty, all languages remain available
@@ -178,22 +243,20 @@ export function updateSupportedLanguages(configLanguages?: string[] | null, defa
// If current language is not in the new supported list, switch to fallback
const currentLang = normalizeLanguageCode(i18n.language || '');
if (currentLang && !validLanguages.includes(currentLang)) {
i18n.changeLanguage(fallback);
} else if (validDefault && !localStorage.getItem('i18nextLng')) {
// User has no saved preference - apply server default
i18n.changeLanguage(validDefault);
setLanguageWithPriority(fallback, LanguageSource.Fallback);
} else if (validDefault) {
// Apply server default (respects user choice if already set)
setLanguageWithPriority(validDefault, LanguageSource.ServerDefault);
}
}
/**
* Apply server default locale when user has no saved language preference
* This respects the priority: localStorage > defaultLocale > browser detection > fallback
* This respects the priority: user-selected language > defaultLocale > browser detection > fallback
*/
function applyDefaultLocale(defaultLocale: string) {
// Only apply if user has no saved preference
if (!localStorage.getItem('i18nextLng')) {
i18n.changeLanguage(defaultLocale);
}
// Apply server default (respects user choice if already set)
setLanguageWithPriority(defaultLocale, LanguageSource.ServerDefault);
}
export default i18n;
+1 -1
View File
@@ -192,7 +192,7 @@ export default function HomePage() {
<div className="mobile-toggle">
<div className="mobile-header">
<div className="mobile-brand">
<img src={brandIconSrc} alt="" className="mobile-brand-icon" />
<img src={brandIconSrc} alt="" aria-hidden="true" className="mobile-brand-icon" />
<img src={brandTextSrc} alt={brandAltText} className="mobile-brand-text" />
</div>
</div>
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.4.5',
appVersion: '2.4.6',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
@@ -91,7 +91,11 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
(id as KnownProviderId) in providerConfig;
const GENERIC_PROVIDER_ICON = 'oidc.svg';
console.log('[DesktopOAuthButtons] Received providers:', providers);
console.log('[DesktopOAuthButtons] Mode:', mode, 'Server URL:', serverUrl);
if (providers.length === 0) {
console.warn('[DesktopOAuthButtons] No providers to display, returning null');
return null;
}
@@ -43,6 +43,14 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
// Check if username/password authentication is allowed
const isUserPassAllowed = loginMethod === 'all' || loginMethod === 'normal';
console.log('[SelfHostedLoginScreen] Props:', {
serverUrl,
enabledOAuthProviders,
loginMethod,
isUserPassAllowed,
shouldShowOAuth: !!(enabledOAuthProviders && enabledOAuthProviders.length > 0)
});
const handleSubmit = async () => {
// Validation
if (!username.trim()) {
@@ -116,9 +116,12 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// Extract provider IDs from authorization URLs
// Example: "/oauth2/authorization/google" → "google"
const providerEntries = Object.entries(data.providerList || {});
console.log('[ServerSelection] providerList from API:', data.providerList);
providerEntries.forEach(([path, label]) => {
const id = path.split('/').pop();
console.log('[ServerSelection] Processing provider path:', path, '→ id:', id);
if (!id) {
console.warn('[ServerSelection] Skipping provider with empty id:', path);
return;
}
@@ -130,6 +133,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
});
console.log('[ServerSelection] ✅ Detected OAuth providers:', enabledProviders);
console.log('[ServerSelection] Login method:', loginMethod);
} catch (err) {
console.error('[ServerSelection] ❌ Failed to fetch login configuration:', err);
@@ -100,6 +100,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
};
const handleServerSelection = (config: ServerConfig) => {
console.log('[SetupWizard] Server selected:', config);
console.log('[SetupWizard] OAuth providers:', config.enabledOAuthProviders);
console.log('[SetupWizard] Login method:', config.loginMethod);
setServerConfig(config);
setError(null);
setSelfHostedMfaCode('');
@@ -283,7 +286,44 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const currentConfig = await connectionModeService.getCurrentConfig();
if (currentConfig.lock_connection_mode && currentConfig.server_config?.url) {
setLockConnectionMode(true);
setServerConfig(currentConfig.server_config);
// Re-fetch OAuth providers for the saved server URL
const savedUrl = currentConfig.server_config.url.replace(/\/+$/, ''); // Remove trailing slashes
let updatedConfig = { ...currentConfig.server_config };
try {
console.log('[SetupWizard] Re-fetching OAuth providers for saved server:', savedUrl);
const response = await fetch(`${savedUrl}/api/v1/proprietary/ui-data/login`);
if (response.ok) {
const data = await response.json();
const enabledProviders: any[] = [];
const providerEntries = Object.entries(data.providerList || {});
providerEntries.forEach(([path, label]) => {
const id = path.split('/').pop();
if (id) {
enabledProviders.push({
id,
path,
label: typeof label === 'string' ? label : undefined,
});
}
});
updatedConfig = {
...updatedConfig,
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
loginMethod: data.loginMethod || 'all',
};
console.log('[SetupWizard] Updated config with OAuth providers:', updatedConfig);
}
} catch (err) {
console.error('[SetupWizard] Failed to re-fetch OAuth providers:', err);
}
setServerConfig(updatedConfig);
setActiveStep(SetupStep.SelfHostedLogin);
}
};
@@ -54,6 +54,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
setLoading(true);
setError(null);
console.debug('[Auth] refreshSession: start', { path: window.location.pathname });
console.debug('[Auth] Refreshing session...');
const { data, error } = await springAuth.refreshSession();
@@ -70,6 +71,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.error('[Auth] Unexpected error during session refresh:', err);
setError(err as AuthError);
} finally {
console.debug('[Auth] refreshSession: done', { hasSession: !!session });
setLoading(false);
}
}, []);
@@ -109,6 +111,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const initializeAuth = async () => {
try {
console.debug(`[Auth:${mountId}] Initializing auth...`);
console.debug(`[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`);
// Clear any platform-specific cached auth on login page init.
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/login')) {
await clearPlatformAuthOnLoginInit();
@@ -137,6 +140,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setError(err as AuthError);
}
} finally {
console.debug(`[Auth:${mountId}] Initialize auth complete. mounted=${mounted}`);
if (mounted) {
setLoading(false);
}
@@ -1,4 +1,5 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import licenseService, { LicenseInfo } from '@app/services/licenseService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { getSimulatedLicenseInfo } from '@app/testing/serverExperienceSimulations';
@@ -18,6 +19,7 @@ interface LicenseProviderProps {
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
const { config } = useAppConfig();
const location = useLocation();
const configRef = useRef(config);
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [loading, setLoading] = useState<boolean>(true);
@@ -33,8 +35,8 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
let currentConfig = configRef.current;
if (!currentConfig) {
console.log('[LicenseContext] Config not loaded yet, waiting...');
// Wait up to 5 seconds for config to load
const maxWait = 5000;
// OPTIMIZATION: Reduced from 5s to 1s - config should load quickly
const maxWait = 1000;
const startTime = Date.now();
while (!configRef.current && Date.now() - startTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, 100));
@@ -82,10 +84,25 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
// Fetch license info when config changes (only if user is admin)
useEffect(() => {
// CRITICAL FIX: Skip license fetch on auth routes to prevent race conditions
// during SAML/OAuth callback processing. License isn't needed until user
// is authenticated and navigates to main app.
const isAuthRoute =
location.pathname === '/login' ||
location.pathname === '/signup' ||
location.pathname === '/auth/callback' ||
location.pathname.startsWith('/invite/');
if (isAuthRoute) {
console.log('[LicenseContext] On auth route, skipping license fetch');
setLoading(false);
return;
}
if (config) {
refetchLicense();
}
}, [config, refetchLicense]);
}, [config, refetchLicense, location.pathname]);
const contextValue: LicenseContextValue = useMemo(
() => ({
@@ -104,6 +121,10 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
);
};
export const useOptionalLicense = (): LicenseContextValue | undefined => {
return useContext(LicenseContext);
};
export const useLicense = (): LicenseContextValue => {
const context = useContext(LicenseContext);
if (!context) {
@@ -1,10 +1,11 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import licenseService, {} from '@app/services/licenseService';
import UpdateSeatsModal from '@app/components/shared/UpdateSeatsModal';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
import { useLicense } from '@app/contexts/LicenseContext';
import { useOptionalLicense } from '@app/contexts/LicenseContext';
import { resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
export interface UpdateSeatsOptions {
@@ -27,7 +28,9 @@ interface UpdateSeatsProviderProps {
export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ children }) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const location = useLocation();
// Use optional hook - won't throw during setup wizard when license provider isn't needed
const license = useOptionalLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentSeats, setCurrentSeats] = useState<number>(1);
@@ -36,6 +39,20 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
// Handle return from Stripe billing portal
useEffect(() => {
// CRITICAL FIX: Don't run billing check on auth routes to prevent race conditions
// during SAML/OAuth callback. This check only matters after successful billing
// portal redirects, which never happen on auth routes.
const isAuthRoute =
location.pathname === '/login' ||
location.pathname === '/signup' ||
location.pathname === '/auth/callback' ||
location.pathname.startsWith('/invite/');
if (isAuthRoute) {
console.log('[UpdateSeatsContext] On auth route, skipping billing return check');
return;
}
const handleBillingReturn = async () => {
const urlParams = new URLSearchParams(window.location.search);
const seatsUpdated = urlParams.get('seats_updated');
@@ -58,7 +75,7 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
console.log('License synced successfully after seat update');
// Refresh global license context
await refetchLicense();
await license?.refetchLicense();
// Get updated license info for notification
const updatedLicense = await licenseService.getLicenseInfo();
@@ -89,8 +106,12 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
}
};
handleBillingReturn();
}, [t, refetchLicense]);
// CRITICAL FIX: Properly handle async function and catch errors
handleBillingReturn().catch((error) => {
console.error('[UpdateSeatsContext] Error in billing return handler:', error);
// Don't throw - this is initialization, should not block rendering
});
}, [t, location.pathname, license]);
const openUpdateSeats = useCallback(async (options: UpdateSeatsOptions = {}) => {
try {
@@ -143,8 +164,8 @@ export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ childr
setCurrentOptions({});
// Refetch license after modal closes to update UI
refetchLicense();
}, [refetchLicense]);
license?.refetchLicense();
}, [license]);
const handleUpdateSeats = useCallback(
async (newSeatCount: number): Promise<string> => {
@@ -33,6 +33,7 @@ export default function AuthCallback() {
console.log(`[AuthCallback:${executionId}] Starting authentication callback`);
console.log(`[AuthCallback:${executionId}] URL: ${window.location.href}`);
console.log(`[AuthCallback:${executionId}] Hash: ${window.location.hash}`);
console.log(`[AuthCallback:${executionId}] Document readyState: ${document.readyState}`);
if (typeof window !== 'undefined' && window.sessionStorage.getItem('stirling_sso_auto_login_logged_out') === '1') {
console.warn(`[AuthCallback:${executionId}] ⚠️ Logout block active, skipping token processing`);
@@ -79,6 +80,7 @@ export default function AuthCallback() {
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
console.log(`[AuthCallback:${executionId}] ✓ Event dispatched`);
console.log(`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`);
console.log(`[AuthCallback:${executionId}] Step 4: Validating token with backend`);
// Validate the token and load user info
@@ -101,7 +103,14 @@ export default function AuthCallback() {
await handleAuthCallbackSuccess(token);
console.log(`[AuthCallback:${executionId}] ✓ Callback handlers complete`);
console.log(`[AuthCallback:${executionId}] Step 6: Navigating to home page`);
console.log(`[AuthCallback:${executionId}] Step 6: Waiting for context stabilization`);
// Wait for all context providers to process jwt-available event
// This prevents infinite render loop when coming from cross-domain SAML redirect
await new Promise(resolve => setTimeout(resolve, 100));
console.log(`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`);
console.log(`[AuthCallback:${executionId}] Step 7: Navigating to home page`);
// Clear the hash from URL and redirect to home page
navigate('/', { replace: true });
+7 -1
View File
@@ -29,10 +29,16 @@ export default function Landing() {
useEffect(() => {
const mountId = Math.random().toString(36).substring(7);
console.log(`[Landing:${mountId}] 🔵 Component mounted at ${location.pathname}`);
console.log(`[Landing:${mountId}] Mount state:`, {
authLoading,
configLoading,
backendLoading: backendProbe.loading,
hasSession: !!session,
});
return () => {
console.log(`[Landing:${mountId}] 🔴 Component unmounting`);
};
}, [location.pathname]);
}, [location.pathname, authLoading, configLoading, backendProbe.loading, session]);
// Periodically probe while backend isn't up so the screen can auto-advance when it comes online
useEffect(() => {
@@ -197,6 +197,11 @@ export default function Login() {
setLoginMethod(data.loginMethod || 'all');
} catch (err) {
console.error('[Login] Failed to fetch enabled providers:', err);
// Set default values on error to ensure UI remains functional
// Login method defaults to 'all' to show both SSO and email/password options
setEnableLogin(true);
setLoginMethod('all');
setEnabledProviders([]);
}
};
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.4.5',
appVersion: '2.4.6',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
View File
+2 -1
View File
@@ -233,7 +233,8 @@ Feature: API Validation
When I send the API request to the endpoint "/api/v1/convert/pdf/markdown"
Then the response status code should be 200
And the response file should have size greater than 100
And the response file should have extension ".md"
And the response file should have extension ".zip"
And the response ZIP should contain 4 files
@positive @pdftocsv