Merge branch 'main' into mfa_20260129

This commit is contained in:
Ludy
2026-01-30 17:28:59 +01:00
committed by GitHub
32 changed files with 1242 additions and 522 deletions
@@ -7,11 +7,13 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.imageio.ImageIO;
@@ -58,6 +60,13 @@ public class StampController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private static final int MAX_DATE_FORMAT_LENGTH = 50;
private static final Pattern SAFE_DATE_FORMAT_PATTERN =
Pattern.compile("^[yMdHhmsS/\\-:\\s.,'+EGuwWDFzZXa]+$");
private static final Pattern CUSTOM_DATE_PATTERN = Pattern.compile("@date\\{([^}]{1,50})\\}");
// Placeholder for escaped @ symbol (using Unicode private use area)
private static final String ESCAPED_AT_PLACEHOLDER = "\uE000ESCAPED_AT\uE000";
/**
* Initialize data binder for multipart file uploads. This method registers a custom editor for
* MultipartFile to handle file uploads. It sets the MultipartFile to null if the uploaded file
@@ -166,7 +175,9 @@ public class StampController {
overrideX,
overrideY,
margin,
customColor);
customColor,
pageIndex,
pdfFileName);
} else if ("image".equalsIgnoreCase(stampType)) {
addImageStamp(
contentStream,
@@ -201,9 +212,11 @@ public class StampController {
float fontSize,
String alphabet,
float overrideX, // X override
float overrideY,
float overrideY, // Y override
float margin,
String colorString) // Y override
String colorString,
int currentPageNumber,
String filename)
throws IOException {
String resourceDir;
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
@@ -231,8 +244,6 @@ public class StampController {
}
}
contentStream.setFont(font, fontSize);
Color redactColor;
try {
if (!colorString.startsWith("#")) {
@@ -240,47 +251,54 @@ public class StampController {
}
redactColor = Color.decode(colorString);
} catch (NumberFormatException e) {
redactColor = Color.LIGHT_GRAY;
}
contentStream.setNonStrokingColor(redactColor);
PDRectangle pageSize = page.getMediaBox();
float x, y;
if (overrideX >= 0 && overrideY >= 0) {
// Use override values if provided
x = overrideX;
y = overrideY;
} else {
x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
y =
calculatePositionY(
pageSize, position, calculateTextCapHeight(font, fontSize), margin);
}
String currentDate = LocalDate.now().toString();
String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
int pageCount = document.getNumberOfPages();
String processedStampText =
stampText
.replace("@date", currentDate)
.replace("@time", currentTime)
.replace("@page_count", String.valueOf(pageCount));
processStampText(stampText, currentPageNumber, pageCount, filename, document);
// Split the stampText into multiple lines
String[] lines =
String normalizedText =
RegexPatternUtils.getInstance()
.getEscapedNewlinePattern()
.split(processedStampText);
.matcher(processedStampText)
.replaceAll("\n");
String[] lines = normalizedText.split("\\r?\\n");
PDRectangle pageSize = page.getMediaBox();
// Use fontSize directly (default 40 if not specified)
float effectiveFontSize = fontSize > 0 ? fontSize : 40f;
contentStream.setFont(font, effectiveFontSize);
// Calculate dynamic line height based on font ascent and descent
float ascent = font.getFontDescriptor().getAscent();
float descent = font.getFontDescriptor().getDescent();
float lineHeight = ((ascent - descent) / 1000) * fontSize;
float lineHeight = ((ascent - descent) / 1000) * effectiveFontSize;
float maxLineWidth = 0;
for (String line : lines) {
float lineWidth = calculateTextWidth(line, font, effectiveFontSize);
if (lineWidth > maxLineWidth) {
maxLineWidth = lineWidth;
}
}
float totalTextHeight = lines.length * lineHeight;
float x, y;
if (overrideX >= 0 && overrideY >= 0) {
x = overrideX;
y = overrideY;
} else {
x = calculatePositionX(pageSize, position, maxLineWidth, margin);
y = calculatePositionY(pageSize, position, totalTextHeight, margin);
}
contentStream.beginText();
for (int i = 0; i < lines.length; i++) {
@@ -293,6 +311,140 @@ public class StampController {
contentStream.endText();
}
/**
* Process stamp text by replacing all @commands with their actual values. Supported commands:
*
* <p>Date & Time:
*
* <ul>
* <li>@date - Current date (YYYY-MM-DD)
* <li>@time - Current time (HH:mm:ss)
* <li>@datetime - Current date and time (YYYY-MM-DD HH:mm:ss)
* <li>@date{format} - Custom date/time format (e.g., @date{dd/MM/yyyy})
* <li>@year - Current year (4 digits)
* <li>@month - Current month (01-12)
* <li>@day - Current day of month (01-31)
* </ul>
*
* <p>Page Information:
*
* <ul>
* <li>@page_number or @page - Current page number
* <li>@total_pages or @page_count - Total number of pages
* </ul>
*
* <p>File Information:
*
* <ul>
* <li>@filename - Original filename (without extension)
* <li>@filename_full - Original filename (with extension)
* </ul>
*
* <p>Document Metadata:
*
* <ul>
* <li>@author - Document author (from PDF metadata)
* <li>@title - Document title (from PDF metadata)
* <li>@subject - Document subject (from PDF metadata)
* </ul>
*
* <p>Other:
*
* <ul>
* <li>@uuid - Short unique identifier (8 characters)
* </ul>
*/
private String processStampText(
String stampText,
int currentPageNumber,
int totalPages,
String filename,
PDDocument document) {
if (stampText == null || stampText.isEmpty()) {
return "";
}
// Handle escaped @@ sequences first - replace with placeholder to preserve literal @
String result = stampText.replace("@@", ESCAPED_AT_PLACEHOLDER);
LocalDateTime now = LocalDateTime.now();
String currentDate = now.toLocalDate().toString();
String currentTime = now.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
String currentDateTime = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
String filenameWithoutExt = filename != null ? filename : "";
if (filename != null && filename.contains(".")) {
int lastDot = filename.lastIndexOf('.');
if (lastDot > 0) { // Ensure there's actually a name before the dot
filenameWithoutExt = filename.substring(0, lastDot);
}
}
String author = "";
String title = "";
String subject = "";
if (document != null && document.getDocumentInformation() != null) {
var info = document.getDocumentInformation();
author = info.getAuthor() != null ? info.getAuthor() : "";
title = info.getTitle() != null ? info.getTitle() : "";
subject = info.getSubject() != null ? info.getSubject() : "";
}
String uuid = UUID.randomUUID().toString().substring(0, 8);
// Process @date{format} with custom format first (must be before simple @date)
Matcher matcher = CUSTOM_DATE_PATTERN.matcher(result);
StringBuilder sb = new StringBuilder();
while (matcher.find()) {
String format = matcher.group(1);
String replacement = processCustomDateFormat(format, now);
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
}
matcher.appendTail(sb);
result = sb.toString();
result =
result.replace("@datetime", currentDateTime)
.replace("@date", currentDate)
.replace("@time", currentTime)
.replace("@year", String.valueOf(now.getYear()))
.replace("@month", String.format("%02d", now.getMonthValue()))
.replace("@day", String.format("%02d", now.getDayOfMonth()))
.replace("@page_number", String.valueOf(currentPageNumber))
.replace(
"@page_count", String.valueOf(totalPages)) // Must come before @page
.replace("@total_pages", String.valueOf(totalPages))
.replace(
"@page",
String.valueOf(currentPageNumber)) // Must come after @page_count
.replace("@filename_full", filename != null ? filename : "")
.replace("@filename", filenameWithoutExt)
.replace("@author", author)
.replace("@title", title)
.replace("@subject", subject)
.replace("@uuid", uuid);
result = result.replace(ESCAPED_AT_PLACEHOLDER, "@");
return result;
}
private String processCustomDateFormat(String format, LocalDateTime now) {
if (format == null || format.length() > MAX_DATE_FORMAT_LENGTH) {
return "[invalid format: too long]";
}
if (!SAFE_DATE_FORMAT_PATTERN.matcher(format).matches()) {
return "[invalid format]";
}
try {
return now.format(DateTimeFormatter.ofPattern(format));
} catch (IllegalArgumentException e) {
return "[invalid format: " + format + "]";
}
}
private void addImageStamp(
PDPageContentStream contentStream,
MultipartFile stampImage,
@@ -329,8 +481,8 @@ public class StampController {
x = overrideX;
y = overrideY;
} else {
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, null, 0, null, margin);
y = calculatePositionY(pageSize, position, fontSize, margin);
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, margin);
y = calculatePositionY(pageSize, position, desiredPhysicalHeight, margin);
}
contentStream.saveGraphicsState();
@@ -341,23 +493,14 @@ public class StampController {
}
private float calculatePositionX(
PDRectangle pageSize,
int position,
float contentWidth,
PDFont font,
float fontSize,
String text,
float margin)
throws IOException {
float actualWidth =
(text != null) ? calculateTextWidth(text, font, fontSize) : contentWidth;
PDRectangle pageSize, int position, float contentWidth, float margin) {
return switch (position % 3) {
case 1: // Left
yield pageSize.getLowerLeftX() + margin;
case 2: // Center
yield (pageSize.getWidth() - actualWidth) / 2;
yield (pageSize.getWidth() - contentWidth) / 2;
case 0: // Right
yield pageSize.getUpperRightX() - actualWidth - margin;
yield pageSize.getUpperRightX() - contentWidth - margin;
default:
yield 0;
};
@@ -366,12 +509,12 @@ public class StampController {
private float calculatePositionY(
PDRectangle pageSize, int position, float height, float margin) {
return switch ((position - 1) / 3) {
case 0: // Top
yield pageSize.getUpperRightY() - height - margin;
case 1: // Middle
yield (pageSize.getHeight() - height) / 2;
case 2: // Bottom
yield pageSize.getLowerLeftY() + margin;
case 0: // Top - first line near the top
yield pageSize.getUpperRightY() - margin;
case 1: // Middle - center of text block at page center
yield (pageSize.getHeight() + height) / 2;
case 2: // Bottom - first line positioned so last line is at bottom margin
yield pageSize.getLowerLeftY() + margin + height;
default:
yield 0;
};
@@ -380,8 +523,4 @@ public class StampController {
private float calculateTextWidth(String text, PDFont font, float fontSize) throws IOException {
return font.getStringWidth(text) / 1000 * fontSize;
}
private float calculateTextCapHeight(PDFont font, float fontSize) {
return font.getFontDescriptor().getCapHeight() / 1000 * fontSize;
}
}
@@ -32,8 +32,8 @@ public class AddStampRequest extends PDFWithPageNums {
private String alphabet = "roman";
@Schema(
description = "The font size of the stamp text and image",
defaultValue = "30",
description = "The font size of the stamp text and image in points.",
defaultValue = "40",
requiredMode = Schema.RequiredMode.REQUIRED)
private float fontSize;
@@ -0,0 +1,567 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class StampControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private StampController stampController;
private Method processStampTextMethod;
private Method processCustomDateFormatMethod;
@BeforeEach
void setUp() throws NoSuchMethodException {
processStampTextMethod =
StampController.class.getDeclaredMethod(
"processStampText",
String.class,
int.class,
int.class,
String.class,
PDDocument.class);
processStampTextMethod.setAccessible(true);
processCustomDateFormatMethod =
StampController.class.getDeclaredMethod(
"processCustomDateFormat", String.class, LocalDateTime.class);
processCustomDateFormatMethod.setAccessible(true);
}
private String invokeProcessStampText(
String stampText, int pageNumber, int totalPages, String filename, PDDocument document)
throws Exception {
try {
return (String)
processStampTextMethod.invoke(
stampController, stampText, pageNumber, totalPages, filename, document);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private String invokeProcessCustomDateFormat(String format, LocalDateTime now)
throws Exception {
try {
return (String) processCustomDateFormatMethod.invoke(stampController, format, now);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
@Nested
@DisplayName("Basic Variable Substitution Tests")
class BasicVariableTests {
@Test
@DisplayName("Should replace @page_number with current page")
void testPageNumberReplacement() throws Exception {
String result = invokeProcessStampText("Page @page_number", 5, 20, "test.pdf", null);
assertEquals("Page 5", result);
}
@Test
@DisplayName("Should replace @total_pages with total page count")
void testTotalPagesReplacement() throws Exception {
String result =
invokeProcessStampText("of @total_pages pages", 1, 100, "test.pdf", null);
assertEquals("of 100 pages", result);
}
@Test
@DisplayName("Should replace combined page variables")
void testCombinedPageVariables() throws Exception {
String result =
invokeProcessStampText(
"Page @page_number of @total_pages", 5, 20, "test.pdf", null);
assertEquals("Page 5 of 20", result);
}
@Test
@DisplayName("Should replace @page alias")
void testPageAlias() throws Exception {
String result = invokeProcessStampText("Page @page", 7, 10, "test.pdf", null);
assertEquals("Page 7", result);
}
@Test
@DisplayName("Should replace @page_count alias for total pages")
void testPageCountAlias() throws Exception {
String result = invokeProcessStampText("Total: @page_count", 1, 50, "test.pdf", null);
assertEquals("Total: 50", result);
}
}
@Nested
@DisplayName("Filename Variable Tests")
class FilenameTests {
@Test
@DisplayName("Should replace @filename with filename without extension")
void testFilenameWithoutExtension() throws Exception {
String result = invokeProcessStampText("File: @filename", 1, 1, "document.pdf", null);
assertEquals("File: document", result);
}
@Test
@DisplayName("Should replace @filename_full with full filename")
void testFilenameWithExtension() throws Exception {
String result =
invokeProcessStampText("File: @filename_full", 1, 1, "document.pdf", null);
assertEquals("File: document.pdf", result);
}
@Test
@DisplayName("Should handle filename without extension")
void testFilenameWithoutDot() throws Exception {
String result = invokeProcessStampText("@filename", 1, 1, "document", null);
assertEquals("document", result);
}
@Test
@DisplayName("Should handle null filename")
void testNullFilename() throws Exception {
String result = invokeProcessStampText("File: @filename", 1, 1, null, null);
assertEquals("File: ", result);
}
@Test
@DisplayName("Should handle filename with multiple dots")
void testFilenameMultipleDots() throws Exception {
String result = invokeProcessStampText("@filename", 1, 1, "my.document.v2.pdf", null);
assertEquals("my.document.v2", result);
}
@Test
@DisplayName("Should handle hidden file (starts with dot)")
void testHiddenFile() throws Exception {
String result = invokeProcessStampText("@filename", 1, 1, ".hidden.pdf", null);
assertEquals(".hidden", result);
}
}
@Nested
@DisplayName("Date/Time Variable Tests")
class DateTimeTests {
@Test
@DisplayName("Should replace @date with current date")
void testDateReplacement() throws Exception {
String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
assertTrue(
result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
"Date should match YYYY-MM-DD format");
}
@Test
@DisplayName("Should replace @time with current time")
void testTimeReplacement() throws Exception {
String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
assertTrue(
result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
"Time should match HH:mm:ss format");
}
@Test
@DisplayName("Should replace @datetime with combined date and time")
void testDateTimeReplacement() throws Exception {
String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
// DateTime format: YYYY-MM-DD HH:mm:ss
assertTrue(
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
"DateTime should match YYYY-MM-DD HH:mm:ss format");
}
@Test
@DisplayName("Should replace @year with current year")
void testYearReplacement() throws Exception {
String result = invokeProcessStampText("© @year", 1, 1, "test.pdf", null);
int currentYear = LocalDateTime.now().getYear();
assertEquals("© " + currentYear, result);
}
@Test
@DisplayName("Should replace @month with zero-padded month")
void testMonthReplacement() throws Exception {
String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
}
@Test
@DisplayName("Should replace @day with zero-padded day")
void testDayReplacement() throws Exception {
String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
}
}
@Nested
@DisplayName("Custom Date Format Tests")
class CustomDateFormatTests {
@Test
@DisplayName("Should handle custom date format dd/MM/yyyy")
void testCustomDateFormatSlash() throws Exception {
String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
assertTrue(
result.matches("\\d{2}/\\d{2}/\\d{4}"),
"Should match dd/MM/yyyy format: " + result);
}
@Test
@DisplayName("Should handle custom date format with time")
void testCustomDateFormatWithTime() throws Exception {
String result =
invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
assertTrue(
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
"Should match yyyy-MM-dd HH:mm format: " + result);
}
@Test
@DisplayName("Should handle multiple custom date formats in same text")
void testMultipleCustomDateFormats() throws Exception {
String result =
invokeProcessStampText(
"Start: @date{dd/MM/yyyy} End: @date{yyyy}", 1, 1, "test.pdf", null);
assertTrue(result.contains("/"), "Should contain slash from first format");
// Should have year twice (once with slashes, once alone)
}
}
@Nested
@DisplayName("Custom Date Format Security Tests")
class CustomDateFormatSecurityTests {
@Test
@DisplayName("Should not match format that is too long - regex won't capture it")
void testFormatTooLong() throws Exception {
String longFormat = "y".repeat(51); // 51 chars, over the 50 char regex limit
String result =
invokeProcessStampText("@date{" + longFormat + "}", 1, 1, "test.pdf", null);
// The CUSTOM_DATE_PATTERN only captures up to 50 chars, so this won't match
// The @date part will be replaced by simple replacement, leaving {yyy...}
assertTrue(
result.contains("{"), "Should contain { because regex didn't match: " + result);
}
@Test
@DisplayName("Should reject format with unsafe characters - shell injection attempt")
void testShellInjectionAttempt() throws Exception {
String result =
invokeProcessStampText("@date{yyyy-MM-dd$(rm -rf /)}", 1, 1, "test.pdf", null);
assertEquals("[invalid format]", result);
}
@Test
@DisplayName("Should reject format with unsafe characters - semicolon")
void testSemicolonInjection() throws Exception {
String result = invokeProcessStampText("@date{yyyy;rm}", 1, 1, "test.pdf", null);
assertEquals("[invalid format]", result);
}
@Test
@DisplayName("Should reject format with unsafe characters - backticks")
void testBacktickInjection() throws Exception {
String result = invokeProcessStampText("@date{`whoami`}", 1, 1, "test.pdf", null);
assertEquals("[invalid format]", result);
}
@ParameterizedTest
@ValueSource(strings = {"$(cmd)", "`cmd`", ";cmd", "|cmd", "&cmd", "<cmd", ">cmd"})
@DisplayName("Should reject various injection attempts")
void testVariousInjectionAttempts(String injection) throws Exception {
String result =
invokeProcessStampText("@date{yyyy" + injection + "}", 1, 1, "test.pdf", null);
assertEquals("[invalid format]", result);
}
@Test
@DisplayName("Should accept valid format characters")
void testValidFormatCharacters() throws Exception {
// All these should be valid based on SAFE_DATE_FORMAT_PATTERN: yMdHhmsS/-:.,
// '+EGuwWDFzZXa and space
String result =
invokeProcessStampText("@date{yyyy-MM-dd HH:mm:ss}", 1, 1, "test.pdf", null);
assertFalse(
result.startsWith("[invalid"), "Valid format should be accepted: " + result);
}
@Test
@DisplayName("Should handle invalid DateTimeFormatter pattern gracefully")
void testInvalidFormatterPattern() throws Exception {
LocalDateTime now = LocalDateTime.now();
// Use 'sssss' - too many seconds digits will throw IllegalArgumentException from
// DateTimeFormatter
// Note: The pattern 'sssss' passes the SAFE_DATE_FORMAT_PATTERN but fails
// DateTimeFormatter.ofPattern()
String result = invokeProcessCustomDateFormat("sssss", now);
assertTrue(
result.startsWith("[invalid format:"),
"Invalid pattern should return error message: " + result);
}
}
@Nested
@DisplayName("Escape Sequence Tests")
class EscapeSequenceTests {
@Test
@DisplayName("Should convert @@ to literal @")
void testDoubleAtEscape() throws Exception {
String result =
invokeProcessStampText("Email: test@@example.com", 1, 1, "test.pdf", null);
assertEquals("Email: test@example.com", result);
}
@Test
@DisplayName("Should preserve @@ before variable")
void testEscapeBeforeVariable() throws Exception {
String result = invokeProcessStampText("@@date is @date", 1, 1, "test.pdf", null);
// @@date should become @date, and @date should be replaced with actual date
assertTrue(result.startsWith("@date is "), "Should start with literal @date");
assertTrue(
result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
"Should have date after: " + result);
}
@Test
@DisplayName("Should handle multiple escape sequences")
void testMultipleEscapes() throws Exception {
String result = invokeProcessStampText("@@one @@two @@three", 1, 1, "test.pdf", null);
assertEquals("@one @two @three", result);
}
@Test
@DisplayName("Should handle escape at end of string")
void testEscapeAtEnd() throws Exception {
String result = invokeProcessStampText("Contact: user@@", 1, 1, "test.pdf", null);
assertEquals("Contact: user@", result);
}
}
@Nested
@DisplayName("Document Metadata Tests")
class DocumentMetadataTests {
@Test
@DisplayName("Should replace @author with document author")
void testAuthorReplacement() throws Exception {
PDDocument doc = new PDDocument();
PDDocumentInformation info = new PDDocumentInformation();
info.setAuthor("John Doe");
doc.setDocumentInformation(info);
try {
String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", doc);
assertEquals("Author: John Doe", result);
} finally {
doc.close();
}
}
@Test
@DisplayName("Should replace @title with document title")
void testTitleReplacement() throws Exception {
PDDocument doc = new PDDocument();
PDDocumentInformation info = new PDDocumentInformation();
info.setTitle("My Document Title");
doc.setDocumentInformation(info);
try {
String result = invokeProcessStampText("Title: @title", 1, 1, "test.pdf", doc);
assertEquals("Title: My Document Title", result);
} finally {
doc.close();
}
}
@Test
@DisplayName("Should replace @subject with document subject")
void testSubjectReplacement() throws Exception {
PDDocument doc = new PDDocument();
PDDocumentInformation info = new PDDocumentInformation();
info.setSubject("Important Subject");
doc.setDocumentInformation(info);
try {
String result = invokeProcessStampText("Subject: @subject", 1, 1, "test.pdf", doc);
assertEquals("Subject: Important Subject", result);
} finally {
doc.close();
}
}
@Test
@DisplayName("Should handle null metadata gracefully")
void testNullMetadata() throws Exception {
PDDocument doc = new PDDocument();
// Don't set any document information
try {
String result =
invokeProcessStampText("@author @title @subject", 1, 1, "test.pdf", doc);
assertEquals(" ", result); // All should be empty strings
} finally {
doc.close();
}
}
@Test
@DisplayName("Should handle null document gracefully")
void testNullDocument() throws Exception {
String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", null);
assertEquals("Author: ", result);
}
}
@Nested
@DisplayName("UUID Variable Tests")
class UuidTests {
@Test
@DisplayName("Should generate 8-character UUID")
void testUuidLength() throws Exception {
String result = invokeProcessStampText("ID: @uuid", 1, 1, "test.pdf", null);
// UUID format: "ID: " + 8 chars
assertEquals(12, result.length(), "Should be 'ID: ' + 8 char UUID");
}
@Test
@DisplayName("Should generate different UUIDs for each call")
void testUuidUniqueness() throws Exception {
String result1 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
String result2 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
assertNotEquals(result1, result2, "UUIDs should be unique");
}
@Test
@DisplayName("UUID should contain only hex characters")
void testUuidFormat() throws Exception {
String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
}
}
@Nested
@DisplayName("Edge Cases and Error Handling")
class EdgeCaseTests {
@Test
@DisplayName("Should handle null stamp text")
void testNullStampText() throws Exception {
String result = invokeProcessStampText(null, 1, 1, "test.pdf", null);
assertEquals("", result);
}
@Test
@DisplayName("Should handle empty stamp text")
void testEmptyStampText() throws Exception {
String result = invokeProcessStampText("", 1, 1, "test.pdf", null);
assertEquals("", result);
}
@Test
@DisplayName("Should handle text with no variables")
void testNoVariables() throws Exception {
String result = invokeProcessStampText("Just plain text", 1, 1, "test.pdf", null);
assertEquals("Just plain text", result);
}
@Test
@DisplayName("Should handle unknown variables")
void testUnknownVariable() throws Exception {
String result = invokeProcessStampText("@unknown_var", 1, 1, "test.pdf", null);
assertEquals("@unknown_var", result);
}
@Test
@DisplayName("Should preserve text around variables")
void testPreservesSurroundingText() throws Exception {
String result =
invokeProcessStampText("Before @page_number After", 5, 10, "test.pdf", null);
assertEquals("Before 5 After", result);
}
@Test
@DisplayName("Should handle multiple same variables")
void testMultipleSameVariables() throws Exception {
String result =
invokeProcessStampText("@page_number / @page_number", 3, 10, "test.pdf", null);
assertEquals("3 / 3", result);
}
@Test
@DisplayName("Should handle variables adjacent to each other")
void testAdjacentVariables() throws Exception {
String result = invokeProcessStampText("@page@page_number", 5, 10, "test.pdf", null);
// @page should be replaced first (it's in the order), then @page_number
// Since @page_number is longer and comes first in replace chain, should work
assertEquals("55", result);
}
}
@Nested
@DisplayName("Complex Scenario Tests")
class ComplexScenarioTests {
@Test
@DisplayName("Should handle legal footer template")
void testLegalFooterTemplate() throws Exception {
String template = "© @year - All Rights Reserved\\n@filename - Page @page_number";
String result = invokeProcessStampText(template, 3, 15, "contract.pdf", null);
int year = LocalDateTime.now().getYear();
String expected = "© " + year + " - All Rights Reserved\\ncontract - Page 3";
assertEquals(expected, result);
}
@Test
@DisplayName("Should handle Brazilian date format template")
void testBrazilianDateFormat() throws Exception {
String template = "Documento criado em @date{dd/MM/yyyy} às @time";
String result = invokeProcessStampText(template, 1, 1, "doc.pdf", null);
assertTrue(result.startsWith("Documento criado em "));
assertTrue(result.contains("/"));
assertTrue(result.contains(":"));
}
@ParameterizedTest
@CsvSource({
"'Page @page_number of @total_pages', 1, 10, 'Page 1 of 10'",
"'Page @page_number of @total_pages', 5, 20, 'Page 5 of 20'",
"'Page @page_number of @total_pages', 100, 1000, 'Page 100 of 1000'"
})
@DisplayName("Should handle page number template with various values")
void testPageNumberTemplates(String template, int page, int total, String expected)
throws Exception {
String result = invokeProcessStampText(template, page, total, "test.pdf", null);
assertEquals(expected, result);
}
}
}
@@ -8,6 +8,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.annotation.Order;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@@ -185,11 +186,39 @@ public class SecurityConfiguration {
}
@Bean
@Order(1)
public SecurityFilterChain samlFilterChain(
HttpSecurity http,
@Lazy IPRateLimitingFilter rateLimitingFilter,
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter)
throws Exception {
http.securityMatcher("/saml2/**", "/login/saml2/**");
SessionCreationPolicy sessionPolicy =
(securityProperties.isSaml2Active() && runningProOrHigher)
? SessionCreationPolicy.IF_REQUIRED
: SessionCreationPolicy.STATELESS;
return configureSecurity(http, rateLimitingFilter, jwtAuthenticationFilter, sessionPolicy);
}
@Bean
@Order(2)
public SecurityFilterChain filterChain(
HttpSecurity http,
@Lazy IPRateLimitingFilter rateLimitingFilter,
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter)
throws Exception {
SessionCreationPolicy sessionPolicy = SessionCreationPolicy.STATELESS;
return configureSecurity(http, rateLimitingFilter, jwtAuthenticationFilter, sessionPolicy);
}
private SecurityFilterChain configureSecurity(
HttpSecurity http,
@Lazy IPRateLimitingFilter rateLimitingFilter,
@Lazy JwtAuthenticationFilter jwtAuthenticationFilter,
SessionCreationPolicy sessionPolicy)
throws Exception {
// Enable CORS only if we have configured origins
CorsConfigurationSource corsSource = corsConfigurationSource();
if (corsSource != null) {
@@ -233,9 +262,7 @@ public class SecurityConfiguration {
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
http.sessionManagement(
sessionManagement ->
sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS));
sessionManagement -> sessionManagement.sessionCreationPolicy(sessionPolicy));
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
@@ -1,135 +0,0 @@
package stirling.software.proprietary.security.saml2;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Slf4j
public class JwtSaml2AuthenticationRequestRepository
implements Saml2AuthenticationRequestRepository<Saml2PostAuthenticationRequest> {
private final Map<String, String> tokenStore;
private final JwtServiceInterface jwtService;
private final RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;
private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token";
public JwtSaml2AuthenticationRequestRepository(
Map<String, String> tokenStore,
JwtServiceInterface jwtService,
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
this.tokenStore = tokenStore;
this.jwtService = jwtService;
this.relyingPartyRegistrationRepository = relyingPartyRegistrationRepository;
}
@Override
public void saveAuthenticationRequest(
Saml2PostAuthenticationRequest authRequest,
HttpServletRequest request,
HttpServletResponse response) {
if (!jwtService.isJwtEnabled()) {
log.debug("V2 is not enabled, skipping SAMLRequest token storage");
return;
}
if (authRequest == null) {
removeAuthenticationRequest(request, response);
return;
}
Map<String, Object> claims = serializeSamlRequest(authRequest);
String token = jwtService.generateToken("", claims);
String relayState = authRequest.getRelayState();
tokenStore.put(relayState, token);
request.setAttribute(SAML_REQUEST_TOKEN, relayState);
response.addHeader(SAML_REQUEST_TOKEN, relayState);
log.debug("Saved SAMLRequest token with RelayState: {}", relayState);
}
@Override
public Saml2PostAuthenticationRequest loadAuthenticationRequest(HttpServletRequest request) {
String token = extractTokenFromStore(request);
if (token == null) {
log.debug("No SAMLResponse token found in RelayState");
return null;
}
Map<String, Object> claims = jwtService.extractClaims(token);
return deserializeSamlRequest(claims);
}
@Override
public Saml2PostAuthenticationRequest removeAuthenticationRequest(
HttpServletRequest request, HttpServletResponse response) {
Saml2PostAuthenticationRequest authRequest = loadAuthenticationRequest(request);
String relayStateId = request.getParameter("RelayState");
if (relayStateId != null) {
tokenStore.remove(relayStateId);
log.debug("Removed SAMLRequest token for RelayState ID: {}", relayStateId);
}
return authRequest;
}
private String extractTokenFromStore(HttpServletRequest request) {
String authnRequestId = request.getParameter("RelayState");
if (authnRequestId != null && !authnRequestId.isEmpty()) {
String token = tokenStore.get(authnRequestId);
if (token != null) {
tokenStore.remove(authnRequestId);
log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
return token;
} else {
log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId);
}
}
return null;
}
private Map<String, Object> serializeSamlRequest(Saml2PostAuthenticationRequest authRequest) {
Map<String, Object> claims = new HashMap<>();
claims.put("id", authRequest.getId());
claims.put("relyingPartyRegistrationId", authRequest.getRelyingPartyRegistrationId());
claims.put("authenticationRequestUri", authRequest.getAuthenticationRequestUri());
claims.put("samlRequest", authRequest.getSamlRequest());
claims.put("relayState", authRequest.getRelayState());
return claims;
}
private Saml2PostAuthenticationRequest deserializeSamlRequest(Map<String, Object> claims) {
String relyingPartyRegistrationId = (String) claims.get("relyingPartyRegistrationId");
RelyingPartyRegistration relyingPartyRegistration =
relyingPartyRegistrationRepository.findByRegistrationId(relyingPartyRegistrationId);
if (relyingPartyRegistration == null) {
return null;
}
return Saml2PostAuthenticationRequest.withRelyingPartyRegistration(relyingPartyRegistration)
.id((String) claims.get("id"))
.authenticationRequestUri((String) claims.get("authenticationRequestUri"))
.samlRequest((String) claims.get("samlRequest"))
.relayState((String) claims.get("relayState"))
.build();
}
}
@@ -3,7 +3,6 @@ package stirling.software.proprietary.security.saml2;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -12,12 +11,10 @@ import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.Resource;
import org.springframework.security.saml2.core.Saml2X509Credential;
import org.springframework.security.saml2.core.Saml2X509Credential.Saml2X509CredentialType;
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
import org.springframework.security.saml2.provider.service.registration.InMemoryRelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import jakarta.servlet.http.HttpServletRequest;
@@ -27,7 +24,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Configuration
@Slf4j
@@ -153,22 +149,10 @@ public class Saml2Configuration {
return new InMemoryRelyingPartyRegistrationRepository(rp);
}
@Bean
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public Saml2AuthenticationRequestRepository<Saml2PostAuthenticationRequest>
saml2AuthenticationRequestRepository(
JwtServiceInterface jwtService,
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
return new JwtSaml2AuthenticationRequestRepository(
new ConcurrentHashMap<>(), jwtService, relyingPartyRegistrationRepository);
}
@Bean
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public OpenSaml4AuthenticationRequestResolver authenticationRequestResolver(
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository,
Saml2AuthenticationRequestRepository<Saml2PostAuthenticationRequest>
saml2AuthenticationRequestRepository) {
RelyingPartyRegistrationRepository relyingPartyRegistrationRepository) {
OpenSaml4AuthenticationRequestResolver resolver =
new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository);
@@ -176,30 +160,10 @@ public class Saml2Configuration {
customizer -> {
HttpServletRequest request = customizer.getRequest();
AuthnRequest authnRequest = customizer.getAuthnRequest();
Saml2PostAuthenticationRequest saml2AuthenticationRequest =
saml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
if (saml2AuthenticationRequest != null) {
String sessionId = request.getSession(false).getId();
// Generate a unique AuthnRequest ID for each SAML request
authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1));
log.debug(
"Retrieving SAML 2 authentication request ID from the current HTTP session {}",
sessionId);
String authenticationRequestId = saml2AuthenticationRequest.getId();
if (!authenticationRequestId.isBlank()) {
authnRequest.setID(authenticationRequestId);
} else {
log.warn(
"No authentication request found for HTTP session {}. Generating new ID",
sessionId);
authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1));
}
} else {
log.debug("Generating new authentication request ID");
authnRequest.setID("ARQ" + UUID.randomUUID().toString().substring(1));
}
logAuthnRequestDetails(authnRequest);
logHttpRequestDetails(request);
});
@@ -67,6 +67,7 @@ public class UserService implements UserServiceInterface {
private final ApplicationProperties.Security.OAUTH2 oAuth2;
@Transactional
public void processSSOPostLogin(
String username,
String ssoProviderId,
@@ -1,243 +0,0 @@
package stirling.software.proprietary.security.saml2;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.saml2.provider.service.authentication.Saml2PostAuthenticationRequest;
import org.springframework.security.saml2.provider.service.registration.AssertingPartyMetadata;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistration;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@ExtendWith(MockitoExtension.class)
class JwtSaml2AuthenticationRequestRepositoryTest {
private static final String SAML_REQUEST_TOKEN = "stirling_saml_request_token";
private Map<String, String> tokenStore;
@Mock private JwtServiceInterface jwtService;
@Mock private RelyingPartyRegistrationRepository relyingPartyRegistrationRepository;
private JwtSaml2AuthenticationRequestRepository jwtSaml2AuthenticationRequestRepository;
@BeforeEach
void setUp() {
tokenStore = new ConcurrentHashMap<>();
jwtSaml2AuthenticationRequestRepository =
new JwtSaml2AuthenticationRequestRepository(
tokenStore, jwtService, relyingPartyRegistrationRepository);
}
@Test
void saveAuthenticationRequest() {
var authRequest = mock(Saml2PostAuthenticationRequest.class);
var request = mock(MockHttpServletRequest.class);
var response = mock(MockHttpServletResponse.class);
String token = "testToken";
String id = "testId";
String relayState = "testRelayState";
String authnRequestUri = "example.com/authnRequest";
String samlRequest = "testSamlRequest";
String relyingPartyRegistrationId = "stirling-pdf";
when(jwtService.isJwtEnabled()).thenReturn(true);
when(authRequest.getRelayState()).thenReturn(relayState);
when(authRequest.getId()).thenReturn(id);
when(authRequest.getAuthenticationRequestUri()).thenReturn(authnRequestUri);
when(authRequest.getSamlRequest()).thenReturn(samlRequest);
when(authRequest.getRelyingPartyRegistrationId()).thenReturn(relyingPartyRegistrationId);
when(jwtService.generateToken(eq(""), anyMap())).thenReturn(token);
jwtSaml2AuthenticationRequestRepository.saveAuthenticationRequest(
authRequest, request, response);
verify(request).setAttribute(SAML_REQUEST_TOKEN, relayState);
verify(response).addHeader(SAML_REQUEST_TOKEN, relayState);
}
@Test
void saveAuthenticationRequestWithNullRequest() {
var request = mock(MockHttpServletRequest.class);
var response = mock(MockHttpServletResponse.class);
jwtSaml2AuthenticationRequestRepository.saveAuthenticationRequest(null, request, response);
assertTrue(tokenStore.isEmpty());
}
@Test
void loadAuthenticationRequest() {
var request = mock(MockHttpServletRequest.class);
var relyingPartyRegistration = mock(RelyingPartyRegistration.class);
var assertingPartyMetadata = mock(AssertingPartyMetadata.class);
String relayState = "testRelayState";
String token = "testToken";
Map<String, Object> claims =
Map.of(
"id", "testId",
"relyingPartyRegistrationId", "stirling-pdf",
"authenticationRequestUri", "example.com/authnRequest",
"samlRequest", "testSamlRequest",
"relayState", relayState);
when(request.getParameter("RelayState")).thenReturn(relayState);
when(jwtService.extractClaims(token)).thenReturn(claims);
when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf"))
.thenReturn(relyingPartyRegistration);
when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf");
when(relyingPartyRegistration.getAssertingPartyMetadata())
.thenReturn(assertingPartyMetadata);
when(assertingPartyMetadata.getSingleSignOnServiceLocation())
.thenReturn("https://example.com/sso");
tokenStore.put(relayState, token);
var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
assertNotNull(result);
assertFalse(tokenStore.containsKey(relayState));
}
@ParameterizedTest
@NullAndEmptySource
void loadAuthenticationRequestWithInvalidRelayState(String relayState) {
var request = mock(MockHttpServletRequest.class);
when(request.getParameter("RelayState")).thenReturn(relayState);
var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
assertNull(result);
}
@Test
void loadAuthenticationRequestWithNonExistentToken() {
var request = mock(MockHttpServletRequest.class);
when(request.getParameter("RelayState")).thenReturn("nonExistentRelayState");
var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
assertNull(result);
}
@Test
void loadAuthenticationRequestWithNullRelyingPartyRegistration() {
var request = mock(MockHttpServletRequest.class);
String relayState = "testRelayState";
String token = "testToken";
Map<String, Object> claims =
Map.of(
"id", "testId",
"relyingPartyRegistrationId", "stirling-pdf",
"authenticationRequestUri", "example.com/authnRequest",
"samlRequest", "testSamlRequest",
"relayState", relayState);
when(request.getParameter("RelayState")).thenReturn(relayState);
when(jwtService.extractClaims(token)).thenReturn(claims);
when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf"))
.thenReturn(null);
tokenStore.put(relayState, token);
var result = jwtSaml2AuthenticationRequestRepository.loadAuthenticationRequest(request);
assertNull(result);
}
@Test
void removeAuthenticationRequest() {
var request = mock(HttpServletRequest.class);
var response = mock(HttpServletResponse.class);
var relyingPartyRegistration = mock(RelyingPartyRegistration.class);
var assertingPartyMetadata = mock(AssertingPartyMetadata.class);
String relayState = "testRelayState";
String token = "testToken";
Map<String, Object> claims =
Map.of(
"id", "testId",
"relyingPartyRegistrationId", "stirling-pdf",
"authenticationRequestUri", "example.com/authnRequest",
"samlRequest", "testSamlRequest",
"relayState", relayState);
when(request.getParameter("RelayState")).thenReturn(relayState);
when(jwtService.extractClaims(token)).thenReturn(claims);
when(relyingPartyRegistrationRepository.findByRegistrationId("stirling-pdf"))
.thenReturn(relyingPartyRegistration);
when(relyingPartyRegistration.getRegistrationId()).thenReturn("stirling-pdf");
when(relyingPartyRegistration.getAssertingPartyMetadata())
.thenReturn(assertingPartyMetadata);
when(assertingPartyMetadata.getSingleSignOnServiceLocation())
.thenReturn("https://example.com/sso");
tokenStore.put(relayState, token);
var result =
jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(
request, response);
assertNotNull(result);
assertFalse(tokenStore.containsKey(relayState));
}
@Test
void removeAuthenticationRequestWithNullRelayState() {
var request = mock(HttpServletRequest.class);
var response = mock(HttpServletResponse.class);
when(request.getParameter("RelayState")).thenReturn(null);
var result =
jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(
request, response);
assertNull(result);
}
@Test
void removeAuthenticationRequestWithNonExistentToken() {
var request = mock(HttpServletRequest.class);
var response = mock(HttpServletResponse.class);
when(request.getParameter("RelayState")).thenReturn("nonExistentRelayState");
var result =
jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(
request, response);
assertNull(result);
}
@Test
void removeAuthenticationRequestWithOnlyRelayState() {
var request = mock(HttpServletRequest.class);
var response = mock(HttpServletResponse.class);
String relayState = "testRelayState";
when(request.getParameter("RelayState")).thenReturn(relayState);
var result =
jwtSaml2AuthenticationRequestRepository.removeAuthenticationRequest(
request, response);
assertNull(result);
assertFalse(tokenStore.containsKey(relayState));
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.4.1'
version = '2.4.2'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@@ -3560,6 +3560,39 @@ imageSize = "Image Size"
margin = "Margin"
positionAndFormatting = "Position & Formatting"
quickPosition = "Select a position on the page to place the stamp."
preview = "Preview:"
useTemplate = "Use Template"
selectTemplate = "Select a template..."
stampTextDescription = "Use dynamic variables below. Use @@ for literal @. Use \\n for new lines."
dynamicVariables = "Dynamic Variables"
clickToExpand = "Click to expand"
variablesHelp = "Click on any variable to insert it into your stamp text. Use @@ for literal @."
dateTimeVars = "Date & Time"
dateDesc = "Current date"
timeDesc = "Current time"
datetimeDesc = "Date and time combined"
customDateDesc = "Custom format"
yearMonthDayDesc = "Individual date parts"
pageVars = "Page Information"
pageNumberDesc = "Current page number"
totalPagesDesc = "Total number of pages"
fileVars = "File Information"
filenameDesc = "Filename without extension"
filenameFullDesc = "Filename with extension"
metadataVars = "Document Metadata"
metadataDesc = "From PDF document properties"
otherVars = "Other"
uuidDesc = "Short unique identifier (8 chars)"
examples = "Examples"
multiLine = "multi-line"
[AddStampRequest.template]
pageNumberFooter = "Page Number Footer"
dateHeader = "Date Header"
europeanDate = "European Date"
timestamp = "Timestamp"
draftWatermark = "Draft Watermark"
custom = "Custom"
[AddStampRequest.error]
failed = "An error occurred while adding stamp to the PDF."
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.4.1",
"version": "2.4.2",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -99,7 +99,7 @@ const StampPositionFormattingSettings = ({ parameters, onParameterChange, disabl
<Group className={styles.sliderGroup} align="center">
<NumberInput
value={parameters.fontSize}
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' ? v : 1)}
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' && v > 0 ? v : 1)}
min={1}
max={400}
step={1}
@@ -114,6 +114,7 @@ const StampPositionFormattingSettings = ({ parameters, onParameterChange, disabl
max={400}
step={1}
className={styles.slider}
disabled={disabled}
/>
</Group>
</Stack>
@@ -1,18 +1,169 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Stack, Textarea, TextInput, Select, Button, Text, Divider } from "@mantine/core";
import { Stack, Textarea, TextInput, Select, Button, Text, Divider, Accordion, Code, Group, Badge, Box, Paper } from "@mantine/core";
import { AddStampParameters } from "@app/components/tools/addStamp/useAddStampParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
import styles from "@app/components/tools/addStamp/StampPreview.module.css";
import { getDefaultFontSizeForAlphabet } from "@app/components/tools/addStamp/StampPreviewUtils";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
const STAMP_TEMPLATES = [
{
id: 'page-numbers',
name: 'Page Numbers',
text: 'Page @page_number of @total_pages',
position: 2, // bottom center
},
{
id: 'draft',
name: 'Draft Watermark',
text: 'DRAFT - @date',
position: 5, // center
},
{
id: 'doc-info',
name: 'Document Info',
text: '@filename\nCreated: @date{dd MMM yyyy}',
position: 7, // top left
},
{
id: 'legal-footer',
name: 'Legal Footer',
text: '© @year - All Rights Reserved\n@filename - Page @page_number',
position: 2, // bottom center
},
{
id: 'european-date',
name: 'European Date (DD/MM/YYYY)',
text: '@date{dd/MM/yyyy}',
position: 9, // top right
},
{
id: 'timestamp',
name: 'Timestamp',
text: '@date{dd/MM/yyyy HH:mm}',
position: 9, // top right
},
];
const resolveVariablesForPreview = (text: string, filename?: string): string => {
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const ESCAPED_AT_PLACEHOLDER = '\uE000ESCAPED_AT\uE000';
let result = text.replace(/@@/g, ESCAPED_AT_PLACEHOLDER);
const actualFilename = filename || 'sample-document.pdf';
const filenameWithoutExt = actualFilename.includes('.')
? actualFilename.substring(0, actualFilename.lastIndexOf('.'))
: actualFilename;
const sampleData: Record<string, string> = {
'@datetime': `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
'@date': `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
'@time': `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`,
'@year': String(now.getFullYear()),
'@month': pad(now.getMonth() + 1),
'@day': pad(now.getDate()),
// Page info - cannot be previewed, show placeholder
'@page_number': '?',
'@page': '?',
'@total_pages': '?',
'@page_count': '?',
// Filename - use actual file if provided
'@filename': filenameWithoutExt,
'@filename_full': actualFilename,
// Metadata - cannot be read from PDF in frontend, show placeholder
'@author': '?',
'@title': '?',
'@subject': '?',
// UUID - will be random each time
'@uuid': '????????',
};
result = result.replace(/@date\{([^}]+)\}/g, (match, format) => {
try {
return format
.replace(/yyyy/g, String(now.getFullYear()))
.replace(/yy/g, String(now.getFullYear()).slice(-2))
.replace(/MMMM/g, now.toLocaleString('default', { month: 'long' }))
.replace(/MMM/g, now.toLocaleString('default', { month: 'short' }))
.replace(/MM/g, pad(now.getMonth() + 1))
.replace(/dd/g, pad(now.getDate()))
.replace(/HH/g, pad(now.getHours()))
.replace(/hh/g, pad(now.getHours() % 12 || 12))
.replace(/mm/g, pad(now.getMinutes()))
.replace(/ss/g, pad(now.getSeconds()));
} catch {
return match;
}
});
Object.entries(sampleData).forEach(([key, value]) => {
result = result.split(key).join(value);
});
result = result.replace(new RegExp(ESCAPED_AT_PLACEHOLDER, 'g'), '@');
result = result.replace(/\\n/g, '\n');
return result;
};
interface ClickableCodeProps {
children: React.ReactNode;
onClick: () => void;
block?: boolean;
}
const ClickableCode = ({ children, onClick, block = false }: ClickableCodeProps) => (
<Code
tabIndex={0}
role="button"
style={{
cursor: 'pointer',
display: block ? 'block' : undefined,
}}
onClick={onClick}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}}
>
{children}
</Code>
);
const StampTextPreview = ({ stampText, filename }: { stampText: string; filename?: string }) => {
const { t } = useTranslation();
const resolvedText = useMemo(() => {
if (!stampText.trim()) return '';
return resolveVariablesForPreview(stampText, filename);
}, [stampText, filename]);
if (!stampText.trim()) return null;
return (
<Paper p="xs" withBorder bg="var(--mantine-color-default)">
<Text size="xs" c="dimmed" mb={4}>{t('AddStampRequest.preview', 'Preview:')}</Text>
<Text size="sm" style={{ whiteSpace: 'pre-wrap', fontFamily: 'monospace', wordBreak: 'break-word' }}>
{resolvedText}
</Text>
</Paper>
);
};
interface StampSetupSettingsProps {
parameters: AddStampParameters;
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
disabled?: boolean;
filename?: string;
}
const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }: StampSetupSettingsProps) => {
const StampSetupSettings = ({ parameters, onParameterChange, disabled = false, filename }: StampSetupSettingsProps) => {
const { t } = useTranslation();
return (
@@ -41,14 +192,172 @@ const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }:
{parameters.stampType === 'text' && (
<>
{/* Template Selector - always shows placeholder, doesn't persist selection */}
<Select
label={t('AddStampRequest.useTemplate', 'Use Template')}
placeholder={t('AddStampRequest.selectTemplate', 'Select a template...')}
value={null}
data={STAMP_TEMPLATES.map(template => ({
value: template.id,
label: t(`AddStampRequest.template.${template.id}`, template.name)
}))}
onChange={(value) => {
const template = STAMP_TEMPLATES.find(t => t.id === value);
if (template) {
onParameterChange('stampText', template.text);
onParameterChange('position', template.position as any);
}
}}
clearable
disabled={disabled}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }}
/>
<Textarea
label={t('AddStampRequest.stampText', 'Stamp Text')}
description={t('AddStampRequest.stampTextDescription', 'Use dynamic variables below. Use @@ for literal @. Use \\n for new lines.')}
value={parameters.stampText}
onChange={(e) => onParameterChange('stampText', e.currentTarget.value)}
autosize
minRows={2}
disabled={disabled}
/>
{/* Live Preview */}
<StampTextPreview stampText={parameters.stampText} filename={filename} />
<Accordion variant="contained" radius="sm">
<Accordion.Item value="variables">
<Accordion.Control>
<Group gap="xs">
<Text size="sm" fw={500}>{t('AddStampRequest.dynamicVariables', 'Dynamic Variables')}</Text>
<Badge size="xs" variant="light" color="blue">{t('AddStampRequest.clickToExpand', 'Click to expand')}</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="xs">
<Text size="xs" c="dimmed" mb="xs">
{t('AddStampRequest.variablesHelp', 'Click on any variable to insert it into your stamp text. Use @@ for literal @.')}
</Text>
<Box>
<Text size="xs" fw={600} mb={4}>{t('AddStampRequest.dateTimeVars', 'Date & Time')}</Text>
<Group gap="xs">
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@date')}>
@date
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.dateDesc', 'Current date')} (YYYY-MM-DD)</Text>
</Group>
<Group gap="xs" mt={4}>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@time')}>
@time
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.timeDesc', 'Current time')} (HH:mm:ss)</Text>
</Group>
<Group gap="xs" mt={4}>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@datetime')}>
@datetime
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.datetimeDesc', 'Date and time combined')}</Text>
</Group>
<Group gap="xs" mt={4}>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@date{dd/MM/yyyy}')}>
@date&#123;format&#125;
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.customDateDesc', 'Custom format')}</Text>
</Group>
<Group gap="xs" mt={4}>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@year')}>
@year
</ClickableCode>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@month')}>
@month
</ClickableCode>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@day')}>
@day
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.yearMonthDayDesc', 'Individual date parts')}</Text>
</Group>
</Box>
<Divider my="xs" />
<Box>
<Text size="xs" fw={600} mb={4}>{t('AddStampRequest.pageVars', 'Page Information')}</Text>
<Group gap="xs">
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@page_number')}>
@page_number
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.pageNumberDesc', 'Current page number')}</Text>
</Group>
<Group gap="xs" mt={4}>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@total_pages')}>
@total_pages
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.totalPagesDesc', 'Total number of pages')}</Text>
</Group>
</Box>
<Divider my="xs" />
<Box>
<Text size="xs" fw={600} mb={4}>{t('AddStampRequest.fileVars', 'File Information')}</Text>
<Group gap="xs">
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@filename')}>
@filename
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.filenameDesc', 'Filename without extension')}</Text>
</Group>
<Group gap="xs" mt={4}>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@filename_full')}>
@filename_full
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.filenameFullDesc', 'Filename with extension')}</Text>
</Group>
</Box>
<Divider my="xs" />
<Box>
<Text size="xs" fw={600} mb={4}>{t('AddStampRequest.metadataVars', 'Document Metadata')}</Text>
<Group gap="xs">
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@author')}>
@author
</ClickableCode>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@title')}>
@title
</ClickableCode>
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@subject')}>
@subject
</ClickableCode>
</Group>
<Text size="xs" c="dimmed" mt={4}>— {t('AddStampRequest.metadataDesc', 'From PDF document properties')}</Text>
</Box>
<Divider my="xs" />
<Box>
<Text size="xs" fw={600} mb={4}>{t('AddStampRequest.otherVars', 'Other')}</Text>
<Group gap="xs">
<ClickableCode onClick={() => onParameterChange('stampText', parameters.stampText + '@uuid')}>
@uuid
</ClickableCode>
<Text size="xs" c="dimmed">— {t('AddStampRequest.uuidDesc', 'Short unique identifier (8 chars)')}</Text>
</Group>
</Box>
<Divider my="xs" />
<Box>
<Text size="xs" fw={600} mb={4}>{t('AddStampRequest.examples', 'Examples')}</Text>
<Stack gap={4}>
<ClickableCode block onClick={() => onParameterChange('stampText', 'Page @page_number of @total_pages')}>
Page @page_number of @total_pages
</ClickableCode>
<ClickableCode block onClick={() => onParameterChange('stampText', 'Created: @date{dd/MM/yyyy HH:mm}')}>
Created: @date&#123;dd/MM/yyyy HH:mm&#125;
</ClickableCode>
<ClickableCode block onClick={() => onParameterChange('stampText', '© @year @author')}>
© @year @author
</ClickableCode>
<ClickableCode block onClick={() => onParameterChange('stampText', '@filename\\n@date')}>
@filename\n@date ({t('AddStampRequest.multiLine', 'multi-line')})
</ClickableCode>
</Stack>
</Box>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Select
label={t('AddStampRequest.alphabet', 'Alphabet')}
value={parameters.alphabet}
@@ -22,7 +22,7 @@ export const defaultParameters: AddStampParameters = {
stampType: 'text',
stampText: '',
alphabet: 'roman',
fontSize: 80,
fontSize: 40,
rotation: 0,
opacity: 50,
position: 5,
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.4.1',
appVersion: '2.4.2',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
+1
View File
@@ -80,6 +80,7 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
parameters={params.parameters}
onParameterChange={params.updateParameter}
disabled={endpointLoading}
filename={selectedFiles[0]?.name}
/>
),
});
@@ -6,7 +6,7 @@ import { SetupWizard } from '@app/components/SetupWizard';
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
import { connectionModeService } from '@desktop/services/connectionModeService';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { authService } from '@app/services/authService';
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { authService, UserInfo } from '@app/services/authService';
import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml';
import { BASE_PATH } from '@app/constants/app';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import '@app/routes/authShared/auth.css';
type KnownProviderId = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc';
@@ -8,7 +8,7 @@ import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLog
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
import { AuthServiceError, authService, UserInfo } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { listen } from '@tauri-apps/api/event';
import { useEffect } from 'react';
import '@app/routes/authShared/auth.css';
@@ -1,6 +1,6 @@
/**
* Desktop Override: Onboarding Orchestrator Hook
*
*
* Simply wraps the core hook with the desktop-specific default runtime state
* which has isDesktopApp set to true.
*/
@@ -9,7 +9,7 @@ import {
useOnboardingOrchestrator as useCoreOnboardingOrchestrator,
type UseOnboardingOrchestratorResult,
} from '@core/components/onboarding/orchestrator/useOnboardingOrchestrator';
import { DEFAULT_RUNTIME_STATE } from '@desktop/components/onboarding/orchestrator/onboardingConfig';
import { DEFAULT_RUNTIME_STATE } from '@app/components/onboarding/orchestrator/onboardingConfig';
export type {
OnboardingOrchestratorState,
@@ -5,7 +5,7 @@ import apiClient from '@app/services/apiClient';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { isBackendNotReadyError } from '@app/constants/backendErrors';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
import { connectionModeService } from '@desktop/services/connectionModeService';
import { connectionModeService } from '@app/services/connectionModeService';
import type { AppConfig } from '@app/contexts/AppConfigContext';
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.4.1',
appVersion: '2.4.2',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
+5 -4
View File
@@ -7,9 +7,10 @@
]
}
},
"exclude": [
"src/proprietary",
"src/desktop",
"node_modules"
"include": [
"src/*.js",
"src/*.ts",
"src/*.tsx",
"src/core"
]
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"paths": {
"@app/*": [
"src/core/*"
]
}
},
"exclude": [
"src/proprietary",
"src/desktop",
"node_modules"
]
}
+6 -7
View File
@@ -7,16 +7,15 @@
"src/proprietary/*",
"src/core/*"
],
"@desktop/*": ["src/desktop/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"]
}
},
"exclude": [
"src/core/**/*.test.ts*",
"src/core/**/*.spec.ts*",
"src/proprietary/**/*.test.ts*",
"src/proprietary/**/*.spec.ts*",
"node_modules"
"include": [
"src/*.js",
"src/*.ts",
"src/*.tsx",
"src/core/setupTests.ts",
"src/desktop"
]
}
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "./tsconfig.proprietary.vite.json",
"compilerOptions": {
"paths": {
"@app/*": [
"src/desktop/*",
"src/proprietary/*",
"src/core/*"
],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"]
}
},
"exclude": [
"src/core/**/*.test.ts*",
"src/core/**/*.spec.ts*",
"src/proprietary/**/*.test.ts*",
"src/proprietary/**/*.spec.ts*",
"node_modules"
]
}
-1
View File
@@ -36,7 +36,6 @@
"src/proprietary/*",
"src/core/*"
],
"@desktop/*": ["src/desktop/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"]
},
+6 -5
View File
@@ -9,10 +9,11 @@
"@core/*": ["src/core/*"]
}
},
"exclude": [
"src/core/**/*.test.ts*",
"src/core/**/*.spec.ts*",
"src/desktop",
"node_modules"
"include": [
"src/*.js",
"src/*.ts",
"src/*.tsx",
"src/core/setupTests.ts",
"src/proprietary"
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"paths": {
"@app/*": [
"src/proprietary/*",
"src/core/*"
],
"@core/*": ["src/core/*"]
}
},
"exclude": [
"src/core/**/*.test.ts*",
"src/core/**/*.spec.ts*",
"src/desktop",
"node_modules"
]
}
+2 -2
View File
@@ -11,8 +11,8 @@ export default defineConfig(({ mode }) => {
process.env.STIRLING_DESKTOP === 'true' ||
process.env.VITE_DESKTOP === 'true';
const baseProject = isProprietary ? './tsconfig.proprietary.json' : './tsconfig.core.json';
const desktopProject = isProprietary ? './tsconfig.desktop.json' : baseProject;
const baseProject = isProprietary ? './tsconfig.proprietary.vite.json' : './tsconfig.core.vite.json';
const desktopProject = isProprietary ? './tsconfig.desktop.vite.json' : baseProject;
const tsconfigProject = isDesktopMode ? desktopProject : baseProject;
return {
+3 -3
View File
@@ -37,7 +37,7 @@ export default defineConfig({
plugins: [
react(),
tsconfigPaths({
projects: ['./tsconfig.core.json'],
projects: ['./tsconfig.core.vite.json'],
}),
],
esbuild: {
@@ -55,7 +55,7 @@ export default defineConfig({
plugins: [
react(),
tsconfigPaths({
projects: ['./tsconfig.proprietary.json'],
projects: ['./tsconfig.proprietary.vite.json'],
}),
],
esbuild: {
@@ -73,7 +73,7 @@ export default defineConfig({
plugins: [
react(),
tsconfigPaths({
projects: ['./tsconfig.desktop.json'],
projects: ['./tsconfig.desktop.vite.json'],
}),
],
esbuild: {
+9 -7
View File
@@ -509,18 +509,20 @@ Examples:
# Positional arguments (backward compatibility):
convert_cff_to_ttf.py font.cff font.otf mapping.tounicode
"""
""",
)
# Add named arguments
parser.add_argument('--input', dest='input_file', help='Input CFF file path')
parser.add_argument('--output', dest='output_file', help='Output OTF file path')
parser.add_argument('--to-unicode', dest='tounicode_file', help='ToUnicode mapping file path')
parser.add_argument("--input", dest="input_file", help="Input CFF file path")
parser.add_argument("--output", dest="output_file", help="Output OTF file path")
parser.add_argument(
"--to-unicode", dest="tounicode_file", help="ToUnicode mapping file path"
)
# Add positional arguments for backward compatibility
parser.add_argument('input_pos', nargs='?', help='Input CFF file (positional)')
parser.add_argument('output_pos', nargs='?', help='Output OTF file (positional)')
parser.add_argument('tounicode_pos', nargs='?', help='ToUnicode file (positional)')
parser.add_argument("input_pos", nargs="?", help="Input CFF file (positional)")
parser.add_argument("output_pos", nargs="?", help="Output OTF file (positional)")
parser.add_argument("tounicode_pos", nargs="?", help="ToUnicode file (positional)")
args = parser.parse_args()