Save signatures to server (#5080)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
Reece Browne
2025-11-29 19:29:06 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent fde449e738
commit 651f17f1c6
19 changed files with 1674 additions and 425 deletions
@@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.Dependency;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.annotations.api.UiDataApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
@@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils;
public class UIDataController {
private final ApplicationProperties applicationProperties;
private final SignatureService signatureService;
private final SharedSignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public UIDataController(
ApplicationProperties applicationProperties,
SignatureService signatureService,
SharedSignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -25,7 +25,7 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.UserServiceInterface;
@@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils;
@Slf4j
public class GeneralWebController {
private final SignatureService signatureService;
private final SharedSignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public GeneralWebController(
SignatureService signatureService,
SharedSignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -1,48 +0,0 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.service.UserServiceInterface;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@RequestMapping("/api/v1/general")
public class SignatureController {
private final SignatureService signatureService;
private final UserServiceInterface userService;
public SignatureController(
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.signatureService = signatureService;
this.userService = userService;
}
@GetMapping("/sign/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName)
throws IOException {
String username = "NON_SECURITY_USER";
if (userService != null) {
username = userService.getCurrentUsername();
}
// Verify access permission
if (!signatureService.hasAccessToFile(username, fileName)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
byte[] imageBytes = signatureService.getSignatureBytes(username, fileName);
return ResponseEntity.ok()
.contentType( // Adjust based on file type
MediaType.IMAGE_JPEG)
.body(imageBytes);
}
}
@@ -0,0 +1,84 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.service.PersonalSignatureServiceInterface;
import stirling.software.common.service.UserServiceInterface;
/**
* Unified signature image controller that works for both authenticated and unauthenticated users.
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared signatures -
* PersonalSignatureService (proprietary, optional): reads personal signatures For authenticated
* signature management (save/delete), see proprietary SignatureController.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/general")
public class SignatureImageController {
private final SharedSignatureService sharedSignatureService;
private final PersonalSignatureServiceInterface personalSignatureService;
private final UserServiceInterface userService;
public SignatureImageController(
SharedSignatureService sharedSignatureService,
@Autowired(required = false) PersonalSignatureServiceInterface personalSignatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.sharedSignatureService = sharedSignatureService;
this.personalSignatureService = personalSignatureService;
this.userService = userService;
}
/**
* Get a signature image (works for both authenticated and unauthenticated users). -
* Authenticated with proprietary: tries personal first, then shared - Unauthenticated or
* community: tries shared only
*/
@GetMapping("/signatures/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName) {
try {
byte[] imageBytes = null;
// If proprietary service available and user authenticated, try personal folder first
if (personalSignatureService != null && userService != null) {
try {
String username = userService.getCurrentUsername();
imageBytes =
personalSignatureService.getPersonalSignatureBytes(username, fileName);
} catch (Exception e) {
// Not found in personal folder or not authenticated, will try shared
log.debug("Personal signature not found, trying shared: {}", e.getMessage());
}
}
// If not found in personal (or no personal service), try shared
if (imageBytes == null) {
imageBytes = sharedSignatureService.getSharedSignatureBytes(fileName);
}
// Determine content type from file extension
MediaType contentType = MediaType.IMAGE_PNG; // Default
String lowerFileName = fileName.toLowerCase();
if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
contentType = MediaType.IMAGE_JPEG;
}
return ResponseEntity.ok().contentType(contentType).body(imageBytes);
} catch (IOException e) {
log.debug("Signature not found: {}", fileName);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
}
@@ -0,0 +1,18 @@
package stirling.software.SPDF.model.api.signature;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SavedSignatureRequest {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
}
@@ -0,0 +1,22 @@
package stirling.software.SPDF.model.api.signature;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SavedSignatureResponse {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types (or URL to fetch image)
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
private Long createdAt;
private Long updatedAt;
}
@@ -0,0 +1,308 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.model.api.signature.SavedSignatureRequest;
import stirling.software.SPDF.model.api.signature.SavedSignatureResponse;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SharedSignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
public SharedSignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
this.objectMapper = new ObjectMapper();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
/**
* Get a signature from the shared (ALL_USERS) folder. This is always available for both
* authenticated and unauthenticated users.
*/
public byte[] getSharedSignatureBytes(String fileName) throws IOException {
validateFileName(fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (!Files.exists(allUsersPath)) {
throw new FileNotFoundException("Shared signature file not found");
}
return Files.readAllBytes(allUsersPath);
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
// Whitelist only safe image extensions
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
}
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
throws IOException {
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
if (!canonicalResolved.startsWith(canonicalTarget)) {
throw new IOException("Resolved path is outside the target directory");
}
}
/** Save a signature as image file */
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
throws IOException {
validateFileName(request.getId());
// Determine folder based on scope
String scope = request.getScope();
if (scope == null || scope.isEmpty()) {
scope = "personal"; // Default to personal
}
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
Files.createDirectories(targetFolder);
long timestamp = System.currentTimeMillis();
SavedSignatureResponse response = new SavedSignatureResponse();
response.setId(request.getId());
response.setLabel(request.getLabel());
response.setType(request.getType());
response.setScope(scope);
response.setCreatedAt(timestamp);
response.setUpdatedAt(timestamp);
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
String extension = validateAndNormalizeExtension(rawExtension);
// Save image file only
String imageFileName = request.getId() + "." + extension;
Path imagePath = targetFolder.resolve(imageFileName);
// Verify path is within target directory
verifyPathWithinDirectory(imagePath, targetFolder);
Files.write(
imagePath,
imageBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
// Store reference to image file
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
}
log.info("Saved signature {} for user {}", request.getId(), username);
return response;
}
/** Get all saved signatures for a user */
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
List<SavedSignatureResponse> signatures = new ArrayList<>();
// Load personal signatures
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("personal");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
// Load shared signatures
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("shared");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
return signatures;
}
/** Delete a saved signature */
public void deleteSignature(String username, String signatureId) throws IOException {
validateFileName(signatureId);
// Try to find and delete image file in personal folder
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
boolean deleted = false;
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
// Try shared folder if not found in personal
if (!deleted) {
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
}
if (!deleted) {
throw new FileNotFoundException("Signature not found");
}
log.info("Deleted signature {} for user {}", signatureId, username);
}
}
@@ -1,107 +0,0 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
public SignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
public byte[] getSignatureBytes(String username, String fileName) throws IOException {
validateFileName(fileName);
// First try user's personal folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
if (Files.exists(userPath)) {
return Files.readAllBytes(userPath);
}
// Then try ALL_USERS folder
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (Files.exists(allUsersPath)) {
return Files.readAllBytes(allUsersPath);
}
throw new FileNotFoundException("Signature file not found");
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg")
|| fileName.endsWith(".jpeg")
|| fileName.endsWith(".png")
|| fileName.endsWith(".gif");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
}
}