mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
Merge branch 'V2' into feature/V2/AddStamp
This commit is contained in:
@@ -18,7 +18,9 @@ public class PDFFile {
|
||||
@Schema(description = "The input PDF file", format = "binary")
|
||||
private MultipartFile fileInput;
|
||||
|
||||
@Schema(description = "File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)")
|
||||
@Schema(
|
||||
description =
|
||||
"File ID for server-side files (can be used instead of fileInput if job was previously done on file in async mode)")
|
||||
private String fileId;
|
||||
|
||||
@AssertTrue(message = "Either fileInput or fileId must be provided")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
|
||||
public interface ServerCertificateServiceInterface {
|
||||
|
||||
boolean isEnabled();
|
||||
|
||||
boolean hasServerCertificate();
|
||||
|
||||
void initializeServerCertificate();
|
||||
|
||||
KeyStore getServerKeyStore() throws Exception;
|
||||
|
||||
String getServerCertificatePassword();
|
||||
|
||||
X509Certificate getServerCertificate() throws Exception;
|
||||
|
||||
byte[] getServerCertificatePublicKey() throws Exception;
|
||||
|
||||
void uploadServerCertificate(InputStream p12Stream, String password) throws Exception;
|
||||
|
||||
void deleteServerCertificate() throws Exception;
|
||||
|
||||
ServerCertificateInfo getServerCertificateInfo() throws Exception;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
class ServerCertificateInfo {
|
||||
private final boolean exists;
|
||||
private final String subject;
|
||||
private final String issuer;
|
||||
private final Date validFrom;
|
||||
private final Date validTo;
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("PageOps", "pdf-organizer");
|
||||
addEndpointToGroup("PageOps", "rotate-pdf");
|
||||
addEndpointToGroup("PageOps", "multi-page-layout");
|
||||
addEndpointToGroup("PageOps", "booklet-imposition");
|
||||
addEndpointToGroup("PageOps", "scale-pages");
|
||||
addEndpointToGroup("PageOps", "crop");
|
||||
addEndpointToGroup("PageOps", "extract-page");
|
||||
@@ -366,6 +367,7 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "cert-sign");
|
||||
addEndpointToGroup("Java", "remove-cert-sign");
|
||||
addEndpointToGroup("Java", "multi-page-layout");
|
||||
addEndpointToGroup("Java", "booklet-imposition");
|
||||
addEndpointToGroup("Java", "scale-pages");
|
||||
addEndpointToGroup("Java", "add-page-numbers");
|
||||
addEndpointToGroup("Java", "auto-rename");
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package stirling.software.SPDF.controller.api;
|
||||
|
||||
import java.awt.*;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.multipdf.LayerUtility;
|
||||
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.form.PDFormXObject;
|
||||
import org.apache.pdfbox.util.Matrix;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/general")
|
||||
@Tag(name = "General", description = "General APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class BookletImpositionController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
@AutoJobPostMapping(value = "/booklet-imposition", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
summary = "Create a booklet with proper page imposition",
|
||||
description =
|
||||
"This operation combines page reordering for booklet printing with multi-page layout. "
|
||||
+ "It rearranges pages in the correct order for booklet printing and places multiple pages "
|
||||
+ "on each sheet for proper folding and binding. Input:PDF Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> createBookletImposition(
|
||||
@ModelAttribute BookletImpositionRequest request) throws IOException {
|
||||
|
||||
MultipartFile file = request.getFileInput();
|
||||
int pagesPerSheet = request.getPagesPerSheet();
|
||||
boolean addBorder = Boolean.TRUE.equals(request.getAddBorder());
|
||||
String spineLocation =
|
||||
request.getSpineLocation() != null ? request.getSpineLocation() : "LEFT";
|
||||
boolean addGutter = Boolean.TRUE.equals(request.getAddGutter());
|
||||
float gutterSize = request.getGutterSize();
|
||||
boolean doubleSided = Boolean.TRUE.equals(request.getDoubleSided());
|
||||
String duplexPass = request.getDuplexPass() != null ? request.getDuplexPass() : "BOTH";
|
||||
boolean flipOnShortEdge = Boolean.TRUE.equals(request.getFlipOnShortEdge());
|
||||
|
||||
// Validate pages per sheet for booklet - only 2-up landscape is proper booklet
|
||||
if (pagesPerSheet != 2) {
|
||||
throw new IllegalArgumentException(
|
||||
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
|
||||
}
|
||||
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
|
||||
// Create proper booklet with signature-based page ordering
|
||||
PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge);
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
|
||||
+ "_booklet.pdf");
|
||||
}
|
||||
|
||||
private static int padToMultipleOf4(int n) {
|
||||
return (n + 3) / 4 * 4;
|
||||
}
|
||||
|
||||
private static class Side {
|
||||
final int left, right;
|
||||
final boolean isBack;
|
||||
|
||||
Side(int left, int right, boolean isBack) {
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.isBack = isBack;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Side> saddleStitchSides(
|
||||
int totalPagesOriginal,
|
||||
boolean doubleSided,
|
||||
String duplexPass,
|
||||
boolean flipOnShortEdge) {
|
||||
int N = padToMultipleOf4(totalPagesOriginal);
|
||||
List<Side> out = new ArrayList<>();
|
||||
int sheets = N / 4;
|
||||
|
||||
for (int s = 0; s < sheets; s++) {
|
||||
int a = N - 1 - (s * 2); // left, front
|
||||
int b = (s * 2); // right, front
|
||||
int c = (s * 2) + 1; // left, back
|
||||
int d = N - 2 - (s * 2); // right, back
|
||||
|
||||
// clamp to -1 (blank) if >= totalPagesOriginal
|
||||
a = (a < totalPagesOriginal) ? a : -1;
|
||||
b = (b < totalPagesOriginal) ? b : -1;
|
||||
c = (c < totalPagesOriginal) ? c : -1;
|
||||
d = (d < totalPagesOriginal) ? d : -1;
|
||||
|
||||
// Handle duplex pass selection
|
||||
boolean includeFront = "BOTH".equals(duplexPass) || "FIRST".equals(duplexPass);
|
||||
boolean includeBack = "BOTH".equals(duplexPass) || "SECOND".equals(duplexPass);
|
||||
|
||||
if (includeFront) {
|
||||
out.add(new Side(a, b, false)); // front side
|
||||
}
|
||||
|
||||
if (includeBack) {
|
||||
// For short-edge duplex, swap back-side left/right
|
||||
// Note: flipOnShortEdge is ignored in manual duplex mode since users physically
|
||||
// flip the stack
|
||||
if (doubleSided && flipOnShortEdge) {
|
||||
out.add(new Side(d, c, true)); // swapped back side (automatic duplex only)
|
||||
} else {
|
||||
out.add(new Side(c, d, true)); // normal back side
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private PDDocument createSaddleBooklet(
|
||||
PDDocument src,
|
||||
int totalPages,
|
||||
boolean addBorder,
|
||||
String spineLocation,
|
||||
boolean addGutter,
|
||||
float gutterSize,
|
||||
boolean doubleSided,
|
||||
String duplexPass,
|
||||
boolean flipOnShortEdge)
|
||||
throws IOException {
|
||||
|
||||
PDDocument dst = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(src);
|
||||
|
||||
// Derive paper size from source document's first page CropBox
|
||||
PDRectangle srcBox = src.getPage(0).getCropBox();
|
||||
PDRectangle portraitPaper = new PDRectangle(srcBox.getWidth(), srcBox.getHeight());
|
||||
// Force landscape for booklet (Acrobat booklet uses landscape paper to fold to portrait)
|
||||
PDRectangle pageSize = new PDRectangle(portraitPaper.getHeight(), portraitPaper.getWidth());
|
||||
|
||||
// Validate and clamp gutter size
|
||||
if (gutterSize < 0) gutterSize = 0;
|
||||
if (gutterSize >= pageSize.getWidth() / 2f) gutterSize = pageSize.getWidth() / 2f - 1f;
|
||||
|
||||
List<Side> sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge);
|
||||
|
||||
for (Side side : sides) {
|
||||
PDPage out = new PDPage(pageSize);
|
||||
dst.addPage(out);
|
||||
|
||||
float cellW = pageSize.getWidth() / 2f;
|
||||
float cellH = pageSize.getHeight();
|
||||
|
||||
// For RIGHT spine (RTL), swap left/right placements
|
||||
boolean rtl = "RIGHT".equalsIgnoreCase(spineLocation);
|
||||
int leftCol = rtl ? 1 : 0;
|
||||
int rightCol = rtl ? 0 : 1;
|
||||
|
||||
// Apply gutter margins with centered gap option
|
||||
float g = addGutter ? gutterSize : 0f;
|
||||
float leftCellX = leftCol * cellW + (g / 2f);
|
||||
float rightCellX = rightCol * cellW - (g / 2f);
|
||||
float leftCellW = cellW - (g / 2f);
|
||||
float rightCellW = cellW - (g / 2f);
|
||||
|
||||
// Create LayerUtility once per page for efficiency
|
||||
LayerUtility layerUtility = new LayerUtility(dst);
|
||||
|
||||
try (PDPageContentStream cs =
|
||||
new PDPageContentStream(
|
||||
dst, out, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
|
||||
if (addBorder) {
|
||||
cs.setLineWidth(1.5f);
|
||||
cs.setStrokingColor(Color.BLACK);
|
||||
}
|
||||
|
||||
// draw left cell
|
||||
drawCell(
|
||||
src,
|
||||
dst,
|
||||
cs,
|
||||
layerUtility,
|
||||
side.left,
|
||||
leftCellX,
|
||||
0f,
|
||||
leftCellW,
|
||||
cellH,
|
||||
addBorder);
|
||||
// draw right cell
|
||||
drawCell(
|
||||
src,
|
||||
dst,
|
||||
cs,
|
||||
layerUtility,
|
||||
side.right,
|
||||
rightCellX,
|
||||
0f,
|
||||
rightCellW,
|
||||
cellH,
|
||||
addBorder);
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
private void drawCell(
|
||||
PDDocument src,
|
||||
PDDocument dst,
|
||||
PDPageContentStream cs,
|
||||
LayerUtility layerUtility,
|
||||
int pageIndex,
|
||||
float cellX,
|
||||
float cellY,
|
||||
float cellW,
|
||||
float cellH,
|
||||
boolean addBorder)
|
||||
throws IOException {
|
||||
|
||||
if (pageIndex < 0) {
|
||||
// Draw border for blank cell if needed
|
||||
if (addBorder) {
|
||||
cs.addRect(cellX, cellY, cellW, cellH);
|
||||
cs.stroke();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
PDPage srcPage = src.getPage(pageIndex);
|
||||
PDRectangle r = srcPage.getCropBox(); // Use CropBox instead of MediaBox
|
||||
int rot = (srcPage.getRotation() + 360) % 360;
|
||||
|
||||
// Calculate scale factors, accounting for rotation
|
||||
float sx = cellW / r.getWidth();
|
||||
float sy = cellH / r.getHeight();
|
||||
float s = Math.min(sx, sy);
|
||||
|
||||
// If rotated 90/270 degrees, swap dimensions for fitting
|
||||
if (rot == 90 || rot == 270) {
|
||||
sx = cellW / r.getHeight();
|
||||
sy = cellH / r.getWidth();
|
||||
s = Math.min(sx, sy);
|
||||
}
|
||||
|
||||
float drawnW = (rot == 90 || rot == 270) ? r.getHeight() * s : r.getWidth() * s;
|
||||
float drawnH = (rot == 90 || rot == 270) ? r.getWidth() * s : r.getHeight() * s;
|
||||
|
||||
// Center in cell, accounting for CropBox offset
|
||||
float tx = cellX + (cellW - drawnW) / 2f - r.getLowerLeftX() * s;
|
||||
float ty = cellY + (cellH - drawnH) / 2f - r.getLowerLeftY() * s;
|
||||
|
||||
cs.saveGraphicsState();
|
||||
cs.transform(Matrix.getTranslateInstance(tx, ty));
|
||||
cs.transform(Matrix.getScaleInstance(s, s));
|
||||
|
||||
// Apply rotation if needed (rotate about origin), then translate to keep in cell
|
||||
switch (rot) {
|
||||
case 90:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI / 2, 0, 0));
|
||||
// After 90° CCW, the content spans x in [-r.getHeight(), 0] and y in [0,
|
||||
// r.getWidth()]
|
||||
cs.transform(Matrix.getTranslateInstance(0, -r.getWidth()));
|
||||
break;
|
||||
case 180:
|
||||
cs.transform(Matrix.getRotateInstance(Math.PI, 0, 0));
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getWidth(), -r.getHeight()));
|
||||
break;
|
||||
case 270:
|
||||
cs.transform(Matrix.getRotateInstance(3 * Math.PI / 2, 0, 0));
|
||||
// After 270° CCW, the content spans x in [0, r.getHeight()] and y in
|
||||
// [-r.getWidth(), 0]
|
||||
cs.transform(Matrix.getTranslateInstance(-r.getHeight(), 0));
|
||||
break;
|
||||
default:
|
||||
// 0°: no-op
|
||||
}
|
||||
|
||||
// Reuse LayerUtility passed from caller
|
||||
PDFormXObject form = layerUtility.importPageAsForm(src, pageIndex);
|
||||
cs.drawForm(form);
|
||||
|
||||
cs.restoreGraphicsState();
|
||||
|
||||
// Draw border on top of form to ensure visibility
|
||||
if (addBorder) {
|
||||
cs.addRect(cellX, cellY, cellW, cellH);
|
||||
cs.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,21 +10,32 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@ConfigApi
|
||||
@RequiredArgsConstructor
|
||||
@Hidden
|
||||
public class ConfigController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
ApplicationContext applicationContext,
|
||||
EndpointConfiguration endpointConfiguration,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
public ResponseEntity<Map<String, Object>> getAppConfig() {
|
||||
@@ -58,6 +69,11 @@ public class ConfigController {
|
||||
// Premium/Enterprise settings
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// Server certificate settings
|
||||
configData.put(
|
||||
"serverCertificateEnabled",
|
||||
serverCertificateService != null && serverCertificateService.isEnabled());
|
||||
|
||||
// Legal settings
|
||||
configData.put(
|
||||
"termsAndConditions", applicationProperties.getLegal().getTermsAndConditions());
|
||||
|
||||
@@ -53,6 +53,7 @@ import org.bouncycastle.operator.InputDecryptorProvider;
|
||||
import org.bouncycastle.operator.OperatorCreationException;
|
||||
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
|
||||
import org.bouncycastle.pkcs.PKCSException;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -68,13 +69,13 @@ import io.micrometer.common.util.StringUtils;
|
||||
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.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -82,7 +83,6 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequestMapping("/api/v1/security")
|
||||
@Slf4j
|
||||
@Tag(name = "Security", description = "Security APIs")
|
||||
@RequiredArgsConstructor
|
||||
public class CertSignController {
|
||||
|
||||
static {
|
||||
@@ -102,6 +102,15 @@ public class CertSignController {
|
||||
}
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
public CertSignController(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@Autowired(required = false)
|
||||
ServerCertificateServiceInterface serverCertificateService) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
}
|
||||
|
||||
private static void sign(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
@@ -177,6 +186,7 @@ public class CertSignController {
|
||||
}
|
||||
|
||||
KeyStore ks = null;
|
||||
String keystorePassword = password;
|
||||
|
||||
switch (certType) {
|
||||
case "PEM":
|
||||
@@ -195,6 +205,24 @@ public class CertSignController {
|
||||
ks = KeyStore.getInstance("JKS");
|
||||
ks.load(jksfile.getInputStream(), password.toCharArray());
|
||||
break;
|
||||
case "SERVER":
|
||||
if (serverCertificateService == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateNotAvailable",
|
||||
"Server certificate service is not available in this edition");
|
||||
}
|
||||
if (!serverCertificateService.isEnabled()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateDisabled",
|
||||
"Server certificate feature is disabled");
|
||||
}
|
||||
if (!serverCertificateService.hasServerCertificate()) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.serverCertificateNotFound", "No server certificate configured");
|
||||
}
|
||||
ks = serverCertificateService.getServerKeyStore();
|
||||
keystorePassword = serverCertificateService.getServerCertificatePassword();
|
||||
break;
|
||||
default:
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.invalidArgument",
|
||||
@@ -202,7 +230,7 @@ public class CertSignController {
|
||||
"certificate type: " + certType);
|
||||
}
|
||||
|
||||
CreateSignature createSignature = new CreateSignature(ks, password.toCharArray());
|
||||
CreateSignature createSignature = new CreateSignature(ks, keystorePassword.toCharArray());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
sign(
|
||||
pdfDocumentFactory,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package stirling.software.SPDF.model.api.general;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class BookletImpositionRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"The number of pages per side for booklet printing (always 2 for proper booklet).",
|
||||
type = "number",
|
||||
defaultValue = "2",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"2"})
|
||||
private int pagesPerSheet = 2;
|
||||
|
||||
@Schema(description = "Boolean for if you wish to add border around the pages")
|
||||
private Boolean addBorder = false;
|
||||
|
||||
@Schema(
|
||||
description = "The spine location for the booklet.",
|
||||
type = "string",
|
||||
defaultValue = "LEFT",
|
||||
allowableValues = {"LEFT", "RIGHT"})
|
||||
private String spineLocation = "LEFT";
|
||||
|
||||
@Schema(description = "Add gutter margin (inner margin for binding)")
|
||||
private Boolean addGutter = false;
|
||||
|
||||
@Schema(
|
||||
description = "Gutter margin size in points (used when addGutter is true)",
|
||||
type = "number",
|
||||
defaultValue = "12")
|
||||
private float gutterSize = 12f;
|
||||
|
||||
@Schema(description = "Generate both front and back sides (double-sided printing)")
|
||||
private Boolean doubleSided = true;
|
||||
|
||||
@Schema(
|
||||
description = "For manual duplex: which pass to generate",
|
||||
type = "string",
|
||||
defaultValue = "BOTH",
|
||||
allowableValues = {"BOTH", "FIRST", "SECOND"})
|
||||
private String duplexPass = "BOTH";
|
||||
|
||||
@Schema(description = "Flip back sides for short-edge duplex printing (default is long-edge)")
|
||||
private Boolean flipOnShortEdge = false;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public class SignPDFWithCertRequest extends PDFFile {
|
||||
|
||||
@Schema(
|
||||
description = "The type of the digital certificate",
|
||||
allowableValues = {"PEM", "PKCS12", "JKS"},
|
||||
allowableValues = {"PEM", "PKCS12", "JKS", "SERVER"},
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String certType;
|
||||
|
||||
|
||||
@@ -114,6 +114,11 @@ system:
|
||||
enableUrlToPDF: false # Set to 'true' to enable URL to PDF, INTERNAL ONLY, known security issues, should not be used externally
|
||||
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
|
||||
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
|
||||
serverCertificate:
|
||||
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
|
||||
organizationName: Stirling-PDF # Organization name for generated certificates
|
||||
validity: 365 # Certificate validity in days
|
||||
regenerateOnStartup: false # Generate new certificate on each startup
|
||||
html:
|
||||
urlSecurity:
|
||||
enabled: true # Enable URL security restrictions for HTML processing
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package stirling.software.proprietary.configuration;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ServerCertificateInitializer {
|
||||
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void initializeServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.initializeServerCertificate();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize server certificate", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/server-certificate")
|
||||
@Slf4j
|
||||
@Tag(
|
||||
name = "Admin - Server Certificate",
|
||||
description = "Admin APIs for server certificate management")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public class ServerCertificateController {
|
||||
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
|
||||
@GetMapping("/info")
|
||||
@Operation(
|
||||
summary = "Get server certificate information",
|
||||
description = "Returns information about the current server certificate")
|
||||
public ResponseEntity<ServerCertificateServiceInterface.ServerCertificateInfo>
|
||||
getServerCertificateInfo() {
|
||||
try {
|
||||
ServerCertificateServiceInterface.ServerCertificateInfo info =
|
||||
serverCertificateService.getServerCertificateInfo();
|
||||
return ResponseEntity.ok(info);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get server certificate info", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(
|
||||
summary = "Upload server certificate",
|
||||
description =
|
||||
"Upload a new PKCS12 certificate file to be used as the server certificate")
|
||||
public ResponseEntity<String> uploadServerCertificate(
|
||||
@Parameter(description = "PKCS12 certificate file", required = true)
|
||||
@RequestParam("file")
|
||||
MultipartFile file,
|
||||
@Parameter(description = "Certificate password", required = true)
|
||||
@RequestParam("password")
|
||||
String password) {
|
||||
|
||||
if (file.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body("Certificate file cannot be empty");
|
||||
}
|
||||
|
||||
if (!file.getOriginalFilename().toLowerCase().endsWith(".p12")
|
||||
&& !file.getOriginalFilename().toLowerCase().endsWith(".pfx")) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body("Only PKCS12 (.p12 or .pfx) files are supported");
|
||||
}
|
||||
|
||||
try {
|
||||
serverCertificateService.uploadServerCertificate(file.getInputStream(), password);
|
||||
return ResponseEntity.ok("Server certificate uploaded successfully");
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid certificate upload: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body("Invalid certificate or password.");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to upload server certificate", e);
|
||||
return ResponseEntity.internalServerError().body("Failed to upload server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(
|
||||
summary = "Delete server certificate",
|
||||
description = "Delete the current server certificate")
|
||||
public ResponseEntity<String> deleteServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.deleteServerCertificate();
|
||||
return ResponseEntity.ok("Server certificate deleted successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to delete server certificate", e);
|
||||
return ResponseEntity.internalServerError().body("Failed to delete server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/generate")
|
||||
@Operation(
|
||||
summary = "Generate new server certificate",
|
||||
description = "Generate a new self-signed server certificate")
|
||||
public ResponseEntity<String> generateServerCertificate() {
|
||||
try {
|
||||
serverCertificateService.deleteServerCertificate(); // Remove existing if any
|
||||
serverCertificateService.initializeServerCertificate(); // Generate new
|
||||
return ResponseEntity.ok("New server certificate generated successfully");
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate server certificate", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body("Failed to generate server certificate");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/certificate")
|
||||
@Operation(
|
||||
summary = "Download server certificate",
|
||||
description = "Download the server certificate in DER format for validation purposes")
|
||||
public ResponseEntity<byte[]> getServerCertificate() {
|
||||
try {
|
||||
if (!serverCertificateService.hasServerCertificate()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
byte[] certificate = serverCertificateService.getServerCertificatePublicKey();
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(
|
||||
HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"server-cert.cer\"")
|
||||
.contentType(MediaType.valueOf("application/pkix-cert"))
|
||||
.body(certificate);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get server certificate", e);
|
||||
return ResponseEntity.internalServerError().build();
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/enabled")
|
||||
@Operation(
|
||||
summary = "Check if server certificate feature is enabled",
|
||||
description =
|
||||
"Returns whether the server certificate feature is enabled in configuration")
|
||||
public ResponseEntity<Boolean> isServerCertificateEnabled() {
|
||||
return ResponseEntity.ok(serverCertificateService.isEnabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.*;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import org.bouncycastle.asn1.x500.X500Name;
|
||||
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||
import org.bouncycastle.asn1.x509.Extension;
|
||||
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.operator.ContentSigner;
|
||||
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class ServerCertificateService implements ServerCertificateServiceInterface {
|
||||
|
||||
private static final String KEYSTORE_FILENAME = "server-certificate.p12";
|
||||
private static final String KEYSTORE_ALIAS = "stirling-pdf-server";
|
||||
private static final String DEFAULT_PASSWORD = "stirling-pdf-server-cert";
|
||||
|
||||
@Value("${system.serverCertificate.enabled:false}")
|
||||
private boolean enabled;
|
||||
|
||||
@Value("${system.serverCertificate.organizationName:Stirling-PDF}")
|
||||
private String organizationName;
|
||||
|
||||
@Value("${system.serverCertificate.validity:365}")
|
||||
private int validityDays;
|
||||
|
||||
@Value("${system.serverCertificate.regenerateOnStartup:false}")
|
||||
private boolean regenerateOnStartup;
|
||||
|
||||
static {
|
||||
Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
|
||||
private Path getKeystorePath() {
|
||||
return Paths.get(InstallationPathConfig.getConfigPath(), KEYSTORE_FILENAME);
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public boolean hasServerCertificate() {
|
||||
return Files.exists(getKeystorePath());
|
||||
}
|
||||
|
||||
public void initializeServerCertificate() {
|
||||
if (!enabled) {
|
||||
log.debug("Server certificate feature is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
Path keystorePath = getKeystorePath();
|
||||
|
||||
if (!Files.exists(keystorePath) || regenerateOnStartup) {
|
||||
try {
|
||||
generateServerCertificate();
|
||||
log.info("Generated new server certificate at: {}", keystorePath);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate server certificate", e);
|
||||
}
|
||||
} else {
|
||||
log.info("Server certificate already exists at: {}", keystorePath);
|
||||
}
|
||||
}
|
||||
|
||||
public KeyStore getServerKeyStore() throws Exception {
|
||||
if (!enabled || !hasServerCertificate()) {
|
||||
throw new IllegalStateException("Server certificate is not available");
|
||||
}
|
||||
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
try (FileInputStream fis = new FileInputStream(getKeystorePath().toFile())) {
|
||||
keyStore.load(fis, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
return keyStore;
|
||||
}
|
||||
|
||||
public String getServerCertificatePassword() {
|
||||
return DEFAULT_PASSWORD;
|
||||
}
|
||||
|
||||
public X509Certificate getServerCertificate() throws Exception {
|
||||
KeyStore keyStore = getServerKeyStore();
|
||||
return (X509Certificate) keyStore.getCertificate(KEYSTORE_ALIAS);
|
||||
}
|
||||
|
||||
public byte[] getServerCertificatePublicKey() throws Exception {
|
||||
X509Certificate cert = getServerCertificate();
|
||||
return cert.getEncoded();
|
||||
}
|
||||
|
||||
public void uploadServerCertificate(InputStream p12Stream, String password) throws Exception {
|
||||
// Validate the uploaded certificate
|
||||
KeyStore uploadedKeyStore = KeyStore.getInstance("PKCS12");
|
||||
uploadedKeyStore.load(p12Stream, password.toCharArray());
|
||||
|
||||
// Find the first private key entry
|
||||
String alias = null;
|
||||
for (String a : java.util.Collections.list(uploadedKeyStore.aliases())) {
|
||||
if (uploadedKeyStore.isKeyEntry(a)) {
|
||||
alias = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (alias == null) {
|
||||
throw new IllegalArgumentException("No private key found in uploaded certificate");
|
||||
}
|
||||
|
||||
// Create new keystore with our standard alias and password
|
||||
KeyStore newKeyStore = KeyStore.getInstance("PKCS12");
|
||||
newKeyStore.load(null, null);
|
||||
|
||||
PrivateKey privateKey = (PrivateKey) uploadedKeyStore.getKey(alias, password.toCharArray());
|
||||
Certificate[] chain = uploadedKeyStore.getCertificateChain(alias);
|
||||
|
||||
newKeyStore.setKeyEntry(KEYSTORE_ALIAS, privateKey, DEFAULT_PASSWORD.toCharArray(), chain);
|
||||
|
||||
// Save to server keystore location
|
||||
Path keystorePath = getKeystorePath();
|
||||
Files.createDirectories(keystorePath.getParent());
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
|
||||
newKeyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
|
||||
log.info("Server certificate updated from uploaded file");
|
||||
}
|
||||
|
||||
public void deleteServerCertificate() throws Exception {
|
||||
Path keystorePath = getKeystorePath();
|
||||
if (Files.exists(keystorePath)) {
|
||||
Files.delete(keystorePath);
|
||||
log.info("Server certificate deleted");
|
||||
}
|
||||
}
|
||||
|
||||
public ServerCertificateInfo getServerCertificateInfo() throws Exception {
|
||||
if (!hasServerCertificate()) {
|
||||
return new ServerCertificateInfo(false, null, null, null, null);
|
||||
}
|
||||
|
||||
X509Certificate cert = getServerCertificate();
|
||||
return new ServerCertificateInfo(
|
||||
true,
|
||||
cert.getSubjectX500Principal().getName(),
|
||||
cert.getIssuerX500Principal().getName(),
|
||||
cert.getNotBefore(),
|
||||
cert.getNotAfter());
|
||||
}
|
||||
|
||||
private void generateServerCertificate() throws Exception {
|
||||
// Generate key pair
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||
KeyPair keyPair = keyPairGenerator.generateKeyPair();
|
||||
|
||||
// Certificate details
|
||||
X500Name subject =
|
||||
new X500Name(
|
||||
"CN=" + organizationName + " Server, O=" + organizationName + ", C=US");
|
||||
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
|
||||
Date notBefore = new Date();
|
||||
Date notAfter = new Date(notBefore.getTime() + ((long) validityDays * 24 * 60 * 60 * 1000));
|
||||
|
||||
// Build certificate
|
||||
JcaX509v3CertificateBuilder certBuilder =
|
||||
new JcaX509v3CertificateBuilder(
|
||||
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
|
||||
|
||||
// Add PDF-specific certificate extensions for optimal PDF signing compatibility
|
||||
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
|
||||
|
||||
// 1) End-entity certificate, not a CA (critical)
|
||||
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
|
||||
|
||||
// 2) Key usage for PDF digital signatures (critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.keyUsage,
|
||||
true,
|
||||
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
|
||||
|
||||
// 3) Extended key usage for document signing (non-critical, widely accepted)
|
||||
certBuilder.addExtension(
|
||||
Extension.extendedKeyUsage,
|
||||
false,
|
||||
new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning));
|
||||
|
||||
// 4) Subject Key Identifier for chain building (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.subjectKeyIdentifier,
|
||||
false,
|
||||
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// 5) Authority Key Identifier for self-signed cert (non-critical)
|
||||
certBuilder.addExtension(
|
||||
Extension.authorityKeyIdentifier,
|
||||
false,
|
||||
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
|
||||
|
||||
// Sign certificate
|
||||
ContentSigner signer =
|
||||
new JcaContentSignerBuilder("SHA256WithRSA")
|
||||
.setProvider("BC")
|
||||
.build(keyPair.getPrivate());
|
||||
|
||||
X509CertificateHolder certHolder = certBuilder.build(signer);
|
||||
X509Certificate cert =
|
||||
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
|
||||
|
||||
// Create keystore
|
||||
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||
keyStore.load(null, null);
|
||||
keyStore.setKeyEntry(
|
||||
KEYSTORE_ALIAS,
|
||||
keyPair.getPrivate(),
|
||||
DEFAULT_PASSWORD.toCharArray(),
|
||||
new Certificate[] {cert});
|
||||
|
||||
// Save keystore
|
||||
Path keystorePath = getKeystorePath();
|
||||
Files.createDirectories(keystorePath.getParent());
|
||||
|
||||
try (FileOutputStream fos = new FileOutputStream(keystorePath.toFile())) {
|
||||
keyStore.store(fos, DEFAULT_PASSWORD.toCharArray());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user