Merge branch 'main' into feature/settingsFlagToHideSettings

This commit is contained in:
Reece Browne
2025-12-24 18:20:59 +00:00
committed by GitHub
7 changed files with 500 additions and 1 deletions

View File

@@ -96,6 +96,22 @@
"moduleName": ".*",
"moduleLicense": "MPL 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License, Version 2.0"
},
{
"moduleName": ".*",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)"
},
{
"moduleName": ".*",
"moduleLicense": "CDDL+GPL License"
},
{
"moduleName": ".*",
"moduleLicense": "BSD"
},
{
"moduleName": ".*",
"moduleLicense": "UnboundID SCIM2 SDK Free Use License"

View File

@@ -353,6 +353,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Security", "auto-redact");
addEndpointToGroup("Security", "redact");
addEndpointToGroup("Security", "validate-signature");
addEndpointToGroup("Security", "verify-pdf");
addEndpointToGroup("Security", "stamp");
addEndpointToGroup("Security", "sign");
@@ -479,6 +480,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-json");
addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("rar", "pdf-to-cbr");
addEndpointToGroup("Java", "pdf-to-video");
addEndpointToGroup("Java", "verify-pdf");
// Javascript
addEndpointToGroup("Javascript", "pdf-organizer");
@@ -535,6 +538,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Weasyprint", "markdown-to-pdf");
addEndpointToGroup("Weasyprint", "eml-to-pdf");
// veraPDF dependent endpoints
addEndpointToGroup("veraPDF", "verify-pdf");
// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
@@ -585,7 +591,9 @@ public class EndpointConfiguration {
|| "Weasyprint".equals(group)
|| "Pdftohtml".equals(group)
|| "ImageMagick".equals(group)
|| "rar".equals(group);
|| "rar".equals(group)
|| "FFmpeg".equals(group)
|| "veraPDF".equals(group);
}
private boolean isEndpointEnabledDirectly(String endpoint) {

View File

@@ -71,6 +71,13 @@ dependencies {
implementation "org.apache.pdfbox:preflight:$pdfboxVersion"
implementation "org.apache.pdfbox:xmpbox:$pdfboxVersion"
implementation 'org.verapdf:validation-model:1.28.2'
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:2.3.0.1'
// https://mvnrepository.com/artifact/technology.tabula/tabula
implementation ('technology.tabula:tabula:1.0.5') {
exclude group: 'org.slf4j', module: 'slf4j-simple'

View File

@@ -0,0 +1,91 @@
package stirling.software.SPDF.controller.api.security;
import java.io.IOException;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.verapdf.core.EncryptedPdfException;
import org.verapdf.core.ModelParsingException;
import org.verapdf.core.ValidationException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.PDFVerificationRequest;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.util.ExceptionUtils;
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
@Slf4j
public class VerifyPDFController {
private final VeraPDFService veraPDFService;
@Operation(
summary = "Verify PDF Standards Compliance",
description =
"Validates PDF files against the standards declared in their metadata. "
+ "Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards "
+ "from the document's XMP metadata and validates compliance. "
+ "Input:PDF Output:JSON Type:SISO")
@PostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<List<PDFVerificationResult>> verifyPDF(
@ModelAttribute PDFVerificationRequest request) {
MultipartFile file = request.getFileInput();
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createRuntimeException(
"error.pdfRequired", "PDF file is required", null);
}
try {
log.info("Detecting and verifying standards in PDF '{}'", file.getOriginalFilename());
List<PDFVerificationResult> results = veraPDFService.validatePDF(file.getInputStream());
log.info(
"Verification complete for '{}': {} standard(s) checked",
file.getOriginalFilename(),
results.size());
return ResponseEntity.ok(results);
} catch (ValidationException e) {
log.error("Validation exception for file: {}", file.getOriginalFilename(), e);
throw ExceptionUtils.createRuntimeException(
"error.validationFailed", "PDF validation failed: {0}", e, e.getMessage());
} catch (ModelParsingException e) {
log.error("Model parsing exception for file: {}", file.getOriginalFilename(), e);
throw ExceptionUtils.createRuntimeException(
"error.modelParsingFailed", "PDF model parsing failed: {0}", e, e.getMessage());
} catch (EncryptedPdfException e) {
log.error("Encrypted PDF exception for file: {}", file.getOriginalFilename(), e);
throw ExceptionUtils.createRuntimeException(
"error.encryptedPdf",
"Cannot verify encrypted PDF. Please remove password first: {0}",
e,
e.getMessage());
} catch (IOException e) {
log.error("IO exception for file: {}", file.getOriginalFilename(), e);
throw ExceptionUtils.createRuntimeException(
"error.ioException",
"IO error during PDF verification: {0}",
e,
e.getMessage());
}
}
}

View File

@@ -0,0 +1,10 @@
package stirling.software.SPDF.model.api.security;
import lombok.Data;
import lombok.EqualsAndHashCode;
import stirling.software.common.model.api.PDFFile;
@Data
@EqualsAndHashCode(callSuper = true)
public class PDFVerificationRequest extends PDFFile {}

View File

@@ -0,0 +1,48 @@
package stirling.software.SPDF.model.api.security;
import java.util.ArrayList;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PDFVerificationResult {
private String standard;
private String standardName;
private String validationProfile;
private String validationProfileName;
private String complianceSummary;
private boolean declaredPdfa;
private boolean compliant;
private int totalFailures;
private int totalWarnings;
private List<ValidationIssue> failures = new ArrayList<>();
private List<ValidationIssue> warnings = new ArrayList<>();
public void addFailure(ValidationIssue failure) {
this.failures.add(failure);
this.totalFailures = this.failures.size();
}
public void addWarning(ValidationIssue warning) {
this.warnings.add(warning);
this.totalWarnings = this.warnings.size();
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class ValidationIssue {
private String ruleId;
private String message;
private String location;
private String specification;
private String clause;
private String testNumber;
}
}

View File

@@ -0,0 +1,319 @@
package stirling.software.SPDF.service;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import org.springframework.stereotype.Service;
import org.verapdf.core.EncryptedPdfException;
import org.verapdf.core.ModelParsingException;
import org.verapdf.core.ValidationException;
import org.verapdf.gf.foundry.VeraGreenfieldFoundryProvider;
import org.verapdf.pdfa.Foundries;
import org.verapdf.pdfa.PDFAParser;
import org.verapdf.pdfa.PDFAValidator;
import org.verapdf.pdfa.flavours.PDFAFlavour;
import org.verapdf.pdfa.flavours.PDFFlavours;
import org.verapdf.pdfa.results.TestAssertion;
import org.verapdf.pdfa.results.ValidationResult;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
@Service
@Slf4j
public class VeraPDFService {
private static final String NOT_PDFA_STANDARD_ID = "not-pdfa";
private static final String NOT_PDFA_STANDARD_NAME =
"Not PDF/A (no PDF/A identification metadata)";
private static PDFVerificationResult convertToVerificationResult(
ValidationResult result, PDFAFlavour declaredFlavour, PDFAFlavour validationFlavour) {
PDFVerificationResult verificationResult = new PDFVerificationResult();
PDFAFlavour validationProfile =
validationFlavour != null ? validationFlavour : result.getPDFAFlavour();
boolean validationIsPdfa = isPdfaFlavour(validationProfile);
if (validationProfile != null) {
verificationResult.setValidationProfile(validationProfile.getId());
verificationResult.setValidationProfileName(getStandardName(validationProfile));
}
if (declaredFlavour != null) {
verificationResult.setStandard(declaredFlavour.getId());
verificationResult.setDeclaredPdfa(isPdfaFlavour(declaredFlavour));
} else if (validationProfile != null && !validationIsPdfa) {
verificationResult.setStandard(validationProfile.getId());
verificationResult.setDeclaredPdfa(false);
} else {
verificationResult.setStandard(NOT_PDFA_STANDARD_ID);
verificationResult.setDeclaredPdfa(false);
}
for (TestAssertion assertion : result.getTestAssertions()) {
if (assertion.getStatus() == TestAssertion.Status.FAILED) {
PDFVerificationResult.ValidationIssue issue = createValidationIssue(assertion);
verificationResult.addFailure(issue);
}
}
verificationResult.setCompliant(result.isCompliant());
String baseName;
if (declaredFlavour != null) {
baseName = getStandardName(declaredFlavour);
} else if (validationIsPdfa) {
baseName = NOT_PDFA_STANDARD_NAME;
} else if (validationProfile != null) {
baseName = getStandardName(validationProfile);
} else {
baseName = "Unknown standard";
}
String standardDisplay =
formatStandardDisplay(
baseName,
verificationResult.getTotalFailures(),
isPdfaFlavour(declaredFlavour),
validationIsPdfa && declaredFlavour == null);
verificationResult.setStandardName(standardDisplay);
verificationResult.setComplianceSummary(standardDisplay);
log.debug(
"Validation complete for profile {} (declared: {}): {} failures",
validationProfile != null ? validationProfile.getId() : "unknown",
declaredFlavour != null ? declaredFlavour.getId() : NOT_PDFA_STANDARD_ID,
verificationResult.getTotalFailures());
return verificationResult;
}
private static PDFVerificationResult.ValidationIssue createValidationIssue(
TestAssertion assertion) {
PDFVerificationResult.ValidationIssue issue = new PDFVerificationResult.ValidationIssue();
if (assertion.getRuleId() != null) {
issue.setRuleId(assertion.getRuleId().toString());
issue.setClause(assertion.getRuleId().getClause());
if (assertion.getRuleId().getSpecification() != null) {
issue.setSpecification(assertion.getRuleId().getSpecification().toString());
}
int testNumber = assertion.getRuleId().getTestNumber();
if (testNumber > 0) {
issue.setTestNumber(String.valueOf(testNumber));
}
}
issue.setMessage(assertion.getMessage());
issue.setLocation(
assertion.getLocation() != null ? assertion.getLocation().toString() : "Unknown");
return issue;
}
private static PDFVerificationResult createNoPdfaDeclarationResult() {
PDFVerificationResult result = new PDFVerificationResult();
result.setStandard(NOT_PDFA_STANDARD_ID);
result.setStandardName(NOT_PDFA_STANDARD_NAME);
result.setComplianceSummary(NOT_PDFA_STANDARD_NAME);
result.setCompliant(false);
result.setDeclaredPdfa(false);
PDFVerificationResult.ValidationIssue issue = new PDFVerificationResult.ValidationIssue();
issue.setMessage("Document does not declare PDF/A compliance in its XMP metadata.");
issue.setSpecification("XMP pdfaid");
result.addFailure(issue);
return result;
}
private static PDFVerificationResult buildErrorResult(
PDFAFlavour declaredFlavour, PDFAFlavour validationFlavour, String errorMessage) {
PDFVerificationResult errorResult = new PDFVerificationResult();
PDFAFlavour declaredForResult =
isPdfaFlavour(validationFlavour) ? declaredFlavour : validationFlavour;
if (declaredForResult != null) {
errorResult.setStandard(declaredForResult.getId());
errorResult.setStandardName(getStandardName(declaredForResult) + " with errors");
errorResult.setDeclaredPdfa(isPdfaFlavour(declaredForResult));
} else if (isPdfaFlavour(validationFlavour)) {
errorResult.setStandard(NOT_PDFA_STANDARD_ID);
errorResult.setStandardName(NOT_PDFA_STANDARD_NAME);
errorResult.setDeclaredPdfa(false);
} else {
errorResult.setStandard(
validationFlavour != null ? validationFlavour.getId() : NOT_PDFA_STANDARD_ID);
errorResult.setStandardName(
(validationFlavour != null
? getStandardName(validationFlavour)
: "Unknown standard")
+ " with errors");
errorResult.setDeclaredPdfa(false);
}
errorResult.setValidationProfile(
validationFlavour != null ? validationFlavour.getId() : NOT_PDFA_STANDARD_ID);
errorResult.setValidationProfileName(
validationFlavour != null
? getStandardName(validationFlavour)
: "Unknown standard");
errorResult.setComplianceSummary(errorResult.getStandardName());
errorResult.setCompliant(false);
PDFVerificationResult.ValidationIssue failure = new PDFVerificationResult.ValidationIssue();
failure.setMessage(errorMessage);
errorResult.addFailure(failure);
return errorResult;
}
@PostConstruct
public void initialize() {
try {
VeraGreenfieldFoundryProvider.initialise();
log.info("VeraPDF Greenfield initialized successfully");
} catch (Exception e) {
log.error("Failed to initialize VeraPDF", e);
}
}
public List<PDFVerificationResult> validatePDF(InputStream pdfStream)
throws IOException, ValidationException, ModelParsingException, EncryptedPdfException {
byte[] pdfBytes = pdfStream.readAllBytes();
List<PDFVerificationResult> results = new ArrayList<>();
PDFAFlavour declaredFlavour;
List<PDFAFlavour> detectedFlavours;
try (PDFAParser detectionParser =
Foundries.defaultInstance().createParser(new ByteArrayInputStream(pdfBytes))) {
declaredFlavour = detectionParser.getFlavour();
detectedFlavours = detectionParser.getFlavours();
}
List<PDFAFlavour> flavoursToValidate = new ArrayList<>();
boolean hasPdfaDeclaration = isPdfaFlavour(declaredFlavour);
if (declaredFlavour != null) {
flavoursToValidate.add(declaredFlavour);
}
for (PDFAFlavour flavour : detectedFlavours) {
if (flavour.equals(declaredFlavour)) {
continue;
}
if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A)) {
if (hasPdfaDeclaration) {
flavoursToValidate.add(flavour);
} else {
log.debug(
"Ignoring detected PDF/A flavour {} because no PDF/A declaration exists in XMP",
flavour.getId());
}
} else if (PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_UA)
|| PDFFlavours.isFlavourFamily(
flavour, PDFAFlavour.SpecificationFamily.WTPDF)) {
flavoursToValidate.add(flavour);
}
}
if (!hasPdfaDeclaration) {
results.add(createNoPdfaDeclarationResult());
}
if (flavoursToValidate.isEmpty()) {
log.info("No verifiable PDF/A, PDF/UA, or WTPDF standards declared via XMP metadata");
return results;
}
for (PDFAFlavour flavour : flavoursToValidate) {
try (PDFAParser parser =
Foundries.defaultInstance()
.createParser(new ByteArrayInputStream(pdfBytes), flavour)) {
PDFAFlavour parserDeclared = parser.getFlavour();
PDFAValidator validator =
Foundries.defaultInstance().createValidator(flavour, false);
ValidationResult result = validator.validate(parser);
PDFAFlavour declaredForResult =
PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A)
? parserDeclared
: flavour;
results.add(convertToVerificationResult(result, declaredForResult, flavour));
} catch (Exception e) {
log.error("Error validating standard {}: {}", flavour.getId(), e.getMessage());
results.add(
buildErrorResult(
declaredFlavour, flavour, "Validation error: " + e.getMessage()));
}
}
return results;
}
private static boolean isPdfaFlavour(PDFAFlavour flavour) {
return PDFFlavours.isFlavourFamily(flavour, PDFAFlavour.SpecificationFamily.PDF_A);
}
private static String formatStandardDisplay(
String baseName,
int errorCount,
boolean declaredPdfa,
boolean inferredPdfaWithoutDeclaration) {
if (inferredPdfaWithoutDeclaration) {
return NOT_PDFA_STANDARD_NAME;
}
if (!declaredPdfa && NOT_PDFA_STANDARD_NAME.equals(baseName)) {
return NOT_PDFA_STANDARD_NAME;
}
if (errorCount > 0) {
return baseName + " with errors";
}
return baseName + " compliant";
}
private static String getStandardName(PDFAFlavour flavour) {
String id = flavour.getId();
String part = flavour.getPart().toString();
String level = flavour.getLevel().toString();
// PDF/A standards - Fixed: proper length check and parentheses
if (!id.isEmpty()
&& (id.charAt(0) == '1'
|| id.charAt(0) == '2'
|| id.charAt(0) == '3'
|| id.charAt(0) == '4')) {
return "PDF/A-" + part + (level.isEmpty() ? "" : level);
}
// PDF/UA standards
else if (id.contains("ua")) {
return "PDF/UA-" + part;
}
// WTPDF standards
else if (id.contains("wtpdf")) {
return "WTPDF " + part;
}
return flavour.toString();
}
}