diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index dab00a89d..a99bc4184 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -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"); diff --git a/app/core/src/main/java/stirling/software/SPDF/configuration/ServerCertificateInitializer.java b/app/core/src/main/java/stirling/software/SPDF/configuration/ServerCertificateInitializer.java new file mode 100644 index 000000000..4131162ae --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/configuration/ServerCertificateInitializer.java @@ -0,0 +1,27 @@ +package stirling.software.SPDF.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.SPDF.service.ServerCertificateService; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ServerCertificateInitializer { + + private final ServerCertificateService serverCertificateService; + + @EventListener(ApplicationReadyEvent.class) + public void initializeServerCertificate() { + try { + serverCertificateService.initializeServerCertificate(); + } catch (Exception e) { + log.error("Failed to initialize server certificate", e); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java new file mode 100644 index 000000000..1ecfe2a6d --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/BookletImpositionController.java @@ -0,0 +1,186 @@ +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 createBookletImposition( + @ModelAttribute BookletImpositionRequest request) throws IOException { + + MultipartFile file = request.getFileInput(); + String bookletType = request.getBookletType(); + int pagesPerSheet = request.getPagesPerSheet(); + boolean addBorder = Boolean.TRUE.equals(request.getAddBorder()); + String pageOrientation = request.getPageOrientation(); + + // Validate pages per sheet for booklet + if (pagesPerSheet != 2 && pagesPerSheet != 4) { + throw new IllegalArgumentException("pagesPerSheet must be 2 or 4 for booklet imposition"); + } + + PDDocument sourceDocument = pdfDocumentFactory.load(file); + int totalPages = sourceDocument.getNumberOfPages(); + + // Step 1: Reorder pages for booklet (reusing logic from RearrangePagesPDFController) + List bookletOrder = getBookletPageOrder(bookletType, totalPages); + + // Step 2: Create new document with multi-page layout (reusing logic from MultiPageLayoutController) + PDDocument newDocument = createBookletWithLayout(sourceDocument, bookletOrder, pagesPerSheet, addBorder, pageOrientation); + + 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"); + } + + // Reused logic from RearrangePagesPDFController + private List getBookletPageOrder(String bookletType, int totalPages) { + if ("SIDE_STITCH_BOOKLET".equals(bookletType)) { + return sideStitchBookletSort(totalPages); + } else { + return bookletSort(totalPages); + } + } + + private List bookletSort(int totalPages) { + List newPageOrder = new ArrayList<>(); + for (int i = 0; i < totalPages / 2; i++) { + newPageOrder.add(i); + newPageOrder.add(totalPages - i - 1); + } + return newPageOrder; + } + + private List sideStitchBookletSort(int totalPages) { + List newPageOrder = new ArrayList<>(); + for (int i = 0; i < (totalPages + 3) / 4; i++) { + int begin = i * 4; + newPageOrder.add(Math.min(begin + 3, totalPages - 1)); + newPageOrder.add(Math.min(begin, totalPages - 1)); + newPageOrder.add(Math.min(begin + 1, totalPages - 1)); + newPageOrder.add(Math.min(begin + 2, totalPages - 1)); + } + return newPageOrder; + } + + // Reused and adapted logic from MultiPageLayoutController + private PDDocument createBookletWithLayout(PDDocument sourceDocument, List pageOrder, + int pagesPerSheet, boolean addBorder, String pageOrientation) throws IOException { + + PDDocument newDocument = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument); + + int cols = pagesPerSheet == 2 ? 2 : 2; // 2x1 for 2 pages, 2x2 for 4 pages + int rows = pagesPerSheet == 2 ? 1 : 2; + + int currentPageIndex = 0; + int totalOrderedPages = pageOrder.size(); + + while (currentPageIndex < totalOrderedPages) { + // Use landscape orientation for booklets (A4 landscape -> A5 portrait when folded) + PDRectangle pageSize = "LANDSCAPE".equals(pageOrientation) ? + new PDRectangle(PDRectangle.A4.getHeight(), PDRectangle.A4.getWidth()) : PDRectangle.A4; + PDPage newPage = new PDPage(pageSize); + newDocument.addPage(newPage); + + float cellWidth = newPage.getMediaBox().getWidth() / cols; + float cellHeight = newPage.getMediaBox().getHeight() / rows; + + PDPageContentStream contentStream = new PDPageContentStream( + newDocument, newPage, PDPageContentStream.AppendMode.APPEND, true, true); + LayerUtility layerUtility = new LayerUtility(newDocument); + + if (addBorder) { + contentStream.setLineWidth(1.5f); + contentStream.setStrokingColor(Color.BLACK); + } + + // Place pages on the current sheet + for (int sheetPosition = 0; sheetPosition < pagesPerSheet && currentPageIndex < totalOrderedPages; sheetPosition++) { + int sourcePageIndex = pageOrder.get(currentPageIndex); + PDPage sourcePage = sourceDocument.getPage(sourcePageIndex); + PDRectangle rect = sourcePage.getMediaBox(); + + float scaleWidth = cellWidth / rect.getWidth(); + float scaleHeight = cellHeight / rect.getHeight(); + float scale = Math.min(scaleWidth, scaleHeight); + + int rowIndex = sheetPosition / cols; + int colIndex = sheetPosition % cols; + + float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2; + float y = newPage.getMediaBox().getHeight() + - ((rowIndex + 1) * cellHeight - (cellHeight - rect.getHeight() * scale) / 2); + + contentStream.saveGraphicsState(); + contentStream.transform(Matrix.getTranslateInstance(x, y)); + contentStream.transform(Matrix.getScaleInstance(scale, scale)); + + PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, sourcePageIndex); + contentStream.drawForm(formXObject); + + contentStream.restoreGraphicsState(); + + if (addBorder) { + float borderX = colIndex * cellWidth; + float borderY = newPage.getMediaBox().getHeight() - (rowIndex + 1) * cellHeight; + contentStream.addRect(borderX, borderY, cellWidth, cellHeight); + contentStream.stroke(); + } + + currentPageIndex++; + } + + contentStream.close(); + } + + return newDocument; + } +} \ No newline at end of file diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 316311d87..621a73a9b 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -72,6 +72,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest; +import stirling.software.SPDF.service.ServerCertificateService; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.service.CustomPDFDocumentFactory; import stirling.software.common.util.ExceptionUtils; @@ -101,6 +102,7 @@ public class CertSignController { } private final CustomPDFDocumentFactory pdfDocumentFactory; + private final ServerCertificateService serverCertificateService; private static void sign( CustomPDFDocumentFactory pdfDocumentFactory, @@ -175,6 +177,7 @@ public class CertSignController { } KeyStore ks = null; + String keystorePassword = password; switch (certType) { case "PEM": @@ -193,6 +196,19 @@ public class CertSignController { ks = KeyStore.getInstance("JKS"); ks.load(jksfile.getInputStream(), password.toCharArray()); break; + case "SERVER": + 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", @@ -200,7 +216,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, diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ServerCertificateController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ServerCertificateController.java new file mode 100644 index 000000000..28119e81a --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/ServerCertificateController.java @@ -0,0 +1,145 @@ +package stirling.software.SPDF.controller.api.security; + +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.SPDF.service.ServerCertificateService; + +@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 ServerCertificateService serverCertificateService; + + @GetMapping("/info") + @Operation( + summary = "Get server certificate information", + description = "Returns information about the current server certificate") + public ResponseEntity + getServerCertificateInfo() { + try { + ServerCertificateService.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 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(e.getMessage()); + } 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 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 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("/public-key") + @Operation( + summary = "Download server certificate public key", + description = + "Download the public key of the server certificate for validation purposes") + public ResponseEntity getServerCertificatePublicKey() { + try { + if (!serverCertificateService.hasServerCertificate()) { + return ResponseEntity.notFound().build(); + } + + byte[] publicKey = serverCertificateService.getServerCertificatePublicKey(); + + return ResponseEntity.ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"server-cert.crt\"") + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(publicKey); + } catch (Exception e) { + log.error("Failed to get server certificate public key", 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 isServerCertificateEnabled() { + return ResponseEntity.ok(serverCertificateService.isEnabled()); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java new file mode 100644 index 000000000..9d0612652 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/general/BookletImpositionRequest.java @@ -0,0 +1,40 @@ +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 booklet type to create.", + type = "string", + defaultValue = "BOOKLET", + requiredMode = Schema.RequiredMode.REQUIRED, + allowableValues = {"BOOKLET", "SIDE_STITCH_BOOKLET"}) + private String bookletType = "BOOKLET"; + + @Schema( + description = "The number of pages to fit onto a single sheet in the output PDF.", + type = "number", + defaultValue = "2", + requiredMode = Schema.RequiredMode.REQUIRED, + allowableValues = {"2", "4"}) + private int pagesPerSheet = 2; + + @Schema(description = "Boolean for if you wish to add border around the pages") + private Boolean addBorder = false; + + @Schema( + description = "The page orientation for the output booklet sheets.", + type = "string", + defaultValue = "LANDSCAPE", + requiredMode = Schema.RequiredMode.REQUIRED, + allowableValues = {"LANDSCAPE", "PORTRAIT"}) + private String pageOrientation = "LANDSCAPE"; +} \ No newline at end of file diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java index acb4b55fd..d75f751f1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/SignPDFWithCertRequest.java @@ -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; diff --git a/app/core/src/main/java/stirling/software/SPDF/service/ServerCertificateService.java b/app/core/src/main/java/stirling/software/SPDF/service/ServerCertificateService.java new file mode 100644 index 000000000..67061b7fd --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/ServerCertificateService.java @@ -0,0 +1,252 @@ +package stirling.software.SPDF.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.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +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; + +@Service +@Slf4j +public class ServerCertificateService { + + 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()); + + // 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()); + } + } + + public static class ServerCertificateInfo { + private final boolean exists; + private final String subject; + private final String issuer; + private final Date validFrom; + private final Date validTo; + + public ServerCertificateInfo( + boolean exists, String subject, String issuer, Date validFrom, Date validTo) { + this.exists = exists; + this.subject = subject; + this.issuer = issuer; + this.validFrom = validFrom; + this.validTo = validTo; + } + + public boolean isExists() { + return exists; + } + + public String getSubject() { + return subject; + } + + public String getIssuer() { + return issuer; + } + + public Date getValidFrom() { + return validFrom; + } + + public Date getValidTo() { + return validTo; + } + } +} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index bbbac5fcd..465f95fb6 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -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 diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index b6979a84b..c2e60dbd8 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -450,6 +450,10 @@ "title": "Sign with Certificate", "desc": "Signs a PDF with a Certificate/Key (PEM/P12)" }, + "manageSignatures": { + "title": "Manage Signatures", + "desc": "Sign PDFs with certificates using manual or server-managed keys" + }, "removeCertSign": { "title": "Remove Certificate Sign", "desc": "Remove certificate signature from PDF" @@ -458,6 +462,10 @@ "title": "Multi-Page Layout", "desc": "Merge multiple pages of a PDF document into a single page" }, + "bookletImposition": { + "title": "Booklet Imposition", + "desc": "Create booklets with proper page ordering and multi-page layout for printing and binding" + }, "scalePages": { "title": "Adjust page size/scale", "desc": "Change the size/scale of a page and/or its contents." @@ -1390,6 +1398,33 @@ "showLogo": "Show Logo", "submit": "Sign PDF" }, + "manageSignatures": { + "tags": "sign,certificate,PEM,PKCS12,JKS,server,manual,auto", + "title": "Manage Signatures", + "header": "Sign PDFs with Certificates", + "files": { + "placeholder": "Select PDF files to sign with certificates" + }, + "signMode": { + "stepTitle": "Sign Mode" + }, + "certType": { + "stepTitle": "Certificate Format" + }, + "certFiles": { + "stepTitle": "Certificate Files" + }, + "appearance": { + "stepTitle": "Signature Appearance" + }, + "sign": { + "submit": "Sign PDF", + "results": "Signed PDF" + }, + "error": { + "failed": "An error occurred whilst signing the PDF." + } + }, "removeCertSign": { "tags": "authenticate,PEM,P12,official,decrypt", "title": "Remove Certificate Signature", @@ -1416,6 +1451,18 @@ "addBorder": "Add Borders", "submit": "Submit" }, + "bookletImposition": { + "tags": "booklet,imposition,printing,binding,folding,signature", + "title": "Booklet Imposition", + "header": "Booklet Imposition", + "submit": "Create Booklet", + "files": { + "placeholder": "Select PDF files to create booklet impositions from" + }, + "error": { + "failed": "An error occurred while creating the booklet imposition." + } + }, "scalePages": { "tags": "resize,modify,dimension,adapt", "title": "Adjust page-scale", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index b0a19539a..5265483fc 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -495,6 +495,10 @@ "title": "Multi-Page Layout", "desc": "Merge multiple pages of a PDF document into a single page" }, + "bookletImposition": { + "title": "Booklet Imposition", + "desc": "Create booklets with proper page ordering and multi-page layout for printing and binding" + }, "scalePages": { "title": "Adjust page size/scale", "desc": "Change the size/scale of a page and/or its contents." @@ -1097,6 +1101,18 @@ "addBorder": "Add Borders", "submit": "Submit" }, + "bookletImposition": { + "tags": "booklet,imposition,printing,binding,folding,signature", + "title": "Booklet Imposition", + "header": "Booklet Imposition", + "submit": "Create Booklet", + "files": { + "placeholder": "Select PDF files to create booklet impositions from" + }, + "error": { + "failed": "An error occurred while creating the booklet imposition." + } + }, "scalePages": { "tags": "resize,modify,dimension,adapt", "title": "Adjust page-scale", diff --git a/frontend/src/components/tools/bookletImposition/BookletImpositionSettings.tsx b/frontend/src/components/tools/bookletImposition/BookletImpositionSettings.tsx new file mode 100644 index 000000000..99c75b583 --- /dev/null +++ b/frontend/src/components/tools/bookletImposition/BookletImpositionSettings.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { Button, Stack, Text, Divider } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { BookletImpositionParameters } from "../../../hooks/tools/bookletImposition/useBookletImpositionParameters"; + +interface BookletImpositionSettingsProps { + parameters: BookletImpositionParameters; + onParameterChange: (key: keyof BookletImpositionParameters, value: any) => void; + disabled?: boolean; +} + +const BookletImpositionSettings = ({ parameters, onParameterChange, disabled = false }: BookletImpositionSettingsProps) => { + const { t } = useTranslation(); + + return ( + + + + {/* Booklet Type */} + + Booklet Type +
+ + +
+ + {parameters.bookletType === 'BOOKLET' + ? "Standard booklet for saddle-stitched binding (fold in half)" + : "Side-stitched booklet for binding along the edge"} + +
+ + + + {/* Pages Per Sheet */} + + Pages Per Sheet +
+ + +
+ + {parameters.pagesPerSheet === 2 + ? "Two pages side by side on each sheet (most common)" + : "Four pages arranged in a 2x2 grid on each sheet"} + +
+ + + + {/* Page Orientation */} + + Page Orientation +
+ + +
+ + {parameters.pageOrientation === 'LANDSCAPE' + ? "A4 landscape → A5 portrait when folded (standard booklet size)" + : "A4 portrait → A6 when folded (smaller booklet)"} + +
+ + + + {/* Add Border Option */} + + + + Helpful for cutting and alignment when printing + + +
+ ); +}; + +export default BookletImpositionSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/manageSignatures/CertificateFilesSettings.tsx b/frontend/src/components/tools/manageSignatures/CertificateFilesSettings.tsx index cd2589a11..d35eb4487 100644 --- a/frontend/src/components/tools/manageSignatures/CertificateFilesSettings.tsx +++ b/frontend/src/components/tools/manageSignatures/CertificateFilesSettings.tsx @@ -56,6 +56,12 @@ const CertificateFilesSettings = ({ parameters, onParameterChange, disabled = fa /> )} + {parameters.certType === 'SERVER' && ( + + {t('manageSignatures.signing.serverCertMessage', 'Using server certificate - no files or password required')} + + )} + {/* Password - only show when files are uploaded */} {parameters.certType && ( (parameters.certType === 'PEM' && parameters.privateKeyFile && parameters.certFile) || diff --git a/frontend/src/components/tools/manageSignatures/CertificateFormatSettings.tsx b/frontend/src/components/tools/manageSignatures/CertificateFormatSettings.tsx new file mode 100644 index 000000000..44643247b --- /dev/null +++ b/frontend/src/components/tools/manageSignatures/CertificateFormatSettings.tsx @@ -0,0 +1,67 @@ +import { Stack, Button, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters"; + +interface CertificateFormatSettingsProps { + parameters: ManageSignaturesParameters; + onParameterChange: (key: keyof ManageSignaturesParameters, value: any) => void; + disabled?: boolean; +} + +const CertificateFormatSettings = ({ parameters, onParameterChange, disabled = false }: CertificateFormatSettingsProps) => { + const { t } = useTranslation(); + + return ( + +
+ {/* First row - PKCS#12 and PEM */} +
+ + +
+ {/* Second row - JKS spanning full width */} +
+ +
+
+ + {parameters.certType === 'PKCS12' && "Upload a single .p12/.pfx file containing both certificate and private key"} + {parameters.certType === 'PEM' && "Upload separate certificate (.crt/.pem) and private key (.key/.pem) files"} + {parameters.certType === 'JKS' && "Upload a Java KeyStore (.jks) file"} + {!parameters.certType && "Choose the format of your certificate files"} + +
+ ); +}; + +export default CertificateFormatSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/manageSignatures/CertificateTypeSettings.tsx b/frontend/src/components/tools/manageSignatures/CertificateTypeSettings.tsx index 9c8bb3645..1f2c7f98e 100644 --- a/frontend/src/components/tools/manageSignatures/CertificateTypeSettings.tsx +++ b/frontend/src/components/tools/manageSignatures/CertificateTypeSettings.tsx @@ -1,4 +1,4 @@ -import { Stack, Button } from "@mantine/core"; +import { Stack, Button, Text, Divider } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ManageSignaturesParameters } from "../../../hooks/tools/manageSignatures/useManageSignaturesParameters"; @@ -13,46 +13,45 @@ const CertificateTypeSettings = ({ parameters, onParameterChange, disabled = fal return ( - {/* Certificate Type Selection */} - -
-
- - +
+ -
- + + +
+ + {parameters.signMode === 'MANUAL' + ? "Upload your own certificate files for signing" + : "Use the server's pre-configured certificate"} + ); }; diff --git a/frontend/src/components/tooltips/useCertificateTypeTips.ts b/frontend/src/components/tooltips/useCertificateTypeTips.ts index c3bc19788..c80e29b54 100644 --- a/frontend/src/components/tooltips/useCertificateTypeTips.ts +++ b/frontend/src/components/tooltips/useCertificateTypeTips.ts @@ -19,7 +19,8 @@ export const useCertificateTypeTips = (): TooltipContent => { bullets: [ t("manageSignatures.certType.tooltip.which.bullet1", "PKCS#12 (.p12 / .pfx) – one combined file (most common)"), t("manageSignatures.certType.tooltip.which.bullet2", "PEM – separate private-key and certificate .pem files"), - t("manageSignatures.certType.tooltip.which.bullet3", "JKS – Java .jks keystore for dev / CI-CD workflows") + t("manageSignatures.certType.tooltip.which.bullet3", "JKS – Java .jks keystore for dev / CI-CD workflows"), + t("manageSignatures.certType.tooltip.which.bullet4", "SERVER – use server's certificate (no files needed)") ] }, { diff --git a/frontend/src/components/tooltips/useManageSignaturesTooltips.ts b/frontend/src/components/tooltips/useManageSignaturesTooltips.ts index 6e0a3e706..3ff95cda5 100644 --- a/frontend/src/components/tooltips/useManageSignaturesTooltips.ts +++ b/frontend/src/components/tooltips/useManageSignaturesTooltips.ts @@ -33,10 +33,11 @@ export const useManageSignaturesTooltips = (): TooltipContent => { title: t("manageSignatures.tooltip.signing.title", "Adding Signatures"), description: t("manageSignatures.tooltip.signing.text", "To sign a PDF, you need a digital certificate (like PEM, PKCS12, or JKS). You can choose to make the signature visible on the document or keep it invisible for security only."), bullets: [ - t("manageSignatures.tooltip.signing.bullet1", "Supports PEM, PKCS12, and JKS certificate formats"), + t("manageSignatures.tooltip.signing.bullet1", "Supports PEM, PKCS12, JKS, and server certificate formats"), t("manageSignatures.tooltip.signing.bullet2", "Option to show or hide signature on the PDF"), t("manageSignatures.tooltip.signing.bullet3", "Add reason, location, and signer name"), - t("manageSignatures.tooltip.signing.bullet4", "Choose which page to place visible signatures") + t("manageSignatures.tooltip.signing.bullet4", "Choose which page to place visible signatures"), + t("manageSignatures.tooltip.signing.bullet5", "Use server certificate for simple 'Sign with Stirling-PDF' option") ] } ] diff --git a/frontend/src/data/useTranslatedToolRegistry.tsx b/frontend/src/data/useTranslatedToolRegistry.tsx index bf79d2ec0..b70042467 100644 --- a/frontend/src/data/useTranslatedToolRegistry.tsx +++ b/frontend/src/data/useTranslatedToolRegistry.tsx @@ -16,6 +16,7 @@ import SingleLargePage from "../tools/SingleLargePage"; import UnlockPdfForms from "../tools/UnlockPdfForms"; import RemoveCertificateSign from "../tools/RemoveCertificateSign"; import ManageSignatures from "../tools/ManageSignatures"; +import BookletImposition from "../tools/BookletImposition"; import { compressOperationConfig } from "../hooks/tools/compress/useCompressOperation"; import { splitOperationConfig } from "../hooks/tools/split/useSplitOperation"; import { addPasswordOperationConfig } from "../hooks/tools/addPassword/useAddPasswordOperation"; @@ -30,6 +31,7 @@ import { convertOperationConfig } from "../hooks/tools/convert/useConvertOperati import { removeCertificateSignOperationConfig } from "../hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation"; import { changePermissionsOperationConfig } from "../hooks/tools/changePermissions/useChangePermissionsOperation"; import { manageSignaturesOperationConfig } from "../hooks/tools/manageSignatures/useManageSignaturesOperation"; +import { bookletImpositionOperationConfig } from "../hooks/tools/bookletImposition/useBookletImpositionOperation"; import CompressSettings from "../components/tools/compress/CompressSettings"; import SplitSettings from "../components/tools/split/SplitSettings"; import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings"; @@ -42,6 +44,7 @@ import OCRSettings from "../components/tools/ocr/OCRSettings"; import ConvertSettings from "../components/tools/convert/ConvertSettings"; import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings"; import CertificateTypeSettings from "../components/tools/manageSignatures/CertificateTypeSettings"; +import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings"; import { ToolId } from "../types/toolId"; const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI @@ -353,6 +356,16 @@ export function useFlatToolRegistry(): ToolRegistry { categoryId: ToolCategoryId.STANDARD_TOOLS, subcategoryId: SubcategoryId.PAGE_FORMATTING, }, + "booklet-imposition": { + icon: , + name: t("home.bookletImposition.title", "Booklet Imposition"), + component: BookletImposition, + operationConfig: bookletImpositionOperationConfig, + settingsComponent: BookletImpositionSettings, + description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"), + categoryId: ToolCategoryId.STANDARD_TOOLS, + subcategoryId: SubcategoryId.PAGE_FORMATTING, + }, "single-large-page": { icon: , name: t("home.pdfToSinglePage.title", "PDF to Single Large Page"), diff --git a/frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts b/frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts new file mode 100644 index 000000000..d27fd547a --- /dev/null +++ b/frontend/src/hooks/tools/bookletImposition/useBookletImpositionOperation.ts @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { useToolOperation, ToolOperationConfig, ToolType } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; +import { BookletImpositionParameters, defaultParameters } from './useBookletImpositionParameters'; + +// Static configuration that can be used by both the hook and automation executor +export const buildBookletImpositionFormData = (parameters: BookletImpositionParameters, file: File): FormData => { + const formData = new FormData(); + formData.append("fileInput", file); + formData.append("bookletType", parameters.bookletType); + formData.append("pagesPerSheet", parameters.pagesPerSheet.toString()); + formData.append("addBorder", parameters.addBorder.toString()); + formData.append("pageOrientation", parameters.pageOrientation); + return formData; +}; + +// Static configuration object +export const bookletImpositionOperationConfig = { + toolType: ToolType.singleFile, + buildFormData: buildBookletImpositionFormData, + operationType: 'bookletImposition', + endpoint: '/api/v1/general/booklet-imposition', + filePrefix: 'booklet_', + defaultParameters, +} as const; + +export const useBookletImpositionOperation = () => { + const { t } = useTranslation(); + + return useToolOperation({ + ...bookletImpositionOperationConfig, + getErrorMessage: createStandardErrorHandler(t('bookletImposition.error.failed', 'An error occurred while creating the booklet imposition.')) + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/bookletImposition/useBookletImpositionParameters.ts b/frontend/src/hooks/tools/bookletImposition/useBookletImpositionParameters.ts new file mode 100644 index 000000000..2e951a1fa --- /dev/null +++ b/frontend/src/hooks/tools/bookletImposition/useBookletImpositionParameters.ts @@ -0,0 +1,28 @@ +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface BookletImpositionParameters extends BaseParameters { + bookletType: 'BOOKLET' | 'SIDE_STITCH_BOOKLET'; + pagesPerSheet: 2 | 4; + addBorder: boolean; + pageOrientation: 'LANDSCAPE' | 'PORTRAIT'; +} + +export const defaultParameters: BookletImpositionParameters = { + bookletType: 'BOOKLET', + pagesPerSheet: 2, + addBorder: false, + pageOrientation: 'LANDSCAPE', +}; + +export type BookletImpositionParametersHook = BaseParametersHook; + +export const useBookletImpositionParameters = (): BookletImpositionParametersHook => { + return useBaseParameters({ + defaultParameters, + endpointName: 'booklet-imposition', + validateFn: (params) => { + return params.pagesPerSheet === 2 || params.pagesPerSheet === 4; + }, + }); +}; \ No newline at end of file diff --git a/frontend/src/hooks/tools/manageSignatures/useManageSignaturesOperation.ts b/frontend/src/hooks/tools/manageSignatures/useManageSignaturesOperation.ts index e2df7c281..5265c2cf9 100644 --- a/frontend/src/hooks/tools/manageSignatures/useManageSignaturesOperation.ts +++ b/frontend/src/hooks/tools/manageSignatures/useManageSignaturesOperation.ts @@ -7,29 +7,35 @@ import { ManageSignaturesParameters, defaultParameters } from './useManageSignat export const buildManageSignaturesFormData = (parameters: ManageSignaturesParameters, file: File): FormData => { const formData = new FormData(); formData.append('fileInput', file); - formData.append('certType', parameters.certType); - formData.append('password', parameters.password); - // Add certificate files based on type - switch (parameters.certType) { - case 'PEM': - if (parameters.privateKeyFile) { - formData.append('privateKeyFile', parameters.privateKeyFile); - } - if (parameters.certFile) { - formData.append('certFile', parameters.certFile); - } - break; - case 'PKCS12': - if (parameters.p12File) { - formData.append('p12File', parameters.p12File); - } - break; - case 'JKS': - if (parameters.jksFile) { - formData.append('jksFile', parameters.jksFile); - } - break; + // Handle sign mode + if (parameters.signMode === 'AUTO') { + formData.append('certType', 'SERVER'); + } else { + formData.append('certType', parameters.certType); + formData.append('password', parameters.password); + + // Add certificate files based on type (only for manual mode) + switch (parameters.certType) { + case 'PEM': + if (parameters.privateKeyFile) { + formData.append('privateKeyFile', parameters.privateKeyFile); + } + if (parameters.certFile) { + formData.append('certFile', parameters.certFile); + } + break; + case 'PKCS12': + if (parameters.p12File) { + formData.append('p12File', parameters.p12File); + } + break; + case 'JKS': + if (parameters.jksFile) { + formData.append('jksFile', parameters.jksFile); + } + break; + } } // Add signature appearance options if enabled diff --git a/frontend/src/hooks/tools/manageSignatures/useManageSignaturesParameters.ts b/frontend/src/hooks/tools/manageSignatures/useManageSignaturesParameters.ts index 4c226eabc..8f6708531 100644 --- a/frontend/src/hooks/tools/manageSignatures/useManageSignaturesParameters.ts +++ b/frontend/src/hooks/tools/manageSignatures/useManageSignaturesParameters.ts @@ -2,7 +2,9 @@ import { BaseParameters } from '../../../types/parameters'; import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; export interface ManageSignaturesParameters extends BaseParameters { - // Certificate signing options + // Sign mode selection + signMode: 'MANUAL' | 'AUTO'; + // Certificate signing options (only for manual mode) certType: '' | 'PEM' | 'PKCS12' | 'JKS'; privateKeyFile?: File; certFile?: File; @@ -20,6 +22,7 @@ export interface ManageSignaturesParameters extends BaseParameters { } export const defaultParameters: ManageSignaturesParameters = { + signMode: 'MANUAL', certType: '', password: '', showSignature: false, @@ -37,7 +40,12 @@ export const useManageSignaturesParameters = (): ManageSignaturesParametersHook defaultParameters, endpointName: 'manage-signatures', validateFn: (params) => { - // Requires certificate type + // Auto mode (server certificate) - no additional validation needed + if (params.signMode === 'AUTO') { + return true; + } + + // Manual mode - requires certificate type and files if (!params.certType) { return false; } diff --git a/frontend/src/tools/BookletImposition.tsx b/frontend/src/tools/BookletImposition.tsx new file mode 100644 index 000000000..bcd619b34 --- /dev/null +++ b/frontend/src/tools/BookletImposition.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings"; +import { useBookletImpositionParameters } from "../hooks/tools/bookletImposition/useBookletImpositionParameters"; +import { useBookletImpositionOperation } from "../hooks/tools/bookletImposition/useBookletImpositionOperation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const BookletImposition = (props: BaseToolProps) => { + const { t } = useTranslation(); + + const base = useBaseTool( + 'bookletImposition', + useBookletImpositionParameters, + useBookletImpositionOperation, + props + ); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + placeholder: t("bookletImposition.files.placeholder", "Select PDF files to create booklet impositions from"), + }, + steps: [ + { + title: "Settings", + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + content: ( + + ), + }, + ], + executeButton: { + text: t("bookletImposition.submit", "Create Booklet"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("bookletImposition.title", "Booklet Imposition Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +export default BookletImposition as ToolComponent; \ No newline at end of file diff --git a/frontend/src/tools/ManageSignatures.tsx b/frontend/src/tools/ManageSignatures.tsx index 03716b265..a39a3546d 100644 --- a/frontend/src/tools/ManageSignatures.tsx +++ b/frontend/src/tools/ManageSignatures.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import { createToolFlow } from "../components/tools/shared/createToolFlow"; import CertificateTypeSettings from "../components/tools/manageSignatures/CertificateTypeSettings"; +import CertificateFormatSettings from "../components/tools/manageSignatures/CertificateFormatSettings"; import CertificateFilesSettings from "../components/tools/manageSignatures/CertificateFilesSettings"; import SignatureAppearanceSettings from "../components/tools/manageSignatures/SignatureAppearanceSettings"; import { useManageSignaturesParameters } from "../hooks/tools/manageSignatures/useManageSignaturesParameters"; @@ -26,6 +27,13 @@ const ManageSignatures = (props: BaseToolProps) => { // Check if certificate files are configured for appearance step const areCertFilesConfigured = () => { const params = base.params.parameters; + + // Auto mode (server certificate) - always configured + if (params.signMode === 'AUTO') { + return true; + } + + // Manual mode - check for required files based on cert type switch (params.certType) { case 'PEM': return !!(params.privateKeyFile && params.certFile); @@ -47,10 +55,9 @@ const ManageSignatures = (props: BaseToolProps) => { }, steps: [ { - title: t("manageSignatures.certType.stepTitle", "Certificate Type"), + title: t("manageSignatures.signMode.stepTitle", "Sign Mode"), isCollapsed: base.settingsCollapsed, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, - tooltip: certTypeTips, content: ( { /> ), }, - { + ...(base.params.parameters.signMode === 'MANUAL' ? [{ + title: t("manageSignatures.certType.stepTitle", "Certificate Format"), + isCollapsed: base.settingsCollapsed, + onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, + tooltip: certTypeTips, + content: ( + + ), + }] : []), + ...(base.params.parameters.signMode === 'MANUAL' ? [{ title: t("manageSignatures.certFiles.stepTitle", "Certificate Files"), isCollapsed: base.settingsCollapsed, onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined, @@ -70,7 +90,7 @@ const ManageSignatures = (props: BaseToolProps) => { disabled={base.endpointLoading} /> ), - }, + }] : []), { title: t("manageSignatures.appearance.stepTitle", "Signature Appearance"), isCollapsed: base.settingsCollapsed || !areCertFilesConfigured(), diff --git a/frontend/src/types/toolId.ts b/frontend/src/types/toolId.ts index a7a60bda5..9859723e1 100644 --- a/frontend/src/types/toolId.ts +++ b/frontend/src/types/toolId.ts @@ -7,7 +7,7 @@ const TOOL_IDS = [ 'detect-split-scanned-photos', 'edit-table-of-contents', 'scanner-effect', - 'auto-rename-pdf-file', 'multi-page-layout', 'adjust-page-size-scale', 'adjust-contrast', 'cropPdf', 'single-large-page', 'multi-tool', + 'auto-rename-pdf-file', 'multi-page-layout', 'booklet-imposition', 'adjust-page-size-scale', 'adjust-contrast', 'cropPdf', 'single-large-page', 'multi-tool', 'repair', 'compare', 'addPageNumbers', 'redact', 'flatten', 'remove-certificate-sign', 'unlock-pdf-forms', 'compress', 'extract-page', 'reorganize-pages', 'extract-images',