From d575609837a17fe8ce3e49ca34ec969697ed9523 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com.> Date: Mon, 19 May 2025 14:04:15 +0100 Subject: [PATCH] summary #2388 --- .gitignore | 1 + .../SPDF/EE/KeygenLicenseVerifier.java | 210 +++++---- .../api/converters/ConvertMarkdownToPdf.java | 3 +- .../api/misc/CompressController.java | 44 +- .../api/security/CertSignController.java | 4 +- .../controller/api/security/GetInfoOnPDF.java | 63 ++- src/main/resources/messages_en_GB.properties | 22 + .../templates/security/get-info-on-pdf.html | 443 +++++++++++++++++- .../security/mail/EmailServiceTest.java | 22 +- .../controller/api/EmailControllerTest.java | 4 +- 10 files changed, 689 insertions(+), 127 deletions(-) diff --git a/.gitignore b/.gitignore index 90d48ccea..06602d03b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ local.properties .recommenders .classpath .project +*.local.json version.properties #### Stirling-PDF Files ### diff --git a/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java b/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java index 2be506bec..092665dc3 100644 --- a/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java +++ b/src/main/java/stirling/software/SPDF/EE/KeygenLicenseVerifier.java @@ -47,19 +47,20 @@ public class KeygenLicenseVerifier { private static final ObjectMapper objectMapper = new ObjectMapper(); private final ApplicationProperties applicationProperties; - + // Shared HTTP client for connection pooling - private static final HttpClient httpClient = HttpClient.newBuilder() - .version(HttpClient.Version.HTTP_2) - .connectTimeout(java.time.Duration.ofSeconds(10)) - .build(); - + private static final HttpClient httpClient = + HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .connectTimeout(java.time.Duration.ofSeconds(10)) + .build(); + // License metadata context class to avoid shared mutable state private static class LicenseContext { private boolean isFloatingLicense = false; private int maxMachines = 1; // Default to 1 if not specified private boolean isEnterpriseLicense = false; - + public LicenseContext() {} } @@ -248,7 +249,7 @@ public class KeygenLicenseVerifier { // Check for floating license context.isFloatingLicense = attributesObj.optBoolean("floating", false); context.maxMachines = attributesObj.optInt("maxMachines", 1); - + // Extract metadata JSONObject metadataObj = attributesObj.optJSONObject("metadata"); if (metadataObj != null) { @@ -411,14 +412,16 @@ public class KeygenLicenseVerifier { // Check for floating license in policy boolean policyFloating = policyObj.optBoolean("floating", false); int policyMaxMachines = policyObj.optInt("maxMachines", 1); - + // Policy settings take precedence if (policyFloating) { context.isFloatingLicense = true; context.maxMachines = policyMaxMachines; - log.info("Policy defines floating license with max machines: {}", context.maxMachines); + log.info( + "Policy defines floating license with max machines: {}", + context.maxMachines); } - + // Extract max users and isEnterprise from policy or metadata int users = policyObj.optInt("users", 1); context.isEnterpriseLicense = policyObj.optBoolean("isEnterprise", false); @@ -474,7 +477,8 @@ public class KeygenLicenseVerifier { activateMachine(licenseKey, licenseId, machineFingerprint, context); if (activated) { // Revalidate after activation - validationResponse = validateLicense(licenseKey, machineFingerprint, context); + validationResponse = + validateLicense(licenseKey, machineFingerprint, context); isValid = validationResponse != null && validationResponse @@ -494,8 +498,8 @@ public class KeygenLicenseVerifier { } } - private JsonNode validateLicense(String licenseKey, String machineFingerprint, LicenseContext context) - throws Exception { + private JsonNode validateLicense( + String licenseKey, String machineFingerprint, LicenseContext context) throws Exception { String requestBody = String.format( "{\"meta\":{\"key\":\"%s\",\"scope\":{\"fingerprint\":\"%s\"}}}", @@ -514,7 +518,8 @@ public class KeygenLicenseVerifier { .POST(HttpRequest.BodyPublishers.ofString(requestBody)) .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); log.info("ValidateLicenseResponse body: {}", response.body()); JsonNode jsonResponse = objectMapper.readTree(response.body()); if (response.statusCode() == 200) { @@ -527,21 +532,23 @@ public class KeygenLicenseVerifier { log.info("License validity: " + isValid); log.info("Validation detail: " + detail); log.info("Validation code: " + code); - + // Check if the license itself has floating attribute JsonNode licenseAttrs = jsonResponse.path("data").path("attributes"); if (!licenseAttrs.isMissingNode()) { context.isFloatingLicense = licenseAttrs.path("floating").asBoolean(false); context.maxMachines = licenseAttrs.path("maxMachines").asInt(1); - - log.info("License floating (from license): {}, maxMachines: {}", - context.isFloatingLicense, context.maxMachines); + + log.info( + "License floating (from license): {}, maxMachines: {}", + context.isFloatingLicense, + context.maxMachines); } - + // Also check the policy for floating license support if included JsonNode includedNode = jsonResponse.path("included"); JsonNode policyNode = null; - + if (includedNode.isArray()) { for (JsonNode node : includedNode) { if ("policies".equals(node.path("type").asText())) { @@ -550,20 +557,23 @@ public class KeygenLicenseVerifier { } } } - + if (policyNode != null) { // Check if this is a floating license from policy - boolean policyFloating = policyNode.path("attributes").path("floating").asBoolean(false); + boolean policyFloating = + policyNode.path("attributes").path("floating").asBoolean(false); int policyMaxMachines = policyNode.path("attributes").path("maxMachines").asInt(1); - + // Policy takes precedence over license attributes if (policyFloating) { context.isFloatingLicense = true; context.maxMachines = policyMaxMachines; } - - log.info("License floating (from policy): {}, maxMachines: {}", - context.isFloatingLicense, context.maxMachines); + + log.info( + "License floating (from policy): {}, maxMachines: {}", + context.isFloatingLicense, + context.maxMachines); } // Extract user count, default to 1 if not specified @@ -593,86 +603,104 @@ public class KeygenLicenseVerifier { return jsonResponse; } - private boolean activateMachine(String licenseKey, String licenseId, String machineFingerprint, - LicenseContext context) throws Exception { + private boolean activateMachine( + String licenseKey, String licenseId, String machineFingerprint, LicenseContext context) + throws Exception { // For floating licenses, we first need to check if we need to deregister any machines if (context.isFloatingLicense) { - log.info("Processing floating license activation. Max machines allowed: {}", context.maxMachines); - + log.info( + "Processing floating license activation. Max machines allowed: {}", + context.maxMachines); + // Get the current machines for this license JsonNode machinesResponse = fetchMachinesForLicense(licenseKey, licenseId); if (machinesResponse != null) { JsonNode machines = machinesResponse.path("data"); int currentMachines = machines.size(); - - log.info("Current machine count: {}, Max allowed: {}", currentMachines, context.maxMachines); - + + log.info( + "Current machine count: {}, Max allowed: {}", + currentMachines, + context.maxMachines); + // Check if the current fingerprint is already activated boolean isCurrentMachineActivated = false; String currentMachineId = null; - + for (JsonNode machine : machines) { - if (machineFingerprint.equals(machine.path("attributes").path("fingerprint").asText())) { + if (machineFingerprint.equals( + machine.path("attributes").path("fingerprint").asText())) { isCurrentMachineActivated = true; currentMachineId = machine.path("id").asText(); - log.info("Current machine is already activated with ID: {}", currentMachineId); + log.info( + "Current machine is already activated with ID: {}", + currentMachineId); break; } } - + // If the current machine is already activated, there's no need to do anything if (isCurrentMachineActivated) { log.info("Machine already activated. No action needed."); return true; } - + // If we've reached the max machines limit, we need to deregister the oldest machine if (currentMachines >= context.maxMachines) { - log.info("Max machines reached. Deregistering oldest machine to make room for the new machine."); - + log.info( + "Max machines reached. Deregistering oldest machine to make room for the new machine."); + // Find the oldest machine based on creation timestamp if (machines.size() > 0) { // Find the machine with the oldest creation date String oldestMachineId = null; java.time.Instant oldestTime = null; - + for (JsonNode machine : machines) { - String createdStr = machine.path("attributes").path("created").asText(null); + String createdStr = + machine.path("attributes").path("created").asText(null); if (createdStr != null && !createdStr.isEmpty()) { try { - java.time.Instant createdTime = java.time.Instant.parse(createdStr); + java.time.Instant createdTime = + java.time.Instant.parse(createdStr); if (oldestTime == null || createdTime.isBefore(oldestTime)) { oldestTime = createdTime; oldestMachineId = machine.path("id").asText(); } } catch (Exception e) { - log.warn("Could not parse creation time for machine: {}", e.getMessage()); + log.warn( + "Could not parse creation time for machine: {}", + e.getMessage()); } } } - + // If we couldn't determine the oldest by timestamp, use the first one if (oldestMachineId == null) { - log.warn("Could not determine oldest machine by timestamp, using first machine in list"); + log.warn( + "Could not determine oldest machine by timestamp, using first machine in list"); oldestMachineId = machines.path(0).path("id").asText(); } - + log.info("Deregistering machine with ID: {}", oldestMachineId); - + boolean deregistered = deregisterMachine(licenseKey, oldestMachineId); if (!deregistered) { - log.error("Failed to deregister machine. Cannot proceed with activation."); + log.error( + "Failed to deregister machine. Cannot proceed with activation."); return false; } - log.info("Machine deregistered successfully. Proceeding with activation of new machine."); + log.info( + "Machine deregistered successfully. Proceeding with activation of new machine."); } else { - log.error("License has reached machine limit but no machines were found to deregister. This is unexpected."); + log.error( + "License has reached machine limit but no machines were found to deregister. This is unexpected."); // We'll still try to activate, but it might fail } } } } - + // Proceed with machine activation String hostname; try { @@ -720,7 +748,8 @@ public class KeygenLicenseVerifier { .POST(HttpRequest.BodyPublishers.ofString(body.toString())) .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); log.info("activateMachine Response body: " + response.body()); if (response.statusCode() == 201) { log.info("Machine activated successfully"); @@ -738,61 +767,76 @@ public class KeygenLicenseVerifier { private String generateMachineFingerprint() { return GeneralUtils.generateMachineFingerprint(); } - + /** * Fetches all machines associated with a specific license - * + * * @param licenseKey The license key to check - * @param licenseId The license ID + * @param licenseId The license ID * @return JsonNode containing the list of machines, or null if an error occurs * @throws Exception if an error occurs during the HTTP request */ private JsonNode fetchMachinesForLicense(String licenseKey, String licenseId) throws Exception { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/" + ACCOUNT_ID + "/licenses/" + licenseId + "/machines")) - .header("Content-Type", "application/vnd.api+json") - .header("Accept", "application/vnd.api+json") - .header("Authorization", "License " + licenseKey) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + HttpRequest request = + HttpRequest.newBuilder() + .uri( + URI.create( + BASE_URL + + "/" + + ACCOUNT_ID + + "/licenses/" + + licenseId + + "/machines")) + .header("Content-Type", "application/vnd.api+json") + .header("Accept", "application/vnd.api+json") + .header("Authorization", "License " + licenseKey) + .GET() + .build(); + + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); log.info("fetchMachinesForLicense Response body: {}", response.body()); - + if (response.statusCode() == 200) { return objectMapper.readTree(response.body()); } else { - log.error("Error fetching machines for license. Status code: {}, error: {}", - response.statusCode(), response.body()); + log.error( + "Error fetching machines for license. Status code: {}, error: {}", + response.statusCode(), + response.body()); return null; } } - + /** * Deregisters a machine from a license - * + * * @param licenseKey The license key * @param machineId The ID of the machine to deregister * @return true if deregistration was successful, false otherwise */ private boolean deregisterMachine(String licenseKey, String machineId) { try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(BASE_URL + "/" + ACCOUNT_ID + "/machines/" + machineId)) - .header("Content-Type", "application/vnd.api+json") - .header("Accept", "application/vnd.api+json") - .header("Authorization", "License " + licenseKey) - .DELETE() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - + HttpRequest request = + HttpRequest.newBuilder() + .uri(URI.create(BASE_URL + "/" + ACCOUNT_ID + "/machines/" + machineId)) + .header("Content-Type", "application/vnd.api+json") + .header("Accept", "application/vnd.api+json") + .header("Authorization", "License " + licenseKey) + .DELETE() + .build(); + + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 204) { log.info("Machine {} successfully deregistered", machineId); return true; } else { - log.error("Error deregistering machine. Status code: {}, error: {}", - response.statusCode(), response.body()); + log.error( + "Error deregistering machine. Status code: {}, error: {}", + response.statusCode(), + response.body()); return false; } } catch (Exception e) { diff --git a/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java b/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java index 1124eceb7..f0c999a45 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java +++ b/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertMarkdownToPdf.java @@ -47,7 +47,8 @@ public class ConvertMarkdownToPdf { description = "This endpoint takes a Markdown file input, converts it to HTML, and then to" + " PDF format. Input:MARKDOWN Output:PDF Type:SISO") - public ResponseEntity markdownToPdf(@ModelAttribute GeneralFile generalFile) throws Exception { + public ResponseEntity markdownToPdf(@ModelAttribute GeneralFile generalFile) + throws Exception { MultipartFile fileInput = generalFile.getFileInput(); if (fileInput == null) { diff --git a/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java b/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java index da4a77962..cbaa12a0c 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/misc/CompressController.java @@ -626,32 +626,32 @@ public class CompressController { // Scale factors for different optimization levels private double getScaleFactorForLevel(int optimizeLevel) { - return switch (optimizeLevel) { - case 3 -> 0.85; - case 4 -> 0.75; - case 5 -> 0.65; - case 6 -> 0.55; - case 7 -> 0.45; - case 8 -> 0.35; - case 9 -> 0.25; - case 10 -> 0.15; - default -> 1.0; - }; + return switch (optimizeLevel) { + case 3 -> 0.85; + case 4 -> 0.75; + case 5 -> 0.65; + case 6 -> 0.55; + case 7 -> 0.45; + case 8 -> 0.35; + case 9 -> 0.25; + case 10 -> 0.15; + default -> 1.0; + }; } // JPEG quality for different optimization levels private float getJpegQualityForLevel(int optimizeLevel) { - return switch (optimizeLevel) { - case 3 -> 0.85f; - case 4 -> 0.80f; - case 5 -> 0.75f; - case 6 -> 0.70f; - case 7 -> 0.60f; - case 8 -> 0.50f; - case 9 -> 0.35f; - case 10 -> 0.2f; - default -> 0.7f; - }; + return switch (optimizeLevel) { + case 3 -> 0.85f; + case 4 -> 0.80f; + case 5 -> 0.75f; + case 6 -> 0.70f; + case 7 -> 0.60f; + case 8 -> 0.50f; + case 9 -> 0.35f; + case 10 -> 0.2f; + default -> 0.7f; + }; } @PostMapping(consumes = "multipart/form-data", value = "/compress-pdf") diff --git a/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 58e5b848c..e853faa62 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -146,8 +146,8 @@ public class CertSignController { summary = "Sign PDF with a Digital Certificate", description = "This endpoint accepts a PDF file, a digital certificate and related" - + " information to sign the PDF. It then returns the digitally signed PDF" - + " file. Input:PDF Output:PDF Type:SISO") + + " information to sign the PDF. It then returns the digitally signed PDF" + + " file. Input:PDF Output:PDF Type:SISO") public ResponseEntity signPDFWithCert(@ModelAttribute SignPDFWithCertRequest request) throws Exception { MultipartFile pdf = request.getFileInput(); diff --git a/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java b/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java index 95049b0bd..2c8825cb5 100644 --- a/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java +++ b/src/main/java/stirling/software/SPDF/controller/api/security/GetInfoOnPDF.java @@ -91,6 +91,59 @@ public class GetInfoOnPDF { } } + /** + * Generates structured summary data about the PDF highlighting its unique characteristics such + * as encryption status, permission restrictions, and standards compliance. + * + * @param document The PDF document to analyze + * @return An ObjectNode containing structured summary data + */ + private ObjectNode generatePDFSummaryData(PDDocument document) { + ObjectNode summaryData = objectMapper.createObjectNode(); + + // Check if encrypted + if (document.isEncrypted()) { + summaryData.put("encrypted", true); + } + + // Check permissions + AccessPermission ap = document.getCurrentAccessPermission(); + ArrayNode restrictedPermissions = objectMapper.createArrayNode(); + + if (!ap.canAssembleDocument()) restrictedPermissions.add("document assembly"); + if (!ap.canExtractContent()) restrictedPermissions.add("content extraction"); + if (!ap.canExtractForAccessibility()) restrictedPermissions.add("accessibility extraction"); + if (!ap.canFillInForm()) restrictedPermissions.add("form filling"); + if (!ap.canModify()) restrictedPermissions.add("modification"); + if (!ap.canModifyAnnotations()) restrictedPermissions.add("annotation modification"); + if (!ap.canPrint()) restrictedPermissions.add("printing"); + + if (restrictedPermissions.size() > 0) { + summaryData.set("restrictedPermissions", restrictedPermissions); + summaryData.put("restrictedPermissionsCount", restrictedPermissions.size()); + } + + // Check standard compliance + if (checkForStandard(document, "PDF/A")) { + summaryData.put("standardCompliance", "PDF/A"); + summaryData.put("standardPurpose", "long-term archiving"); + } else if (checkForStandard(document, "PDF/X")) { + summaryData.put("standardCompliance", "PDF/X"); + summaryData.put("standardPurpose", "graphic exchange"); + } else if (checkForStandard(document, "PDF/UA")) { + summaryData.put("standardCompliance", "PDF/UA"); + summaryData.put("standardPurpose", "universal accessibility"); + } else if (checkForStandard(document, "PDF/E")) { + summaryData.put("standardCompliance", "PDF/E"); + summaryData.put("standardPurpose", "engineering workflows"); + } else if (checkForStandard(document, "PDF/VT")) { + summaryData.put("standardCompliance", "PDF/VT"); + summaryData.put("standardPurpose", "variable and transactional printing"); + } + + return summaryData; + } + public static boolean checkForStandard(PDDocument document, String standardKeyword) { // Check XMP Metadata try { @@ -191,6 +244,12 @@ public class GetInfoOnPDF { } jsonOutput.set("FormFields", formFieldsNode); + // Generate structured summary data about PDF characteristics + ObjectNode summaryData = generatePDFSummaryData(pdfBoxDoc); + if (summaryData != null && summaryData.size() > 0) { + jsonOutput.set("SummaryData", summaryData); + } + // embeed files TODO size if (catalog.getNames() != null) { PDEmbeddedFilesNameTreeNode efTree = catalog.getNames().getEmbeddedFiles(); @@ -622,8 +681,8 @@ public class GetInfoOnPDF { permissionsNode.put("Document Assembly", getPermissionState(ap.canAssembleDocument())); permissionsNode.put("Extracting Content", getPermissionState(ap.canExtractContent())); permissionsNode.put( - "Extracting for accessibility", - getPermissionState(ap.canExtractForAccessibility())); + "Extracting for accessibility", + getPermissionState(ap.canExtractForAccessibility())); permissionsNode.put("Form Filling", getPermissionState(ap.canFillInForm())); permissionsNode.put("Modifying", getPermissionState(ap.canModify())); permissionsNode.put("Modifying annotations", getPermissionState(ap.canModifyAnnotations())); diff --git a/src/main/resources/messages_en_GB.properties b/src/main/resources/messages_en_GB.properties index 6b2935747..56b1559c0 100644 --- a/src/main/resources/messages_en_GB.properties +++ b/src/main/resources/messages_en_GB.properties @@ -675,6 +675,28 @@ getPdfInfo.title=Get Info on PDF getPdfInfo.header=Get Info on PDF getPdfInfo.submit=Get Info getPdfInfo.downloadJson=Download JSON +getPdfInfo.summary=PDF Summary +getPdfInfo.summary.encrypted=This PDF is encrypted so may face issues with some applications +getPdfInfo.summary.permissions=This PDF has {0} restricted permissions which may limit what you can do with it +getPdfInfo.summary.compliance=This PDF complies with the {0} standard +getPdfInfo.summary.basicInfo=Basic Information +getPdfInfo.summary.docInfo=Document Information +getPdfInfo.summary.encrypted.alert=Encrypted PDF - This document is password protected +getPdfInfo.summary.not.encrypted.alert=Unencrypted PDF - No password protection +getPdfInfo.summary.permissions.alert=Restricted Permissions - {0} actions are not allowed +getPdfInfo.summary.all.permissions.alert=All Permissions Allowed +getPdfInfo.summary.compliance.alert={0} Compliant +getPdfInfo.summary.no.compliance.alert=No Compliance Standards +getPdfInfo.summary.security.section=Security Status +getPdfInfo.section.BasicInfo=Basic Information about the PDF document including file size, page count, and language +getPdfInfo.section.Metadata=Document metadata including title, author, creation date and other document properties +getPdfInfo.section.DocumentInfo=Technical details about the PDF document structure and version +getPdfInfo.section.Compliancy=PDF standards compliance information (PDF/A, PDF/X, etc.) +getPdfInfo.section.Encryption=Security and encryption details of the document +getPdfInfo.section.Permissions=Document permission settings that control what actions can be performed +getPdfInfo.section.Other=Additional document components like bookmarks, layers, and embedded files +getPdfInfo.section.FormFields=Interactive form fields present in the document +getPdfInfo.section.PerPageInfo=Detailed information about each page in the document #markdown-to-pdf diff --git a/src/main/resources/templates/security/get-info-on-pdf.html b/src/main/resources/templates/security/get-info-on-pdf.html index 97ddf723a..95a5ad391 100644 --- a/src/main/resources/templates/security/get-info-on-pdf.html +++ b/src/main/resources/templates/security/get-info-on-pdf.html @@ -11,7 +11,7 @@

-
+
info @@ -22,6 +22,82 @@
+ + +
@@ -31,10 +107,48 @@
-