mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
co-authored by
Anthony Stirling
parent
fde449e738
commit
651f17f1c6
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package stirling.software.common.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for personal signature access (proprietary feature). Implemented only in proprietary
|
||||||
|
* module to provide authenticated users access to their personal signatures.
|
||||||
|
*/
|
||||||
|
public interface PersonalSignatureServiceInterface {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a personal signature from the user's folder. Only checks personal folder, not shared
|
||||||
|
* folder.
|
||||||
|
*
|
||||||
|
* @param username Username of the signature owner
|
||||||
|
* @param fileName Signature filename
|
||||||
|
* @return Personal signature image bytes
|
||||||
|
* @throws IOException If file not found or read error
|
||||||
|
*/
|
||||||
|
byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException;
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
|
|
||||||
import stirling.software.SPDF.model.Dependency;
|
import stirling.software.SPDF.model.Dependency;
|
||||||
import stirling.software.SPDF.model.SignatureFile;
|
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.annotations.api.UiDataApi;
|
||||||
import stirling.software.common.configuration.InstallationPathConfig;
|
import stirling.software.common.configuration.InstallationPathConfig;
|
||||||
import stirling.software.common.configuration.RuntimePathConfig;
|
import stirling.software.common.configuration.RuntimePathConfig;
|
||||||
@@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils;
|
|||||||
public class UIDataController {
|
public class UIDataController {
|
||||||
|
|
||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
private final SignatureService signatureService;
|
private final SharedSignatureService signatureService;
|
||||||
private final UserServiceInterface userService;
|
private final UserServiceInterface userService;
|
||||||
private final ResourceLoader resourceLoader;
|
private final ResourceLoader resourceLoader;
|
||||||
private final RuntimePathConfig runtimePathConfig;
|
private final RuntimePathConfig runtimePathConfig;
|
||||||
|
|
||||||
public UIDataController(
|
public UIDataController(
|
||||||
ApplicationProperties applicationProperties,
|
ApplicationProperties applicationProperties,
|
||||||
SignatureService signatureService,
|
SharedSignatureService signatureService,
|
||||||
@Autowired(required = false) UserServiceInterface userService,
|
@Autowired(required = false) UserServiceInterface userService,
|
||||||
ResourceLoader resourceLoader,
|
ResourceLoader resourceLoader,
|
||||||
RuntimePathConfig runtimePathConfig) {
|
RuntimePathConfig runtimePathConfig) {
|
||||||
|
|||||||
+3
-3
@@ -25,7 +25,7 @@ import lombok.Setter;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.SPDF.model.SignatureFile;
|
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.InstallationPathConfig;
|
||||||
import stirling.software.common.configuration.RuntimePathConfig;
|
import stirling.software.common.configuration.RuntimePathConfig;
|
||||||
import stirling.software.common.service.UserServiceInterface;
|
import stirling.software.common.service.UserServiceInterface;
|
||||||
@@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
public class GeneralWebController {
|
public class GeneralWebController {
|
||||||
|
|
||||||
private final SignatureService signatureService;
|
private final SharedSignatureService signatureService;
|
||||||
private final UserServiceInterface userService;
|
private final UserServiceInterface userService;
|
||||||
private final ResourceLoader resourceLoader;
|
private final ResourceLoader resourceLoader;
|
||||||
private final RuntimePathConfig runtimePathConfig;
|
private final RuntimePathConfig runtimePathConfig;
|
||||||
|
|
||||||
public GeneralWebController(
|
public GeneralWebController(
|
||||||
SignatureService signatureService,
|
SharedSignatureService signatureService,
|
||||||
@Autowired(required = false) UserServiceInterface userService,
|
@Autowired(required = false) UserServiceInterface userService,
|
||||||
ResourceLoader resourceLoader,
|
ResourceLoader resourceLoader,
|
||||||
RuntimePathConfig runtimePathConfig) {
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+84
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -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
|
||||||
|
}
|
||||||
+22
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,7 +23,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
|
|||||||
class SignatureServiceTest {
|
class SignatureServiceTest {
|
||||||
|
|
||||||
@TempDir Path tempDir;
|
@TempDir Path tempDir;
|
||||||
private SignatureService signatureService;
|
private SharedSignatureService signatureService;
|
||||||
private Path personalSignatureFolder;
|
private Path personalSignatureFolder;
|
||||||
private Path sharedSignatureFolder;
|
private Path sharedSignatureFolder;
|
||||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||||
@@ -53,7 +53,7 @@ class SignatureServiceTest {
|
|||||||
.thenReturn(tempDir.toString());
|
.thenReturn(tempDir.toString());
|
||||||
|
|
||||||
// Initialize the service with our temp directory
|
// Initialize the service with our temp directory
|
||||||
signatureService = new SignatureService();
|
signatureService = new SharedSignatureService();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ class SignatureServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testGetSignatureBytes_PersonalFile() throws IOException {
|
void testGetSharedSignatureBytes_SharedFile() throws IOException {
|
||||||
// Mock static method for each test
|
// Mock static method for each test
|
||||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||||
mockStatic(InstallationPathConfig.class)) {
|
mockStatic(InstallationPathConfig.class)) {
|
||||||
@@ -173,28 +173,8 @@ class SignatureServiceTest {
|
|||||||
.when(InstallationPathConfig::getSignaturesPath)
|
.when(InstallationPathConfig::getSignaturesPath)
|
||||||
.thenReturn(tempDir.toString());
|
.thenReturn(tempDir.toString());
|
||||||
|
|
||||||
// Test
|
// Test - core service only reads shared signatures
|
||||||
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "personal.png");
|
byte[] bytes = signatureService.getSharedSignatureBytes("shared.jpg");
|
||||||
|
|
||||||
// Verify
|
|
||||||
assertEquals(
|
|
||||||
"personal signature content",
|
|
||||||
new String(bytes),
|
|
||||||
"Should return the correct content for personal file");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void testGetSignatureBytes_SharedFile() throws IOException {
|
|
||||||
// Mock static method for each test
|
|
||||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
|
||||||
mockStatic(InstallationPathConfig.class)) {
|
|
||||||
mockedConfig
|
|
||||||
.when(InstallationPathConfig::getSignaturesPath)
|
|
||||||
.thenReturn(tempDir.toString());
|
|
||||||
|
|
||||||
// Test
|
|
||||||
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "shared.jpg");
|
|
||||||
|
|
||||||
// Verify
|
// Verify
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -205,7 +185,7 @@ class SignatureServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testGetSignatureBytes_FileNotFound() {
|
void testGetSharedSignatureBytes_FileNotFound() {
|
||||||
// Mock static method for each test
|
// Mock static method for each test
|
||||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||||
mockStatic(InstallationPathConfig.class)) {
|
mockStatic(InstallationPathConfig.class)) {
|
||||||
@@ -216,13 +196,13 @@ class SignatureServiceTest {
|
|||||||
// Test and verify
|
// Test and verify
|
||||||
assertThrows(
|
assertThrows(
|
||||||
FileNotFoundException.class,
|
FileNotFoundException.class,
|
||||||
() -> signatureService.getSignatureBytes(TEST_USER, "nonexistent.png"),
|
() -> signatureService.getSharedSignatureBytes("nonexistent.png"),
|
||||||
"Should throw exception for non-existent files");
|
"Should throw exception for non-existent files");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testGetSignatureBytes_InvalidFileName() {
|
void testGetSharedSignatureBytes_InvalidFileName() {
|
||||||
// Mock static method for each test
|
// Mock static method for each test
|
||||||
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||||
mockStatic(InstallationPathConfig.class)) {
|
mockStatic(InstallationPathConfig.class)) {
|
||||||
@@ -233,11 +213,28 @@ class SignatureServiceTest {
|
|||||||
// Test and verify
|
// Test and verify
|
||||||
assertThrows(
|
assertThrows(
|
||||||
IllegalArgumentException.class,
|
IllegalArgumentException.class,
|
||||||
() -> signatureService.getSignatureBytes(TEST_USER, "../invalid.png"),
|
() -> signatureService.getSharedSignatureBytes("../invalid.png"),
|
||||||
"Should throw exception for file names with directory traversal");
|
"Should throw exception for file names with directory traversal");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testGetSharedSignatureBytes_CannotAccessPersonalFiles() {
|
||||||
|
// Mock static method for each test
|
||||||
|
try (MockedStatic<InstallationPathConfig> mockedConfig =
|
||||||
|
mockStatic(InstallationPathConfig.class)) {
|
||||||
|
mockedConfig
|
||||||
|
.when(InstallationPathConfig::getSignaturesPath)
|
||||||
|
.thenReturn(tempDir.toString());
|
||||||
|
|
||||||
|
// Test and verify - core service should NOT be able to read personal files
|
||||||
|
assertThrows(
|
||||||
|
FileNotFoundException.class,
|
||||||
|
() -> signatureService.getSharedSignatureBytes("personal.png"),
|
||||||
|
"Core service should not have access to personal signatures");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void testGetAvailableSignatures_EmptyUsername() throws IOException {
|
void testGetAvailableSignatures_EmptyUsername() throws IOException {
|
||||||
// Mock static method for each test
|
// Mock static method for each test
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
package stirling.software.proprietary.controller.api;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.annotations.api.UserApi;
|
||||||
|
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
|
||||||
|
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||||
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
|
import stirling.software.proprietary.service.SignatureService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for managing user signatures in proprietary/authenticated mode only. Requires user
|
||||||
|
* authentication and enforces per-user storage limits. All endpoints require authentication
|
||||||
|
* via @PreAuthorize("isAuthenticated()").
|
||||||
|
*/
|
||||||
|
@UserApi
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/proprietary/signatures")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@PreAuthorize("isAuthenticated()")
|
||||||
|
public class SignatureController {
|
||||||
|
|
||||||
|
private final SignatureService signatureService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a new signature for the authenticated user. Enforces storage limits and authentication
|
||||||
|
* requirements.
|
||||||
|
*/
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<SavedSignatureResponse> saveSignature(
|
||||||
|
@RequestBody SavedSignatureRequest request) {
|
||||||
|
try {
|
||||||
|
String username = userService.getCurrentUsername();
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if (request.getDataUrl() == null || request.getDataUrl().isEmpty()) {
|
||||||
|
log.warn("User {} attempted to save signature without dataUrl", username);
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedSignatureResponse response = signatureService.saveSignature(username, request);
|
||||||
|
log.info("User {} saved signature {}", username, request.getId());
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
log.warn("Invalid signature save request: {}", e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to save signature", e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all signatures accessible to the authenticated user. Includes both personal and shared
|
||||||
|
* signatures.
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<List<SavedSignatureResponse>> listSignatures() {
|
||||||
|
try {
|
||||||
|
String username = userService.getCurrentUsername();
|
||||||
|
List<SavedSignatureResponse> signatures = signatureService.getSavedSignatures(username);
|
||||||
|
return ResponseEntity.ok(signatures);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to list signatures for user", e);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a signature owned by the authenticated user. Users can only delete their own personal
|
||||||
|
* signatures, not shared ones.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{signatureId}")
|
||||||
|
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
|
||||||
|
try {
|
||||||
|
String username = userService.getCurrentUsername();
|
||||||
|
signatureService.deleteSignature(username, signatureId);
|
||||||
|
log.info("User {} deleted signature {}", username, signatureId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package stirling.software.proprietary.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
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
package stirling.software.proprietary.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;
|
||||||
|
}
|
||||||
+299
@@ -0,0 +1,299 @@
|
|||||||
|
package stirling.software.proprietary.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 lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.configuration.InstallationPathConfig;
|
||||||
|
import stirling.software.common.service.PersonalSignatureServiceInterface;
|
||||||
|
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
|
||||||
|
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing user signatures with authentication and storage limits. This proprietary
|
||||||
|
* version enforces per-user quotas and requires authentication. Provides access to personal
|
||||||
|
* signatures only (shared signatures handled by core service).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class SignatureService implements PersonalSignatureServiceInterface {
|
||||||
|
|
||||||
|
private final String SIGNATURE_BASE_PATH;
|
||||||
|
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||||
|
|
||||||
|
// Storage limits per user
|
||||||
|
private static final int MAX_SIGNATURES_PER_USER = 20;
|
||||||
|
private static final long MAX_SIGNATURE_SIZE_BYTES = 2_000_000; // 2MB per signature
|
||||||
|
private static final long MAX_TOTAL_USER_STORAGE_BYTES = 20_000_000; // 20MB total per user
|
||||||
|
|
||||||
|
public SignatureService() {
|
||||||
|
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a personal signature from the user's folder only. Does NOT check shared folder (that's
|
||||||
|
* handled by core service).
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException {
|
||||||
|
validateFileName(fileName);
|
||||||
|
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
|
||||||
|
|
||||||
|
if (!Files.exists(userPath)) {
|
||||||
|
throw new FileNotFoundException("Personal signature not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Files.readAllBytes(userPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save a signature with storage limits enforced. */
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Only enforce limits for personal signatures (not shared)
|
||||||
|
if ("personal".equals(scope)) {
|
||||||
|
enforceStorageLimits(username, request.getDataUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
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/")) {
|
||||||
|
// Validate dataUrl size before decoding
|
||||||
|
if (dataUrl.length() > MAX_SIGNATURE_SIZE_BYTES * 2) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Signature data too large (max "
|
||||||
|
+ (MAX_SIGNATURE_SIZE_BYTES / 1024)
|
||||||
|
+ "KB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract base64 data
|
||||||
|
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
|
||||||
|
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
|
||||||
|
|
||||||
|
// Validate decoded size
|
||||||
|
if (imageBytes.length > MAX_SIGNATURE_SIZE_BYTES) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Signature image too large (max "
|
||||||
|
+ (MAX_SIGNATURE_SIZE_BYTES / 1024)
|
||||||
|
+ "KB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
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 (unified endpoint for all signatures)
|
||||||
|
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all saved signatures for a user (personal + shared). */
|
||||||
|
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)) {
|
||||||
|
signatures.addAll(loadSignaturesFromFolder(personalFolder, "personal", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load shared signatures
|
||||||
|
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||||
|
if (Files.exists(sharedFolder)) {
|
||||||
|
signatures.addAll(loadSignaturesFromFolder(sharedFolder, "shared", false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a signature from user's personal folder. Cannot delete shared signatures. */
|
||||||
|
public void deleteSignature(String username, String signatureId) throws IOException {
|
||||||
|
validateFileName(signatureId);
|
||||||
|
|
||||||
|
// Only allow deletion from 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;
|
||||||
|
log.info("Deleted signature file: {}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new FileNotFoundException("Signature not found or cannot be deleted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper methods
|
||||||
|
|
||||||
|
private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
|
||||||
|
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||||
|
|
||||||
|
if (!Files.exists(userFolder)) {
|
||||||
|
return; // First signature, no limits to check
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count existing signatures
|
||||||
|
long signatureCount;
|
||||||
|
try (Stream<Path> stream = Files.list(userFolder)) {
|
||||||
|
signatureCount = stream.filter(this::isImageFile).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signatureCount >= MAX_SIGNATURES_PER_USER) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Maximum signatures limit reached (" + MAX_SIGNATURES_PER_USER + ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total storage used
|
||||||
|
long totalSize = 0;
|
||||||
|
try (Stream<Path> stream = Files.list(userFolder)) {
|
||||||
|
totalSize =
|
||||||
|
stream.filter(this::isImageFile)
|
||||||
|
.mapToLong(
|
||||||
|
path -> {
|
||||||
|
try {
|
||||||
|
return Files.size(path);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate new signature size (base64 decodes to ~75% of original)
|
||||||
|
long estimatedNewSize = (long) (dataUrlToAdd.length() * 0.75);
|
||||||
|
|
||||||
|
if (totalSize + estimatedNewSize > MAX_TOTAL_USER_STORAGE_BYTES) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Storage quota exceeded (max "
|
||||||
|
+ (MAX_TOTAL_USER_STORAGE_BYTES / 1_000_000)
|
||||||
|
+ "MB)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SavedSignatureResponse> loadSignaturesFromFolder(
|
||||||
|
Path folder, String scope, boolean isPersonal) throws IOException {
|
||||||
|
List<SavedSignatureResponse> signatures = new ArrayList<>();
|
||||||
|
|
||||||
|
try (Stream<Path> stream = Files.list(folder)) {
|
||||||
|
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);
|
||||||
|
sig.setType("image");
|
||||||
|
sig.setScope(scope);
|
||||||
|
sig.setCreatedAt(Files.getLastModifiedTime(path).toMillis());
|
||||||
|
sig.setUpdatedAt(Files.getLastModifiedTime(path).toMillis());
|
||||||
|
|
||||||
|
// Set unified URL path (works for both personal and shared)
|
||||||
|
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||||
|
|
||||||
|
signatures.add(sig);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Error reading signature file: " + path, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||||
|
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String validateAndNormalizeExtension(String extension) {
|
||||||
|
String normalized = extension.toLowerCase().trim();
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2267,8 +2267,16 @@ defaultCanvasLabel = "Drawing signature"
|
|||||||
defaultImageLabel = "Uploaded signature"
|
defaultImageLabel = "Uploaded signature"
|
||||||
defaultTextLabel = "Typed signature"
|
defaultTextLabel = "Typed signature"
|
||||||
saveButton = "Save signature"
|
saveButton = "Save signature"
|
||||||
|
savePersonal = "Save Personal"
|
||||||
|
saveShared = "Save Shared"
|
||||||
saveUnavailable = "Create a signature first to save it."
|
saveUnavailable = "Create a signature first to save it."
|
||||||
noChanges = "Current signature is already saved."
|
noChanges = "Current signature is already saved."
|
||||||
|
tempStorageTitle = "Temporary browser storage"
|
||||||
|
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
|
||||||
|
personalHeading = "Personal Signatures"
|
||||||
|
sharedHeading = "Shared Signatures"
|
||||||
|
personalDescription = "Only you can see these signatures."
|
||||||
|
sharedDescription = "All users can see and use these signatures."
|
||||||
|
|
||||||
[sign.saved.type]
|
[sign.saved.type]
|
||||||
canvas = "Drawing"
|
canvas = "Drawing"
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core';
|
import { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core';
|
||||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||||
import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
|
import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||||
|
import type { StorageType } from '@app/services/signatureStorageService';
|
||||||
|
|
||||||
interface SavedSignaturesSectionProps {
|
interface SavedSignaturesSectionProps {
|
||||||
signatures: SavedSignature[];
|
signatures: SavedSignature[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isAtCapacity: boolean;
|
isAtCapacity: boolean;
|
||||||
|
storageType?: StorageType | null;
|
||||||
onUseSignature: (signature: SavedSignature) => void;
|
onUseSignature: (signature: SavedSignature) => void;
|
||||||
onDeleteSignature: (signature: SavedSignature) => void;
|
onDeleteSignature: (signature: SavedSignature) => void;
|
||||||
onRenameSignature: (id: string, label: string) => void;
|
onRenameSignature: (id: string, label: string) => void;
|
||||||
@@ -24,6 +26,7 @@ export const SavedSignaturesSection = ({
|
|||||||
signatures,
|
signatures,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isAtCapacity,
|
isAtCapacity,
|
||||||
|
storageType: _storageType,
|
||||||
onUseSignature,
|
onUseSignature,
|
||||||
onDeleteSignature,
|
onDeleteSignature,
|
||||||
onRenameSignature,
|
onRenameSignature,
|
||||||
@@ -36,20 +39,30 @@ export const SavedSignaturesSection = ({
|
|||||||
[t, translationScope]
|
[t, translationScope]
|
||||||
);
|
);
|
||||||
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
|
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const activeSignature = signatures[activeIndex];
|
// Group signatures by scope
|
||||||
const activeSignatureRef = useRef<SavedSignature | null>(activeSignature ?? null);
|
const groupedSignatures = useMemo(() => {
|
||||||
const appliedSignatureIdRef = useRef<string | null>(null);
|
const personal = signatures.filter(sig => sig.scope === 'personal');
|
||||||
|
const shared = signatures.filter(sig => sig.scope === 'shared');
|
||||||
|
const localStorage = signatures.filter(sig => sig.scope === 'localStorage');
|
||||||
|
return { personal, shared, localStorage };
|
||||||
|
}, [signatures]);
|
||||||
|
|
||||||
|
// Separate carousel state for each category
|
||||||
|
const [activePersonalIndex, setActivePersonalIndex] = useState(0);
|
||||||
|
const [activeSharedIndex, setActiveSharedIndex] = useState(0);
|
||||||
|
const [activeLocalStorageIndex, setActiveLocalStorageIndex] = useState(0);
|
||||||
|
|
||||||
|
const activePersonalSignature = groupedSignatures.personal[activePersonalIndex];
|
||||||
|
const activeSharedSignature = groupedSignatures.shared[activeSharedIndex];
|
||||||
|
const activeLocalStorageSignature = groupedSignatures.localStorage[activeLocalStorageIndex];
|
||||||
|
|
||||||
const onUseSignatureRef = useRef(onUseSignature);
|
const onUseSignatureRef = useRef(onUseSignature);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onUseSignatureRef.current = onUseSignature;
|
onUseSignatureRef.current = onUseSignature;
|
||||||
}, [onUseSignature]);
|
}, [onUseSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
activeSignatureRef.current = activeSignature ?? null;
|
|
||||||
}, [activeSignature]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLabelDrafts(prev => {
|
setLabelDrafts(prev => {
|
||||||
const nextDrafts: Record<string, string> = {};
|
const nextDrafts: Record<string, string> = {};
|
||||||
@@ -60,25 +73,18 @@ export const SavedSignaturesSection = ({
|
|||||||
});
|
});
|
||||||
}, [signatures]);
|
}, [signatures]);
|
||||||
|
|
||||||
|
// Reset carousel indices when categories change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (signatures.length === 0) {
|
setActivePersonalIndex(prev => Math.min(prev, Math.max(groupedSignatures.personal.length - 1, 0)));
|
||||||
setActiveIndex(0);
|
}, [groupedSignatures.personal.length]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0)));
|
|
||||||
}, [signatures.length]);
|
|
||||||
|
|
||||||
const handleNavigate = useCallback(
|
useEffect(() => {
|
||||||
(direction: 'prev' | 'next') => {
|
setActiveSharedIndex(prev => Math.min(prev, Math.max(groupedSignatures.shared.length - 1, 0)));
|
||||||
setActiveIndex(prev => {
|
}, [groupedSignatures.shared.length]);
|
||||||
if (direction === 'prev') {
|
|
||||||
return Math.max(0, prev - 1);
|
useEffect(() => {
|
||||||
}
|
setActiveLocalStorageIndex(prev => Math.min(prev, Math.max(groupedSignatures.localStorage.length - 1, 0)));
|
||||||
return Math.min(signatures.length - 1, prev + 1);
|
}, [groupedSignatures.localStorage.length]);
|
||||||
});
|
|
||||||
},
|
|
||||||
[signatures.length]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderPreview = (signature: SavedSignature) => {
|
const renderPreview = (signature: SavedSignature) => {
|
||||||
if (signature.type === 'text') {
|
if (signature.type === 'text') {
|
||||||
@@ -193,21 +199,6 @@ export const SavedSignaturesSection = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const signature = activeSignatureRef.current;
|
|
||||||
if (!signature || disabled) {
|
|
||||||
appliedSignatureIdRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appliedSignatureIdRef.current === signature.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
appliedSignatureIdRef.current = signature.id;
|
|
||||||
onUseSignatureRef.current(signature);
|
|
||||||
}, [activeSignature?.id, disabled]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" align="flex-start">
|
<Group justify="space-between" align="flex-start">
|
||||||
@@ -234,67 +225,253 @@ export const SavedSignaturesSection = ({
|
|||||||
{signatures.length === 0 ? (
|
{signatures.length === 0 ? (
|
||||||
emptyState
|
emptyState
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="xs">
|
<Stack gap="md">
|
||||||
<Group justify="space-between" align="center">
|
{/* Personal Signatures */}
|
||||||
<Text size="sm" c="dimmed">
|
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
|
||||||
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
|
<Stack gap="xs">
|
||||||
current: activeIndex + 1,
|
<Group gap="xs">
|
||||||
total: signatures.length,
|
<LocalIcon icon="material-symbols:person-rounded" width={18} height={18} />
|
||||||
})}
|
<Text fw={600} size="sm">
|
||||||
</Text>
|
{translate('saved.personalHeading', 'Personal Signatures')}
|
||||||
<Group gap={4}>
|
</Text>
|
||||||
<ActionIcon
|
</Group>
|
||||||
variant="light"
|
<Text size="xs" c="dimmed">
|
||||||
aria-label={translate('saved.prev', 'Previous')}
|
{translate('saved.personalDescription', 'Only you can see these signatures.')}
|
||||||
onClick={() => handleNavigate('prev')}
|
</Text>
|
||||||
disabled={disabled || activeIndex === 0}
|
|
||||||
>
|
|
||||||
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
variant="light"
|
|
||||||
aria-label={translate('saved.next', 'Next')}
|
|
||||||
onClick={() => handleNavigate('next')}
|
|
||||||
disabled={disabled || activeIndex >= signatures.length - 1}
|
|
||||||
>
|
|
||||||
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{activeSignature && (
|
<Group justify="space-between" align="center">
|
||||||
<Card withBorder padding="sm" key={activeSignature.id}>
|
<Text size="sm" c="dimmed">
|
||||||
<Stack gap="sm">
|
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
|
||||||
<Group justify="space-between" align="center">
|
current: activePersonalIndex + 1,
|
||||||
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
|
total: groupedSignatures.personal.length,
|
||||||
{typeLabel(activeSignature.type)}
|
})}
|
||||||
</Badge>
|
</Text>
|
||||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
<Group gap={4}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="light"
|
||||||
color="red"
|
aria-label={translate('saved.prev', 'Previous')}
|
||||||
aria-label={translate('saved.delete', 'Remove')}
|
onClick={() => setActivePersonalIndex(prev => Math.max(0, prev - 1))}
|
||||||
onClick={() => onDeleteSignature(activeSignature)}
|
disabled={disabled || activePersonalIndex === 0}
|
||||||
disabled={disabled}
|
>
|
||||||
>
|
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
|
||||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
</ActionIcon>
|
||||||
</ActionIcon>
|
<ActionIcon
|
||||||
</Tooltip>
|
variant="light"
|
||||||
|
aria-label={translate('saved.next', 'Next')}
|
||||||
|
onClick={() => setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))}
|
||||||
|
disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 1}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
{renderPreview(activeSignature)}
|
<Card withBorder padding="sm">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Badge color={typeBadgeColor[activePersonalSignature.type]} variant="light">
|
||||||
|
{typeLabel(activePersonalSignature.type)}
|
||||||
|
</Badge>
|
||||||
|
<Group gap="xs">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
aria-label="Use signature"
|
||||||
|
onClick={() => onUseSignature(activePersonalSignature)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label={translate('saved.delete', 'Remove')}
|
||||||
|
onClick={() => onDeleteSignature(activePersonalSignature)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
{renderPreview(activePersonalSignature)}
|
||||||
|
<TextInput
|
||||||
|
label={translate('saved.label', 'Label')}
|
||||||
|
value={labelDrafts[activePersonalSignature.id] ?? activePersonalSignature.label}
|
||||||
|
onChange={event => handleLabelChange(event, activePersonalSignature)}
|
||||||
|
onBlur={() => handleLabelBlur(activePersonalSignature)}
|
||||||
|
onKeyDown={event => handleLabelKeyDown(event, activePersonalSignature)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
<TextInput
|
{/* Shared Signatures */}
|
||||||
label={translate('saved.label', 'Label')}
|
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
|
||||||
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
|
<Stack gap="xs">
|
||||||
onChange={event => handleLabelChange(event, activeSignature)}
|
<Group gap="xs">
|
||||||
onBlur={() => handleLabelBlur(activeSignature)}
|
<LocalIcon icon="material-symbols:groups-rounded" width={18} height={18} />
|
||||||
onKeyDown={event => handleLabelKeyDown(event, activeSignature)}
|
<Text fw={600} size="sm">
|
||||||
disabled={disabled}
|
{translate('saved.sharedHeading', 'Shared Signatures')}
|
||||||
/>
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{translate('saved.sharedDescription', 'All users can see and use these signatures.')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
</Stack>
|
<Group justify="space-between" align="center">
|
||||||
</Card>
|
<Text size="sm" c="dimmed">
|
||||||
|
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
|
||||||
|
current: activeSharedIndex + 1,
|
||||||
|
total: groupedSignatures.shared.length,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
aria-label={translate('saved.prev', 'Previous')}
|
||||||
|
onClick={() => setActiveSharedIndex(prev => Math.max(0, prev - 1))}
|
||||||
|
disabled={disabled || activeSharedIndex === 0}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
aria-label={translate('saved.next', 'Next')}
|
||||||
|
onClick={() => setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))}
|
||||||
|
disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 1}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Card withBorder padding="sm">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Badge color={typeBadgeColor[activeSharedSignature.type]} variant="light">
|
||||||
|
{typeLabel(activeSharedSignature.type)}
|
||||||
|
</Badge>
|
||||||
|
<Group gap="xs">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
aria-label="Use signature"
|
||||||
|
onClick={() => onUseSignature(activeSharedSignature)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label={translate('saved.delete', 'Remove')}
|
||||||
|
onClick={() => onDeleteSignature(activeSharedSignature)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
{renderPreview(activeSharedSignature)}
|
||||||
|
<TextInput
|
||||||
|
label={translate('saved.label', 'Label')}
|
||||||
|
value={labelDrafts[activeSharedSignature.id] ?? activeSharedSignature.label}
|
||||||
|
onChange={event => handleLabelChange(event, activeSharedSignature)}
|
||||||
|
onBlur={() => handleLabelBlur(activeSharedSignature)}
|
||||||
|
onKeyDown={event => handleLabelKeyDown(event, activeSharedSignature)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Browser Storage (localStorage) - Temporary */}
|
||||||
|
{groupedSignatures.localStorage.length > 0 && activeLocalStorageSignature && (
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Alert color="blue" title={translate('saved.tempStorageTitle', 'Temporary browser storage')}>
|
||||||
|
<Text size="xs">
|
||||||
|
{translate(
|
||||||
|
'saved.tempStorageDescription',
|
||||||
|
'Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
|
||||||
|
current: activeLocalStorageIndex + 1,
|
||||||
|
total: groupedSignatures.localStorage.length,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Group gap={4}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
aria-label={translate('saved.prev', 'Previous')}
|
||||||
|
onClick={() => setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))}
|
||||||
|
disabled={disabled || activeLocalStorageIndex === 0}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
aria-label={translate('saved.next', 'Next')}
|
||||||
|
onClick={() => setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))}
|
||||||
|
disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 1}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Card withBorder padding="sm">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Badge color={typeBadgeColor[activeLocalStorageSignature.type]} variant="light">
|
||||||
|
{typeLabel(activeLocalStorageSignature.type)}
|
||||||
|
</Badge>
|
||||||
|
<Group gap="xs">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
aria-label="Use signature"
|
||||||
|
onClick={() => onUseSignature(activeLocalStorageSignature)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label={translate('saved.delete', 'Remove')}
|
||||||
|
onClick={() => onDeleteSignature(activeLocalStorageSignature)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
{renderPreview(activeLocalStorageSignature)}
|
||||||
|
<TextInput
|
||||||
|
label={translate('saved.label', 'Label')}
|
||||||
|
value={labelDrafts[activeLocalStorageSignature.id] ?? activeLocalStorageSignature.label}
|
||||||
|
onChange={event => handleLabelChange(event, activeLocalStorageSignature)}
|
||||||
|
onBlur={() => handleLabelBlur(activeLocalStorageSignature)}
|
||||||
|
onKeyDown={event => handleLabelKeyDown(event, activeLocalStorageSignature)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
|||||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||||
import { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, MAX_SAVED_SIGNATURES, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures';
|
import { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, MAX_SAVED_SIGNATURES, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||||
import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection';
|
import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection';
|
||||||
|
import { buildSignaturePreview } from '@app/utils/signaturePreview';
|
||||||
|
|
||||||
type SignatureDrafts = {
|
type SignatureDrafts = {
|
||||||
canvas?: string;
|
canvas?: string;
|
||||||
@@ -99,6 +100,7 @@ const SignSettings = ({
|
|||||||
removeSignature,
|
removeSignature,
|
||||||
updateSignatureLabel,
|
updateSignatureLabel,
|
||||||
byTypeCounts,
|
byTypeCounts,
|
||||||
|
storageType,
|
||||||
} = useSavedSignatures();
|
} = useSavedSignatures();
|
||||||
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
|
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
|
||||||
const paramSource = parameters.signatureType as SignatureSource;
|
const paramSource = parameters.signatureType as SignatureSource;
|
||||||
@@ -157,11 +159,11 @@ const SignSettings = ({
|
|||||||
}, [canvasSignatureData, imageSignatureData, buildTextSignatureKey, parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
|
}, [canvasSignatureData, imageSignatureData, buildTextSignatureKey, parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
|
||||||
|
|
||||||
const saveSignatureToLibrary = useCallback(
|
const saveSignatureToLibrary = useCallback(
|
||||||
(payload: SavedSignaturePayload, type: SavedSignatureType): AddSignatureResult => {
|
async (payload: SavedSignaturePayload, type: SavedSignatureType, scope: 'personal' | 'shared'): Promise<AddSignatureResult> => {
|
||||||
if (isSavedSignatureLimitReached) {
|
if (isSavedSignatureLimitReached) {
|
||||||
return { success: false, reason: 'limit' };
|
return { success: false, reason: 'limit' };
|
||||||
}
|
}
|
||||||
return addSignature(payload, getDefaultSavedLabel(type));
|
return await addSignature(payload, getDefaultSavedLabel(type), scope);
|
||||||
},
|
},
|
||||||
[addSignature, getDefaultSavedLabel, isSavedSignatureLimitReached]
|
[addSignature, getDefaultSavedLabel, isSavedSignatureLimitReached]
|
||||||
);
|
);
|
||||||
@@ -176,40 +178,57 @@ const SignSettings = ({
|
|||||||
[signatureKeysByType]
|
[signatureKeysByType]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveCanvasSignature = useCallback(() => {
|
const handleSaveCanvasSignature = useCallback(async (scope: 'personal' | 'shared') => {
|
||||||
if (!canvasSignatureData) {
|
if (!canvasSignatureData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas');
|
const result = await saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas', scope);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setLastSavedKeyForType('canvas');
|
setLastSavedKeyForType('canvas');
|
||||||
}
|
}
|
||||||
}, [canvasSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
|
}, [canvasSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
|
||||||
|
|
||||||
const handleSaveImageSignature = useCallback(() => {
|
const handleSaveImageSignature = useCallback(async (scope: 'personal' | 'shared') => {
|
||||||
if (!imageSignatureData) {
|
if (!imageSignatureData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image');
|
const result = await saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image', scope);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setLastSavedKeyForType('image');
|
setLastSavedKeyForType('image');
|
||||||
}
|
}
|
||||||
}, [imageSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
|
}, [imageSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
|
||||||
|
|
||||||
const handleSaveTextSignature = useCallback(() => {
|
const handleSaveTextSignature = useCallback(async (scope: 'personal' | 'shared') => {
|
||||||
const signerName = (parameters.signerName ?? '').trim();
|
const signerName = (parameters.signerName ?? '').trim();
|
||||||
if (!signerName) {
|
if (!signerName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = saveSignatureToLibrary(
|
|
||||||
|
// Generate image from text signature
|
||||||
|
const preview = await buildSignaturePreview({
|
||||||
|
signatureType: 'text',
|
||||||
|
signerName,
|
||||||
|
fontFamily: parameters.fontFamily ?? 'Helvetica',
|
||||||
|
fontSize: parameters.fontSize ?? 16,
|
||||||
|
textColor: parameters.textColor ?? '#000000',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!preview?.dataUrl) {
|
||||||
|
console.error('Failed to generate text signature preview');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await saveSignatureToLibrary(
|
||||||
{
|
{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
dataUrl: preview.dataUrl,
|
||||||
signerName,
|
signerName,
|
||||||
fontFamily: parameters.fontFamily ?? 'Helvetica',
|
fontFamily: parameters.fontFamily ?? 'Helvetica',
|
||||||
fontSize: parameters.fontSize ?? 16,
|
fontSize: parameters.fontSize ?? 16,
|
||||||
textColor: parameters.textColor ?? '#000000',
|
textColor: parameters.textColor ?? '#000000',
|
||||||
},
|
},
|
||||||
'text'
|
'text',
|
||||||
|
scope
|
||||||
);
|
);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setLastSavedKeyForType('text');
|
setLastSavedKeyForType('text');
|
||||||
@@ -286,16 +305,21 @@ const SignSettings = ({
|
|||||||
[updateSignatureLabel]
|
[updateSignatureLabel]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
|
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void, scope: 'personal' | 'shared', icon: string, label: string) => {
|
||||||
if (!canUseSavedLibrary) {
|
if (!canUseSavedLibrary) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const label = translate('saved.saveButton', 'Save signature');
|
|
||||||
const currentKey = signatureKeysByType[type];
|
const currentKey = signatureKeysByType[type];
|
||||||
const lastSavedKey = lastSavedSignatureKeys[type];
|
const lastSavedKey = lastSavedSignatureKeys[type];
|
||||||
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
|
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
|
||||||
const isSaved = isReady && !hasChanges;
|
const isSaved = isReady && !hasChanges;
|
||||||
|
|
||||||
|
// Only show backend storage buttons when backend is available
|
||||||
|
const showButton = storageType === 'backend' || storageType === null;
|
||||||
|
if (!showButton) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
let tooltipMessage: string | undefined;
|
let tooltipMessage: string | undefined;
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
tooltipMessage = translate('saved.saveUnavailable', 'Create a signature first to save it.');
|
tooltipMessage = translate('saved.saveUnavailable', 'Create a signature first to save it.');
|
||||||
@@ -312,11 +336,11 @@ const SignSettings = ({
|
|||||||
size="xs"
|
size="xs"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color={isSaved ? 'green' : undefined}
|
color={isSaved ? 'green' : undefined}
|
||||||
onClick={onClick}
|
onClick={() => onClick(scope)}
|
||||||
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
|
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
|
||||||
leftSection={<LocalIcon icon="material-symbols:save-rounded" width={16} height={16} />}
|
leftSection={<LocalIcon icon={icon} width={16} height={16} />}
|
||||||
>
|
>
|
||||||
{isSaved ? translate('saved.status.saved', 'Saved') : label}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -331,15 +355,38 @@ const SignSettings = ({
|
|||||||
return button;
|
return button;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
|
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void) => {
|
||||||
const button = renderSaveButton(type, isReady, onClick);
|
if (!canUseSavedLibrary) {
|
||||||
if (!button) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const personalButton = renderSaveButton(
|
||||||
|
type,
|
||||||
|
isReady,
|
||||||
|
onClick,
|
||||||
|
'personal',
|
||||||
|
'material-symbols:person-rounded',
|
||||||
|
translate('saved.savePersonal', 'Save Personal')
|
||||||
|
);
|
||||||
|
|
||||||
|
const sharedButton = renderSaveButton(
|
||||||
|
type,
|
||||||
|
isReady,
|
||||||
|
onClick,
|
||||||
|
'shared',
|
||||||
|
'material-symbols:groups-rounded',
|
||||||
|
translate('saved.saveShared', 'Save Shared')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!personalButton && !sharedButton) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
|
<Group gap="xs" style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
|
||||||
{button}
|
{personalButton}
|
||||||
</Box>
|
{sharedButton}
|
||||||
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -744,6 +791,7 @@ const SignSettings = ({
|
|||||||
signatures={savedSignatures}
|
signatures={savedSignatures}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isAtCapacity={isSavedSignatureLimitReached}
|
isAtCapacity={isSavedSignatureLimitReached}
|
||||||
|
storageType={storageType}
|
||||||
onUseSignature={handleUseSavedSignature}
|
onUseSignature={handleUseSavedSignature}
|
||||||
onDeleteSignature={handleDeleteSavedSignature}
|
onDeleteSignature={handleDeleteSavedSignature}
|
||||||
onRenameSignature={handleRenameSavedSignature}
|
onRenameSignature={handleRenameSavedSignature}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { signatureStorageService, type StorageType } from '@app/services/signatureStorageService';
|
||||||
|
|
||||||
const STORAGE_KEY = 'stirling:saved-signatures:v1';
|
|
||||||
export const MAX_SAVED_SIGNATURES = 10;
|
export const MAX_SAVED_SIGNATURES = 10;
|
||||||
|
|
||||||
export type SavedSignatureType = 'canvas' | 'image' | 'text';
|
export type SavedSignatureType = 'canvas' | 'image' | 'text';
|
||||||
|
export type SignatureScope = 'personal' | 'shared' | 'localStorage';
|
||||||
|
|
||||||
export type SavedSignaturePayload =
|
export type SavedSignaturePayload =
|
||||||
| {
|
| {
|
||||||
@@ -16,6 +17,7 @@ export type SavedSignaturePayload =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'text';
|
type: 'text';
|
||||||
|
dataUrl: string;
|
||||||
signerName: string;
|
signerName: string;
|
||||||
fontFamily: string;
|
fontFamily: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
@@ -25,6 +27,7 @@ export type SavedSignaturePayload =
|
|||||||
export type SavedSignature = SavedSignaturePayload & {
|
export type SavedSignature = SavedSignaturePayload & {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
scope: SignatureScope;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
@@ -35,68 +38,6 @@ export type AddSignatureResult =
|
|||||||
|
|
||||||
const isSupportedEnvironment = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
const isSupportedEnvironment = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||||
|
|
||||||
const safeParse = (raw: string | null): SavedSignature[] => {
|
|
||||||
if (!raw) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.filter((entry: any): entry is SavedSignature => {
|
|
||||||
if (!entry || typeof entry !== 'object') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (typeof entry.id !== 'string' || typeof entry.label !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (typeof entry.type !== 'string') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.type === 'text') {
|
|
||||||
return (
|
|
||||||
typeof entry.signerName === 'string' &&
|
|
||||||
typeof entry.fontFamily === 'string' &&
|
|
||||||
typeof entry.fontSize === 'number' &&
|
|
||||||
typeof entry.textColor === 'string'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof entry.dataUrl === 'string';
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const readFromStorage = (): SavedSignature[] => {
|
|
||||||
if (!isSupportedEnvironment()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return safeParse(window.localStorage.getItem(STORAGE_KEY));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const writeToStorage = (entries: SavedSignature[]) => {
|
|
||||||
if (!isSupportedEnvironment()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
|
||||||
} catch {
|
|
||||||
// Swallow storage errors silently; we still keep state in memory.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateId = () => {
|
const generateId = () => {
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -105,29 +46,61 @@ const generateId = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useSavedSignatures = () => {
|
export const useSavedSignatures = () => {
|
||||||
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
|
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
|
||||||
|
const [storageType, setStorageType] = useState<StorageType | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// Load signatures and detect storage type on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSupportedEnvironment()) {
|
const loadSignatures = async () => {
|
||||||
|
try {
|
||||||
|
const [signatures, type] = await Promise.all([
|
||||||
|
signatureStorageService.loadSignatures(),
|
||||||
|
signatureStorageService.getStorageType(),
|
||||||
|
]);
|
||||||
|
setSavedSignatures(signatures);
|
||||||
|
setStorageType(type);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useSavedSignatures] Failed to load signatures:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSignatures();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Attempt migration from localStorage to backend when backend becomes available
|
||||||
|
useEffect(() => {
|
||||||
|
if (storageType === 'backend' && !isLoading) {
|
||||||
|
signatureStorageService.migrateToBackend().then(result => {
|
||||||
|
if (result.migrated > 0) {
|
||||||
|
console.log(`[useSavedSignatures] Migrated ${result.migrated} signatures to backend`);
|
||||||
|
// Reload after migration
|
||||||
|
signatureStorageService.loadSignatures().then(setSavedSignatures);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [storageType, isLoading]);
|
||||||
|
|
||||||
|
// Listen for storage events (for localStorage only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSupportedEnvironment() || storageType !== 'localStorage') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncFromStorage = () => {
|
const syncFromStorage = () => {
|
||||||
setSavedSignatures(readFromStorage());
|
signatureStorageService.loadSignatures().then(setSavedSignatures);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('storage', syncFromStorage);
|
window.addEventListener('storage', syncFromStorage);
|
||||||
return () => window.removeEventListener('storage', syncFromStorage);
|
return () => window.removeEventListener('storage', syncFromStorage);
|
||||||
}, []);
|
}, [storageType]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
writeToStorage(savedSignatures);
|
|
||||||
}, [savedSignatures]);
|
|
||||||
|
|
||||||
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
|
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
|
||||||
|
|
||||||
const addSignature = useCallback(
|
const addSignature = useCallback(
|
||||||
(payload: SavedSignaturePayload, label?: string): AddSignatureResult => {
|
async (payload: SavedSignaturePayload, label?: string, scope?: SignatureScope): Promise<AddSignatureResult> => {
|
||||||
if (
|
if (
|
||||||
(payload.type === 'text' && !payload.signerName.trim()) ||
|
(payload.type === 'text' && !payload.signerName.trim()) ||
|
||||||
((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl)
|
((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl)
|
||||||
@@ -135,62 +108,85 @@ export const useSavedSignatures = () => {
|
|||||||
return { success: false, reason: 'invalid' };
|
return { success: false, reason: 'invalid' };
|
||||||
}
|
}
|
||||||
|
|
||||||
let createdSignature: SavedSignature | null = null;
|
if (savedSignatures.length >= MAX_SAVED_SIGNATURES) {
|
||||||
setSavedSignatures(prev => {
|
return { success: false, reason: 'limit' };
|
||||||
if (prev.length >= MAX_SAVED_SIGNATURES) {
|
}
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const nextEntry: SavedSignature = {
|
const newSignature: SavedSignature = {
|
||||||
...payload,
|
...payload,
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
label: (label || 'Signature').trim() || 'Signature',
|
label: (label || 'Signature').trim() || 'Signature',
|
||||||
createdAt: timestamp,
|
scope: scope || (storageType === 'backend' ? 'personal' : 'localStorage'),
|
||||||
updatedAt: timestamp,
|
createdAt: timestamp,
|
||||||
};
|
updatedAt: timestamp,
|
||||||
createdSignature = nextEntry;
|
};
|
||||||
return [nextEntry, ...prev];
|
|
||||||
});
|
|
||||||
|
|
||||||
return createdSignature
|
try {
|
||||||
? { success: true, signature: createdSignature }
|
await signatureStorageService.saveSignature(newSignature);
|
||||||
: { success: false, reason: 'limit' };
|
setSavedSignatures(prev => [newSignature, ...prev]);
|
||||||
|
return { success: true, signature: newSignature };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useSavedSignatures] Failed to save signature:', error);
|
||||||
|
return { success: false, reason: 'invalid' };
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[]
|
[savedSignatures.length, storageType]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeSignature = useCallback((id: string) => {
|
const removeSignature = useCallback(async (id: string) => {
|
||||||
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
|
try {
|
||||||
|
await signatureStorageService.deleteSignature(id);
|
||||||
|
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useSavedSignatures] Failed to delete signature:', error);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateSignatureLabel = useCallback((id: string, nextLabel: string) => {
|
const updateSignatureLabel = useCallback(async (id: string, nextLabel: string) => {
|
||||||
setSavedSignatures(prev =>
|
try {
|
||||||
prev.map(entry =>
|
await signatureStorageService.updateSignatureLabel(id, nextLabel);
|
||||||
entry.id === id
|
setSavedSignatures(prev =>
|
||||||
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
|
prev.map(entry =>
|
||||||
: entry
|
entry.id === id
|
||||||
)
|
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
|
||||||
);
|
: entry
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useSavedSignatures] Failed to update signature label:', error);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const replaceSignature = useCallback((id: string, payload: SavedSignaturePayload) => {
|
const replaceSignature = useCallback(
|
||||||
setSavedSignatures(prev =>
|
async (id: string, payload: SavedSignaturePayload) => {
|
||||||
prev.map(entry =>
|
const existing = savedSignatures.find(s => s.id === id);
|
||||||
entry.id === id
|
if (!existing) return;
|
||||||
? {
|
|
||||||
...entry,
|
|
||||||
...payload,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
}
|
|
||||||
: entry
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearSignatures = useCallback(() => {
|
const updated: SavedSignature = {
|
||||||
setSavedSignatures([]);
|
...existing,
|
||||||
}, []);
|
...payload,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signatureStorageService.saveSignature(updated);
|
||||||
|
setSavedSignatures(prev => prev.map(entry => (entry.id === id ? updated : entry)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useSavedSignatures] Failed to replace signature:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[savedSignatures]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearSignatures = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await Promise.all(savedSignatures.map(sig => signatureStorageService.deleteSignature(sig.id)));
|
||||||
|
setSavedSignatures([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useSavedSignatures] Failed to clear signatures:', error);
|
||||||
|
}
|
||||||
|
}, [savedSignatures]);
|
||||||
|
|
||||||
const byTypeCounts = useMemo(() => {
|
const byTypeCounts = useMemo(() => {
|
||||||
return savedSignatures.reduce<Record<SavedSignatureType, number>>(
|
return savedSignatures.reduce<Record<SavedSignatureType, number>>(
|
||||||
@@ -211,6 +207,8 @@ export const useSavedSignatures = () => {
|
|||||||
replaceSignature,
|
replaceSignature,
|
||||||
clearSignatures,
|
clearSignatures,
|
||||||
byTypeCounts,
|
byTypeCounts,
|
||||||
|
storageType,
|
||||||
|
isLoading,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import apiClient from '@app/services/apiClient';
|
||||||
|
import type { SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||||
|
|
||||||
|
export type StorageType = 'backend' | 'localStorage';
|
||||||
|
|
||||||
|
interface SignatureStorageCapabilities {
|
||||||
|
supportsBackend: boolean;
|
||||||
|
storageType: StorageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service to handle signature storage with adaptive backend/localStorage fallback
|
||||||
|
*/
|
||||||
|
class SignatureStorageService {
|
||||||
|
private capabilities: SignatureStorageCapabilities | null = null;
|
||||||
|
private detectionPromise: Promise<SignatureStorageCapabilities> | null = null;
|
||||||
|
private blobUrls: Set<string> = new Set();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect if backend supports signature storage API
|
||||||
|
*/
|
||||||
|
async detectCapabilities(): Promise<SignatureStorageCapabilities> {
|
||||||
|
// Return cached result if already detected
|
||||||
|
if (this.capabilities) {
|
||||||
|
return this.capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return in-flight detection if already running
|
||||||
|
if (this.detectionPromise) {
|
||||||
|
return this.detectionPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new detection
|
||||||
|
this.detectionPromise = this._performDetection();
|
||||||
|
this.capabilities = await this.detectionPromise;
|
||||||
|
this.detectionPromise = null;
|
||||||
|
|
||||||
|
return this.capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _performDetection(): Promise<SignatureStorageCapabilities> {
|
||||||
|
try {
|
||||||
|
// Probe the proprietary signatures endpoint (requires authentication)
|
||||||
|
await apiClient.get('/api/v1/proprietary/signatures', {
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 200 = Backend available and accessible (authenticated)
|
||||||
|
console.log('[SignatureStorage] Backend signature API detected and accessible (authenticated)');
|
||||||
|
return {
|
||||||
|
supportsBackend: true,
|
||||||
|
storageType: 'backend',
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
// Check if it's an HTTP error with status code
|
||||||
|
if (error?.response?.status === 401 || error?.response?.status === 403) {
|
||||||
|
// Backend exists but needs auth - gracefully fall back to localStorage
|
||||||
|
console.log('[SignatureStorage] Backend signature API requires authentication, using localStorage');
|
||||||
|
} else if (error?.response?.status === 404) {
|
||||||
|
// Endpoint doesn't exist (not running proprietary mode)
|
||||||
|
console.log('[SignatureStorage] Backend signature API not available (not in proprietary mode), using localStorage');
|
||||||
|
} else {
|
||||||
|
// Network error, timeout, or other error
|
||||||
|
console.log('[SignatureStorage] Backend signature API not available, using localStorage');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
supportsBackend: false,
|
||||||
|
storageType: 'localStorage',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current storage type
|
||||||
|
*/
|
||||||
|
async getStorageType(): Promise<StorageType> {
|
||||||
|
const capabilities = await this.detectCapabilities();
|
||||||
|
return capabilities.storageType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all signatures
|
||||||
|
*/
|
||||||
|
async loadSignatures(): Promise<SavedSignature[]> {
|
||||||
|
// Clean up old blob URLs before loading new ones
|
||||||
|
this.cleanup();
|
||||||
|
|
||||||
|
const capabilities = await this.detectCapabilities();
|
||||||
|
|
||||||
|
if (capabilities.supportsBackend) {
|
||||||
|
return this._loadFromBackend();
|
||||||
|
} else {
|
||||||
|
return this._loadFromLocalStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a signature
|
||||||
|
*/
|
||||||
|
async saveSignature(signature: SavedSignature): Promise<void> {
|
||||||
|
const capabilities = await this.detectCapabilities();
|
||||||
|
|
||||||
|
if (capabilities.supportsBackend && signature.scope !== 'localStorage') {
|
||||||
|
await this._saveToBackend(signature);
|
||||||
|
} else {
|
||||||
|
// Force scope to localStorage for browser storage
|
||||||
|
signature.scope = 'localStorage';
|
||||||
|
this._saveToLocalStorage(signature);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a signature
|
||||||
|
*/
|
||||||
|
async deleteSignature(id: string): Promise<void> {
|
||||||
|
const capabilities = await this.detectCapabilities();
|
||||||
|
|
||||||
|
if (capabilities.supportsBackend) {
|
||||||
|
await this._deleteFromBackend(id);
|
||||||
|
} else {
|
||||||
|
this._deleteFromLocalStorage(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update signature label
|
||||||
|
*/
|
||||||
|
async updateSignatureLabel(id: string, label: string): Promise<void> {
|
||||||
|
const capabilities = await this.detectCapabilities();
|
||||||
|
|
||||||
|
if (capabilities.supportsBackend) {
|
||||||
|
// Backend only stores images - labels not supported for backend signatures
|
||||||
|
console.log('[SignatureStorage] Label updates not supported for backend signatures');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this._updateLabelInLocalStorage(id, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend methods
|
||||||
|
private async _loadFromBackend(): Promise<SavedSignature[]> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<SavedSignature[]>('/api/v1/proprietary/signatures');
|
||||||
|
const signatures = response.data;
|
||||||
|
|
||||||
|
// Fetch image data for each signature and convert to blob URLs
|
||||||
|
const signaturePromises = signatures.map(async (sig) => {
|
||||||
|
if (sig.dataUrl && sig.dataUrl.startsWith('/api/v1/general/signatures/')) {
|
||||||
|
try {
|
||||||
|
// Fetch image via apiClient (unified endpoint works for both authenticated and unauthenticated)
|
||||||
|
const imageResponse = await apiClient.get<ArrayBuffer>(sig.dataUrl, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to blob URL
|
||||||
|
const blob = new Blob([imageResponse.data], {
|
||||||
|
type: imageResponse.headers['content-type'] || 'image/png',
|
||||||
|
});
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
this.blobUrls.add(blobUrl);
|
||||||
|
|
||||||
|
return { ...sig, dataUrl: blobUrl };
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SignatureStorage] Failed to load image for ${sig.id}:`, error);
|
||||||
|
return sig; // Return original if image fetch fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sig;
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Promise.all(signaturePromises);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SignatureStorage] Failed to load from backend:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _saveToBackend(signature: SavedSignature): Promise<void> {
|
||||||
|
await apiClient.post('/api/v1/proprietary/signatures', signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _deleteFromBackend(id: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/api/v1/proprietary/signatures/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalStorage methods
|
||||||
|
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
|
||||||
|
|
||||||
|
private _loadFromLocalStorage(): SavedSignature[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(this.STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const signatures = JSON.parse(raw);
|
||||||
|
// Ensure all localStorage signatures have the correct scope
|
||||||
|
return signatures.map((sig: SavedSignature) => ({
|
||||||
|
...sig,
|
||||||
|
scope: 'localStorage' as const,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _saveToLocalStorage(signature: SavedSignature): void {
|
||||||
|
const signatures = this._loadFromLocalStorage();
|
||||||
|
const index = signatures.findIndex(s => s.id === signature.id);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
signatures[index] = signature;
|
||||||
|
} else {
|
||||||
|
signatures.unshift(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _deleteFromLocalStorage(id: string): void {
|
||||||
|
const signatures = this._loadFromLocalStorage();
|
||||||
|
const filtered = signatures.filter(s => s.id !== id);
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateLabelInLocalStorage(id: string, label: string): void {
|
||||||
|
const signatures = this._loadFromLocalStorage();
|
||||||
|
const signature = signatures.find(s => s.id === id);
|
||||||
|
if (signature) {
|
||||||
|
signature.label = label;
|
||||||
|
signature.updatedAt = Date.now();
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate signatures from localStorage to backend
|
||||||
|
*/
|
||||||
|
async migrateToBackend(): Promise<{ migrated: number; failed: number }> {
|
||||||
|
const capabilities = await this.detectCapabilities();
|
||||||
|
|
||||||
|
if (!capabilities.supportsBackend) {
|
||||||
|
return { migrated: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const localSignatures = this._loadFromLocalStorage();
|
||||||
|
if (localSignatures.length === 0) {
|
||||||
|
return { migrated: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let migrated = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const signature of localSignatures) {
|
||||||
|
try {
|
||||||
|
await this._saveToBackend(signature);
|
||||||
|
migrated++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SignatureStorage] Failed to migrate signature ${signature.id}:`, error);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear localStorage after successful migration
|
||||||
|
if (migrated > 0 && failed === 0) {
|
||||||
|
localStorage.removeItem(this.STORAGE_KEY);
|
||||||
|
console.log(`[SignatureStorage] Successfully migrated ${migrated} signatures to backend`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { migrated, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up blob URLs to prevent memory leaks
|
||||||
|
*/
|
||||||
|
cleanup(): void {
|
||||||
|
this.blobUrls.forEach(url => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
});
|
||||||
|
this.blobUrls.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signatureStorageService = new SignatureStorageService();
|
||||||
Reference in New Issue
Block a user