Merge remote-tracking branch 'origin/V2' into
codex/add-pdf-to-json-and-json-to-pdf-features
This commit is contained in:
+2
-1
@@ -3,6 +3,7 @@ package stirling.software.common.annotations;
|
||||
import java.lang.annotation.*;
|
||||
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
|
||||
@@ -40,7 +41,7 @@ public @interface AutoJobPostMapping {
|
||||
|
||||
/** MIME types this endpoint accepts. Defaults to {@code multipart/form-data}. */
|
||||
@AliasFor(annotation = RequestMapping.class, attribute = "consumes")
|
||||
String[] consumes() default {"multipart/form-data"};
|
||||
String[] consumes() default {MediaType.MULTIPART_FORM_DATA_VALUE};
|
||||
|
||||
/**
|
||||
* Maximum execution time in milliseconds before the job is aborted. A negative value means "use
|
||||
|
||||
@@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.FileOrUploadService;
|
||||
import stirling.software.common.service.FileStorage;
|
||||
import stirling.software.common.service.JobExecutorService;
|
||||
|
||||
@@ -34,7 +33,6 @@ public class AutoJobAspect {
|
||||
|
||||
private final JobExecutorService jobExecutorService;
|
||||
private final HttpServletRequest request;
|
||||
private final FileOrUploadService fileOrUploadService;
|
||||
private final FileStorage fileStorage;
|
||||
|
||||
@Around("@annotation(autoJobPostMapping)")
|
||||
@@ -53,7 +51,8 @@ public class AutoJobAspect {
|
||||
boolean trackProgress = autoJobPostMapping.trackProgress();
|
||||
|
||||
log.debug(
|
||||
"AutoJobPostMapping execution with async={}, timeout={}, retryCount={}, trackProgress={}",
|
||||
"AutoJobPostMapping execution with async={}, timeout={}, retryCount={},"
|
||||
+ " trackProgress={}",
|
||||
async,
|
||||
timeout > 0 ? timeout : "default",
|
||||
retryCount,
|
||||
@@ -148,7 +147,8 @@ public class AutoJobAspect {
|
||||
} catch (Throwable ex) {
|
||||
lastException = ex;
|
||||
log.error(
|
||||
"AutoJobAspect caught exception during job execution (attempt {}/{}): {}",
|
||||
"AutoJobAspect caught exception during job execution (attempt"
|
||||
+ " {}/{}): {}",
|
||||
currentAttempt,
|
||||
maxRetries,
|
||||
ex.getMessage(),
|
||||
|
||||
+21
-1
@@ -23,10 +23,30 @@ import stirling.software.common.util.YamlHelper;
|
||||
@Slf4j
|
||||
public class ConfigInitializer {
|
||||
|
||||
private static final int MIN_SETTINGS_FILE_LINES = 31;
|
||||
|
||||
public void ensureConfigExists() throws IOException, URISyntaxException {
|
||||
// 1) If settings file doesn't exist, create from template
|
||||
Path destPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
if (Files.notExists(destPath)) {
|
||||
|
||||
boolean settingsFileExists = Files.exists(destPath);
|
||||
|
||||
long lineCount = settingsFileExists ? Files.readAllLines(destPath).size() : 0;
|
||||
|
||||
log.info("Current settings file line count: {}", lineCount);
|
||||
|
||||
if (!settingsFileExists || lineCount < MIN_SETTINGS_FILE_LINES) {
|
||||
if (settingsFileExists) {
|
||||
// move settings.yml to settings.yml.{timestamp}.bak
|
||||
Path backupPath =
|
||||
Paths.get(
|
||||
InstallationPathConfig.getSettingsPath()
|
||||
+ "."
|
||||
+ System.currentTimeMillis()
|
||||
+ ".bak");
|
||||
Files.move(destPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.info("Moved existing settings file to backup: {}", backupPath);
|
||||
}
|
||||
Files.createDirectories(destPath.getParent());
|
||||
try (InputStream in =
|
||||
getClass().getClassLoader().getResourceAsStream("settings.yml.template")) {
|
||||
|
||||
+16
-2
@@ -14,12 +14,17 @@ public class InstallationPathConfig {
|
||||
private static final String CONFIG_PATH;
|
||||
private static final String CUSTOM_FILES_PATH;
|
||||
private static final String CLIENT_WEBUI_PATH;
|
||||
private static final String SCRIPTS_PATH;
|
||||
private static final String PIPELINE_PATH;
|
||||
|
||||
// Config paths
|
||||
private static final String SETTINGS_PATH;
|
||||
private static final String CUSTOM_SETTINGS_PATH;
|
||||
private static final String SCRIPTS_PATH;
|
||||
private static final String BACKUP_PATH;
|
||||
|
||||
// Backup paths
|
||||
private static final String BACKUP_DB_PATH;
|
||||
private static final String BACKUP_PRIVATE_KEY_PATH;
|
||||
|
||||
// Custom file paths
|
||||
private static final String STATIC_PATH;
|
||||
@@ -41,6 +46,11 @@ public class InstallationPathConfig {
|
||||
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
|
||||
CUSTOM_SETTINGS_PATH = CONFIG_PATH + "custom_settings.yml";
|
||||
SCRIPTS_PATH = CONFIG_PATH + "scripts" + File.separator;
|
||||
BACKUP_PATH = CONFIG_PATH + "backup" + File.separator;
|
||||
|
||||
// Initialize backup paths
|
||||
BACKUP_DB_PATH = BACKUP_PATH + "db" + File.separator;
|
||||
BACKUP_PRIVATE_KEY_PATH = BACKUP_PATH + "keys" + File.separator;
|
||||
|
||||
// Initialize custom file paths
|
||||
STATIC_PATH = CUSTOM_FILES_PATH + "static" + File.separator;
|
||||
@@ -124,6 +134,10 @@ public class InstallationPathConfig {
|
||||
}
|
||||
|
||||
public static String getPrivateKeyPath() {
|
||||
return PRIVATE_KEY_PATH;
|
||||
return BACKUP_PRIVATE_KEY_PATH;
|
||||
}
|
||||
|
||||
public static String getBackupPath() {
|
||||
return BACKUP_DB_PATH;
|
||||
}
|
||||
}
|
||||
|
||||
+26
-14
@@ -41,6 +41,7 @@ import stirling.software.common.model.oauth2.GitHubProvider;
|
||||
import stirling.software.common.model.oauth2.GoogleProvider;
|
||||
import stirling.software.common.model.oauth2.KeycloakProvider;
|
||||
import stirling.software.common.model.oauth2.Provider;
|
||||
import stirling.software.common.service.SsrfProtectionService.SsrfProtectionLevel;
|
||||
import stirling.software.common.util.ValidationUtils;
|
||||
|
||||
@Data
|
||||
@@ -72,11 +73,11 @@ public class ApplicationProperties {
|
||||
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
|
||||
throws IOException {
|
||||
String configPath = InstallationPathConfig.getSettingsPath();
|
||||
log.debug("Attempting to load settings from: " + configPath);
|
||||
log.debug("Attempting to load settings from: {}", configPath);
|
||||
|
||||
File file = new File(configPath);
|
||||
if (!file.exists()) {
|
||||
log.error("Warning: Settings file does not exist at: " + configPath);
|
||||
log.error("Warning: Settings file does not exist at: {}", configPath);
|
||||
}
|
||||
|
||||
Resource resource = new FileSystemResource(configPath);
|
||||
@@ -89,7 +90,7 @@ public class ApplicationProperties {
|
||||
new YamlPropertySourceFactory().createPropertySource(null, encodedResource);
|
||||
environment.getPropertySources().addFirst(propertySource);
|
||||
|
||||
log.debug("Loaded properties: " + propertySource.getSource());
|
||||
log.debug("Loaded properties: {}", propertySource.getSource());
|
||||
|
||||
return propertySource;
|
||||
}
|
||||
@@ -365,7 +366,12 @@ public class ApplicationProperties {
|
||||
private CustomPaths customPaths = new CustomPaths();
|
||||
private String fileUploadLimit;
|
||||
private TempFileManagement tempFileManagement = new TempFileManagement();
|
||||
private DatabaseBackup databaseBackup = new DatabaseBackup();
|
||||
private List<String> corsAllowedOrigins = new ArrayList<>();
|
||||
private String
|
||||
frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set,
|
||||
|
||||
// falls back to backend URL.
|
||||
|
||||
public boolean isAnalyticsEnabled() {
|
||||
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
|
||||
@@ -384,6 +390,11 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class DatabaseBackup {
|
||||
private String cron = "0 0 0 * * ?"; // daily at midnight
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class CustomPaths {
|
||||
private Pipeline pipeline = new Pipeline();
|
||||
@@ -420,17 +431,19 @@ public class ApplicationProperties {
|
||||
|
||||
@JsonIgnore
|
||||
public String getBaseTmpDir() {
|
||||
return baseTmpDir != null && !baseTmpDir.isEmpty()
|
||||
? baseTmpDir
|
||||
: java.lang.System.getProperty("java.io.tmpdir").replaceAll("/+$", "")
|
||||
+ "/stirling-pdf";
|
||||
if (baseTmpDir != null && !baseTmpDir.isEmpty()) {
|
||||
return baseTmpDir;
|
||||
}
|
||||
String tmp = java.lang.System.getProperty("java.io.tmpdir");
|
||||
return new File(tmp, "stirling-pdf").getPath();
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public String getLibreofficeDir() {
|
||||
return libreofficeDir != null && !libreofficeDir.isEmpty()
|
||||
? libreofficeDir
|
||||
: getBaseTmpDir() + "/libreoffice";
|
||||
if (libreofficeDir != null && !libreofficeDir.isEmpty()) {
|
||||
return libreofficeDir;
|
||||
}
|
||||
return new File(getBaseTmpDir(), "libreoffice").getPath();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,7 +454,7 @@ public class ApplicationProperties {
|
||||
@Data
|
||||
public static class UrlSecurity {
|
||||
private boolean enabled = true;
|
||||
private String level = "MEDIUM"; // MAX, MEDIUM, OFF
|
||||
private SsrfProtectionLevel level = SsrfProtectionLevel.MEDIUM; // MAX, MEDIUM, OFF
|
||||
private List<String> allowedDomains = new ArrayList<>();
|
||||
private List<String> blockedDomains = new ArrayList<>();
|
||||
private List<String> internalTlds =
|
||||
@@ -494,9 +507,7 @@ public class ApplicationProperties {
|
||||
private List<String> languages;
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && appNameNavbar.trim().length() > 0
|
||||
? appNameNavbar
|
||||
: null;
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,6 +560,7 @@ public class ApplicationProperties {
|
||||
public static class Mail {
|
||||
private boolean enabled;
|
||||
private boolean enableInvites = false;
|
||||
private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days)
|
||||
private String host;
|
||||
private int port;
|
||||
private String username;
|
||||
|
||||
@@ -8,9 +8,11 @@ import java.util.Locale;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@AllArgsConstructor
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class FileInfo {
|
||||
private static final DateTimeFormatter DATE_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
+5
-7
@@ -4,14 +4,12 @@ package stirling.software.common.model;
|
||||
* This class will be removed when frontend migration to React is complete
|
||||
|
||||
|
||||
public class InputStreamTemplateResource implements ITemplateResource {
|
||||
private InputStream inputStream;
|
||||
private String characterEncoding;
|
||||
|
||||
public InputStreamTemplateResource(InputStream inputStream, String characterEncoding) {
|
||||
this.inputStream = inputStream;
|
||||
this.characterEncoding = characterEncoding;
|
||||
}
|
||||
@RequiredArgsConstructor
|
||||
@Getter
|
||||
public class InputStreamTemplateResource implements ITemplateResource {
|
||||
private final InputStream inputStream;
|
||||
private final String characterEncoding;
|
||||
|
||||
@Override
|
||||
public Reader reader() throws IOException {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import java.util.Calendar;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PdfMetadata {
|
||||
private String author;
|
||||
private String producer;
|
||||
@@ -14,6 +18,6 @@ public class PdfMetadata {
|
||||
private String creator;
|
||||
private String subject;
|
||||
private String keywords;
|
||||
private Calendar creationDate;
|
||||
private Calendar modificationDate;
|
||||
private ZonedDateTime creationDate;
|
||||
private ZonedDateTime modificationDate;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.common.model.api;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
@@ -15,7 +16,11 @@ import lombok.NoArgsConstructor;
|
||||
@EqualsAndHashCode
|
||||
@Schema(description = "PDF file input - either upload a file or provide a server-side file ID")
|
||||
public class PDFFile {
|
||||
@Schema(description = "The input PDF file", format = "binary")
|
||||
|
||||
@Schema(
|
||||
description = "The input PDF file",
|
||||
contentMediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
format = "binary")
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(
|
||||
|
||||
@@ -4,4 +4,5 @@ public enum ReplaceAndInvert {
|
||||
HIGH_CONTRAST_COLOR,
|
||||
CUSTOM_COLOR,
|
||||
FULL_INVERSION,
|
||||
COLOR_SPACE_CONVERSION,
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.util.ExecutorFactory;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
/** Service for executing jobs asynchronously or synchronously */
|
||||
@Service
|
||||
@@ -274,7 +275,8 @@ public class JobExecutorService {
|
||||
if (result instanceof byte[]) {
|
||||
// Store byte array directly to disk to avoid double memory consumption
|
||||
String fileId = fileStorage.storeBytes((byte[]) result, "result.pdf");
|
||||
taskManager.setFileResult(jobId, fileId, "result.pdf", "application/pdf");
|
||||
taskManager.setFileResult(
|
||||
jobId, fileId, "result.pdf", MediaType.APPLICATION_PDF_VALUE);
|
||||
log.debug("Stored byte[] result with fileId: {}", fileId);
|
||||
|
||||
// Let the byte array get collected naturally in the next GC cycle
|
||||
@@ -286,7 +288,7 @@ public class JobExecutorService {
|
||||
if (body instanceof byte[]) {
|
||||
// Extract filename from content-disposition header if available
|
||||
String filename = "result.pdf";
|
||||
String contentType = "application/pdf";
|
||||
String contentType = MediaType.APPLICATION_PDF_VALUE;
|
||||
|
||||
if (response.getHeaders().getContentDisposition() != null) {
|
||||
String disposition =
|
||||
@@ -299,8 +301,10 @@ public class JobExecutorService {
|
||||
}
|
||||
}
|
||||
|
||||
if (response.getHeaders().getContentType() != null) {
|
||||
contentType = response.getHeaders().getContentType().toString();
|
||||
MediaType mediaType = response.getHeaders().getContentType();
|
||||
|
||||
if (mediaType != null) {
|
||||
contentType = mediaType.toString();
|
||||
}
|
||||
|
||||
// Store byte array directly to disk
|
||||
@@ -321,7 +325,7 @@ public class JobExecutorService {
|
||||
if (fileId != null && !fileId.isEmpty()) {
|
||||
// Try to get filename and content type
|
||||
String filename = "result.pdf";
|
||||
String contentType = "application/pdf";
|
||||
String contentType = MediaType.APPLICATION_PDF_VALUE;
|
||||
|
||||
try {
|
||||
java.lang.reflect.Method getOriginalFileName =
|
||||
@@ -362,8 +366,7 @@ public class JobExecutorService {
|
||||
// Store generic result
|
||||
taskManager.setResult(jobId, body);
|
||||
}
|
||||
} else if (result instanceof MultipartFile) {
|
||||
MultipartFile file = (MultipartFile) result;
|
||||
} else if (result instanceof MultipartFile file) {
|
||||
String fileId = fileStorage.storeFile(file);
|
||||
taskManager.setFileResult(
|
||||
jobId, fileId, file.getOriginalFilename(), file.getContentType());
|
||||
@@ -380,7 +383,7 @@ public class JobExecutorService {
|
||||
if (fileId != null && !fileId.isEmpty()) {
|
||||
// Try to get filename and content type
|
||||
String filename = "result.pdf";
|
||||
String contentType = "application/pdf";
|
||||
String contentType = MediaType.APPLICATION_PDF_VALUE;
|
||||
|
||||
try {
|
||||
java.lang.reflect.Method getOriginalFileName =
|
||||
@@ -443,9 +446,8 @@ public class JobExecutorService {
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"form-data; name=\"attachment\"; filename=\"result.pdf\"")
|
||||
.body(result);
|
||||
} else if (result instanceof MultipartFile) {
|
||||
} else if (result instanceof MultipartFile file) {
|
||||
// Return MultipartFile content
|
||||
MultipartFile file = (MultipartFile) result;
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(file.getContentType()))
|
||||
.header(
|
||||
@@ -472,8 +474,16 @@ public class JobExecutorService {
|
||||
}
|
||||
|
||||
try {
|
||||
String value = timeout.replaceAll("[^\\d.]", "");
|
||||
String unit = timeout.replaceAll("[\\d.]", "");
|
||||
String value =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNonDigitDotPattern()
|
||||
.matcher(timeout)
|
||||
.replaceAll("");
|
||||
String unit =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getDigitDotPattern()
|
||||
.matcher(timeout)
|
||||
.replaceAll("");
|
||||
|
||||
double numericValue = Double.parseDouble(value);
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Calendar;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -29,17 +33,19 @@ public class PdfMetadataService {
|
||||
this.runningProOrHigher = runningProOrHigher;
|
||||
}
|
||||
|
||||
public PdfMetadata extractMetadataFromPdf(PDDocument pdf) {
|
||||
return PdfMetadata.builder()
|
||||
.author(pdf.getDocumentInformation().getAuthor())
|
||||
.producer(pdf.getDocumentInformation().getProducer())
|
||||
.title(pdf.getDocumentInformation().getTitle())
|
||||
.creator(pdf.getDocumentInformation().getCreator())
|
||||
.subject(pdf.getDocumentInformation().getSubject())
|
||||
.keywords(pdf.getDocumentInformation().getKeywords())
|
||||
.creationDate(pdf.getDocumentInformation().getCreationDate())
|
||||
.modificationDate(pdf.getDocumentInformation().getModificationDate())
|
||||
.build();
|
||||
/**
|
||||
* Converts ZonedDateTime to Calendar for PDFBox compatibility.
|
||||
*
|
||||
* @param zonedDateTime the ZonedDateTime to convert
|
||||
* @return Calendar instance or null if input is null
|
||||
*/
|
||||
public static Calendar toCalendar(ZonedDateTime zonedDateTime) {
|
||||
if (zonedDateTime == null) {
|
||||
return null;
|
||||
}
|
||||
Calendar calendar = Calendar.getInstance();
|
||||
calendar.setTimeInMillis(zonedDateTime.toInstant().toEpochMilli());
|
||||
return calendar;
|
||||
}
|
||||
|
||||
public void setDefaultMetadata(PDDocument pdf) {
|
||||
@@ -58,6 +64,52 @@ public class PdfMetadataService {
|
||||
setCommonMetadata(pdf, pdfMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a date string and converts it to Calendar for PDFBox compatibility.
|
||||
*
|
||||
* @param dateString the date string in "yyyy/MM/dd HH:mm:ss" format
|
||||
* @return Calendar instance or null if parsing fails or input is empty
|
||||
*/
|
||||
public static Calendar parseToCalendar(String dateString) {
|
||||
if (dateString == null || dateString.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
|
||||
ZonedDateTime zonedDateTime =
|
||||
LocalDateTime.parse(dateString, formatter).atZone(ZoneId.systemDefault());
|
||||
return toCalendar(zonedDateTime);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public PdfMetadata extractMetadataFromPdf(PDDocument pdf) {
|
||||
Calendar creationCal = pdf.getDocumentInformation().getCreationDate();
|
||||
Calendar modificationCal = pdf.getDocumentInformation().getModificationDate();
|
||||
|
||||
ZonedDateTime creationDate =
|
||||
creationCal != null
|
||||
? ZonedDateTime.ofInstant(creationCal.toInstant(), ZoneId.systemDefault())
|
||||
: null;
|
||||
ZonedDateTime modificationDate =
|
||||
modificationCal != null
|
||||
? ZonedDateTime.ofInstant(
|
||||
modificationCal.toInstant(), ZoneId.systemDefault())
|
||||
: null;
|
||||
|
||||
return PdfMetadata.builder()
|
||||
.author(pdf.getDocumentInformation().getAuthor())
|
||||
.producer(pdf.getDocumentInformation().getProducer())
|
||||
.title(pdf.getDocumentInformation().getTitle())
|
||||
.creator(pdf.getDocumentInformation().getCreator())
|
||||
.subject(pdf.getDocumentInformation().getSubject())
|
||||
.keywords(pdf.getDocumentInformation().getKeywords())
|
||||
.creationDate(creationDate)
|
||||
.modificationDate(modificationDate)
|
||||
.build();
|
||||
}
|
||||
|
||||
private void setNewDocumentMetadata(PDDocument pdf, PdfMetadata pdfMetadata) {
|
||||
|
||||
String creator = stirlingPDFLabel;
|
||||
@@ -79,7 +131,13 @@ public class PdfMetadataService {
|
||||
}
|
||||
|
||||
pdf.getDocumentInformation().setCreator(creator);
|
||||
pdf.getDocumentInformation().setCreationDate(Calendar.getInstance());
|
||||
|
||||
// Use existing creation date if available, otherwise create new one
|
||||
Calendar creationCal =
|
||||
pdfMetadata.getCreationDate() != null
|
||||
? toCalendar(pdfMetadata.getCreationDate())
|
||||
: Calendar.getInstance();
|
||||
pdf.getDocumentInformation().setCreationDate(creationCal);
|
||||
}
|
||||
|
||||
private void setCommonMetadata(PDDocument pdf, PdfMetadata pdfMetadata) {
|
||||
@@ -88,7 +146,13 @@ public class PdfMetadataService {
|
||||
pdf.getDocumentInformation().setProducer(stirlingPDFLabel);
|
||||
pdf.getDocumentInformation().setSubject(pdfMetadata.getSubject());
|
||||
pdf.getDocumentInformation().setKeywords(pdfMetadata.getKeywords());
|
||||
pdf.getDocumentInformation().setModificationDate(Calendar.getInstance());
|
||||
|
||||
// Convert ZonedDateTime to Calendar for PDFBox compatibility
|
||||
Calendar modificationCal =
|
||||
pdfMetadata.getModificationDate() != null
|
||||
? toCalendar(pdfMetadata.getModificationDate())
|
||||
: Calendar.getInstance();
|
||||
pdf.getDocumentInformation().setModificationDate(modificationCal);
|
||||
|
||||
String author = pdfMetadata.getAuthor();
|
||||
if (applicationProperties
|
||||
|
||||
+83
-22
@@ -1,5 +1,7 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.net.Inet4Address;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.UnknownHostException;
|
||||
@@ -11,6 +13,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -20,8 +23,9 @@ public class SsrfProtectionService {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
private static final Pattern DATA_URL_PATTERN =
|
||||
Pattern.compile("^data:.*", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern FRAGMENT_PATTERN = Pattern.compile("^#.*");
|
||||
RegexPatternUtils.getInstance().getPattern("^data:.*", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern FRAGMENT_PATTERN =
|
||||
RegexPatternUtils.getInstance().getPattern("^#.*");
|
||||
|
||||
public enum SsrfProtectionLevel {
|
||||
OFF, // No SSRF protection - allows all URLs
|
||||
@@ -51,21 +55,17 @@ public class SsrfProtectionService {
|
||||
|
||||
SsrfProtectionLevel level = parseProtectionLevel(config.getLevel());
|
||||
|
||||
switch (level) {
|
||||
case OFF:
|
||||
return true;
|
||||
case MAX:
|
||||
return isMaxSecurityAllowed(trimmedUrl, config);
|
||||
case MEDIUM:
|
||||
return isMediumSecurityAllowed(trimmedUrl, config);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return switch (level) {
|
||||
case OFF -> true;
|
||||
case MAX -> isMaxSecurityAllowed(trimmedUrl, config);
|
||||
case MEDIUM -> isMediumSecurityAllowed(trimmedUrl, config);
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private SsrfProtectionLevel parseProtectionLevel(String level) {
|
||||
private SsrfProtectionLevel parseProtectionLevel(SsrfProtectionLevel level) {
|
||||
try {
|
||||
return SsrfProtectionLevel.valueOf(level.toUpperCase());
|
||||
return SsrfProtectionLevel.valueOf(level.name());
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid SSRF protection level '{}', defaulting to MEDIUM", level);
|
||||
return SsrfProtectionLevel.MEDIUM;
|
||||
@@ -172,15 +172,62 @@ public class SsrfProtectionService {
|
||||
}
|
||||
|
||||
private boolean isPrivateAddress(InetAddress address) {
|
||||
return address.isSiteLocalAddress()
|
||||
|| address.isAnyLocalAddress()
|
||||
|| isPrivateIPv4Range(address.getHostAddress());
|
||||
if (address.isAnyLocalAddress() || address.isLoopbackAddress()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address instanceof Inet4Address) {
|
||||
return isPrivateIPv4Range(address.getHostAddress());
|
||||
}
|
||||
|
||||
if (address instanceof Inet6Address addr6) {
|
||||
if (addr6.isLinkLocalAddress() || addr6.isSiteLocalAddress()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
byte[] bytes = addr6.getAddress();
|
||||
if (isIpv4MappedAddress(bytes)) {
|
||||
String ipv4 =
|
||||
(bytes[12] & 0xff)
|
||||
+ "."
|
||||
+ (bytes[13] & 0xff)
|
||||
+ "."
|
||||
+ (bytes[14] & 0xff)
|
||||
+ "."
|
||||
+ (bytes[15] & 0xff);
|
||||
return isPrivateIPv4Range(ipv4);
|
||||
}
|
||||
|
||||
int firstByte = bytes[0] & 0xff;
|
||||
// Check for IPv6 unique local addresses (fc00::/7)
|
||||
if ((firstByte & 0xfe) == 0xfc) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isIpv4MappedAddress(byte[] addr) {
|
||||
if (addr.length != 16) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (addr[i] != 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// For IPv4-mapped IPv6 addresses, bytes 10 and 11 must be 0xff (i.e., address is
|
||||
// ::ffff:w.x.y.z)
|
||||
return addr[10] == (byte) 0xff && addr[11] == (byte) 0xff;
|
||||
}
|
||||
|
||||
private boolean isPrivateIPv4Range(String ip) {
|
||||
// Includes RFC1918, loopback, link-local, and unspecified addresses
|
||||
return ip.startsWith("10.")
|
||||
|| ip.startsWith("192.168.")
|
||||
|| (ip.startsWith("172.") && isInRange172(ip))
|
||||
|| ip.startsWith("169.254.")
|
||||
|| ip.startsWith("127.")
|
||||
|| "0.0.0.0".equals(ip);
|
||||
}
|
||||
@@ -192,17 +239,31 @@ public class SsrfProtectionService {
|
||||
int secondOctet = Integer.parseInt(parts[1]);
|
||||
return secondOctet >= 16 && secondOctet <= 31;
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean isCloudMetadataAddress(String ip) {
|
||||
String normalizedIp = normalizeIpv4MappedAddress(ip);
|
||||
// Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud
|
||||
return ip.startsWith("169.254.169.254") // AWS/GCP/Azure
|
||||
|| ip.startsWith("fd00:ec2::254") // AWS IPv6
|
||||
|| ip.startsWith("169.254.169.253") // Oracle Cloud
|
||||
|| ip.startsWith("169.254.169.250"); // IBM Cloud
|
||||
return normalizedIp.startsWith("169.254.169.254") // AWS/GCP/Azure
|
||||
|| normalizedIp.startsWith("fd00:ec2::254") // AWS IPv6
|
||||
|| normalizedIp.startsWith("169.254.169.253") // Oracle Cloud
|
||||
|| normalizedIp.startsWith("169.254.169.250"); // IBM Cloud
|
||||
}
|
||||
|
||||
private String normalizeIpv4MappedAddress(String ip) {
|
||||
if (ip == null) {
|
||||
return "";
|
||||
}
|
||||
if (ip.startsWith("::ffff:")) {
|
||||
return ip.substring(7);
|
||||
}
|
||||
int lastColon = ip.lastIndexOf(':');
|
||||
if (lastColon >= 0 && ip.indexOf('.') > lastColon) {
|
||||
return ip.substring(lastColon + 1);
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
+9
-2
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.TempFileRegistry;
|
||||
|
||||
@@ -61,8 +62,14 @@ public class TempFileCleanupService {
|
||||
// File patterns that identify common system temp files
|
||||
private static final Predicate<String> IS_SYSTEM_TEMP_FILE =
|
||||
fileName ->
|
||||
fileName.matches("lu\\d+[a-z0-9]*\\.tmp")
|
||||
|| fileName.matches("ocr_process\\d+")
|
||||
RegexPatternUtils.getInstance()
|
||||
.getSystemTempFile1Pattern()
|
||||
.matcher(fileName)
|
||||
.matches()
|
||||
|| RegexPatternUtils.getInstance()
|
||||
.getSystemTempFile2Pattern()
|
||||
.matcher(fileName)
|
||||
.matches()
|
||||
|| (fileName.startsWith("tmp") && !fileName.contains("jetty"))
|
||||
|| fileName.startsWith("OSL_PIPE_")
|
||||
|| (fileName.endsWith(".tmp") && !fileName.contains("jetty"));
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import com.github.junrar.Archive;
|
||||
import com.github.junrar.exception.CorruptHeaderException;
|
||||
import com.github.junrar.exception.RarException;
|
||||
import com.github.junrar.rarfile.FileHeader;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class CbrUtils {
|
||||
|
||||
public byte[] convertCbrToPdf(
|
||||
MultipartFile cbrFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager)
|
||||
throws IOException {
|
||||
return convertCbrToPdf(cbrFile, pdfDocumentFactory, tempFileManager, false);
|
||||
}
|
||||
|
||||
public byte[] convertCbrToPdf(
|
||||
MultipartFile cbrFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager,
|
||||
boolean optimizeForEbook)
|
||||
throws IOException {
|
||||
|
||||
validateCbrFile(cbrFile);
|
||||
|
||||
try (TempFile tempFile = new TempFile(tempFileManager, ".cbr")) {
|
||||
cbrFile.transferTo(tempFile.getFile());
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.createNewDocument()) {
|
||||
|
||||
Archive archive;
|
||||
try {
|
||||
archive = new Archive(tempFile.getFile());
|
||||
} catch (CorruptHeaderException e) {
|
||||
log.warn(
|
||||
"Failed to open CBR/RAR archive due to corrupt header: {}",
|
||||
e.getMessage());
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat",
|
||||
"Invalid or corrupted CBR/RAR archive. "
|
||||
+ "The file may be corrupted, use an unsupported RAR format (RAR5+), "
|
||||
+ "or may not be a valid RAR archive. "
|
||||
+ "Please ensure the file is a valid RAR archive.");
|
||||
} catch (RarException e) {
|
||||
log.warn("Failed to open CBR/RAR archive: {}", e.getMessage());
|
||||
String errorMessage;
|
||||
String exMessage = e.getMessage() != null ? e.getMessage() : "";
|
||||
|
||||
if (exMessage.contains("encrypted")) {
|
||||
errorMessage = "Encrypted CBR/RAR archives are not supported.";
|
||||
} else if (exMessage.isEmpty()) {
|
||||
errorMessage =
|
||||
"Invalid CBR/RAR archive. "
|
||||
+ "The file may be encrypted, corrupted, or use an unsupported format.";
|
||||
} else {
|
||||
errorMessage =
|
||||
"Invalid CBR/RAR archive: "
|
||||
+ exMessage
|
||||
+ ". The file may be encrypted, corrupted, or use an unsupported format.";
|
||||
}
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidFormat", errorMessage);
|
||||
} catch (IOException e) {
|
||||
log.warn("IO error reading CBR/RAR archive: {}", e.getMessage());
|
||||
throw ExceptionUtils.createFileProcessingException("CBR extraction", e);
|
||||
}
|
||||
|
||||
List<ImageEntryData> imageEntries = new ArrayList<>();
|
||||
|
||||
try {
|
||||
for (FileHeader fileHeader : archive) {
|
||||
if (!fileHeader.isDirectory() && isImageFile(fileHeader.getFileName())) {
|
||||
try (InputStream is = archive.getInputStream(fileHeader)) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
is.transferTo(baos);
|
||||
imageEntries.add(
|
||||
new ImageEntryData(
|
||||
fileHeader.getFileName(), baos.toByteArray()));
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Error reading image {}: {}",
|
||||
fileHeader.getFileName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
archive.close();
|
||||
} catch (IOException e) {
|
||||
log.warn("Error closing CBR/RAR archive: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
imageEntries.sort(
|
||||
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
|
||||
|
||||
if (imageEntries.isEmpty()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileProcessing",
|
||||
"No valid images found in the CBR file. The archive may be empty or contain no supported image formats.");
|
||||
}
|
||||
|
||||
for (ImageEntryData imageEntry : imageEntries) {
|
||||
try {
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromByteArray(
|
||||
document, imageEntry.data(), imageEntry.name());
|
||||
PDPage page =
|
||||
new PDPage(
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(document, page)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Error processing image {}: {}", imageEntry.name(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileProcessing",
|
||||
"No images could be processed from the CBR file. All images may be corrupted or in unsupported formats.");
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
byte[] pdfBytes = baos.toByteArray();
|
||||
|
||||
// Apply Ghostscript optimization if requested
|
||||
if (optimizeForEbook) {
|
||||
try {
|
||||
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
} catch (IOException e) {
|
||||
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCbrFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"cbr".equals(extension) && !"rar".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a CBR or RAR archive");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCbrFile(MultipartFile file) {
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
return "cbr".equals(extension) || "rar".equals(extension);
|
||||
}
|
||||
|
||||
private boolean isImageFile(String filename) {
|
||||
return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches();
|
||||
}
|
||||
|
||||
private record ImageEntryData(String name, byte[] data) {}
|
||||
|
||||
private class NaturalOrderComparator implements Comparator<String> {
|
||||
private static String getChunk(String s, int length, int marker) {
|
||||
StringBuilder chunk = new StringBuilder();
|
||||
char c = s.charAt(marker);
|
||||
chunk.append(c);
|
||||
marker++;
|
||||
|
||||
if (isDigit(c)) {
|
||||
while (marker < length && isDigit(s.charAt(marker))) {
|
||||
chunk.append(s.charAt(marker));
|
||||
marker++;
|
||||
}
|
||||
} else {
|
||||
while (marker < length && !isDigit(s.charAt(marker))) {
|
||||
chunk.append(s.charAt(marker));
|
||||
marker++;
|
||||
}
|
||||
}
|
||||
return chunk.toString();
|
||||
}
|
||||
|
||||
private static boolean isDigit(char ch) {
|
||||
return ch >= '0' && ch <= '9';
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(String s1, String s2) {
|
||||
int len1 = s1.length();
|
||||
int len2 = s2.length();
|
||||
int marker1 = 0, marker2 = 0;
|
||||
|
||||
while (marker1 < len1 && marker2 < len2) {
|
||||
String chunk1 = getChunk(s1, len1, marker1);
|
||||
marker1 += chunk1.length();
|
||||
|
||||
String chunk2 = getChunk(s2, len2, marker2);
|
||||
marker2 += chunk2.length();
|
||||
|
||||
int result;
|
||||
if (isDigit(chunk1.charAt(0)) && isDigit(chunk2.charAt(0))) {
|
||||
int thisNumericValue = Integer.parseInt(chunk1);
|
||||
int thatNumericValue = Integer.parseInt(chunk2);
|
||||
result = Integer.compare(thisNumericValue, thatNumericValue);
|
||||
} else {
|
||||
result = chunk1.compareTo(chunk2);
|
||||
}
|
||||
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return Integer.compare(len1, len2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.BufferedInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipFile;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class CbzUtils {
|
||||
|
||||
public byte[] convertCbzToPdf(
|
||||
MultipartFile cbzFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager)
|
||||
throws IOException {
|
||||
return convertCbzToPdf(cbzFile, pdfDocumentFactory, tempFileManager, false);
|
||||
}
|
||||
|
||||
public byte[] convertCbzToPdf(
|
||||
MultipartFile cbzFile,
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
TempFileManager tempFileManager,
|
||||
boolean optimizeForEbook)
|
||||
throws IOException {
|
||||
|
||||
validateCbzFile(cbzFile);
|
||||
|
||||
try (TempFile tempFile = new TempFile(tempFileManager, ".cbz")) {
|
||||
cbzFile.transferTo(tempFile.getFile());
|
||||
|
||||
// Early ZIP validity check using ZipInputStream (fail fast on non-zip content)
|
||||
try (BufferedInputStream bis =
|
||||
new BufferedInputStream(
|
||||
new java.io.FileInputStream(tempFile.getFile()));
|
||||
ZipInputStream zis = new ZipInputStream(bis)) {
|
||||
if (zis.getNextEntry() == null) {
|
||||
throw new IllegalArgumentException("Archive is empty or invalid ZIP");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Invalid CBZ/ZIP archive", e);
|
||||
}
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.createNewDocument();
|
||||
ZipFile zipFile = new ZipFile(tempFile.getFile())) {
|
||||
Enumeration<? extends ZipEntry> entries = zipFile.entries();
|
||||
List<ImageEntryData> imageEntries = new ArrayList<>();
|
||||
while (entries.hasMoreElements()) {
|
||||
ZipEntry entry = entries.nextElement();
|
||||
if (!entry.isDirectory() && isImageFile(entry.getName())) {
|
||||
try (InputStream is = zipFile.getInputStream(entry)) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
is.transferTo(baos);
|
||||
imageEntries.add(
|
||||
new ImageEntryData(entry.getName(), baos.toByteArray()));
|
||||
} catch (IOException e) {
|
||||
log.warn("Error reading image {}: {}", entry.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
imageEntries.sort(
|
||||
Comparator.comparing(ImageEntryData::name, new NaturalOrderComparator()));
|
||||
|
||||
if (imageEntries.isEmpty()) {
|
||||
throw new IllegalArgumentException("No valid images found in the CBZ file");
|
||||
}
|
||||
|
||||
for (ImageEntryData imageEntry : imageEntries) {
|
||||
try {
|
||||
PDImageXObject pdImage =
|
||||
PDImageXObject.createFromByteArray(
|
||||
document, imageEntry.data(), imageEntry.name());
|
||||
PDPage page =
|
||||
new PDPage(
|
||||
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
|
||||
document.addPage(page);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(document, page)) {
|
||||
contentStream.drawImage(pdImage, 0, 0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Error processing image {}: {}", imageEntry.name(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"No images could be processed from the CBZ file");
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
byte[] pdfBytes = baos.toByteArray();
|
||||
|
||||
// Apply Ghostscript optimization if requested
|
||||
if (optimizeForEbook) {
|
||||
try {
|
||||
return GeneralUtils.optimizePdfWithGhostscript(pdfBytes);
|
||||
} catch (IOException e) {
|
||||
log.warn("Ghostscript optimization failed, returning unoptimized PDF", e);
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
|
||||
return pdfBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCbzFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"cbz".equals(extension) && !"zip".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a CBZ or ZIP archive");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCbzFile(MultipartFile file) {
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
return "cbz".equals(extension) || "zip".equals(extension);
|
||||
}
|
||||
|
||||
public static boolean isComicBookFile(MultipartFile file) {
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
return "cbz".equals(extension)
|
||||
|| "zip".equals(extension)
|
||||
|| "cbr".equals(extension)
|
||||
|| "rar".equals(extension);
|
||||
}
|
||||
|
||||
private boolean isImageFile(String filename) {
|
||||
return RegexPatternUtils.getInstance().getImageFilePattern().matcher(filename).matches();
|
||||
}
|
||||
|
||||
private record ImageEntryData(String name, byte[] data) {}
|
||||
|
||||
private class NaturalOrderComparator implements Comparator<String> {
|
||||
@Override
|
||||
public int compare(String s1, String s2) {
|
||||
int len1 = s1.length();
|
||||
int len2 = s2.length();
|
||||
int marker1 = 0, marker2 = 0;
|
||||
|
||||
while (marker1 < len1 && marker2 < len2) {
|
||||
String chunk1 = getChunk(s1, len1, marker1);
|
||||
marker1 += chunk1.length();
|
||||
|
||||
String chunk2 = getChunk(s2, len2, marker2);
|
||||
marker2 += chunk2.length();
|
||||
|
||||
int result;
|
||||
if (isDigit(chunk1.charAt(0)) && isDigit(chunk2.charAt(0))) {
|
||||
int thisNumericValue = Integer.parseInt(chunk1);
|
||||
int thatNumericValue = Integer.parseInt(chunk2);
|
||||
result = Integer.compare(thisNumericValue, thatNumericValue);
|
||||
} else {
|
||||
result = chunk1.compareTo(chunk2);
|
||||
}
|
||||
|
||||
if (result != 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return Integer.compare(len1, len2);
|
||||
}
|
||||
|
||||
private static String getChunk(String s, int length, int marker) {
|
||||
StringBuilder chunk = new StringBuilder();
|
||||
char c = s.charAt(marker);
|
||||
chunk.append(c);
|
||||
marker++;
|
||||
|
||||
if (isDigit(c)) {
|
||||
while (marker < length && isDigit(s.charAt(marker))) {
|
||||
chunk.append(s.charAt(marker));
|
||||
marker++;
|
||||
}
|
||||
} else {
|
||||
while (marker < length && !isDigit(s.charAt(marker))) {
|
||||
chunk.append(s.charAt(marker));
|
||||
marker++;
|
||||
}
|
||||
}
|
||||
return chunk.toString();
|
||||
}
|
||||
|
||||
private static boolean isDigit(char ch) {
|
||||
return ch >= '0' && ch <= '9';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.zip.Adler32;
|
||||
import java.util.zip.CRC32;
|
||||
import java.util.zip.Checksum;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
@UtilityClass
|
||||
public class ChecksumUtils {
|
||||
|
||||
/** Shared buffer size for streaming I/O. */
|
||||
private static final int BUFFER_SIZE = 8192;
|
||||
|
||||
/** Mask to extract the lower 32 bits of a long value (unsigned int). */
|
||||
private static final long UNSIGNED_32_BIT_MASK = 0xFFFFFFFFL;
|
||||
|
||||
/**
|
||||
* Computes a checksum for the given file using the chosen algorithm and returns a lowercase hex
|
||||
* string.
|
||||
*
|
||||
* <p>For digest algorithms (e.g., SHA-256, SHA-1, MD5), this returns the digest as hex. For
|
||||
* 32-bit {@link Checksum} algorithms ("CRC32", "ADLER32"), this returns an 8-character
|
||||
* lowercase hex string of the unsigned 32-bit value.
|
||||
*
|
||||
* @param path file to read
|
||||
* @param algorithm algorithm name (case-insensitive). Special values: "CRC32", "ADLER32".
|
||||
* @return hex string of the checksum
|
||||
* @throws IOException if the file cannot be read
|
||||
*/
|
||||
public static String checksum(Path path, String algorithm) throws IOException {
|
||||
try (InputStream is = Files.newInputStream(path)) {
|
||||
return checksum(is, algorithm);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a checksum for the given stream using the chosen algorithm and returns a lowercase
|
||||
* hex string.
|
||||
*
|
||||
* <p><strong>Note:</strong> This method does <em>not</em> close the provided stream.
|
||||
*
|
||||
* @param is input stream (not closed by this method)
|
||||
* @param algorithm algorithm name (case-insensitive). Special values: "CRC32", "ADLER32".
|
||||
* @return hex string of the checksum
|
||||
* @throws IOException if reading from the stream fails
|
||||
*/
|
||||
public static String checksum(InputStream is, String algorithm) throws IOException {
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
return checksumChecksum(is, new CRC32());
|
||||
case "ADLER32":
|
||||
return checksumChecksum(is, new Adler32());
|
||||
default:
|
||||
return toHex(checksumBytes(is, algorithm));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a checksum for the given file using the chosen algorithm and returns a Base64
|
||||
* encoded string.
|
||||
*
|
||||
* <p>For digest algorithms this is the Base64 of the raw digest bytes. For 32-bit checksum
|
||||
* algorithms ("CRC32", "ADLER32"), this is the Base64 of the 4-byte big-endian unsigned value.
|
||||
*
|
||||
* @param path file to read
|
||||
* @param algorithm algorithm name (case-insensitive). Special values: "CRC32", "ADLER32".
|
||||
* @return Base64-encoded checksum bytes
|
||||
* @throws IOException if the file cannot be read
|
||||
*/
|
||||
public static String checksumBase64(Path path, String algorithm) throws IOException {
|
||||
try (InputStream is = Files.newInputStream(path)) {
|
||||
return checksumBase64(is, algorithm);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a checksum for the given stream using the chosen algorithm and returns a Base64
|
||||
* encoded string.
|
||||
*
|
||||
* <p><strong>Note:</strong> This method does <em>not</em> close the provided stream.
|
||||
*
|
||||
* @param is input stream (not closed by this method)
|
||||
* @param algorithm algorithm name (case-insensitive). Special values: "CRC32", "ADLER32".
|
||||
* @return Base64-encoded checksum bytes
|
||||
* @throws IOException if reading from the stream fails
|
||||
*/
|
||||
public static String checksumBase64(InputStream is, String algorithm) throws IOException {
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new CRC32()));
|
||||
case "ADLER32":
|
||||
return Base64.getEncoder().encodeToString(checksumChecksumBytes(is, new Adler32()));
|
||||
default:
|
||||
return Base64.getEncoder().encodeToString(checksumBytes(is, algorithm));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes multiple checksums for the given file in a single pass over the data.
|
||||
*
|
||||
* <p>Returns a map from algorithm name to lowercase hex string. Order of results follows the
|
||||
* order of the provided {@code algorithms}.
|
||||
*
|
||||
* @param path file to read
|
||||
* @param algorithms algorithm names (case-insensitive). Special: "CRC32", "ADLER32".
|
||||
* @return map of algorithm → hex string
|
||||
* @throws IOException if the file cannot be read
|
||||
*/
|
||||
public static Map<String, String> checksums(Path path, String... algorithms)
|
||||
throws IOException {
|
||||
try (InputStream is = Files.newInputStream(path)) {
|
||||
return checksums(is, algorithms);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes multiple checksums for the given stream in a single pass over the data.
|
||||
*
|
||||
* <p><strong>Note:</strong> This method does <em>not</em> close the provided stream.
|
||||
*
|
||||
* @param is input stream (not closed by this method)
|
||||
* @param algorithms algorithm names (case-insensitive). Special: "CRC32", "ADLER32".
|
||||
* @return map of algorithm → hex string
|
||||
* @throws IOException if reading from the stream fails
|
||||
*/
|
||||
public static Map<String, String> checksums(InputStream is, String... algorithms)
|
||||
throws IOException {
|
||||
// Use LinkedHashMap to preserve the order of requested algorithms in the result.
|
||||
Map<String, MessageDigest> digests = new LinkedHashMap<>();
|
||||
Map<String, Checksum> checksums = new LinkedHashMap<>();
|
||||
|
||||
for (String algorithm : algorithms) {
|
||||
String key = algorithm; // keep original key for output
|
||||
switch (algorithm.toUpperCase(Locale.ROOT)) {
|
||||
case "CRC32":
|
||||
checksums.put(key, new CRC32());
|
||||
break;
|
||||
case "ADLER32":
|
||||
checksums.put(key, new Adler32());
|
||||
break;
|
||||
default:
|
||||
try {
|
||||
// For MessageDigest, pass the original name (case-insensitive per JCA)
|
||||
digests.put(key, MessageDigest.getInstance(algorithm));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("Unsupported algorithm: " + algorithm, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int read;
|
||||
while ((read = is.read(buffer)) != -1) {
|
||||
for (MessageDigest digest : digests.values()) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
for (Checksum cs : checksums.values()) {
|
||||
cs.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> results = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, MessageDigest> entry : digests.entrySet()) {
|
||||
results.put(entry.getKey(), toHex(entry.getValue().digest()));
|
||||
}
|
||||
for (Map.Entry<String, Checksum> entry : checksums.entrySet()) {
|
||||
// Keep value as long and mask to ensure unsigned hex formatting.
|
||||
long unsigned32 = entry.getValue().getValue() & UNSIGNED_32_BIT_MASK;
|
||||
results.put(entry.getKey(), String.format("%08x", unsigned32));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the checksum of a file with an expected hex string (case-insensitive).
|
||||
*
|
||||
* @param path file to read
|
||||
* @param algorithm algorithm name (case-insensitive). Special: "CRC32", "ADLER32".
|
||||
* @param expected expected hex string (case-insensitive)
|
||||
* @return {@code true} if they match, otherwise {@code false}
|
||||
* @throws IOException if the file cannot be read
|
||||
*/
|
||||
public static boolean matches(Path path, String algorithm, String expected) throws IOException {
|
||||
try (InputStream is = Files.newInputStream(path)) {
|
||||
return matches(is, algorithm, expected);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the checksum of a stream with an expected hex string (case-insensitive).
|
||||
*
|
||||
* <p><strong>Note:</strong> This method does <em>not</em> close the provided stream.
|
||||
*
|
||||
* @param is input stream (not closed by this method)
|
||||
* @param algorithm algorithm name (case-insensitive). Special: "CRC32", "ADLER32".
|
||||
* @param expected expected hex string (case-insensitive)
|
||||
* @return {@code true} if they match, otherwise {@code false}
|
||||
* @throws IOException if reading from the stream fails
|
||||
*/
|
||||
public static boolean matches(InputStream is, String algorithm, String expected)
|
||||
throws IOException {
|
||||
return checksum(is, algorithm).equalsIgnoreCase(expected);
|
||||
}
|
||||
|
||||
// ---------- Internal helpers ----------
|
||||
|
||||
/**
|
||||
* Computes a MessageDigest over a stream and returns the raw digest bytes.
|
||||
*
|
||||
* @param is input stream (not closed)
|
||||
* @param algorithm JCA MessageDigest algorithm (e.g., "SHA-256")
|
||||
* @return raw digest bytes
|
||||
* @throws IOException if reading fails
|
||||
* @throws IllegalStateException if the algorithm is unsupported
|
||||
*/
|
||||
private static byte[] checksumBytes(InputStream is, String algorithm) throws IOException {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance(algorithm);
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int read;
|
||||
while ((read = is.read(buffer)) != -1) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
return digest.digest();
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
// Keep the message explicit to aid debugging
|
||||
throw new IllegalStateException("Unsupported algorithm: " + algorithm, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a 32-bit {@link Checksum} over a stream and returns the lowercase 8-char hex of the
|
||||
* unsigned 32-bit value.
|
||||
*
|
||||
* @param is input stream (not closed)
|
||||
* @param checksum checksum implementation (CRC32, Adler32, etc.)
|
||||
* @return 8-character lowercase hex (big-endian representation)
|
||||
* @throws IOException if reading fails
|
||||
*/
|
||||
private static String checksumChecksum(InputStream is, Checksum checksum) throws IOException {
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int read;
|
||||
while ((read = is.read(buffer)) != -1) {
|
||||
checksum.update(buffer, 0, read);
|
||||
}
|
||||
// Keep as long and mask to ensure correct unsigned representation.
|
||||
long unsigned32 = checksum.getValue() & UNSIGNED_32_BIT_MASK;
|
||||
return String.format("%08x", unsigned32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a 32-bit {@link Checksum} over a stream and returns the raw 4-byte big-endian
|
||||
* representation of the unsigned 32-bit value.
|
||||
*
|
||||
* <p>Cast to int already truncates to the lower 32 bits; the sign is irrelevant because we
|
||||
* serialize the bit pattern directly into 4 bytes.
|
||||
*
|
||||
* @param is input stream (not closed)
|
||||
* @param checksum checksum implementation (CRC32, Adler32, etc.)
|
||||
* @return 4 bytes (big-endian)
|
||||
* @throws IOException if reading fails
|
||||
*/
|
||||
private static byte[] checksumChecksumBytes(InputStream is, Checksum checksum)
|
||||
throws IOException {
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
int read;
|
||||
while ((read = is.read(buffer)) != -1) {
|
||||
checksum.update(buffer, 0, read);
|
||||
}
|
||||
// Cast keeps only the lower 32 bits; mask is unnecessary here.
|
||||
int v = (int) checksum.getValue();
|
||||
return ByteBuffer.allocate(4).order(ByteOrder.BIG_ENDIAN).putInt(v).array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts bytes to a lowercase hex string.
|
||||
*
|
||||
* @param hash the byte array to convert
|
||||
* @return the lowercase hex string
|
||||
*/
|
||||
private static String toHex(byte[] hash) {
|
||||
StringBuilder sb = new StringBuilder(hash.length * 2);
|
||||
for (byte b : hash) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import org.owasp.html.AttributePolicy;
|
||||
import org.owasp.html.HtmlPolicyBuilder;
|
||||
import org.owasp.html.PolicyFactory;
|
||||
import org.owasp.html.Sanitizers;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -16,7 +15,6 @@ public class CustomHtmlSanitizer {
|
||||
private final SsrfProtectionService ssrfProtectionService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Autowired
|
||||
public CustomHtmlSanitizer(
|
||||
SsrfProtectionService ssrfProtectionService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
@@ -28,7 +26,7 @@ public class CustomHtmlSanitizer {
|
||||
new AttributePolicy() {
|
||||
@Override
|
||||
public String apply(String elementName, String attributeName, String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
if (value.trim().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ import java.io.InputStream;
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
@@ -25,11 +29,11 @@ public class EmlParser {
|
||||
private static volatile boolean mimeUtilityChecked = false;
|
||||
|
||||
private static final Pattern MIME_ENCODED_PATTERN =
|
||||
Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
|
||||
RegexPatternUtils.getInstance().getMimeEncodedWordPattern();
|
||||
|
||||
private static final String DISPOSITION_ATTACHMENT = "attachment";
|
||||
private static final String TEXT_PLAIN = "text/plain";
|
||||
private static final String TEXT_HTML = "text/html";
|
||||
private static final String TEXT_PLAIN = MediaType.TEXT_PLAIN_VALUE;
|
||||
private static final String TEXT_HTML = MediaType.TEXT_HTML_VALUE;
|
||||
private static final String MULTIPART_PREFIX = "multipart/";
|
||||
|
||||
private static final String HEADER_CONTENT_TYPE = "content-type:";
|
||||
@@ -69,12 +73,12 @@ public class EmlParser {
|
||||
if (isJakartaMailAvailable()) {
|
||||
return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
|
||||
} else {
|
||||
return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer);
|
||||
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
|
||||
}
|
||||
}
|
||||
|
||||
private static EmailContent extractEmailContentBasic(
|
||||
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
byte[] emlBytes, CustomHtmlSanitizer customHtmlSanitizer) {
|
||||
String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
|
||||
EmailContent content = new EmailContent();
|
||||
|
||||
@@ -121,7 +125,7 @@ public class EmlParser {
|
||||
return extractFromMimeMessage(message, request, customHtmlSanitizer);
|
||||
|
||||
} catch (ReflectiveOperationException e) {
|
||||
return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer);
|
||||
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +147,11 @@ public class EmlParser {
|
||||
extractRecipients(message, messageClass, content);
|
||||
|
||||
Method getSentDate = messageClass.getMethod("getSentDate");
|
||||
content.setDate((Date) getSentDate.invoke(message));
|
||||
Date legacyDate = (Date) getSentDate.invoke(message);
|
||||
if (legacyDate != null) {
|
||||
content.setDate(
|
||||
ZonedDateTime.ofInstant(legacyDate.toInstant(), ZoneId.systemDefault()));
|
||||
}
|
||||
|
||||
Method getContent = messageClass.getMethod("getContent");
|
||||
Object messageContent = getContent.invoke(message);
|
||||
@@ -349,7 +357,11 @@ public class EmlParser {
|
||||
for (String contentIdHeader : contentIdHeaders) {
|
||||
if (contentIdHeader != null && !contentIdHeader.trim().isEmpty()) {
|
||||
attachment.setEmbedded(true);
|
||||
String contentId = contentIdHeader.trim().replaceAll("[<>]", "");
|
||||
String contentId =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getAngleBracketsPattern()
|
||||
.matcher(contentIdHeader.trim())
|
||||
.replaceAll("");
|
||||
attachment.setContentId(contentId);
|
||||
break;
|
||||
}
|
||||
@@ -406,7 +418,8 @@ public class EmlParser {
|
||||
|
||||
private static String extractBasicHeader(String emlContent, String headerName) {
|
||||
try {
|
||||
String[] lines = emlContent.split("\r?\n");
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
if (line.toLowerCase().startsWith(headerName.toLowerCase())) {
|
||||
@@ -477,7 +490,10 @@ public class EmlParser {
|
||||
}
|
||||
|
||||
private static int findPartEnd(String content, int start) {
|
||||
String[] lines = content.substring(start).split("\r?\n");
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getNewlineSplitPattern()
|
||||
.split(content.substring(start));
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
for (String line : lines) {
|
||||
@@ -491,7 +507,8 @@ public class EmlParser {
|
||||
private static List<EmailAttachment> extractAttachmentsBasic(String emlContent) {
|
||||
List<EmailAttachment> attachments = new ArrayList<>();
|
||||
try {
|
||||
String[] lines = emlContent.split("\r?\n");
|
||||
String[] lines =
|
||||
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
|
||||
boolean inHeaders = true;
|
||||
String currentContentType = "";
|
||||
String currentDisposition = "";
|
||||
@@ -554,7 +571,11 @@ public class EmlParser {
|
||||
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
|
||||
String extendedFilename =
|
||||
disposition.substring(filenameStarStart, filenameStarEnd).trim();
|
||||
extendedFilename = extendedFilename.replaceAll("^\"|\"$", "");
|
||||
extendedFilename =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getQuotesRemovalPattern()
|
||||
.matcher(extendedFilename)
|
||||
.replaceAll("");
|
||||
|
||||
if (extendedFilename.contains("'")) {
|
||||
String[] parts = extendedFilename.split("'", 3);
|
||||
@@ -569,7 +590,11 @@ public class EmlParser {
|
||||
int filenameEnd = disposition.indexOf(";", filenameStart);
|
||||
if (filenameEnd == -1) filenameEnd = disposition.length();
|
||||
String filename = disposition.substring(filenameStart, filenameEnd).trim();
|
||||
filename = filename.replaceAll("^\"|\"$", "");
|
||||
filename =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getQuotesRemovalPattern()
|
||||
.matcher(filename)
|
||||
.replaceAll("");
|
||||
return safeMimeDecode(filename);
|
||||
}
|
||||
|
||||
@@ -614,7 +639,7 @@ public class EmlParser {
|
||||
private String to;
|
||||
private String cc;
|
||||
private String bcc;
|
||||
private Date date;
|
||||
private ZonedDateTime date;
|
||||
private String dateString; // For basic parsing fallback
|
||||
private String htmlBody;
|
||||
private String textBody;
|
||||
@@ -622,11 +647,23 @@ public class EmlParser {
|
||||
private List<EmailAttachment> attachments = new ArrayList<>();
|
||||
|
||||
public void setHtmlBody(String htmlBody) {
|
||||
this.htmlBody = htmlBody != null ? htmlBody.replaceAll("\r", "") : null;
|
||||
this.htmlBody =
|
||||
htmlBody != null
|
||||
? RegexPatternUtils.getInstance()
|
||||
.getCarriageReturnPattern()
|
||||
.matcher(htmlBody)
|
||||
.replaceAll("")
|
||||
: null;
|
||||
}
|
||||
|
||||
public void setTextBody(String textBody) {
|
||||
this.textBody = textBody != null ? textBody.replaceAll("\r", "") : null;
|
||||
this.textBody =
|
||||
textBody != null
|
||||
? RegexPatternUtils.getInstance()
|
||||
.getCarriageReturnPattern()
|
||||
.matcher(textBody)
|
||||
.replaceAll("")
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
|
||||
import stirling.software.common.model.api.converters.EmlToPdfRequest;
|
||||
@@ -33,10 +35,10 @@ public class EmlProcessingUtils {
|
||||
// MIME type detection
|
||||
private static final Map<String, String> EXTENSION_TO_MIME_TYPE =
|
||||
Map.of(
|
||||
".png", "image/png",
|
||||
".jpg", "image/jpeg",
|
||||
".jpeg", "image/jpeg",
|
||||
".gif", "image/gif",
|
||||
".png", MediaType.IMAGE_PNG_VALUE,
|
||||
".jpg", MediaType.IMAGE_JPEG_VALUE,
|
||||
".jpeg", MediaType.IMAGE_JPEG_VALUE,
|
||||
".gif", MediaType.IMAGE_GIF_VALUE,
|
||||
".bmp", "image/bmp",
|
||||
".webp", "image/webp",
|
||||
".svg", "image/svg+xml",
|
||||
@@ -81,8 +83,8 @@ public class EmlProcessingUtils {
|
||||
|| lowerContent.contains("bcc:");
|
||||
boolean hasMimeStructure =
|
||||
lowerContent.contains("multipart/")
|
||||
|| lowerContent.contains("text/plain")
|
||||
|| lowerContent.contains("text/html")
|
||||
|| lowerContent.contains(MediaType.TEXT_PLAIN_VALUE)
|
||||
|| lowerContent.contains(MediaType.TEXT_HTML_VALUE)
|
||||
|| lowerContent.contains("boundary=");
|
||||
|
||||
int headerCount = 0;
|
||||
@@ -197,8 +199,16 @@ public class EmlProcessingUtils {
|
||||
String processed =
|
||||
customHtmlSanitizer != null ? customHtmlSanitizer.sanitize(htmlBody) : htmlBody;
|
||||
|
||||
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*fixed[^;]*;?", "");
|
||||
processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*absolute[^;]*;?", "");
|
||||
processed =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getFixedPositionCssPattern()
|
||||
.matcher(processed)
|
||||
.replaceAll("");
|
||||
processed =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getAbsolutePositionCssPattern()
|
||||
.matcher(processed)
|
||||
.replaceAll("");
|
||||
|
||||
if (emailContent != null && !emailContent.getAttachments().isEmpty()) {
|
||||
processed = PdfAttachmentHandler.processInlineImages(processed, emailContent);
|
||||
@@ -220,14 +230,18 @@ public class EmlProcessingUtils {
|
||||
html = html.replace("\n", "<br>\n");
|
||||
|
||||
html =
|
||||
html.replaceAll(
|
||||
"(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)",
|
||||
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
|
||||
RegexPatternUtils.getInstance()
|
||||
.getUrlLinkPattern()
|
||||
.matcher(html)
|
||||
.replaceAll(
|
||||
"<a href=\"$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
|
||||
|
||||
html =
|
||||
html.replaceAll(
|
||||
"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63})",
|
||||
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
|
||||
RegexPatternUtils.getInstance()
|
||||
.getEmailLinkPattern()
|
||||
.matcher(html)
|
||||
.replaceAll(
|
||||
"<a href=\"mailto:$1\" style=\"color: #1a73e8; text-decoration: underline;\">$1</a>");
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -464,7 +478,7 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
}
|
||||
|
||||
return "image/png";
|
||||
return MediaType.IMAGE_PNG_VALUE; // Default MIME type
|
||||
}
|
||||
|
||||
public static String decodeUrlEncoded(String encoded) {
|
||||
@@ -488,9 +502,13 @@ public class EmlProcessingUtils {
|
||||
Matcher concatenatedMatcher = concatenatedPattern.matcher(encodedText);
|
||||
String processedText =
|
||||
concatenatedMatcher.replaceAll(
|
||||
match -> match.group().replaceAll("\\s+(?==\\?)", ""));
|
||||
match ->
|
||||
RegexPatternUtils.getInstance()
|
||||
.getMimeHeaderWhitespacePattern()
|
||||
.matcher(match.group())
|
||||
.replaceAll(""));
|
||||
|
||||
Pattern mimePattern = Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
|
||||
Pattern mimePattern = RegexPatternUtils.getInstance().getMimeEncodedWordPattern();
|
||||
Matcher matcher = mimePattern.matcher(processedText);
|
||||
int lastEnd = 0;
|
||||
|
||||
@@ -505,7 +523,11 @@ public class EmlProcessingUtils {
|
||||
String decodedValue =
|
||||
switch (encoding) {
|
||||
case "B" -> {
|
||||
String cleanBase64 = encodedValue.replaceAll("\\s", "");
|
||||
String cleanBase64 =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getWhitespacePattern()
|
||||
.matcher(encodedValue)
|
||||
.replaceAll("");
|
||||
byte[] decodedBytes = Base64.getDecoder().decode(cleanBase64);
|
||||
Charset targetCharset;
|
||||
try {
|
||||
@@ -594,8 +616,16 @@ public class EmlProcessingUtils {
|
||||
}
|
||||
|
||||
public static String simplifyHtmlContent(String htmlContent) {
|
||||
String simplified = htmlContent.replaceAll("(?i)<script[^>]*>.*?</script>", "");
|
||||
simplified = simplified.replaceAll("(?i)<style[^>]*>.*?</style>", "");
|
||||
String simplified =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getScriptTagPattern()
|
||||
.matcher(htmlContent)
|
||||
.replaceAll("");
|
||||
simplified =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getStyleTagPattern()
|
||||
.matcher(simplified)
|
||||
.replaceAll("");
|
||||
return simplified;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,4 +324,63 @@ public class ExceptionUtils {
|
||||
return createIllegalArgumentException(
|
||||
"error.argumentRequired", "{0} must not be null", argumentName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a RuntimeException for memory/image size errors when rendering PDF images with DPI.
|
||||
* Handles OutOfMemoryError and related conditions (e.g., NegativeArraySizeException) that
|
||||
* result from images exceeding Java's array/memory limits.
|
||||
*
|
||||
* @param pageNumber the page number that caused the error
|
||||
* @param dpi the DPI value used
|
||||
* @param cause the original error/exception (e.g., OutOfMemoryError,
|
||||
* NegativeArraySizeException)
|
||||
* @return RuntimeException with user-friendly message
|
||||
*/
|
||||
public static RuntimeException createOutOfMemoryDpiException(
|
||||
int pageNumber, int dpi, Throwable cause) {
|
||||
String message =
|
||||
MessageFormat.format(
|
||||
"Out of memory or image-too-large error while rendering PDF page {0} at {1} DPI. "
|
||||
+ "This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). "
|
||||
+ "Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks.",
|
||||
pageNumber, dpi);
|
||||
return new RuntimeException(message, cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a RuntimeException for OutOfMemoryError when rendering PDF images with DPI.
|
||||
*
|
||||
* @param pageNumber the page number that caused the error
|
||||
* @param dpi the DPI value used
|
||||
* @param cause the original OutOfMemoryError
|
||||
* @return RuntimeException with user-friendly message
|
||||
*/
|
||||
public static RuntimeException createOutOfMemoryDpiException(
|
||||
int pageNumber, int dpi, OutOfMemoryError cause) {
|
||||
return createOutOfMemoryDpiException(pageNumber, dpi, (Throwable) cause);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a RuntimeException for memory/image size errors when rendering PDF images with DPI.
|
||||
* Handles OutOfMemoryError and related conditions (e.g., NegativeArraySizeException) that
|
||||
* result from images exceeding Java's array/memory limits.
|
||||
*
|
||||
* @param dpi the DPI value used
|
||||
* @param cause the original error/exception (e.g., OutOfMemoryError,
|
||||
* NegativeArraySizeException)
|
||||
* @return RuntimeException with user-friendly message
|
||||
*/
|
||||
public static RuntimeException createOutOfMemoryDpiException(int dpi, Throwable cause) {
|
||||
String message =
|
||||
MessageFormat.format(
|
||||
"Out of memory or image-too-large error while rendering PDF at {0} DPI. "
|
||||
+ "This can occur when the resulting image exceeds Java's array/memory limits (e.g., NegativeArraySizeException). "
|
||||
+ "Please use a lower DPI value (recommended: 150 or less) or process the document in smaller chunks.",
|
||||
dpi);
|
||||
return new RuntimeException(message, cause);
|
||||
}
|
||||
|
||||
public static RuntimeException createOutOfMemoryDpiException(int dpi, OutOfMemoryError cause) {
|
||||
return createOutOfMemoryDpiException(dpi, (Throwable) cause);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.*;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.FileVisitResult;
|
||||
import java.nio.file.Files;
|
||||
@@ -124,20 +127,21 @@ public class FileToPdf {
|
||||
private static void zipDirectory(Path sourceDir, Path zipFilePath) throws IOException {
|
||||
try (ZipOutputStream zos =
|
||||
new ZipOutputStream(new FileOutputStream(zipFilePath.toFile()))) {
|
||||
Files.walk(sourceDir)
|
||||
.filter(path -> !Files.isDirectory(path))
|
||||
.forEach(
|
||||
path -> {
|
||||
ZipEntry zipEntry =
|
||||
new ZipEntry(sourceDir.relativize(path).toString());
|
||||
try {
|
||||
zos.putNextEntry(zipEntry);
|
||||
Files.copy(path, zos);
|
||||
zos.closeEntry();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
try (Stream<Path> walk = Files.walk(sourceDir)) {
|
||||
walk.filter(path -> !Files.isDirectory(path))
|
||||
.forEach(
|
||||
path -> {
|
||||
ZipEntry zipEntry =
|
||||
new ZipEntry(sourceDir.relativize(path).toString());
|
||||
try {
|
||||
zos.putNextEntry(zipEntry);
|
||||
Files.copy(path, zos);
|
||||
zos.closeEntry();
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,15 +208,27 @@ public class FileToPdf {
|
||||
return "";
|
||||
}
|
||||
// Remove any drive letters (e.g., "C:\") and leading forward/backslashes
|
||||
entryName = entryName.replaceAll("^[a-zA-Z]:[\\\\/]+", "");
|
||||
entryName = entryName.replaceAll("^[\\\\/]+", "");
|
||||
entryName =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getDriveLetterPattern()
|
||||
.matcher(entryName)
|
||||
.replaceAll("");
|
||||
entryName =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getLeadingSlashesPattern()
|
||||
.matcher(entryName)
|
||||
.replaceAll("");
|
||||
|
||||
// Recursively remove path traversal sequences
|
||||
while (entryName.contains("../") || entryName.contains("..\\")) {
|
||||
entryName = entryName.replace("../", "").replace("..\\", "");
|
||||
}
|
||||
// Normalize all backslashes to forward slashes
|
||||
entryName = entryName.replaceAll("\\\\", "/");
|
||||
entryName =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getBackslashPattern()
|
||||
.matcher(entryName)
|
||||
.replaceAll("/");
|
||||
return entryName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.apache.pdfbox.pdmodel.font.PDType1Font;
|
||||
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
|
||||
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
|
||||
import org.apache.pdfbox.pdmodel.interactive.form.*;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public final class FormUtils {
|
||||
|
||||
private FormUtils() {}
|
||||
|
||||
public static boolean hasAnyRotatedPage(PDDocument document) {
|
||||
try {
|
||||
for (PDPage page : document.getPages()) {
|
||||
int rot = page.getRotation();
|
||||
int norm = ((rot % 360) + 360) % 360;
|
||||
if (norm != 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void copyAndTransformFormFields(
|
||||
PDDocument sourceDocument,
|
||||
PDDocument newDocument,
|
||||
int totalPages,
|
||||
int pagesPerSheet,
|
||||
int cols,
|
||||
int rows,
|
||||
float cellWidth,
|
||||
float cellHeight)
|
||||
throws IOException {
|
||||
|
||||
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
|
||||
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
|
||||
|
||||
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
|
||||
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
|
||||
newCatalog.setAcroForm(newAcroForm);
|
||||
|
||||
PDResources dr = new PDResources();
|
||||
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
|
||||
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
|
||||
dr.put(COSName.getPDFName("Helv"), helvetica);
|
||||
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
|
||||
newAcroForm.setDefaultResources(dr);
|
||||
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
|
||||
// Do not mutate the source AcroForm; skip bad widgets during copy
|
||||
newAcroForm.setNeedAppearances(true);
|
||||
|
||||
Map<String, Integer> fieldNameCounters = new HashMap<>();
|
||||
|
||||
// Build widget -> field map once for efficient lookups
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(pageIndex);
|
||||
List<PDAnnotation> annotations = sourcePage.getAnnotations();
|
||||
|
||||
if (annotations.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
int destinationPageIndex = pageIndex / pagesPerSheet;
|
||||
int adjustedPageIndex = pageIndex % pagesPerSheet;
|
||||
int rowIndex = adjustedPageIndex / cols;
|
||||
int colIndex = adjustedPageIndex % cols;
|
||||
|
||||
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
|
||||
PDRectangle sourceRect = sourcePage.getMediaBox();
|
||||
|
||||
float scaleWidth = cellWidth / sourceRect.getWidth();
|
||||
float scaleHeight = cellHeight / sourceRect.getHeight();
|
||||
float scale = Math.min(scaleWidth, scaleHeight);
|
||||
|
||||
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
|
||||
float y =
|
||||
destinationPage.getMediaBox().getHeight()
|
||||
- ((rowIndex + 1) * cellHeight
|
||||
- (cellHeight - sourceRect.getHeight() * scale) / 2);
|
||||
|
||||
copyBasicFormFields(
|
||||
sourceAcroForm,
|
||||
newAcroForm,
|
||||
sourcePage,
|
||||
destinationPage,
|
||||
x,
|
||||
y,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters,
|
||||
widgetFieldMap);
|
||||
}
|
||||
|
||||
// Refresh appearances to ensure widgets render correctly across viewers
|
||||
try {
|
||||
// Use reflection to avoid compile-time dependency on PDFBox version
|
||||
Method m = newAcroForm.getClass().getMethod("refreshAppearances");
|
||||
m.invoke(newAcroForm);
|
||||
} catch (NoSuchMethodException nsme) {
|
||||
log.warn(
|
||||
"AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances.");
|
||||
} catch (Throwable t) {
|
||||
log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t);
|
||||
}
|
||||
}
|
||||
|
||||
private static void copyBasicFormFields(
|
||||
PDAcroForm sourceAcroForm,
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage sourcePage,
|
||||
PDPage destinationPage,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters,
|
||||
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
|
||||
|
||||
try {
|
||||
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
|
||||
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
|
||||
|
||||
for (PDAnnotation annotation : sourceAnnotations) {
|
||||
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
|
||||
if (widgetAnnotation.getRectangle() == null) {
|
||||
continue;
|
||||
}
|
||||
PDField sourceField =
|
||||
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
|
||||
if (sourceField == null) {
|
||||
continue; // skip widgets without a matching field
|
||||
}
|
||||
if (sourceField instanceof PDTextField pdtextfield) {
|
||||
createSimpleTextField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdtextfield,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDCheckBox pdCheckBox) {
|
||||
createSimpleCheckBoxField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdCheckBox,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDRadioButton pdRadioButton) {
|
||||
createSimpleRadioButtonField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdRadioButton,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDComboBox pdComboBox) {
|
||||
createSimpleComboBoxField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdComboBox,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDListBox pdlistbox) {
|
||||
createSimpleListBoxField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdlistbox,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDSignatureField pdSignatureField) {
|
||||
createSimpleSignatureField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdSignatureField,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
} else if (sourceField instanceof PDPushButton pdPushButton) {
|
||||
createSimplePushButtonField(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
pdPushButton,
|
||||
widgetAnnotation,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to copy basic form fields for page {}: {}",
|
||||
pageIndex,
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleTextField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDTextField sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDTextField newTextField = new PDTextField(newAcroForm);
|
||||
newTextField.setDefaultAppearance("/Helv 12 Tf 0 g");
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newTextField,
|
||||
sourceField.getPartialName(),
|
||||
"textField",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.getValueAsString() != null) {
|
||||
newTextField.setValue(sourceField.getValueAsString());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create text field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleCheckBoxField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDCheckBox sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDCheckBox newCheckBox = new PDCheckBox(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newCheckBox,
|
||||
sourceField.getPartialName(),
|
||||
"checkBox",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.isChecked()) {
|
||||
newCheckBox.check();
|
||||
} else {
|
||||
newCheckBox.unCheck();
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create checkbox field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleRadioButtonField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDRadioButton sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDRadioButton newRadioButton = new PDRadioButton(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newRadioButton,
|
||||
sourceField.getPartialName(),
|
||||
"radioButton",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.getExportValues() != null) {
|
||||
newRadioButton.setExportValues(sourceField.getExportValues());
|
||||
}
|
||||
if (sourceField.getValue() != null) {
|
||||
newRadioButton.setValue(sourceField.getValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create radio button field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleComboBoxField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDComboBox sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDComboBox newComboBox = new PDComboBox(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newComboBox,
|
||||
sourceField.getPartialName(),
|
||||
"comboBox",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.getOptions() != null) {
|
||||
newComboBox.setOptions(sourceField.getOptions());
|
||||
}
|
||||
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
|
||||
newComboBox.setValue(sourceField.getValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create combo box field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleListBoxField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDListBox sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDListBox newListBox = new PDListBox(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newListBox,
|
||||
sourceField.getPartialName(),
|
||||
"listBox",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceField.getOptions() != null) {
|
||||
newListBox.setOptions(sourceField.getOptions());
|
||||
}
|
||||
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
|
||||
newListBox.setValue(sourceField.getValue());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create list box field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimpleSignatureField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDSignatureField sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDSignatureField newSignatureField = new PDSignatureField(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newSignatureField,
|
||||
sourceField.getPartialName(),
|
||||
"signature",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
if (!initialized) {
|
||||
return;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create signature field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSimplePushButtonField(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
PDPushButton sourceField,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
try {
|
||||
PDPushButton newPushButton = new PDPushButton(newAcroForm);
|
||||
|
||||
boolean initialized =
|
||||
initializeFieldWithWidget(
|
||||
newAcroForm,
|
||||
destinationPage,
|
||||
destinationAnnotations,
|
||||
newPushButton,
|
||||
sourceField.getPartialName(),
|
||||
"pushButton",
|
||||
sourceWidget,
|
||||
offsetX,
|
||||
offsetY,
|
||||
scale,
|
||||
pageIndex,
|
||||
fieldNameCounters);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to create push button field '{}': {}",
|
||||
sourceField.getPartialName(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
private static <T extends PDTerminalField> boolean initializeFieldWithWidget(
|
||||
PDAcroForm newAcroForm,
|
||||
PDPage destinationPage,
|
||||
List<PDAnnotation> destinationAnnotations,
|
||||
T newField,
|
||||
String originalName,
|
||||
String fallbackName,
|
||||
PDAnnotationWidget sourceWidget,
|
||||
float offsetX,
|
||||
float offsetY,
|
||||
float scale,
|
||||
int pageIndex,
|
||||
Map<String, Integer> fieldNameCounters) {
|
||||
|
||||
String baseName = (originalName != null) ? originalName : fallbackName;
|
||||
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
|
||||
newField.setPartialName(newFieldName);
|
||||
|
||||
PDAnnotationWidget newWidget = new PDAnnotationWidget();
|
||||
PDRectangle sourceRect = sourceWidget.getRectangle();
|
||||
if (sourceRect == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
|
||||
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
|
||||
float newWidth = sourceRect.getWidth() * scale;
|
||||
float newHeight = sourceRect.getHeight() * scale;
|
||||
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
|
||||
newWidget.setPage(destinationPage);
|
||||
|
||||
newField.getWidgets().add(newWidget);
|
||||
newWidget.setParent(newField);
|
||||
newAcroForm.getFields().add(newField);
|
||||
destinationAnnotations.add(newWidget);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String generateUniqueFieldName(
|
||||
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
|
||||
String baseName = "page" + pageIndex + "_" + originalName;
|
||||
|
||||
Integer counter = fieldNameCounters.get(baseName);
|
||||
if (counter == null) {
|
||||
counter = 0;
|
||||
} else {
|
||||
counter++;
|
||||
}
|
||||
fieldNameCounters.put(baseName, counter);
|
||||
|
||||
return counter == 0 ? baseName : baseName + "_" + counter;
|
||||
}
|
||||
|
||||
private static Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
|
||||
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
|
||||
if (acroForm == null) {
|
||||
return map;
|
||||
}
|
||||
try {
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets != null) {
|
||||
for (PDAnnotationWidget w : widgets) {
|
||||
if (w != null) {
|
||||
map.put(w, field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,9 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -28,25 +24,37 @@ import com.fathzer.soft.javaluator.DoubleEvaluator;
|
||||
import io.github.pixee.security.HostValidator;
|
||||
import io.github.pixee.security.Urls;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class GeneralUtils {
|
||||
|
||||
private static final Set<String> DEFAULT_VALID_SCRIPTS =
|
||||
Set.of("png_to_webp.py", "split_photos.py");
|
||||
private static final Set<String> DEFAULT_VALID_PIPELINE =
|
||||
private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py");
|
||||
private final Set<String> DEFAULT_VALID_PIPELINE =
|
||||
Set.of(
|
||||
"OCR images.json",
|
||||
"Prepare-pdfs-for-email.json",
|
||||
"split-rotate-auto-rename.json");
|
||||
|
||||
private static final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
|
||||
private static final String PYTHON_SCRIPTS_DIR = "python";
|
||||
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
|
||||
private final String PYTHON_SCRIPTS_DIR = "python";
|
||||
private final RegexPatternUtils patternCache = RegexPatternUtils.getInstance();
|
||||
// Valid size units used for convertSizeToBytes validation and parsing
|
||||
private final Set<String> VALID_SIZE_UNITS = Set.of("B", "KB", "MB", "GB", "TB");
|
||||
|
||||
public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
|
||||
/*
|
||||
* Converts a MultipartFile to a regular File with improved performance and security.
|
||||
*
|
||||
* @param multipartFile the multipart file to convert
|
||||
* @return temporary File containing the multipart file data
|
||||
* @throws IOException if I/O error occurs during conversion
|
||||
* @throws IllegalArgumentException if file exceeds maximum allowed size
|
||||
*/
|
||||
public File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException {
|
||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||
if (customTempDir == null || customTempDir.isEmpty()) {
|
||||
customTempDir = System.getProperty("stirling.tempfiles.directory");
|
||||
@@ -81,10 +89,137 @@ public class GeneralUtils {
|
||||
return tempFile;
|
||||
}
|
||||
|
||||
public static void deleteDirectory(Path path) throws IOException {
|
||||
/*
|
||||
* Gets the configured temporary directory, creating it if necessary.
|
||||
*
|
||||
* @return Path to the temporary directory
|
||||
* @throws IOException if directory creation fails
|
||||
*/
|
||||
private Path getTempDirectory() throws IOException {
|
||||
String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY");
|
||||
if (customTempDir == null || customTempDir.isEmpty()) {
|
||||
customTempDir = System.getProperty("stirling.tempfiles.directory");
|
||||
}
|
||||
|
||||
Path tempDir;
|
||||
if (customTempDir != null && !customTempDir.isEmpty()) {
|
||||
tempDir = Path.of(customTempDir);
|
||||
} else {
|
||||
tempDir = Path.of(System.getProperty("java.io.tmpdir"), "stirling-pdf");
|
||||
}
|
||||
|
||||
if (!Files.exists(tempDir)) {
|
||||
Files.createDirectories(tempDir);
|
||||
}
|
||||
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
/*
|
||||
* Remove file extension
|
||||
*
|
||||
* <p>Uses fast string operations for common cases (valid extensions) and falls back to
|
||||
* optimized regex for edge cases (no extension, hidden files, etc.).
|
||||
*
|
||||
* <ul>
|
||||
* <li>String operations avoid regex engine overhead for common cases
|
||||
* <li>Cached pattern compilation eliminates recompilation costs
|
||||
* <li>Fresh Matcher instances ensure thread safety
|
||||
* </ul>
|
||||
*
|
||||
* @param filename the filename to process, may be null
|
||||
* @return filename without extension, or "default" if input is null
|
||||
*/
|
||||
public String removeExtension(String filename) {
|
||||
if (filename == null) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (filename.isEmpty()) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
int dotIndex = filename.lastIndexOf('.');
|
||||
if (dotIndex > 0 && dotIndex < filename.length() - 1) {
|
||||
return filename.substring(0, dotIndex);
|
||||
}
|
||||
|
||||
if (dotIndex == 0 || dotIndex == filename.length() - 1 || dotIndex == -1) {
|
||||
return filename;
|
||||
}
|
||||
|
||||
Pattern pattern = patternCache.getPattern(RegexPatternUtils.getExtensionRegex());
|
||||
Matcher matcher = pattern.matcher(filename);
|
||||
return matcher.find() ? matcher.replaceFirst("") : filename;
|
||||
}
|
||||
|
||||
/*
|
||||
* Append suffix to base name with null safety.
|
||||
*
|
||||
* @param baseName the base filename, null becomes "default"
|
||||
* @param suffix the suffix to append, null becomes empty string
|
||||
* @return concatenated string with null safety
|
||||
*/
|
||||
public String appendSuffix(String baseName, String suffix) {
|
||||
return (baseName == null ? "default" : baseName) + (suffix != null ? suffix : "");
|
||||
}
|
||||
|
||||
/*
|
||||
* Generate a PDF filename by removing extension from first file and adding suffix.
|
||||
*
|
||||
* <p>High-level utility method for common PDF naming scenarios. Handles null safety and uses
|
||||
* extension removal.
|
||||
*
|
||||
* @param firstFilename the filename of the first file being, may be null
|
||||
* @param suffix the suffix to append (e.g., "_merged.pdf")
|
||||
* @return filename with suffix, or default name if input is null
|
||||
*/
|
||||
public String generateFilename(String firstFilename, String suffix) {
|
||||
String baseName = removeExtension(firstFilename);
|
||||
return appendSuffix(baseName, suffix);
|
||||
}
|
||||
|
||||
/*
|
||||
* Process a list of filenames by removing extensions and adding suffix.
|
||||
*
|
||||
* <p>Efficiently processes multiple filenames using streaming operations and bulk operations
|
||||
* where possible. Handles null safety for both input list and individual filenames.
|
||||
*
|
||||
* @param filenames the list of filenames to process, may be null
|
||||
* @param suffix the suffix to append to each processed filename
|
||||
* @param processor consumer to handle each processed filename, may be null
|
||||
*/
|
||||
public void processFilenames(
|
||||
List<String> filenames, String suffix, java.util.function.Consumer<String> processor) {
|
||||
if (filenames == null || processor == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
filenames.stream()
|
||||
.map(filename -> appendSuffix(removeExtension(filename), suffix))
|
||||
.forEach(processor);
|
||||
}
|
||||
|
||||
/*
|
||||
* Extract title from filename by removing extension, with fallback handling.
|
||||
*
|
||||
* <p>Returns "Untitled" for null or empty filenames, otherwise removes the extension using the
|
||||
* optimized removeExtension method.
|
||||
*
|
||||
* @param filename the filename to extract title from, may be null
|
||||
* @return the title without extension, or "Untitled" if input is null/empty
|
||||
*/
|
||||
public String getTitleFromFilename(String filename) {
|
||||
if (filename == null || filename.isEmpty()) {
|
||||
return "Untitled";
|
||||
}
|
||||
return removeExtension(filename);
|
||||
}
|
||||
|
||||
public void deleteDirectory(Path path) throws IOException {
|
||||
Files.walkFileTree(
|
||||
path,
|
||||
new SimpleFileVisitor<Path>() {
|
||||
new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
|
||||
throws IOException {
|
||||
@@ -101,8 +236,18 @@ public class GeneralUtils {
|
||||
});
|
||||
}
|
||||
|
||||
public static String convertToFileName(String name) {
|
||||
String safeName = name.replaceAll("[^a-zA-Z0-9]", "_");
|
||||
public String convertToFileName(String name) {
|
||||
if (name == null) return "_";
|
||||
StringBuilder safeNameBuilder = new StringBuilder(name.length());
|
||||
for (int i = 0; i < name.length(); i++) {
|
||||
char c = name.charAt(i);
|
||||
if (Character.isLetterOrDigit(c)) {
|
||||
safeNameBuilder.append(c);
|
||||
} else {
|
||||
safeNameBuilder.append('_');
|
||||
}
|
||||
}
|
||||
String safeName = safeNameBuilder.toString();
|
||||
if (safeName.length() > 50) {
|
||||
safeName = safeName.substring(0, 50);
|
||||
}
|
||||
@@ -110,19 +255,20 @@ public class GeneralUtils {
|
||||
}
|
||||
|
||||
// Get resources from a location pattern
|
||||
public static Resource[] getResourcesFromLocationPattern(
|
||||
public Resource[] getResourcesFromLocationPattern(
|
||||
String locationPattern, ResourceLoader resourceLoader) throws Exception {
|
||||
// Normalize the path for file resources
|
||||
if (locationPattern.startsWith("file:")) {
|
||||
String rawPath = locationPattern.substring(5).replace("\\*", "").replace("/*", "");
|
||||
String pattern = locationPattern;
|
||||
if (pattern.startsWith("file:")) {
|
||||
String rawPath = pattern.substring(5).replace("\\*", "").replace("/*", "");
|
||||
Path normalizePath = Paths.get(rawPath).normalize();
|
||||
locationPattern = "file:" + normalizePath.toString().replace("\\", "/") + "/*";
|
||||
pattern = "file:" + normalizePath.toString().replace("\\", "/") + "/*";
|
||||
}
|
||||
return ResourcePatternUtils.getResourcePatternResolver(resourceLoader)
|
||||
.getResources(locationPattern);
|
||||
.getResources(pattern);
|
||||
}
|
||||
|
||||
public static boolean isValidURL(String urlStr) {
|
||||
public boolean isValidURL(String urlStr) {
|
||||
try {
|
||||
Urls.create(
|
||||
urlStr, Urls.HTTP_PROTOCOLS, HostValidator.DENY_COMMON_INFRASTRUCTURE_TARGETS);
|
||||
@@ -132,7 +278,25 @@ public class GeneralUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isURLReachable(String urlStr) {
|
||||
/*
|
||||
* Checks if a URL is reachable with proper timeout configuration and error handling.
|
||||
*
|
||||
* @param urlStr the URL string to check
|
||||
* @return true if URL is reachable, false otherwise
|
||||
*/
|
||||
public boolean isURLReachable(String urlStr) {
|
||||
return isURLReachable(urlStr, 5000, 5000);
|
||||
}
|
||||
|
||||
/*
|
||||
* Checks if a URL is reachable with configurable timeouts.
|
||||
*
|
||||
* @param urlStr the URL string to check
|
||||
* @param connectTimeout connection timeout in milliseconds
|
||||
* @param readTimeout read timeout in milliseconds
|
||||
* @return true if URL is reachable, false otherwise
|
||||
*/
|
||||
public boolean isURLReachable(String urlStr, int connectTimeout, int readTimeout) {
|
||||
try {
|
||||
// Parse the URL
|
||||
URL url = URI.create(urlStr).toURL();
|
||||
@@ -152,16 +316,19 @@ public class GeneralUtils {
|
||||
// Check if the URL is reachable
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("HEAD");
|
||||
// connection.setConnectTimeout(5000); // Set connection timeout
|
||||
// connection.setReadTimeout(5000); // Set read timeout
|
||||
connection.setConnectTimeout(connectTimeout);
|
||||
connection.setReadTimeout(readTimeout);
|
||||
connection.setInstanceFollowRedirects(false); // Security: prevent redirect loops
|
||||
|
||||
int responseCode = connection.getResponseCode();
|
||||
return (200 <= responseCode && responseCode <= 399);
|
||||
} catch (Exception e) {
|
||||
log.debug("URL {} is not reachable: {}", urlStr, e.getMessage());
|
||||
return false; // Return false in case of any exception
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isLocalAddress(String host) {
|
||||
private boolean isLocalAddress(String host) {
|
||||
try {
|
||||
// Resolve DNS to IP address
|
||||
InetAddress address = InetAddress.getByName(host);
|
||||
@@ -181,7 +348,14 @@ public class GeneralUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static File multipartToFile(MultipartFile multipart) throws IOException {
|
||||
/*
|
||||
* Improved multipart file conversion using the shared helper method.
|
||||
*
|
||||
* @param multipart the multipart file to convert
|
||||
* @return temporary File containing the multipart file data
|
||||
* @throws IOException if I/O error occurs during conversion
|
||||
*/
|
||||
public File multipartToFile(MultipartFile multipart) throws IOException {
|
||||
Path tempFile = Files.createTempFile("overlay-", ".pdf");
|
||||
try (InputStream in = multipart.getInputStream();
|
||||
FileOutputStream out = new FileOutputStream(tempFile.toFile())) {
|
||||
@@ -194,54 +368,105 @@ public class GeneralUtils {
|
||||
return tempFile.toFile();
|
||||
}
|
||||
|
||||
public static Long convertSizeToBytes(String sizeStr) {
|
||||
/*
|
||||
* Supports TB/PB units and provides detailed error messages.
|
||||
*
|
||||
* @param sizeStr the size string to convert (e.g., "100MB", "1.5GB")
|
||||
* @param defaultUnit the default unit to assume if none specified ("MB", "GB", etc.)
|
||||
* @return size in bytes, or null if parsing fails
|
||||
* @throws IllegalArgumentException if defaultUnit is invalid
|
||||
*/
|
||||
public Long convertSizeToBytes(String sizeStr, String defaultUnit) {
|
||||
if (sizeStr == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (defaultUnit != null && !isValidSizeUnit(defaultUnit)) {
|
||||
throw new IllegalArgumentException("Invalid default unit: " + defaultUnit);
|
||||
}
|
||||
|
||||
sizeStr = sizeStr.trim().toUpperCase();
|
||||
sizeStr = sizeStr.replace(",", ".").replace(" ", "");
|
||||
|
||||
try {
|
||||
if (sizeStr.endsWith("KB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2)) * 1024);
|
||||
} else if (sizeStr.endsWith("MB")) {
|
||||
if (sizeStr.endsWith("TB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024
|
||||
* 1024);
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
} else if (sizeStr.endsWith("GB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024
|
||||
* 1024
|
||||
* 1024);
|
||||
} else if (sizeStr.endsWith("B")) {
|
||||
* 1024L
|
||||
* 1024L
|
||||
* 1024L);
|
||||
} else if (sizeStr.endsWith("MB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
|
||||
* 1024L
|
||||
* 1024L);
|
||||
} else if (sizeStr.endsWith("KB")) {
|
||||
return (long)
|
||||
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2)) * 1024L);
|
||||
} else if (!sizeStr.isEmpty() && sizeStr.charAt(sizeStr.length() - 1) == 'B') {
|
||||
return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
|
||||
} else {
|
||||
// Assume MB if no unit is specified
|
||||
return (long) (Double.parseDouble(sizeStr) * 1024 * 1024);
|
||||
// Use provided default unit or fall back to MB
|
||||
String unit = defaultUnit != null ? defaultUnit.toUpperCase() : "MB";
|
||||
double value = Double.parseDouble(sizeStr);
|
||||
return switch (unit) {
|
||||
case "TB" -> (long) (value * 1024L * 1024L * 1024L * 1024L);
|
||||
case "GB" -> (long) (value * 1024L * 1024L * 1024L);
|
||||
case "MB" -> (long) (value * 1024L * 1024L);
|
||||
case "KB" -> (long) (value * 1024L);
|
||||
case "B" -> (long) value;
|
||||
default -> (long) (value * 1024L * 1024L); // Default to MB
|
||||
};
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// The numeric part of the input string cannot be parsed, handle this case
|
||||
log.warn("Failed to parse size string '{}': {}", sizeStr, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String formatBytes(long bytes) {
|
||||
/*
|
||||
* Converts size string to bytes using MB as default unit.
|
||||
*
|
||||
* @param sizeStr the size string to convert
|
||||
* @return size in bytes, or null if parsing fails
|
||||
*/
|
||||
public Long convertSizeToBytes(String sizeStr) {
|
||||
return convertSizeToBytes(sizeStr, "MB");
|
||||
}
|
||||
|
||||
/* Validates if a string represents a valid size unit. */
|
||||
private boolean isValidSizeUnit(String unit) {
|
||||
// Use a precomputed Set for O(1) lookup, normalize using a locale-safe toUpperCase
|
||||
return unit != null && VALID_SIZE_UNITS.contains(unit.toUpperCase(Locale.ROOT));
|
||||
}
|
||||
|
||||
/* Enhanced byte formatting with TB/PB support and better precision. */
|
||||
public String formatBytes(long bytes) {
|
||||
if (bytes < 0) {
|
||||
return "Invalid size";
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
} else if (bytes < 1024L * 1024L) {
|
||||
return String.format(Locale.US, "%.2f KB", bytes / 1024.0);
|
||||
} else if (bytes < 1024 * 1024 * 1024) {
|
||||
} else if (bytes < 1024L * 1024L * 1024L) {
|
||||
return String.format(Locale.US, "%.2f MB", bytes / (1024.0 * 1024.0));
|
||||
} else {
|
||||
} else if (bytes < 1024L * 1024L * 1024L * 1024L) {
|
||||
return String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0));
|
||||
} else {
|
||||
return String.format(Locale.US, "%.2f TB", bytes / (1024.0 * 1024.0 * 1024.0 * 1024.0));
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Integer> parsePageList(String pages, int totalPages, boolean oneBased) {
|
||||
public List<Integer> parsePageList(String pages, int totalPages, boolean oneBased) {
|
||||
if (pages == null) {
|
||||
return List.of(1); // Default to first page if input is null
|
||||
}
|
||||
@@ -252,11 +477,11 @@ public class GeneralUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Integer> parsePageList(String[] pages, int totalPages) {
|
||||
public List<Integer> parsePageList(String[] pages, int totalPages) {
|
||||
return parsePageList(pages, totalPages, false);
|
||||
}
|
||||
|
||||
public static List<Integer> parsePageList(String[] pages, int totalPages, boolean oneBased) {
|
||||
public List<Integer> parsePageList(String[] pages, int totalPages, boolean oneBased) {
|
||||
List<Integer> result = new ArrayList<>();
|
||||
int offset = oneBased ? 1 : 0;
|
||||
for (String page : pages) {
|
||||
@@ -278,33 +503,72 @@ public class GeneralUtils {
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<Integer> evaluateNFunc(String expression, int maxValue) {
|
||||
/*
|
||||
* Enhanced mathematical expression evaluation with bounds checking and timeout protection.
|
||||
*
|
||||
* @param expression the mathematical expression containing 'n'
|
||||
* @param maxValue the maximum value for 'n' and result bounds
|
||||
* @return list of valid page numbers
|
||||
* @throws IllegalArgumentException if expression is invalid or unsafe
|
||||
*/
|
||||
public List<Integer> evaluateNFunc(String expression, int maxValue) {
|
||||
if (expression == null || expression.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("Expression cannot be null or empty");
|
||||
}
|
||||
|
||||
if (maxValue <= 0 || maxValue > 10000) {
|
||||
throw new IllegalArgumentException("maxValue must be between 1 and 10000 for safety");
|
||||
}
|
||||
|
||||
List<Integer> results = new ArrayList<>();
|
||||
DoubleEvaluator evaluator = new DoubleEvaluator();
|
||||
|
||||
// Validate the expression
|
||||
if (!expression.matches("[0-9n+\\-*/() ]+")) {
|
||||
throw new IllegalArgumentException("Invalid expression");
|
||||
// Validate the expression format
|
||||
if (!RegexPatternUtils.getInstance()
|
||||
.getMathExpressionPattern()
|
||||
.matcher(expression.trim())
|
||||
.matches()) {
|
||||
throw new IllegalArgumentException("Invalid expression format: " + expression);
|
||||
}
|
||||
|
||||
for (int n = 1; n <= maxValue; n++) {
|
||||
// Replace 'n' with the current value of n, correctly handling numbers before
|
||||
// 'n'
|
||||
String sanitizedExpression = sanitizeNFunction(expression, n);
|
||||
Double result = evaluator.evaluate(sanitizedExpression);
|
||||
try {
|
||||
// Replace 'n' with the current value of n, correctly handling numbers before 'n'
|
||||
String sanitizedExpression = sanitizeNFunction(expression.trim(), n);
|
||||
Double result = evaluator.evaluate(sanitizedExpression);
|
||||
|
||||
// Check if the result is null or not within bounds
|
||||
if (result == null) break;
|
||||
// Check if the result is null or not within bounds
|
||||
if (result == null || !Double.isFinite(result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.intValue() > 0 && result.intValue() <= maxValue)
|
||||
results.add(result.intValue());
|
||||
int intResult = result.intValue();
|
||||
if (intResult > 0 && intResult <= maxValue) {
|
||||
results.add(intResult);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to evaluate expression '{}' for n={}: {}",
|
||||
expression,
|
||||
n,
|
||||
e.getMessage());
|
||||
// Continue with next value instead of breaking
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static String sanitizeNFunction(String expression, int nValue) {
|
||||
String sanitizedExpression = expression.replace(" ", "");
|
||||
private String sanitizeNFunction(String expression, int nValue) {
|
||||
// Remove all spaces using a specialized character removal
|
||||
StringBuilder sb = new StringBuilder(expression.length());
|
||||
for (int i = 0; i < expression.length(); i++) {
|
||||
char c = expression.charAt(i);
|
||||
if (c != ' ') {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
String sanitizedExpression = sb.toString();
|
||||
String multiplyByOpeningRoundBracketPattern =
|
||||
"([0-9n)])\\("; // example: n(n-1), 9(n-1), (n-1)(n-2)
|
||||
sanitizedExpression =
|
||||
@@ -319,23 +583,45 @@ public class GeneralUtils {
|
||||
return sanitizedExpression;
|
||||
}
|
||||
|
||||
private static String insertMultiplicationBeforeN(String expression, int nValue) {
|
||||
// Insert multiplication between a number and 'n' (e.g., "4n" becomes "4*n")
|
||||
String withMultiplication = expression.replaceAll("(\\d)n", "$1*n");
|
||||
private String insertMultiplicationBeforeN(String expression, int nValue) {
|
||||
// Insert multiplication between a number and 'n' (e.g., "4n" becomes "4*n") using a loop
|
||||
StringBuilder sb = new StringBuilder(expression.length() + 4); // +4 for possible extra '*'
|
||||
for (int i = 0; i < expression.length(); i++) {
|
||||
char c = expression.charAt(i);
|
||||
sb.append(c);
|
||||
if (Character.isDigit(c)
|
||||
&& i + 1 < expression.length()
|
||||
&& expression.charAt(i + 1) == 'n') {
|
||||
sb.append('*');
|
||||
}
|
||||
}
|
||||
String withMultiplication = sb.toString();
|
||||
withMultiplication = formatConsecutiveNsForNFunction(withMultiplication);
|
||||
// Now replace 'n' with its current value
|
||||
return withMultiplication.replace("n", String.valueOf(nValue));
|
||||
}
|
||||
|
||||
private static String formatConsecutiveNsForNFunction(String expression) {
|
||||
private String formatConsecutiveNsForNFunction(String expression) {
|
||||
String text = expression;
|
||||
while (text.matches(".*n{2,}.*")) {
|
||||
text = text.replaceAll("(?<!n)n{2}", "n*n");
|
||||
// Replace all consecutive 'nn' with 'n*n' until no more 'nn' is found
|
||||
while (text.contains("nn")) {
|
||||
StringBuilder sb = new StringBuilder(text.length() + 2); // +2 for possible extra '*'
|
||||
int i = 0;
|
||||
while (i < text.length()) {
|
||||
if (i < text.length() - 1 && text.charAt(i) == 'n' && text.charAt(i + 1) == 'n') {
|
||||
sb.append("n*n");
|
||||
i += 2;
|
||||
} else {
|
||||
sb.append(text.charAt(i));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
text = sb.toString();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
private static List<Integer> handlePart(String part, int totalPages, int offset) {
|
||||
private List<Integer> handlePart(String part, int totalPages, int offset) {
|
||||
List<Integer> partResult = new ArrayList<>();
|
||||
|
||||
// First check for n-syntax because it should not be processed as a range
|
||||
@@ -361,7 +647,7 @@ public class GeneralUtils {
|
||||
}
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
// Range is invalid, ignore this part
|
||||
log.debug("Invalid range: {}", part);
|
||||
}
|
||||
} else {
|
||||
// This is a single page number
|
||||
@@ -370,14 +656,14 @@ public class GeneralUtils {
|
||||
if (pageNum >= 1 && pageNum <= totalPages) {
|
||||
partResult.add(pageNum - 1 + offset);
|
||||
}
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Ignore invalid numbers
|
||||
} catch (NumberFormatException e) {
|
||||
log.debug("Invalid page number: {}", part);
|
||||
}
|
||||
}
|
||||
return partResult;
|
||||
}
|
||||
|
||||
public static boolean createDir(String path) {
|
||||
public boolean createDir(String path) {
|
||||
Path folder = Paths.get(path);
|
||||
if (!Files.exists(folder)) {
|
||||
try {
|
||||
@@ -390,7 +676,7 @@ public class GeneralUtils {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static boolean isValidUUID(String uuid) {
|
||||
public boolean isValidUUID(String uuid) {
|
||||
if (uuid == null) {
|
||||
return false;
|
||||
}
|
||||
@@ -406,7 +692,7 @@ public class GeneralUtils {
|
||||
* Internal Implementation Details *
|
||||
*------------------------------------------------------------------------*/
|
||||
|
||||
public static void saveKeyToSettings(String key, Object newValue) throws IOException {
|
||||
public void saveKeyToSettings(String key, Object newValue) throws IOException {
|
||||
String[] keyArray = key.split("\\.");
|
||||
Path settingsPath = Paths.get(InstallationPathConfig.getSettingsPath());
|
||||
YamlHelper settingsYaml = new YamlHelper(settingsPath);
|
||||
@@ -414,48 +700,67 @@ public class GeneralUtils {
|
||||
settingsYaml.saveOverride(settingsPath);
|
||||
}
|
||||
|
||||
public static String generateMachineFingerprint() {
|
||||
/*
|
||||
* Machine fingerprint generation with better error logging and fallbacks.
|
||||
*
|
||||
* @return unique machine fingerprint or "GenericID" if generation fails
|
||||
*/
|
||||
public String generateMachineFingerprint() {
|
||||
try {
|
||||
// Get the MAC address
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
// Try to get MAC address from primary network interface
|
||||
InetAddress ip = InetAddress.getLocalHost();
|
||||
NetworkInterface network = NetworkInterface.getByInetAddress(ip);
|
||||
|
||||
if (network == null) {
|
||||
if (network == null || network.getHardwareAddress() == null) {
|
||||
// Fallback: iterate through all network interfaces
|
||||
Enumeration<NetworkInterface> networks = NetworkInterface.getNetworkInterfaces();
|
||||
while (networks.hasMoreElements()) {
|
||||
NetworkInterface net = networks.nextElement();
|
||||
byte[] mac = net.getHardwareAddress();
|
||||
if (mac != null) {
|
||||
for (int i = 0; i < mac.length; i++) {
|
||||
sb.append(String.format("%02X", mac[i]));
|
||||
if (net.isUp() && !net.isLoopback() && !net.isVirtual()) {
|
||||
byte[] mac = net.getHardwareAddress();
|
||||
if (mac != null && mac.length > 0) {
|
||||
for (byte b : mac) {
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
break; // Use the first valid network interface
|
||||
}
|
||||
break; // Use the first network interface with a MAC address
|
||||
}
|
||||
}
|
||||
} else {
|
||||
byte[] mac = network.getHardwareAddress();
|
||||
if (mac != null) {
|
||||
for (int i = 0; i < mac.length; i++) {
|
||||
sb.append(String.format("%02X", mac[i]));
|
||||
for (byte b : mac) {
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hash the MAC address for privacy and consistency
|
||||
// If no MAC address found, use hostname as fallback
|
||||
if (sb.length() == 0) {
|
||||
String hostname = InetAddress.getLocalHost().getHostName();
|
||||
sb.append(hostname != null ? hostname : "unknown-host");
|
||||
log.warn("No MAC address found, using hostname for fingerprint generation");
|
||||
}
|
||||
|
||||
// Hash the collected data for privacy and consistency
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder fingerprint = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
fingerprint.append(String.format("%02x", b));
|
||||
}
|
||||
|
||||
log.debug("Successfully generated machine fingerprint");
|
||||
return fingerprint.toString();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to generate machine fingerprint: {}", e.getMessage());
|
||||
return "GenericID";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* Extracts the default pipeline configurations from the classpath to the installation path.
|
||||
* Creates directories if needed and copies default JSON files.
|
||||
*
|
||||
@@ -464,7 +769,7 @@ public class GeneralUtils {
|
||||
*
|
||||
* @throws IOException if an I/O error occurs during file operations
|
||||
*/
|
||||
public static void extractPipeline() throws IOException {
|
||||
public void extractPipeline() throws IOException {
|
||||
Path pipelineDir =
|
||||
Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR);
|
||||
Files.createDirectories(pipelineDir);
|
||||
@@ -486,7 +791,7 @@ public class GeneralUtils {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* Extracts the specified Python script from the classpath to the installation path. Validates
|
||||
* name and copies file atomically when possible, overwriting existing.
|
||||
*
|
||||
@@ -497,7 +802,7 @@ public class GeneralUtils {
|
||||
* @throws IllegalArgumentException if the script name is invalid or not allowed
|
||||
* @throws IOException if an I/O error occurs
|
||||
*/
|
||||
public static Path extractScript(String scriptName) throws IOException {
|
||||
public Path extractScript(String scriptName) throws IOException {
|
||||
// Validate input
|
||||
if (scriptName == null || scriptName.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("scriptName must not be null or empty");
|
||||
@@ -530,15 +835,14 @@ public class GeneralUtils {
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
/*
|
||||
* Copies a resource from the classpath to a specified target file.
|
||||
*
|
||||
* @param resource the ClassPathResource to copy
|
||||
* @param target the target Path where the resource will be copied
|
||||
* @throws IOException if an I/O error occurs during the copy operation
|
||||
*/
|
||||
private static void copyResourceToFile(ClassPathResource resource, Path target)
|
||||
throws IOException {
|
||||
private void copyResourceToFile(ClassPathResource resource, Path target) throws IOException {
|
||||
Path dir = target.getParent();
|
||||
Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp");
|
||||
try (InputStream in = resource.getInputStream()) {
|
||||
@@ -573,7 +877,7 @@ public class GeneralUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isVersionHigher(String currentVersion, String compareVersion) {
|
||||
public boolean isVersionHigher(String currentVersion, String compareVersion) {
|
||||
if (currentVersion == null || compareVersion == null) {
|
||||
return false;
|
||||
}
|
||||
@@ -601,4 +905,67 @@ public class GeneralUtils {
|
||||
// If all components so far are equal, the longer version is considered higher
|
||||
return current.length > compare.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimizes a PDF using Ghostscript with ebook settings for better e-reader compatibility. Uses
|
||||
* -dPDFSETTINGS=/ebook -dFastWebView=true settings to create an optimized PDF.
|
||||
*
|
||||
* @param inputPdfBytes Original PDF as byte array
|
||||
* @return Optimized PDF as byte array
|
||||
* @throws IOException if Ghostscript optimization fails
|
||||
*/
|
||||
public byte[] optimizePdfWithGhostscript(byte[] inputPdfBytes) throws IOException {
|
||||
Path tempInput = null;
|
||||
Path tempOutput = null;
|
||||
|
||||
try {
|
||||
tempInput = Files.createTempFile("gs_input_", ".pdf");
|
||||
tempOutput = Files.createTempFile("gs_output_", ".pdf");
|
||||
|
||||
Files.write(tempInput, inputPdfBytes);
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("gs");
|
||||
command.add("-sDEVICE=pdfwrite");
|
||||
command.add("-dPDFSETTINGS=/ebook");
|
||||
command.add("-dFastWebView=true");
|
||||
command.add("-dNOPAUSE");
|
||||
command.add("-dQUIET");
|
||||
command.add("-dBATCH");
|
||||
command.add("-sOutputFile=" + tempOutput.toString());
|
||||
command.add(tempInput.toString());
|
||||
|
||||
ProcessExecutor.ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.warn(
|
||||
"Ghostscript ebook optimization failed with return code: {}",
|
||||
result.getRc());
|
||||
throw ExceptionUtils.createGhostscriptCompressionException();
|
||||
}
|
||||
|
||||
return Files.readAllBytes(tempOutput);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("Ghostscript ebook optimization failed", e);
|
||||
throw ExceptionUtils.createGhostscriptCompressionException(e);
|
||||
} finally {
|
||||
if (tempInput != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempInput);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp input file: {}", tempInput, e);
|
||||
}
|
||||
}
|
||||
if (tempOutput != null) {
|
||||
try {
|
||||
Files.deleteIfExists(tempOutput);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp output file: {}", tempOutput, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,19 +85,16 @@ public class ImageProcessingUtils {
|
||||
return 0;
|
||||
}
|
||||
int orientationTag = directory.getInt(ExifSubIFDDirectory.TAG_ORIENTATION);
|
||||
switch (orientationTag) {
|
||||
case 1:
|
||||
return 0;
|
||||
case 6:
|
||||
return 90;
|
||||
case 3:
|
||||
return 180;
|
||||
case 8:
|
||||
return 270;
|
||||
default:
|
||||
return switch (orientationTag) {
|
||||
case 1 -> 0;
|
||||
case 6 -> 90;
|
||||
case 3 -> 180;
|
||||
case 8 -> 270;
|
||||
default -> {
|
||||
log.warn("Unknown orientation tag: {}", orientationTag);
|
||||
return 0;
|
||||
}
|
||||
yield 0;
|
||||
}
|
||||
};
|
||||
} catch (ImageProcessingException | MetadataException e) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -43,26 +43,45 @@ public class JarPathUtil {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the path to the restart-helper.jar file Expected to be in the same directory as the main
|
||||
* JAR
|
||||
* Gets the path to the restart-helper.jar file. Checks multiple possible locations: 1. Same
|
||||
* directory as the main JAR (production deployment) 2. ./build/libs/restart-helper.jar
|
||||
* (development build) 3. app/common/build/libs/restart-helper.jar (multi-module build)
|
||||
*
|
||||
* @return Path to restart-helper.jar, or null if not found
|
||||
*/
|
||||
public static Path restartHelperJar() {
|
||||
Path appJar = currentJar();
|
||||
if (appJar == null) {
|
||||
return null;
|
||||
|
||||
// Define possible locations to check (in order of preference)
|
||||
Path[] possibleLocations = new Path[4];
|
||||
|
||||
// Location 1: Same directory as main JAR (production)
|
||||
if (appJar != null) {
|
||||
possibleLocations[0] = appJar.getParent().resolve("restart-helper.jar");
|
||||
}
|
||||
|
||||
Path helperJar = appJar.getParent().resolve("restart-helper.jar");
|
||||
// Location 2: ./build/libs/ (development build)
|
||||
possibleLocations[1] = Paths.get("build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
|
||||
if (Files.isRegularFile(helperJar)) {
|
||||
log.debug("Restart helper JAR located at: {}", helperJar);
|
||||
return helperJar;
|
||||
} else {
|
||||
log.warn("Restart helper JAR not found at: {}", helperJar);
|
||||
return null;
|
||||
// Location 3: app/common/build/libs/ (multi-module build)
|
||||
possibleLocations[2] =
|
||||
Paths.get("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Location 4: Current working directory
|
||||
possibleLocations[3] = Paths.get("restart-helper.jar").toAbsolutePath();
|
||||
|
||||
// Check each location
|
||||
for (Path location : possibleLocations) {
|
||||
if (location != null && Files.isRegularFile(location)) {
|
||||
log.info("Restart helper JAR found at: {}", location);
|
||||
return location;
|
||||
} else if (location != null) {
|
||||
log.debug("Restart helper JAR not found at: {}", location);
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("Restart helper JAR not found in any expected location");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PDFService {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
/*
|
||||
* Merge multiple PDF documents into a single PDF document
|
||||
*
|
||||
* @param documents List of PDDocument to be merged
|
||||
* @return Merged PDDocument
|
||||
* @throws IOException If an error occurs during merging
|
||||
*/
|
||||
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
|
||||
PDDocument merged = pdfDocumentFactory.createNewDocument();
|
||||
PDFMergerUtility merger = new PDFMergerUtility();
|
||||
for (PDDocument doc : documents) {
|
||||
merger.appendDocument(merged, doc);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
@@ -25,18 +25,22 @@ import com.vladsch.flexmark.util.data.MutableDataSet;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
@Slf4j
|
||||
@NoArgsConstructor
|
||||
public class PDFToFile {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
public PDFToFile(TempFileManager tempFileManager) {
|
||||
this.tempFileManager = tempFileManager;
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToMarkdown(MultipartFile inputFile)
|
||||
throws IOException, InterruptedException {
|
||||
if (!"application/pdf".equals(inputFile.getContentType())) {
|
||||
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@@ -71,15 +75,12 @@ public class PDFToFile {
|
||||
pdfBaseName = originalPdfFileName.substring(0, originalPdfFileName.lastIndexOf('.'));
|
||||
}
|
||||
|
||||
Path tempInputFile = null;
|
||||
Path tempOutputDir = null;
|
||||
byte[] fileBytes;
|
||||
String fileName = "temp.file";
|
||||
String fileName;
|
||||
|
||||
try {
|
||||
tempInputFile = Files.createTempFile("input_", ".pdf");
|
||||
inputFile.transferTo(tempInputFile);
|
||||
tempOutputDir = Files.createTempDirectory("output_");
|
||||
try (TempFile tempInputFile = new TempFile(tempFileManager, ".pdf");
|
||||
TempDirectory tempOutputDir = new TempDirectory(tempFileManager)) {
|
||||
inputFile.transferTo(tempInputFile.getFile());
|
||||
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
@@ -88,14 +89,16 @@ public class PDFToFile {
|
||||
"-s",
|
||||
"-noframes",
|
||||
"-c",
|
||||
tempInputFile.toString(),
|
||||
tempInputFile.getAbsolutePath(),
|
||||
pdfBaseName));
|
||||
|
||||
ProcessExecutorResult returnCode =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.PDFTOHTML)
|
||||
.runCommandWithOutputHandling(command, tempOutputDir.toFile());
|
||||
.runCommandWithOutputHandling(
|
||||
command, tempOutputDir.getPath().toFile());
|
||||
// Process HTML files to Markdown
|
||||
File[] outputFiles = Objects.requireNonNull(tempOutputDir.toFile().listFiles());
|
||||
File[] outputFiles =
|
||||
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
|
||||
List<File> markdownFiles = new ArrayList<>();
|
||||
|
||||
// Convert HTML files to Markdown
|
||||
@@ -105,7 +108,7 @@ public class PDFToFile {
|
||||
String markdown = htmlToMarkdownConverter.convert(html);
|
||||
|
||||
String mdFileName = outputFile.getName().replace(".html", ".md");
|
||||
File mdFile = new File(tempOutputDir.toFile(), mdFileName);
|
||||
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
|
||||
Files.writeString(mdFile.toPath(), markdown);
|
||||
markdownFiles.add(mdFile);
|
||||
}
|
||||
@@ -142,10 +145,6 @@ public class PDFToFile {
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
|
||||
} finally {
|
||||
if (tempInputFile != null) Files.deleteIfExists(tempInputFile);
|
||||
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
@@ -153,7 +152,7 @@ public class PDFToFile {
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
|
||||
throws IOException, InterruptedException {
|
||||
if (!"application/pdf".equals(inputFile.getContentType())) {
|
||||
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@@ -164,19 +163,18 @@ public class PDFToFile {
|
||||
pdfBaseName = originalPdfFileName.substring(0, originalPdfFileName.lastIndexOf('.'));
|
||||
}
|
||||
|
||||
Path tempInputFile = null;
|
||||
Path tempOutputDir = null;
|
||||
byte[] fileBytes;
|
||||
String fileName = "temp.file";
|
||||
String fileName;
|
||||
|
||||
try (TempFile inputFileTemp = new TempFile(tempFileManager, ".pdf");
|
||||
TempDirectory outputDirTemp = new TempDirectory(tempFileManager)) {
|
||||
|
||||
Path tempInputFile = inputFileTemp.getPath();
|
||||
Path tempOutputDir = outputDirTemp.getPath();
|
||||
|
||||
try {
|
||||
// Save the uploaded file to a temporary location
|
||||
tempInputFile = Files.createTempFile("input_", ".pdf");
|
||||
inputFile.transferTo(tempInputFile);
|
||||
|
||||
// Prepare the output directory
|
||||
tempOutputDir = Files.createTempDirectory("output_");
|
||||
|
||||
// Run the pdftohtml command with complex output
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
@@ -208,11 +206,6 @@ public class PDFToFile {
|
||||
log.error("Exception writing zip", e);
|
||||
}
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
|
||||
} finally {
|
||||
// Clean up the temporary files
|
||||
if (tempInputFile != null) Files.deleteIfExists(tempInputFile);
|
||||
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
|
||||
}
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
@@ -223,14 +216,14 @@ public class PDFToFile {
|
||||
MultipartFile inputFile, String outputFormat, String libreOfficeFilter)
|
||||
throws IOException, InterruptedException {
|
||||
|
||||
if (!"application/pdf".equals(inputFile.getContentType())) {
|
||||
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Get the original PDF file name without the extension
|
||||
String originalPdfFileName = Filenames.toSimpleFileName(inputFile.getOriginalFilename());
|
||||
|
||||
if (originalPdfFileName == null || "".equals(originalPdfFileName.trim())) {
|
||||
if (originalPdfFileName == null || originalPdfFileName.trim().isEmpty()) {
|
||||
originalPdfFileName = "output.pdf";
|
||||
}
|
||||
// Assume file is pdf if no extension
|
||||
@@ -245,19 +238,18 @@ public class PDFToFile {
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
Path tempInputFile = null;
|
||||
Path tempOutputDir = null;
|
||||
byte[] fileBytes;
|
||||
String fileName = "temp.file";
|
||||
String fileName;
|
||||
|
||||
try (TempFile inputFileTemp = new TempFile(tempFileManager, ".pdf");
|
||||
TempDirectory outputDirTemp = new TempDirectory(tempFileManager)) {
|
||||
|
||||
Path tempInputFile = inputFileTemp.getPath();
|
||||
Path tempOutputDir = outputDirTemp.getPath();
|
||||
|
||||
try {
|
||||
// Save the uploaded file to a temporary location
|
||||
tempInputFile = Files.createTempFile("input_", ".pdf");
|
||||
inputFile.transferTo(tempInputFile);
|
||||
|
||||
// Prepare the output directory
|
||||
tempOutputDir = Files.createTempDirectory("output_");
|
||||
|
||||
// Run the LibreOffice command
|
||||
List<String> command =
|
||||
new ArrayList<>(
|
||||
@@ -308,11 +300,6 @@ public class PDFToFile {
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Clean up the temporary files
|
||||
Files.deleteIfExists(tempInputFile);
|
||||
if (tempOutputDir != null) FileUtils.deleteDirectory(tempOutputDir.toFile());
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
+144
-132
@@ -8,7 +8,9 @@ import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
@@ -18,7 +20,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -37,6 +38,7 @@ import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.apache.pdfbox.text.TextPosition;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.Data;
|
||||
@@ -118,7 +120,7 @@ public class PdfAttachmentHandler {
|
||||
public String getContentType() {
|
||||
return attachment.getContentType() != null
|
||||
? attachment.getContentType()
|
||||
: "application/octet-stream";
|
||||
: MediaType.APPLICATION_OCTET_STREAM_VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -256,10 +258,7 @@ public class PdfAttachmentHandler {
|
||||
|
||||
if (contentIdMap.isEmpty()) return htmlContent;
|
||||
|
||||
Pattern cidPattern =
|
||||
Pattern.compile(
|
||||
"(?i)<img[^>]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
Pattern cidPattern = RegexPatternUtils.getInstance().getInlineCidImagePattern();
|
||||
Matcher matcher = cidPattern.matcher(htmlContent);
|
||||
|
||||
StringBuilder result = new StringBuilder();
|
||||
@@ -289,11 +288,15 @@ public class PdfAttachmentHandler {
|
||||
|
||||
public static String formatEmailDate(Date date) {
|
||||
if (date == null) return "";
|
||||
return formatEmailDate(ZonedDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault()));
|
||||
}
|
||||
|
||||
SimpleDateFormat formatter =
|
||||
new SimpleDateFormat("EEE, MMM d, yyyy 'at' h:mm a z", Locale.ENGLISH);
|
||||
formatter.setTimeZone(TimeZone.getTimeZone("UTC"));
|
||||
return formatter.format(date);
|
||||
public static String formatEmailDate(ZonedDateTime dateTime) {
|
||||
if (dateTime == null) return "";
|
||||
|
||||
DateTimeFormatter formatter =
|
||||
DateTimeFormatter.ofPattern("EEE, MMM d, yyyy 'at' h:mm a z", Locale.ENGLISH);
|
||||
return dateTime.withZoneSameInstant(ZoneId.of("UTC")).format(formatter);
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -313,121 +316,20 @@ public class PdfAttachmentHandler {
|
||||
}
|
||||
}
|
||||
|
||||
public static class AttachmentMarkerPositionFinder extends PDFTextStripper {
|
||||
@Getter private final List<MarkerPosition> positions = new ArrayList<>();
|
||||
private int currentPageIndex;
|
||||
protected boolean sortByPosition;
|
||||
private boolean isInAttachmentSection;
|
||||
private boolean attachmentSectionFound;
|
||||
private final StringBuilder currentText = new StringBuilder();
|
||||
|
||||
private static final Pattern ATTACHMENT_SECTION_PATTERN =
|
||||
Pattern.compile("attachments\\s*\\(\\d+\\)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Pattern FILENAME_PATTERN =
|
||||
Pattern.compile("@\\s*([^\\s\\(]+(?:\\.[a-zA-Z0-9]+)?)");
|
||||
|
||||
public AttachmentMarkerPositionFinder() {
|
||||
super();
|
||||
this.currentPageIndex = 0;
|
||||
this.sortByPosition = false; // Disable sorting to preserve document order
|
||||
this.isInAttachmentSection = false;
|
||||
this.attachmentSectionFound = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getText(PDDocument document) throws IOException {
|
||||
super.getText(document);
|
||||
|
||||
if (sortByPosition) {
|
||||
positions.sort(
|
||||
(a, b) -> {
|
||||
int pageCompare = Integer.compare(a.getPageIndex(), b.getPageIndex());
|
||||
if (pageCompare != 0) return pageCompare;
|
||||
return Float.compare(
|
||||
b.getY(), a.getY()); // Descending Y per PDF coordinate system
|
||||
});
|
||||
}
|
||||
|
||||
return ""; // Return empty string as we only need positions
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startPage(PDPage page) throws IOException {
|
||||
super.startPage(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void endPage(PDPage page) throws IOException {
|
||||
currentPageIndex++;
|
||||
super.endPage(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String string, List<TextPosition> textPositions)
|
||||
throws IOException {
|
||||
String lowerString = string.toLowerCase();
|
||||
|
||||
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
|
||||
isInAttachmentSection = true;
|
||||
attachmentSectionFound = true;
|
||||
}
|
||||
|
||||
if (isInAttachmentSection
|
||||
&& (lowerString.contains("</body>")
|
||||
|| lowerString.contains("</html>")
|
||||
|| (attachmentSectionFound
|
||||
&& lowerString.trim().isEmpty()
|
||||
&& string.length() > 50))) {
|
||||
isInAttachmentSection = false;
|
||||
}
|
||||
|
||||
if (isInAttachmentSection) {
|
||||
currentText.append(string);
|
||||
|
||||
for (int i = 0; (i = string.indexOf(ATTACHMENT_MARKER, i)) != -1; i++) {
|
||||
if (i < textPositions.size()) {
|
||||
TextPosition textPosition = textPositions.get(i);
|
||||
|
||||
String filename = extractFilenameAfterMarker(string, i);
|
||||
|
||||
MarkerPosition position =
|
||||
new MarkerPosition(
|
||||
currentPageIndex,
|
||||
textPosition.getXDirAdj(),
|
||||
textPosition.getYDirAdj(),
|
||||
ATTACHMENT_MARKER,
|
||||
filename);
|
||||
positions.add(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
super.writeString(string, textPositions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSortByPosition(boolean sortByPosition) {
|
||||
this.sortByPosition = sortByPosition;
|
||||
}
|
||||
|
||||
private String extractFilenameAfterMarker(String text, int markerIndex) {
|
||||
String afterMarker = text.substring(markerIndex + 1);
|
||||
|
||||
Matcher matcher = FILENAME_PATTERN.matcher("@" + afterMarker);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
|
||||
String[] parts = afterMarker.split("[\\s\\(\\)]+");
|
||||
for (String part : parts) {
|
||||
part = part.trim();
|
||||
if (part.length() > 3 && part.contains(".")) {
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
private static String normalizeFilename(String filename) {
|
||||
if (filename == null) return "";
|
||||
String normalized = filename.toLowerCase().trim();
|
||||
normalized =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getWhitespacePattern()
|
||||
.matcher(normalized)
|
||||
.replaceAll(" ");
|
||||
normalized =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getPattern("[^a-zA-Z0-9._-]")
|
||||
.matcher(normalized)
|
||||
.replaceAll("");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static Map<Integer, String> addAttachmentsToDocumentWithMapping(
|
||||
@@ -607,12 +509,122 @@ public class PdfAttachmentHandler {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String normalizeFilename(String filename) {
|
||||
if (filename == null) return "";
|
||||
return filename.toLowerCase()
|
||||
.trim()
|
||||
.replaceAll("\\s+", " ")
|
||||
.replaceAll("[^a-zA-Z0-9._-]", "");
|
||||
public static class AttachmentMarkerPositionFinder extends PDFTextStripper {
|
||||
private static final Pattern ATTACHMENT_SECTION_PATTERN =
|
||||
RegexPatternUtils.getInstance().getAttachmentSectionPattern();
|
||||
private static final Pattern FILENAME_PATTERN =
|
||||
RegexPatternUtils.getInstance().getAttachmentFilenamePattern();
|
||||
@Getter private final List<MarkerPosition> positions = new ArrayList<>();
|
||||
private final StringBuilder currentText = new StringBuilder();
|
||||
protected boolean sortByPosition;
|
||||
private int currentPageIndex;
|
||||
private boolean isInAttachmentSection;
|
||||
private boolean attachmentSectionFound;
|
||||
|
||||
public AttachmentMarkerPositionFinder() {
|
||||
super();
|
||||
this.currentPageIndex = 0;
|
||||
this.sortByPosition = false; // Disable sorting to preserve document order
|
||||
this.isInAttachmentSection = false;
|
||||
this.attachmentSectionFound = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getText(PDDocument document) throws IOException {
|
||||
super.getText(document);
|
||||
|
||||
if (sortByPosition) {
|
||||
positions.sort(
|
||||
(a, b) -> {
|
||||
int pageCompare = Integer.compare(a.getPageIndex(), b.getPageIndex());
|
||||
if (pageCompare != 0) return pageCompare;
|
||||
return Float.compare(
|
||||
b.getY(), a.getY()); // Descending Y per PDF coordinate system
|
||||
});
|
||||
}
|
||||
|
||||
return ""; // Return empty string as we only need positions
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startPage(PDPage page) throws IOException {
|
||||
super.startPage(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void endPage(PDPage page) throws IOException {
|
||||
currentPageIndex++;
|
||||
super.endPage(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeString(String string, List<TextPosition> textPositions)
|
||||
throws IOException {
|
||||
String lowerString = string.toLowerCase();
|
||||
|
||||
if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) {
|
||||
isInAttachmentSection = true;
|
||||
attachmentSectionFound = true;
|
||||
}
|
||||
|
||||
if (isInAttachmentSection
|
||||
&& (lowerString.contains("</body>")
|
||||
|| lowerString.contains("</html>")
|
||||
|| (attachmentSectionFound
|
||||
&& lowerString.trim().isEmpty()
|
||||
&& string.length() > 50))) {
|
||||
isInAttachmentSection = false;
|
||||
}
|
||||
|
||||
if (isInAttachmentSection) {
|
||||
currentText.append(string);
|
||||
|
||||
for (int i = 0; (i = string.indexOf(ATTACHMENT_MARKER, i)) != -1; i++) {
|
||||
if (i < textPositions.size()) {
|
||||
TextPosition textPosition = textPositions.get(i);
|
||||
|
||||
String filename = extractFilenameAfterMarker(string, i);
|
||||
|
||||
MarkerPosition position =
|
||||
new MarkerPosition(
|
||||
currentPageIndex,
|
||||
textPosition.getXDirAdj(),
|
||||
textPosition.getYDirAdj(),
|
||||
ATTACHMENT_MARKER,
|
||||
filename);
|
||||
positions.add(position);
|
||||
}
|
||||
}
|
||||
}
|
||||
super.writeString(string, textPositions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setSortByPosition(boolean sortByPosition) {
|
||||
this.sortByPosition = sortByPosition;
|
||||
}
|
||||
|
||||
private String extractFilenameAfterMarker(String text, int markerIndex) {
|
||||
String afterMarker = text.substring(markerIndex + 1);
|
||||
|
||||
Matcher matcher = FILENAME_PATTERN.matcher("@" + afterMarker);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
|
||||
String[] parts =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getWhitespaceParenthesesSplitPattern()
|
||||
.split(afterMarker);
|
||||
for (String part : parts) {
|
||||
part = part.trim();
|
||||
if (part.length() > 3 && part.contains(".")) {
|
||||
return part;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void addAttachmentAnnotationToPageWithMapping(
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
|
||||
@Slf4j
|
||||
public class PdfToCbrUtils {
|
||||
|
||||
public static byte[] convertPdfToCbr(
|
||||
MultipartFile pdfFile, int dpi, CustomPDFDocumentFactory pdfDocumentFactory)
|
||||
throws IOException {
|
||||
|
||||
validatePdfFile(pdfFile);
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IllegalArgumentException("PDF file contains no pages");
|
||||
}
|
||||
|
||||
return createCbrFromPdf(document, dpi);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validatePdfFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"pdf".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a PDF");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
|
||||
Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-");
|
||||
List<Path> generatedImages = new ArrayList<>();
|
||||
try {
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
try {
|
||||
BufferedImage image =
|
||||
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
|
||||
String imageFilename = String.format("page_%03d.png", pageIndex + 1);
|
||||
Path imagePath = tempDir.resolve(imageFilename);
|
||||
|
||||
ImageIO.write(image, "PNG", imagePath.toFile());
|
||||
generatedImages.add(imagePath);
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (generatedImages.isEmpty()) {
|
||||
throw new IOException("Failed to render any pages to images for CBR conversion");
|
||||
}
|
||||
|
||||
return createRarArchive(tempDir, generatedImages);
|
||||
} finally {
|
||||
cleanupTempFiles(generatedImages, tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createRarArchive(Path tempDir, List<Path> images) throws IOException {
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("rar");
|
||||
command.add("a");
|
||||
command.add("-m5");
|
||||
command.add("-ep1");
|
||||
|
||||
Path rarFile = tempDir.resolve("output.cbr");
|
||||
command.add(rarFile.getFileName().toString());
|
||||
|
||||
for (Path image : images) {
|
||||
command.add(image.getFileName().toString());
|
||||
}
|
||||
|
||||
ProcessExecutor executor =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.INSTALL_APP);
|
||||
try {
|
||||
ProcessExecutorResult result =
|
||||
executor.runCommandWithOutputHandling(command, tempDir.toFile());
|
||||
if (result.getRc() != 0) {
|
||||
throw new IOException("RAR command failed: " + result.getMessages());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("RAR command interrupted", e);
|
||||
}
|
||||
|
||||
if (!Files.exists(rarFile)) {
|
||||
throw new IOException("RAR file was not created");
|
||||
}
|
||||
|
||||
try (FileInputStream fis = new FileInputStream(rarFile.toFile());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
fis.transferTo(baos);
|
||||
return baos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static void cleanupTempFiles(List<Path> images, Path tempDir) {
|
||||
for (Path image : images) {
|
||||
try {
|
||||
Files.deleteIfExists(image);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to delete temp image file {}: {}", image, e.getMessage());
|
||||
}
|
||||
}
|
||||
if (tempDir != null) {
|
||||
try (var paths = Files.walk(tempDir)) {
|
||||
paths.sorted(Comparator.reverseOrder())
|
||||
.forEach(
|
||||
path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException e) {
|
||||
log.warn(
|
||||
"Failed to delete temp path {}: {}",
|
||||
path,
|
||||
e.getMessage());
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to clean up temp directory {}: {}", tempDir, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isPdfFile(MultipartFile file) {
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
return "pdf".equals(extension);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@Slf4j
|
||||
public class PdfToCbzUtils {
|
||||
|
||||
public static byte[] convertPdfToCbz(
|
||||
MultipartFile pdfFile, int dpi, CustomPDFDocumentFactory pdfDocumentFactory)
|
||||
throws IOException {
|
||||
|
||||
validatePdfFile(pdfFile);
|
||||
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
|
||||
if (document.getNumberOfPages() == 0) {
|
||||
throw new IllegalArgumentException("PDF file contains no pages");
|
||||
}
|
||||
|
||||
return createCbzFromPdf(document, dpi);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validatePdfFile(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("File cannot be null or empty");
|
||||
}
|
||||
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
throw new IllegalArgumentException("File must have a name");
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
if (!"pdf".equals(extension)) {
|
||||
throw new IllegalArgumentException("File must be a PDF");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
|
||||
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
|
||||
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
|
||||
|
||||
int totalPages = document.getNumberOfPages();
|
||||
|
||||
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||||
try {
|
||||
BufferedImage image =
|
||||
pdfRenderer.renderImageWithDPI(pageIndex, dpi, ImageType.RGB);
|
||||
|
||||
String imageFilename = String.format("page_%03d.png", pageIndex + 1);
|
||||
|
||||
ZipEntry zipEntry = new ZipEntry(imageFilename);
|
||||
zipOut.putNextEntry(zipEntry);
|
||||
|
||||
ImageIO.write(image, "PNG", zipOut);
|
||||
zipOut.closeEntry();
|
||||
|
||||
} catch (IOException e) {
|
||||
log.warn("Error processing page {}: {}", pageIndex + 1, e.getMessage());
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(pageIndex + 1, dpi, e);
|
||||
}
|
||||
}
|
||||
|
||||
zipOut.finish();
|
||||
return cbzOutputStream.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean isPdfFile(MultipartFile file) {
|
||||
String filename = file.getOriginalFilename();
|
||||
if (filename == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String extension = FilenameUtils.getExtension(filename).toLowerCase();
|
||||
return "pdf".equals(extension);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipOutputStream;
|
||||
|
||||
@@ -29,19 +31,25 @@ import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
|
||||
@Slf4j
|
||||
@UtilityClass
|
||||
public class PdfUtils {
|
||||
|
||||
public static PDRectangle textToPageSize(String size) {
|
||||
private final RegexPatternUtils patternCache = RegexPatternUtils.getInstance();
|
||||
|
||||
public PDRectangle textToPageSize(String size) {
|
||||
|
||||
switch (size.toUpperCase()) {
|
||||
case "A0" -> {
|
||||
return PDRectangle.A0;
|
||||
@@ -74,7 +82,7 @@ public class PdfUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static List<RenderedImage> getAllImages(PDResources resources) throws IOException {
|
||||
public List<RenderedImage> getAllImages(PDResources resources) throws IOException {
|
||||
List<RenderedImage> images = new ArrayList<>();
|
||||
|
||||
for (COSName name : resources.getXObjectNames()) {
|
||||
@@ -91,7 +99,7 @@ public class PdfUtils {
|
||||
return images;
|
||||
}
|
||||
|
||||
public static boolean hasImages(PDDocument document, String pagesToCheck) throws IOException {
|
||||
public boolean hasImages(PDDocument document, String pagesToCheck) throws IOException {
|
||||
String[] pageOrderArr = pagesToCheck.split(",");
|
||||
List<Integer> pageList =
|
||||
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages());
|
||||
@@ -106,7 +114,7 @@ public class PdfUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean hasText(PDDocument document, String pageNumbersToCheck, String phrase)
|
||||
public boolean hasText(PDDocument document, String pageNumbersToCheck, String phrase)
|
||||
throws IOException {
|
||||
String[] pageOrderArr = pageNumbersToCheck.split(",");
|
||||
List<Integer> pageList =
|
||||
@@ -122,11 +130,11 @@ public class PdfUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean hasImagesOnPage(PDPage page) throws IOException {
|
||||
return getAllImages(page.getResources()).size() > 0;
|
||||
public boolean hasImagesOnPage(PDPage page) throws IOException {
|
||||
return !getAllImages(page.getResources()).isEmpty();
|
||||
}
|
||||
|
||||
public static boolean hasTextOnPage(PDPage page, String phrase) throws IOException {
|
||||
public boolean hasTextOnPage(PDPage page, String phrase) throws IOException {
|
||||
PDFTextStripper textStripper = new PDFTextStripper();
|
||||
PDDocument tempDoc = new PDDocument();
|
||||
tempDoc.addPage(page);
|
||||
@@ -135,14 +143,15 @@ public class PdfUtils {
|
||||
return pageText.contains(phrase);
|
||||
}
|
||||
|
||||
public static byte[] convertFromPdf(
|
||||
public byte[] convertFromPdf(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
byte[] inputStream,
|
||||
String imageType,
|
||||
ImageType colorType,
|
||||
boolean singleImage,
|
||||
int DPI,
|
||||
String filename)
|
||||
String filename,
|
||||
boolean includeAnnotations)
|
||||
throws IOException, Exception {
|
||||
|
||||
// Validate and limit DPI to prevent excessive memory usage
|
||||
@@ -155,7 +164,8 @@ public class PdfUtils {
|
||||
if (DPI > maxSafeDpi) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.dpiExceedsLimit",
|
||||
"DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.",
|
||||
"DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause"
|
||||
+ " memory issues and crashes. Please use a lower DPI value.",
|
||||
DPI,
|
||||
maxSafeDpi);
|
||||
}
|
||||
@@ -163,6 +173,9 @@ public class PdfUtils {
|
||||
try (PDDocument document = pdfDocumentFactory.load(inputStream)) {
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
if (!includeAnnotations) {
|
||||
pdfRenderer.setAnnotationsFilter(annotation -> false);
|
||||
}
|
||||
int pageCount = document.getNumberOfPages();
|
||||
|
||||
// Create a ByteArrayOutputStream to save the image(s) to
|
||||
@@ -192,11 +205,17 @@ public class PdfUtils {
|
||||
.contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigForDpi",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please"
|
||||
+ " try a lower DPI value (recommended: 150 or"
|
||||
+ " less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
writer.writeToSequence(new IIOImage(image, null, null), param);
|
||||
}
|
||||
@@ -237,11 +256,18 @@ public class PdfUtils {
|
||||
.contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigExceedsArray",
|
||||
"PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).",
|
||||
"PDF page {0} is too large to render at {1} DPI. The"
|
||||
+ " resulting image would exceed Java's maximum"
|
||||
+ " array size. Please try a lower DPI value"
|
||||
+ " (recommended: 150 or less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
pdfSizeImageIndex = i;
|
||||
dimension =
|
||||
@@ -278,11 +304,17 @@ public class PdfUtils {
|
||||
.contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigForDpi",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please"
|
||||
+ " try a lower DPI value (recommended: 150 or"
|
||||
+ " less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,11 +343,16 @@ public class PdfUtils {
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigForDpi",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).",
|
||||
"PDF page {0} is too large to render at {1} DPI. Please try"
|
||||
+ " a lower DPI value (recommended: 150 or less).",
|
||||
i + 1,
|
||||
DPI);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, DPI, e);
|
||||
}
|
||||
try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) {
|
||||
ImageIO.write(image, imageType, baosImage);
|
||||
@@ -349,23 +386,38 @@ public class PdfUtils {
|
||||
* @return converted document to PDF-Image
|
||||
* @throws IOException if conversion fails
|
||||
*/
|
||||
public static PDDocument convertPdfToPdfImage(PDDocument document) throws IOException {
|
||||
public PDDocument convertPdfToPdfImage(PDDocument document) throws IOException {
|
||||
PDDocument imageDocument = new PDDocument();
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
pdfRenderer.setSubsamplingAllowed(true);
|
||||
for (int page = 0; page < document.getNumberOfPages(); ++page) {
|
||||
BufferedImage bim;
|
||||
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
|
||||
try {
|
||||
bim = pdfRenderer.renderImageWithDPI(page, 300, ImageType.RGB);
|
||||
bim = pdfRenderer.renderImageWithDPI(page, renderDpi, ImageType.RGB);
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Maximum size of image exceeded")) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.pageTooBigFor300Dpi",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image would exceed Java's maximum array size. Please use a lower DPI value for PDF-to-image conversion.",
|
||||
"PDF page {0} is too large to render at 300 DPI. The resulting image"
|
||||
+ " would exceed Java's maximum array size. Please use a lower DPI"
|
||||
+ " value for PDF-to-image conversion.",
|
||||
page + 1);
|
||||
}
|
||||
throw e;
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, 300, e);
|
||||
}
|
||||
PDPage originalPage = document.getPage(page);
|
||||
|
||||
@@ -383,8 +435,7 @@ public class PdfUtils {
|
||||
return imageDocument;
|
||||
}
|
||||
|
||||
private static BufferedImage prepareImageForPdfToImage(
|
||||
int maxWidth, int height, String imageType) {
|
||||
private BufferedImage prepareImageForPdfToImage(int maxWidth, int height, String imageType) {
|
||||
BufferedImage combined;
|
||||
if ("png".equalsIgnoreCase(imageType)) {
|
||||
combined = new BufferedImage(maxWidth, height, BufferedImage.TYPE_INT_ARGB);
|
||||
@@ -400,7 +451,7 @@ public class PdfUtils {
|
||||
return combined;
|
||||
}
|
||||
|
||||
public static byte[] imageToPdf(
|
||||
public byte[] imageToPdf(
|
||||
MultipartFile[] files,
|
||||
String fitOption,
|
||||
boolean autoRotate,
|
||||
@@ -431,7 +482,7 @@ public class PdfUtils {
|
||||
ImageProcessingUtils.convertColorType(image, colorType);
|
||||
// Use JPEGFactory if it's JPEG since JPEG is lossy
|
||||
PDImageXObject pdImage =
|
||||
(contentType != null && "image/jpeg".equals(contentType))
|
||||
(contentType != null && MediaType.IMAGE_JPEG_VALUE.equals(contentType))
|
||||
? JPEGFactory.createFromImage(doc, convertedImage)
|
||||
: LosslessFactory.createFromImage(doc, convertedImage);
|
||||
addImageToDocument(doc, pdImage, fitOption, autoRotate);
|
||||
@@ -444,7 +495,7 @@ public class PdfUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static void addImageToDocument(
|
||||
public void addImageToDocument(
|
||||
PDDocument doc, PDImageXObject image, String fitOption, boolean autoRotate)
|
||||
throws IOException {
|
||||
boolean imageIsLandscape = image.getWidth() > image.getHeight();
|
||||
@@ -494,7 +545,7 @@ public class PdfUtils {
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] overlayImage(
|
||||
public byte[] overlayImage(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
byte[] pdfBytes,
|
||||
byte[] imageBytes,
|
||||
@@ -536,13 +587,16 @@ public class PdfUtils {
|
||||
public boolean containsTextInFile(PDDocument pdfDocument, String text, String pagesToCheck)
|
||||
throws IOException {
|
||||
PDFTextStripper textStripper = new PDFTextStripper();
|
||||
String pdfText = "";
|
||||
StringBuilder pdfText = new StringBuilder();
|
||||
|
||||
if (pagesToCheck == null || "all".equals(pagesToCheck)) {
|
||||
pdfText = textStripper.getText(pdfDocument);
|
||||
pdfText = new StringBuilder(textStripper.getText(pdfDocument));
|
||||
} else {
|
||||
// remove whitespaces
|
||||
pagesToCheck = pagesToCheck.replaceAll("\\s+", "");
|
||||
// remove whitespaces using cached pattern
|
||||
Pattern whitespacePattern =
|
||||
patternCache.getPattern(RegexPatternUtils.getWhitespaceRegex());
|
||||
Matcher whitespaceMatcher = whitespacePattern.matcher(pagesToCheck);
|
||||
pagesToCheck = whitespaceMatcher.replaceAll("");
|
||||
|
||||
String[] splitPoints = pagesToCheck.split(",");
|
||||
for (String splitPoint : splitPoints) {
|
||||
@@ -555,21 +609,21 @@ public class PdfUtils {
|
||||
for (int i = startPage; i <= endPage; i++) {
|
||||
textStripper.setStartPage(i);
|
||||
textStripper.setEndPage(i);
|
||||
pdfText += textStripper.getText(pdfDocument);
|
||||
pdfText.append(textStripper.getText(pdfDocument));
|
||||
}
|
||||
} else {
|
||||
// Handle individual page
|
||||
int page = Integer.parseInt(splitPoint);
|
||||
textStripper.setStartPage(page);
|
||||
textStripper.setEndPage(page);
|
||||
pdfText += textStripper.getText(pdfDocument);
|
||||
pdfText.append(textStripper.getText(pdfDocument));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pdfDocument.close();
|
||||
|
||||
return pdfText.contains(text);
|
||||
return pdfText.toString().contains(text);
|
||||
}
|
||||
|
||||
public boolean pageCount(PDDocument pdfDocument, int pageCount, String comparator)
|
||||
@@ -577,16 +631,13 @@ public class PdfUtils {
|
||||
int actualPageCount = pdfDocument.getNumberOfPages();
|
||||
pdfDocument.close();
|
||||
|
||||
switch (comparator.toLowerCase()) {
|
||||
case "greater":
|
||||
return actualPageCount > pageCount;
|
||||
case "equal":
|
||||
return actualPageCount == pageCount;
|
||||
case "less":
|
||||
return actualPageCount < pageCount;
|
||||
default:
|
||||
throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
|
||||
}
|
||||
return switch (comparator.toLowerCase()) {
|
||||
case "greater" -> actualPageCount > pageCount;
|
||||
case "equal" -> actualPageCount == pageCount;
|
||||
case "less" -> actualPageCount < pageCount;
|
||||
default ->
|
||||
throw ExceptionUtils.createInvalidArgumentException("comparator", comparator);
|
||||
};
|
||||
}
|
||||
|
||||
public boolean pageSize(PDDocument pdfDocument, String expectedPageSize) throws IOException {
|
||||
|
||||
@@ -15,6 +15,8 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
import io.github.pixee.security.BoundedLineReader;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
@@ -167,7 +169,7 @@ public class ProcessExecutor {
|
||||
semaphore.acquire();
|
||||
try {
|
||||
|
||||
log.info("Running command: " + String.join(" ", command));
|
||||
log.info("Running command: {}", String.join(" ", command));
|
||||
ProcessBuilder processBuilder = new ProcessBuilder(command);
|
||||
|
||||
// Use the working directory if it's set
|
||||
@@ -252,7 +254,7 @@ public class ProcessExecutor {
|
||||
String outputMessage = String.join("\n", outputLines);
|
||||
messages += outputMessage;
|
||||
if (!liveUpdates) {
|
||||
log.info("Command output:\n" + outputMessage);
|
||||
log.info("Command output:\n{}", outputMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +262,7 @@ public class ProcessExecutor {
|
||||
String errorMessage = String.join("\n", errorLines);
|
||||
messages += errorMessage;
|
||||
if (!liveUpdates) {
|
||||
log.warn("Command error output:\n" + errorMessage);
|
||||
log.warn("Command error output:\n{}", errorMessage);
|
||||
}
|
||||
if (exitCode != 0) {
|
||||
if (isQpdf && exitCode == 3) {
|
||||
@@ -306,6 +308,8 @@ public class ProcessExecutor {
|
||||
CFF_CONVERTER
|
||||
}
|
||||
|
||||
@Setter
|
||||
@Getter
|
||||
public class ProcessExecutorResult {
|
||||
int rc;
|
||||
String messages;
|
||||
@@ -314,21 +318,5 @@ public class ProcessExecutor {
|
||||
this.rc = rc;
|
||||
this.messages = messages;
|
||||
}
|
||||
|
||||
public int getRc() {
|
||||
return rc;
|
||||
}
|
||||
|
||||
public void setRc(int rc) {
|
||||
this.rc = rc;
|
||||
}
|
||||
|
||||
public String getMessages() {
|
||||
return messages;
|
||||
}
|
||||
|
||||
public void setMessages(String messages) {
|
||||
this.messages = messages;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
package stirling.software.common.util;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public final class RegexPatternUtils {
|
||||
|
||||
private static final RegexPatternUtils INSTANCE = new RegexPatternUtils();
|
||||
private final ConcurrentHashMap<PatternKey, Pattern> patternCache = new ConcurrentHashMap<>();
|
||||
|
||||
private static final String WHITESPACE_REGEX = "\\s++";
|
||||
private static final String EXTENSION_REGEX = "\\.(?:[^.]*+)?$";
|
||||
|
||||
private RegexPatternUtils() {
|
||||
super();
|
||||
// Initialize with commonly used patterns for immediate availability
|
||||
precompileCommonPatterns();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the pattern cache.
|
||||
*
|
||||
* @return the singleton RegexPatternCache instance
|
||||
*/
|
||||
public static RegexPatternUtils getInstance() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a compiled pattern from cache, compiling and caching if not present.
|
||||
*
|
||||
* <p>This method is thread-safe and uses lazy initialization. Multiple threads calling with the
|
||||
* same regex will result in only one compilation, with all threads receiving the same cached
|
||||
* Pattern instance.
|
||||
*
|
||||
* <p>Performance: first call compiles and caches (expensive), subsequent calls return cached
|
||||
* pattern (fast O(1) lookup).
|
||||
*
|
||||
* @param regex the regular expression string to compile
|
||||
* @return compiled Pattern object, never null
|
||||
* @throws PatternSyntaxException if the regex syntax is invalid
|
||||
* @throws IllegalArgumentException if regex is null
|
||||
*/
|
||||
public Pattern getPattern(String regex) {
|
||||
if (regex == null) {
|
||||
throw new IllegalArgumentException("Regex pattern cannot be null");
|
||||
}
|
||||
|
||||
return patternCache.computeIfAbsent(new PatternKey(regex, 0), this::compilePattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a compiled pattern with flags.
|
||||
*
|
||||
* <p>Patterns with different flags are cached separately using a composite key. Common flags
|
||||
* include:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link Pattern#CASE_INSENSITIVE} - ignore case differences
|
||||
* <li>{@link Pattern#MULTILINE} - ^ and $ match line boundaries
|
||||
* <li>{@link Pattern#DOTALL} - . matches any character including newlines
|
||||
* </ul>
|
||||
*
|
||||
* @param regex the regular expression string
|
||||
* @param flags pattern flags (e.g., Pattern.CASE_INSENSITIVE)
|
||||
* @return compiled Pattern object with specified flags
|
||||
* @throws PatternSyntaxException if the regex syntax is invalid
|
||||
* @throws IllegalArgumentException if regex is null
|
||||
*/
|
||||
public Pattern getPattern(String regex, int flags) {
|
||||
if (regex == null) {
|
||||
throw new IllegalArgumentException("Regex pattern cannot be null");
|
||||
}
|
||||
|
||||
return patternCache.computeIfAbsent(new PatternKey(regex, flags), this::compilePattern);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pattern is already cached.
|
||||
*
|
||||
* @param regex the regular expression string
|
||||
* @return true if pattern is cached, false otherwise
|
||||
*/
|
||||
public boolean isCached(String regex) {
|
||||
return isCached(regex, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pattern with flags is already cached.
|
||||
*
|
||||
* @param regex the regular expression string
|
||||
* @param flags pattern flags
|
||||
* @return true if pattern is cached, false otherwise
|
||||
*/
|
||||
public boolean isCached(String regex, int flags) {
|
||||
return regex != null && patternCache.containsKey(new PatternKey(regex, flags));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current cache size (number of cached patterns). Useful for monitoring and debugging.
|
||||
*
|
||||
* @return number of patterns currently cached
|
||||
*/
|
||||
public int getCacheSize() {
|
||||
return patternCache.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached patterns. Use sparingly as it forces recompilation of all patterns. Mainly
|
||||
* useful for testing or memory cleanup in long-running applications.
|
||||
*/
|
||||
public void clearCache() {
|
||||
patternCache.clear();
|
||||
log.debug("Regex pattern cache cleared");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific pattern from cache.
|
||||
*
|
||||
* @param regex the regular expression string to remove
|
||||
* @return true if pattern was cached and removed, false otherwise
|
||||
*/
|
||||
public boolean removeFromCache(String regex) {
|
||||
return removeFromCache(regex, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a specific pattern with flags from cache.
|
||||
*
|
||||
* @param regex the regular expression string to remove
|
||||
* @param flags pattern flags
|
||||
* @return true if pattern was cached and removed, false otherwise
|
||||
*/
|
||||
public boolean removeFromCache(String regex, int flags) {
|
||||
if (regex == null) {
|
||||
return false;
|
||||
}
|
||||
PatternKey key = new PatternKey(regex, flags);
|
||||
boolean removed = patternCache.remove(key) != null;
|
||||
if (removed) {
|
||||
log.debug("Removed regex pattern from cache: {} (flags: {})", regex, flags);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to compile a pattern and handle errors consistently.
|
||||
*
|
||||
* @return compiled Pattern
|
||||
* @throws PatternSyntaxException if regex is invalid
|
||||
*/
|
||||
private Pattern compilePattern(PatternKey key) {
|
||||
String regex = key.regex;
|
||||
int flags = key.flags;
|
||||
|
||||
try {
|
||||
Pattern pattern = Pattern.compile(regex, flags);
|
||||
log.trace("Compiled and cached regex pattern with flags {}: {}", flags, regex);
|
||||
return pattern;
|
||||
} catch (PatternSyntaxException e) {
|
||||
log.error(
|
||||
"Invalid regex pattern: '{}' with flags {} - {}", regex, flags, e.getMessage());
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
public static String getWhitespaceRegex() {
|
||||
return WHITESPACE_REGEX;
|
||||
}
|
||||
|
||||
/** Creates a case-insensitive pattern for text searching */
|
||||
public Pattern createSearchPattern(String regex, boolean caseInsensitive) {
|
||||
int flags = caseInsensitive ? (Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE) : 0;
|
||||
return getPattern(regex, flags);
|
||||
}
|
||||
|
||||
/** Pattern for matching trailing slashes (e.g., "/path/to/dir///") */
|
||||
public Pattern getTrailingSlashesPattern() {
|
||||
return getPattern("/+$");
|
||||
}
|
||||
|
||||
/** Pattern for removing drive letters from paths */
|
||||
public Pattern getDriveLetterPattern() {
|
||||
return getPattern("^[a-zA-Z]:[\\\\/]+");
|
||||
}
|
||||
|
||||
/** Pattern for removing leading slashes from paths */
|
||||
public Pattern getLeadingSlashesPattern() {
|
||||
return getPattern("^[\\\\/]+");
|
||||
}
|
||||
|
||||
/** Pattern for matching backslashes */
|
||||
public Pattern getBackslashPattern() {
|
||||
return getPattern("\\\\");
|
||||
}
|
||||
|
||||
/** Pattern for sanitizing filenames by removing problematic characters */
|
||||
public Pattern getSafeFilenamePattern() {
|
||||
return getPattern("[/\\\\?%*:|\"<>]");
|
||||
}
|
||||
|
||||
/** Pattern for sanitizing filenames (keeps only alphanumeric) */
|
||||
public Pattern getFilenameSafePattern() {
|
||||
return getPattern("[^a-zA-Z0-9]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern for replacing non-alphanumeric characters with underscore (explicit underscore
|
||||
* variant)
|
||||
*/
|
||||
public Pattern getNonAlnumUnderscorePattern() {
|
||||
return getPattern("[^A-Za-z0-9_]");
|
||||
}
|
||||
|
||||
/** Pattern for collapsing multiple underscores */
|
||||
public Pattern getMultipleUnderscoresPattern() {
|
||||
return getPattern("_+");
|
||||
}
|
||||
|
||||
/** Pattern for trimming leading underscores */
|
||||
public Pattern getLeadingUnderscoresPattern() {
|
||||
return getPattern("^_+");
|
||||
}
|
||||
|
||||
/** Pattern for trimming trailing underscores */
|
||||
public Pattern getTrailingUnderscoresPattern() {
|
||||
return getPattern("_+$");
|
||||
}
|
||||
|
||||
/** Pattern for matching upload/download paths (case insensitive) */
|
||||
public Pattern getUploadDownloadPathPattern() {
|
||||
return getPattern("(?i).*/(upload|download)/.*");
|
||||
}
|
||||
|
||||
/** Pattern for matching one or more whitespace characters */
|
||||
public Pattern getWhitespacePattern() {
|
||||
return getPattern("\\s+");
|
||||
}
|
||||
|
||||
/** Pattern for matching newlines (Windows and Unix style) */
|
||||
public Pattern getNewlinesPattern() {
|
||||
return getPattern("\\r?\\n");
|
||||
}
|
||||
|
||||
/** Pattern for splitting on newlines (Windows and Unix style) */
|
||||
public Pattern getNewlineSplitPattern() {
|
||||
return getPattern("\\r?\\n");
|
||||
}
|
||||
|
||||
/** Pattern for splitting text into words */
|
||||
public Pattern getWordSplitPattern() {
|
||||
return getPattern("\\s+");
|
||||
}
|
||||
|
||||
/** Pattern for removing carriage returns */
|
||||
public Pattern getCarriageReturnPattern() {
|
||||
return getPattern("\\r");
|
||||
}
|
||||
|
||||
/** Pattern for matching newline characters */
|
||||
public Pattern getNewlineCharsPattern() {
|
||||
return getPattern("[\n\r]");
|
||||
}
|
||||
|
||||
/** Pattern for multi-format newline splitting (Windows, Mac, Unix) */
|
||||
public Pattern getMultiFormatNewlinePattern() {
|
||||
return getPattern("\r\n|\r|\n");
|
||||
}
|
||||
|
||||
/** Pattern for encoded payload newline removal */
|
||||
public Pattern getEncodedPayloadNewlinePattern() {
|
||||
return getPattern("\\r?\\n");
|
||||
}
|
||||
|
||||
/** Pattern for escaped newlines in watermark text */
|
||||
public Pattern getEscapedNewlinePattern() {
|
||||
return getPattern("\\\\n");
|
||||
}
|
||||
|
||||
/** Pattern for input sanitization (allows only alphanumeric and spaces) */
|
||||
public Pattern getInputSanitizePattern() {
|
||||
return getPattern("[^a-zA-Z0-9 ]");
|
||||
}
|
||||
|
||||
/** Pattern for removing angle brackets */
|
||||
public Pattern getAngleBracketsPattern() {
|
||||
return getPattern("[<>]");
|
||||
}
|
||||
|
||||
/** Pattern for removing leading and trailing quotes */
|
||||
public Pattern getQuotesRemovalPattern() {
|
||||
return getPattern("^\"|\"$");
|
||||
}
|
||||
|
||||
/** Pattern for plus signs (URL encoding replacement) */
|
||||
public Pattern getPlusSignPattern() {
|
||||
return getPattern("\\+");
|
||||
}
|
||||
|
||||
/** Pattern for username validation */
|
||||
public Pattern getUsernameValidationPattern() {
|
||||
return getPattern("^[a-zA-Z0-9](?!.*[-@._+]{2,})[a-zA-Z0-9@._+-]{1,48}[a-zA-Z0-9]$");
|
||||
}
|
||||
|
||||
public static String getExtensionRegex() {
|
||||
return EXTENSION_REGEX;
|
||||
}
|
||||
|
||||
/** Pattern for extracting non-numeric characters */
|
||||
public Pattern getNumericExtractionPattern() {
|
||||
return getPattern("\\D");
|
||||
}
|
||||
|
||||
/** Pattern for removing non-digit/dot characters (for timeout parsing) */
|
||||
public Pattern getNonDigitDotPattern() {
|
||||
return getPattern("[^\\d.]");
|
||||
}
|
||||
|
||||
/** Pattern for matching digit/dot characters (for timeout parsing) */
|
||||
public Pattern getDigitDotPattern() {
|
||||
return getPattern("[\\d.]");
|
||||
}
|
||||
|
||||
/** Pattern for detecting strings containing digits */
|
||||
public Pattern getContainsDigitsPattern() {
|
||||
return getPattern(".*\\d+.*");
|
||||
}
|
||||
|
||||
/** Pattern for matching 1-3 digit numbers */
|
||||
public Pattern getNumberRangePattern() {
|
||||
return getPattern("[1-9][0-9]{0,2}");
|
||||
}
|
||||
|
||||
/** Pattern for validating mathematical expressions */
|
||||
public Pattern getMathExpressionPattern() {
|
||||
return getPattern("[0-9n+\\-*/() ]+");
|
||||
}
|
||||
|
||||
/** Pattern for adding multiplication between numbers and 'n' */
|
||||
public Pattern getNumberBeforeNPattern() {
|
||||
return getPattern("(\\d)n");
|
||||
}
|
||||
|
||||
/** Pattern for detecting consecutive 'n' characters */
|
||||
public Pattern getConsecutiveNPattern() {
|
||||
return getPattern(".*n{2,}.*");
|
||||
}
|
||||
|
||||
/** Pattern for replacing consecutive 'n' characters */
|
||||
public Pattern getConsecutiveNReplacementPattern() {
|
||||
return getPattern("(?<!n)n{2}");
|
||||
}
|
||||
|
||||
/** Pattern for validating HTTP/HTTPS URLs */
|
||||
public Pattern getHttpUrlPattern() {
|
||||
return getPattern("^https?://.*");
|
||||
}
|
||||
|
||||
/** Pattern for matching URLs in text for link creation */
|
||||
public Pattern getUrlLinkPattern() {
|
||||
return getPattern("(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)");
|
||||
}
|
||||
|
||||
/** Pattern for matching email addresses in text for link creation */
|
||||
public Pattern getEmailLinkPattern() {
|
||||
return getPattern("([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63})");
|
||||
}
|
||||
|
||||
/** Pattern for removing script tags from HTML */
|
||||
public Pattern getScriptTagPattern() {
|
||||
return getPattern("(?i)<script[^>]*>.*?</script>");
|
||||
}
|
||||
|
||||
/** Pattern for removing style tags from HTML */
|
||||
public Pattern getStyleTagPattern() {
|
||||
return getPattern("(?i)<style[^>]*>.*?</style>");
|
||||
}
|
||||
|
||||
/** Pattern for removing fixed position CSS */
|
||||
public Pattern getFixedPositionCssPattern() {
|
||||
return getPattern("(?i)\\s*position\\s*:\\s*fixed[^;]*;?");
|
||||
}
|
||||
|
||||
/** Pattern for removing absolute position CSS */
|
||||
public Pattern getAbsolutePositionCssPattern() {
|
||||
return getPattern("(?i)\\s*position\\s*:\\s*absolute[^;]*;?");
|
||||
}
|
||||
|
||||
/** Pattern for matching size unit suffixes (KB, MB, GB, etc.) */
|
||||
public Pattern getSizeUnitPattern() {
|
||||
return getPattern("[KMGkmg][Bb]");
|
||||
}
|
||||
|
||||
/** Pattern for system temp file type 1 */
|
||||
public Pattern getSystemTempFile1Pattern() {
|
||||
return getPattern("lu\\d+[a-z0-9]*\\.tmp");
|
||||
}
|
||||
|
||||
/** Pattern for system temp file type 2 (OCR processes) */
|
||||
public Pattern getSystemTempFile2Pattern() {
|
||||
return getPattern("ocr_process\\d+");
|
||||
}
|
||||
|
||||
/** Pattern for splitting on whitespace and parentheses */
|
||||
public Pattern getWhitespaceParenthesesSplitPattern() {
|
||||
return getPattern("[\\s\\(\\)]+");
|
||||
}
|
||||
|
||||
/** Pattern for MIME header whitespace cleanup before encoded sequences */
|
||||
public Pattern getMimeHeaderWhitespacePattern() {
|
||||
return getPattern("\\s+(?==\\?)");
|
||||
}
|
||||
|
||||
/** Pattern for font name validation (6 uppercase letters + plus + rest) */
|
||||
public Pattern getFontNamePattern() {
|
||||
return getPattern("^[A-Z]{6}\\+.*");
|
||||
}
|
||||
|
||||
/** Pattern for matching access="readOnly" attribute in XFA XML (with optional whitespace) */
|
||||
public Pattern getAccessReadOnlyPattern() {
|
||||
return getPattern("access\\s*=\\s*\"readOnly\"");
|
||||
}
|
||||
|
||||
/** Pattern for matching MIME encoded-word headers (RFC 2047) Example: =?charset?B?encoded?= */
|
||||
public Pattern getMimeEncodedWordPattern() {
|
||||
return getPattern("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?=");
|
||||
}
|
||||
|
||||
/** Pattern for matching inline CID images in HTML (case-insensitive) */
|
||||
public Pattern getInlineCidImagePattern() {
|
||||
return getPattern(
|
||||
"(?i)<img[^>]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
/** Pattern for matching image file extensions (case-insensitive) */
|
||||
public Pattern getImageFilePattern() {
|
||||
return getPattern(".*\\.(jpg|jpeg|png|gif|bmp|webp)$", Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
/** Pattern for matching attachment section headers (case-insensitive) */
|
||||
public Pattern getAttachmentSectionPattern() {
|
||||
return getPattern("attachments\\s*\\(\\d+\\)", Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
/** Pattern for matching filenames in attachment markers */
|
||||
public Pattern getAttachmentFilenamePattern() {
|
||||
return getPattern("@\\s*([^\\s\\(]+(?:\\.[a-zA-Z0-9]+)?)");
|
||||
}
|
||||
|
||||
/** Pattern for matching pdfaid:part attribute in XMP metadata */
|
||||
public Pattern getPdfAidPartPattern() {
|
||||
return getPattern("pdfaid:part[\"\\s]*=[\"\\s]*([0-9]+)");
|
||||
}
|
||||
|
||||
/** Pattern for matching pdfaid:conformance attribute in XMP metadata */
|
||||
public Pattern getPdfAidConformancePattern() {
|
||||
return getPattern("pdfaid:conformance[\"\\s]*=[\"\\s]*([A-Za-z]+)");
|
||||
}
|
||||
|
||||
/** Pattern for matching slash in page mode description */
|
||||
public Pattern getPageModePattern() {
|
||||
return getPattern("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-compile commonly used patterns for immediate availability. This eliminates first-call
|
||||
* compilation overhead for frequent patterns.
|
||||
*/
|
||||
private void precompileCommonPatterns() {
|
||||
getPattern("\\.(?:[^.]*+)?$"); // Extension removal - possessive, optional, anchored
|
||||
getPattern("\\.[^.]+$"); // Simple extension match - anchored
|
||||
|
||||
getPattern("\\s+"); // One or more whitespace
|
||||
getPattern("\\s*"); // Zero or more whitespace
|
||||
|
||||
getPattern("/+$"); // Trailing slashes
|
||||
getPattern("\\D"); // Non-numeric characters
|
||||
getPattern("[/\\\\?%*:|\"<>]"); // Unsafe filename characters
|
||||
getPattern("[^a-zA-Z0-9 ]"); // Input sanitization
|
||||
getPattern("[^a-zA-Z0-9]"); // Filename sanitization
|
||||
// API doc patterns
|
||||
getPattern("Output:(\\w+)"); // precompiled single-escaped for runtime regex \w
|
||||
getPattern("Input:(\\w+)");
|
||||
getPattern("Type:(\\w+)");
|
||||
log.debug("Pre-compiled {} common regex patterns", patternCache.size());
|
||||
}
|
||||
|
||||
/** Pattern for email validation */
|
||||
public Pattern getEmailValidationPattern() {
|
||||
return getPattern(
|
||||
"^(?=.{1,320}$)(?=.{1,64}@)[A-Za-z0-9](?:[A-Za-z0-9_.+-]*[A-Za-z0-9])?@[^-][A-Za-z0-9-]+(?:\\.[A-Za-z0-9-]+)*(?:\\.[A-Za-z]{2,})$");
|
||||
}
|
||||
|
||||
/* Pattern for matching Output:<TYPE> in API descriptions */
|
||||
public Pattern getApiDocOutputTypePattern() {
|
||||
return getPattern("Output:(\\w+)");
|
||||
}
|
||||
|
||||
/* Pattern for matching Input:<TYPE> in API descriptions */
|
||||
public Pattern getApiDocInputTypePattern() {
|
||||
return getPattern("Input:(\\w+)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern for matching Type:<CODE> in API descriptions
|
||||
*/
|
||||
public Pattern getApiDocTypePattern() {
|
||||
return getPattern("Type:(\\w+)");
|
||||
}
|
||||
|
||||
/* Pattern for validating file extensions (2-4 alphanumeric, case-insensitive) */
|
||||
public Pattern getFileExtensionValidationPattern() {
|
||||
return getPattern("^[a-zA-Z0-9]{2,4}$", Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
|
||||
private record PatternKey(String regex, int flags) {
|
||||
// Record automatically provides equals, hashCode, and toString
|
||||
}
|
||||
}
|
||||
@@ -49,4 +49,35 @@ public class RequestUriUtils {
|
||||
|| requestURI.startsWith("/fonts")
|
||||
|| requestURI.startsWith("/pdfjs"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the request URI is a public authentication endpoint that doesn't require
|
||||
* authentication. This includes login, signup, OAuth callbacks, and public config endpoints.
|
||||
*
|
||||
* @param requestURI The full request URI
|
||||
* @param contextPath The servlet context path
|
||||
* @return true if the endpoint is public and doesn't require authentication
|
||||
*/
|
||||
public static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
|
||||
// Remove context path from URI to normalize path matching
|
||||
String trimmedUri =
|
||||
requestURI.startsWith(contextPath)
|
||||
? requestURI.substring(contextPath.length())
|
||||
: requestURI;
|
||||
|
||||
// Public auth endpoints that don't require authentication
|
||||
return trimmedUri.startsWith("/login")
|
||||
|| trimmedUri.startsWith("/auth/")
|
||||
|| trimmedUri.startsWith("/oauth2")
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| trimmedUri.contains("/login/oauth2/code/") // Spring Security OAuth2 callback
|
||||
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|
||||
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|| trimmedUri.contains("/v1/api-docs");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
@@ -14,17 +15,13 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class TempFile implements AutoCloseable {
|
||||
|
||||
private final TempFileManager manager;
|
||||
private final File file;
|
||||
@Getter private final File file;
|
||||
|
||||
public TempFile(TempFileManager manager, String suffix) throws IOException {
|
||||
this.manager = manager;
|
||||
this.file = manager.createTempFile(suffix);
|
||||
}
|
||||
|
||||
public File getFile() {
|
||||
return file;
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return file.toPath();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
@@ -24,8 +25,22 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class TempFileRegistry {
|
||||
|
||||
private final ConcurrentMap<Path, Instant> registeredFiles = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* -- GETTER -- Get all registered third-party temporary files.
|
||||
*
|
||||
* @return Set of third-party file paths
|
||||
*/
|
||||
@Getter
|
||||
private final Set<Path> thirdPartyTempFiles =
|
||||
Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
/**
|
||||
* -- GETTER -- Get all registered temporary directories.
|
||||
*
|
||||
* @return Set of temporary directory paths
|
||||
*/
|
||||
@Getter
|
||||
private final Set<Path> tempDirectories = Collections.newSetFromMap(new ConcurrentHashMap<>());
|
||||
|
||||
/**
|
||||
@@ -133,24 +148,6 @@ public class TempFileRegistry {
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered third-party temporary files.
|
||||
*
|
||||
* @return Set of third-party file paths
|
||||
*/
|
||||
public Set<Path> getThirdPartyTempFiles() {
|
||||
return thirdPartyTempFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered temporary directories.
|
||||
*
|
||||
* @return Set of temporary directory paths
|
||||
*/
|
||||
public Set<Path> getTempDirectories() {
|
||||
return tempDirectories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is registered in the registry.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,8 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -11,9 +13,13 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
public class WebResponseUtils {
|
||||
|
||||
public static ResponseEntity<byte[]> baosToWebResponse(
|
||||
@@ -44,7 +50,10 @@ public class WebResponseUtils {
|
||||
headers.setContentType(mediaType);
|
||||
headers.setContentLength(bytes.length);
|
||||
String encodedDocName =
|
||||
URLEncoder.encode(docName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||
RegexPatternUtils.getInstance()
|
||||
.getPlusSignPattern()
|
||||
.matcher(URLEncoder.encode(docName, StandardCharsets.UTF_8))
|
||||
.replaceAll("%20");
|
||||
headers.setContentDispositionFormData("attachment", encodedDocName);
|
||||
return new ResponseEntity<>(bytes, headers, HttpStatus.OK);
|
||||
}
|
||||
@@ -64,4 +73,59 @@ public class WebResponseUtils {
|
||||
|
||||
return baosToWebResponse(baos, docName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File to a web response (PDF default).
|
||||
*
|
||||
* @param outputTempFile The temporary file to be sent as a response.
|
||||
* @param docName The name of the document.
|
||||
* @return A ResponseEntity containing the file as a resource.
|
||||
*/
|
||||
public static ResponseEntity<StreamingResponseBody> pdfFileToWebResponse(
|
||||
TempFile outputTempFile, String docName) throws IOException {
|
||||
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_PDF);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File to a web response (ZIP default).
|
||||
*
|
||||
* @param outputTempFile The temporary file to be sent as a response.
|
||||
* @param docName The name of the document.
|
||||
* @return A ResponseEntity containing the file as a resource.
|
||||
*/
|
||||
public static ResponseEntity<StreamingResponseBody> zipFileToWebResponse(
|
||||
TempFile outputTempFile, String docName) throws IOException {
|
||||
return fileToWebResponse(outputTempFile, docName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File to a web response with explicit media type (e.g., ZIP).
|
||||
*
|
||||
* @param outputTempFile The temporary file to be sent as a response.
|
||||
* @param docName The name of the document.
|
||||
* @param mediaType The content type to set on the response.
|
||||
* @return A ResponseEntity containing the file as a resource.
|
||||
*/
|
||||
public static ResponseEntity<StreamingResponseBody> fileToWebResponse(
|
||||
TempFile outputTempFile, String docName, MediaType mediaType) throws IOException {
|
||||
|
||||
Path path = outputTempFile.getFile().toPath().normalize();
|
||||
long len = Files.size(path);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(mediaType);
|
||||
headers.setContentLength(len);
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + docName + "\"");
|
||||
|
||||
StreamingResponseBody body =
|
||||
os -> {
|
||||
try (os) {
|
||||
Files.copy(path, os);
|
||||
os.flush();
|
||||
} finally {
|
||||
outputTempFile.close();
|
||||
}
|
||||
};
|
||||
|
||||
return new ResponseEntity<>(body, headers, HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import java.util.Arrays;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
@@ -135,6 +136,17 @@ public class YamlHelper {
|
||||
} else if ("true".equals(newValue) || "false".equals(newValue)) {
|
||||
newValueNode =
|
||||
new ScalarNode(Tag.BOOL, String.valueOf(newValue), ScalarStyle.PLAIN);
|
||||
} else if (newValue instanceof Map<?, ?> map) {
|
||||
// Handle Map objects - convert to MappingNode
|
||||
List<NodeTuple> mapTuples = new ArrayList<>();
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
ScalarNode mapKeyNode =
|
||||
new ScalarNode(
|
||||
Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN);
|
||||
Node mapValueNode = convertValueToNode(entry.getValue());
|
||||
mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode));
|
||||
}
|
||||
newValueNode = new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK);
|
||||
} else if (newValue instanceof List<?> list) {
|
||||
List<Node> sequenceNodes = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
@@ -458,6 +470,43 @@ public class YamlHelper {
|
||||
return isInteger(object) || isShort(object) || isByte(object) || isLong(object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Java value to a YAML Node.
|
||||
*
|
||||
* @param value The value to convert.
|
||||
* @return The corresponding YAML Node.
|
||||
*/
|
||||
private Node convertValueToNode(Object value) {
|
||||
if (value == null) {
|
||||
return new ScalarNode(Tag.NULL, "null", ScalarStyle.PLAIN);
|
||||
} else if (isAnyInteger(value)) {
|
||||
return new ScalarNode(Tag.INT, String.valueOf(value), ScalarStyle.PLAIN);
|
||||
} else if (isFloat(value)) {
|
||||
Object floatValue = Float.valueOf(String.valueOf(value));
|
||||
return new ScalarNode(Tag.FLOAT, String.valueOf(floatValue), ScalarStyle.PLAIN);
|
||||
} else if (value instanceof Boolean || "true".equals(value) || "false".equals(value)) {
|
||||
return new ScalarNode(Tag.BOOL, String.valueOf(value), ScalarStyle.PLAIN);
|
||||
} else if (value instanceof Map<?, ?> map) {
|
||||
// Recursively handle nested maps
|
||||
List<NodeTuple> mapTuples = new ArrayList<>();
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
ScalarNode mapKeyNode =
|
||||
new ScalarNode(Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN);
|
||||
Node mapValueNode = convertValueToNode(entry.getValue());
|
||||
mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode));
|
||||
}
|
||||
return new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK);
|
||||
} else if (value instanceof List<?> list) {
|
||||
List<Node> sequenceNodes = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
sequenceNodes.add(convertValueToNode(item));
|
||||
}
|
||||
return new SequenceNode(Tag.SEQ, sequenceNodes, FlowStyle.FLOW);
|
||||
} else {
|
||||
return new ScalarNode(Tag.STR, String.valueOf(value), ScalarStyle.PLAIN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies comments from an old node to a new one.
|
||||
*
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package stirling.software.common.util.misc;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.api.misc.ReplaceAndInvert;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@Slf4j
|
||||
public class ColorSpaceConversionStrategy extends ReplaceAndInvertColorStrategy {
|
||||
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
public ColorSpaceConversionStrategy(
|
||||
MultipartFile file,
|
||||
ReplaceAndInvert replaceAndInvert,
|
||||
TempFileManager tempFileManager) {
|
||||
super(file, replaceAndInvert);
|
||||
this.tempFileManager = tempFileManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStreamResource replace() throws IOException {
|
||||
try (TempFile tempInput = new TempFile(tempFileManager, ".pdf");
|
||||
TempFile tempOutput = new TempFile(tempFileManager, ".pdf")) {
|
||||
|
||||
Path tempInputFile = tempInput.getPath();
|
||||
Path tempOutputFile = tempOutput.getPath();
|
||||
|
||||
Files.write(tempInputFile, getFileInput().getBytes());
|
||||
|
||||
log.info("Starting CMYK color space conversion");
|
||||
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add("gs");
|
||||
command.add("-sDEVICE=pdfwrite");
|
||||
command.add("-dCompatibilityLevel=1.5");
|
||||
command.add("-dPDFSETTINGS=/prepress");
|
||||
command.add("-dNOPAUSE");
|
||||
command.add("-dQUIET");
|
||||
command.add("-dBATCH");
|
||||
command.add("-sProcessColorModel=DeviceCMYK");
|
||||
command.add("-sColorConversionStrategy=CMYK");
|
||||
command.add("-sColorConversionStrategyForImages=CMYK");
|
||||
command.add("-sOutputFile=" + tempOutputFile.toString());
|
||||
command.add(tempInputFile.toString());
|
||||
|
||||
log.debug("Executing Ghostscript command for CMYK conversion: {}", command);
|
||||
|
||||
ProcessExecutorResult result =
|
||||
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
|
||||
.runCommandWithOutputHandling(command);
|
||||
|
||||
if (result.getRc() != 0) {
|
||||
log.error(
|
||||
"Ghostscript CMYK conversion failed with return code: {}. Output: {}",
|
||||
result.getRc(),
|
||||
result.getMessages());
|
||||
throw new IOException(
|
||||
"CMYK color space conversion failed: " + result.getMessages());
|
||||
}
|
||||
|
||||
log.info("CMYK color space conversion completed successfully");
|
||||
|
||||
byte[] pdfBytes = Files.readAllBytes(tempOutputFile);
|
||||
return new InputStreamResource(new ByteArrayInputStream(pdfBytes));
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("CMYK color space conversion failed", e);
|
||||
throw new IOException(
|
||||
"Failed to convert PDF to CMYK color space: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-2
@@ -19,7 +19,10 @@ import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.core.io.InputStreamResource;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.api.misc.ReplaceAndInvert;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
|
||||
@@ -44,8 +47,25 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
|
||||
// Render each page and invert colors
|
||||
PDFRenderer pdfRenderer = new PDFRenderer(document);
|
||||
for (int page = 0; page < document.getNumberOfPages(); page++) {
|
||||
BufferedImage image =
|
||||
pdfRenderer.renderImageWithDPI(page, 300); // Render page at 300 DPI
|
||||
BufferedImage image;
|
||||
|
||||
// Use global maximum DPI setting, fallback to 300 if not set
|
||||
int renderDpi = 300; // Default fallback
|
||||
ApplicationProperties properties =
|
||||
ApplicationContextProvider.getBean(ApplicationProperties.class);
|
||||
if (properties != null && properties.getSystem() != null) {
|
||||
renderDpi = properties.getSystem().getMaxDPI();
|
||||
}
|
||||
|
||||
try {
|
||||
image =
|
||||
pdfRenderer.renderImageWithDPI(
|
||||
page, renderDpi); // Render page with global DPI setting
|
||||
} catch (OutOfMemoryError e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
|
||||
} catch (NegativeArraySizeException e) {
|
||||
throw ExceptionUtils.createOutOfMemoryDpiException(page + 1, renderDpi, e);
|
||||
}
|
||||
|
||||
// Invert the colors
|
||||
invertImageColors(image);
|
||||
|
||||
Reference in New Issue
Block a user