diff --git a/app/common/src/main/java/stirling/software/common/service/PersonalSignatureServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/PersonalSignatureServiceInterface.java new file mode 100644 index 000000000..0f031c2e3 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/PersonalSignatureServiceInterface.java @@ -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; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index 18445d7da..82dc3b204 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.Dependency; import stirling.software.SPDF.model.SignatureFile; -import stirling.software.SPDF.service.SignatureService; +import stirling.software.SPDF.service.SharedSignatureService; import stirling.software.common.annotations.api.UiDataApi; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.RuntimePathConfig; @@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils; public class UIDataController { private final ApplicationProperties applicationProperties; - private final SignatureService signatureService; + private final SharedSignatureService signatureService; private final UserServiceInterface userService; private final ResourceLoader resourceLoader; private final RuntimePathConfig runtimePathConfig; public UIDataController( ApplicationProperties applicationProperties, - SignatureService signatureService, + SharedSignatureService signatureService, @Autowired(required = false) UserServiceInterface userService, ResourceLoader resourceLoader, RuntimePathConfig runtimePathConfig) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java index d2a2e5a17..e1796b827 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/GeneralWebController.java @@ -25,7 +25,7 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.model.SignatureFile; -import stirling.software.SPDF.service.SignatureService; +import stirling.software.SPDF.service.SharedSignatureService; import stirling.software.common.configuration.InstallationPathConfig; import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.service.UserServiceInterface; @@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils; @Slf4j public class GeneralWebController { - private final SignatureService signatureService; + private final SharedSignatureService signatureService; private final UserServiceInterface userService; private final ResourceLoader resourceLoader; private final RuntimePathConfig runtimePathConfig; public GeneralWebController( - SignatureService signatureService, + SharedSignatureService signatureService, @Autowired(required = false) UserServiceInterface userService, ResourceLoader resourceLoader, RuntimePathConfig runtimePathConfig) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java deleted file mode 100644 index ad046f326..000000000 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureController.java +++ /dev/null @@ -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 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); - } -} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureImageController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureImageController.java new file mode 100644 index 000000000..90313af29 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/SignatureImageController.java @@ -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 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(); + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequest.java new file mode 100644 index 000000000..b1c23ad71 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureRequest.java @@ -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 +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponse.java b/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponse.java new file mode 100644 index 000000000..e7e21338e --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/signature/SavedSignatureResponse.java @@ -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; +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java b/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java new file mode 100644 index 000000000..6c349581d --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java @@ -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 getAvailableSignatures(String username) { + List 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 getSignaturesFromFolder(Path folder, String category) + throws IOException { + try (Stream 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 getSavedSignatures(String username) throws IOException { + List signatures = new ArrayList<>(); + + // Load personal signatures + Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username); + if (Files.exists(personalFolder)) { + try (Stream 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 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 stream = Files.list(personalFolder)) { + List 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 stream = Files.list(sharedFolder)) { + List 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); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java b/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java deleted file mode 100644 index 579ea5507..000000000 --- a/app/core/src/main/java/stirling/software/SPDF/service/SignatureService.java +++ /dev/null @@ -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 getAvailableSignatures(String username) { - List 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 getSignaturesFromFolder(Path folder, String category) - throws IOException { - try (Stream 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"); - } - } -} diff --git a/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java b/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java index 5161e82ea..8c8ce97cf 100644 --- a/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java +++ b/app/core/src/test/java/stirling/software/SPDF/service/SignatureServiceTest.java @@ -23,7 +23,7 @@ import stirling.software.common.configuration.InstallationPathConfig; class SignatureServiceTest { @TempDir Path tempDir; - private SignatureService signatureService; + private SharedSignatureService signatureService; private Path personalSignatureFolder; private Path sharedSignatureFolder; private final String ALL_USERS_FOLDER = "ALL_USERS"; @@ -53,7 +53,7 @@ class SignatureServiceTest { .thenReturn(tempDir.toString()); // Initialize the service with our temp directory - signatureService = new SignatureService(); + signatureService = new SharedSignatureService(); } } @@ -165,7 +165,7 @@ class SignatureServiceTest { } @Test - void testGetSignatureBytes_PersonalFile() throws IOException { + void testGetSharedSignatureBytes_SharedFile() throws IOException { // Mock static method for each test try (MockedStatic mockedConfig = mockStatic(InstallationPathConfig.class)) { @@ -173,28 +173,8 @@ class SignatureServiceTest { .when(InstallationPathConfig::getSignaturesPath) .thenReturn(tempDir.toString()); - // Test - byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "personal.png"); - - // 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 mockedConfig = - mockStatic(InstallationPathConfig.class)) { - mockedConfig - .when(InstallationPathConfig::getSignaturesPath) - .thenReturn(tempDir.toString()); - - // Test - byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "shared.jpg"); + // Test - core service only reads shared signatures + byte[] bytes = signatureService.getSharedSignatureBytes("shared.jpg"); // Verify assertEquals( @@ -205,7 +185,7 @@ class SignatureServiceTest { } @Test - void testGetSignatureBytes_FileNotFound() { + void testGetSharedSignatureBytes_FileNotFound() { // Mock static method for each test try (MockedStatic mockedConfig = mockStatic(InstallationPathConfig.class)) { @@ -216,13 +196,13 @@ class SignatureServiceTest { // Test and verify assertThrows( FileNotFoundException.class, - () -> signatureService.getSignatureBytes(TEST_USER, "nonexistent.png"), + () -> signatureService.getSharedSignatureBytes("nonexistent.png"), "Should throw exception for non-existent files"); } } @Test - void testGetSignatureBytes_InvalidFileName() { + void testGetSharedSignatureBytes_InvalidFileName() { // Mock static method for each test try (MockedStatic mockedConfig = mockStatic(InstallationPathConfig.class)) { @@ -233,11 +213,28 @@ class SignatureServiceTest { // Test and verify assertThrows( IllegalArgumentException.class, - () -> signatureService.getSignatureBytes(TEST_USER, "../invalid.png"), + () -> signatureService.getSharedSignatureBytes("../invalid.png"), "Should throw exception for file names with directory traversal"); } } + @Test + void testGetSharedSignatureBytes_CannotAccessPersonalFiles() { + // Mock static method for each test + try (MockedStatic 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 void testGetAvailableSignatures_EmptyUsername() throws IOException { // Mock static method for each test diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java new file mode 100644 index 000000000..f42b23a97 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java @@ -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 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> listSignatures() { + try { + String username = userService.getCurrentUsername(); + List 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 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(); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureRequest.java new file mode 100644 index 000000000..21c43a360 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureRequest.java @@ -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 +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureResponse.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureResponse.java new file mode 100644 index 000000000..7ab6876d2 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/signature/SavedSignatureResponse.java @@ -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; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java new file mode 100644 index 000000000..6f849ebfe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java @@ -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 getSavedSignatures(String username) throws IOException { + List 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 stream = Files.list(personalFolder)) { + List 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 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 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 loadSignaturesFromFolder( + Path folder, String scope, boolean isPersonal) throws IOException { + List signatures = new ArrayList<>(); + + try (Stream 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"); + } + } +} diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 3ff331b18..da021cdde 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -2267,8 +2267,16 @@ defaultCanvasLabel = "Drawing signature" defaultImageLabel = "Uploaded signature" defaultTextLabel = "Typed signature" saveButton = "Save signature" +savePersonal = "Save Personal" +saveShared = "Save Shared" saveUnavailable = "Create a signature first to save it." 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] canvas = "Drawing" diff --git a/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx b/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx index bdb6c07e5..c2e8ca8f1 100644 --- a/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx +++ b/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx @@ -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 { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core'; import { LocalIcon } from '@app/components/shared/LocalIcon'; import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures'; +import type { StorageType } from '@app/services/signatureStorageService'; interface SavedSignaturesSectionProps { signatures: SavedSignature[]; disabled?: boolean; isAtCapacity: boolean; + storageType?: StorageType | null; onUseSignature: (signature: SavedSignature) => void; onDeleteSignature: (signature: SavedSignature) => void; onRenameSignature: (id: string, label: string) => void; @@ -24,6 +26,7 @@ export const SavedSignaturesSection = ({ signatures, disabled = false, isAtCapacity, + storageType: _storageType, onUseSignature, onDeleteSignature, onRenameSignature, @@ -36,20 +39,30 @@ export const SavedSignaturesSection = ({ [t, translationScope] ); const [labelDrafts, setLabelDrafts] = useState>({}); - const [activeIndex, setActiveIndex] = useState(0); - const activeSignature = signatures[activeIndex]; - const activeSignatureRef = useRef(activeSignature ?? null); - const appliedSignatureIdRef = useRef(null); + + // Group signatures by scope + const groupedSignatures = useMemo(() => { + 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); useEffect(() => { onUseSignatureRef.current = onUseSignature; }, [onUseSignature]); - useEffect(() => { - activeSignatureRef.current = activeSignature ?? null; - }, [activeSignature]); - useEffect(() => { setLabelDrafts(prev => { const nextDrafts: Record = {}; @@ -60,25 +73,18 @@ export const SavedSignaturesSection = ({ }); }, [signatures]); + // Reset carousel indices when categories change useEffect(() => { - if (signatures.length === 0) { - setActiveIndex(0); - return; - } - setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0))); - }, [signatures.length]); + setActivePersonalIndex(prev => Math.min(prev, Math.max(groupedSignatures.personal.length - 1, 0))); + }, [groupedSignatures.personal.length]); - const handleNavigate = useCallback( - (direction: 'prev' | 'next') => { - setActiveIndex(prev => { - if (direction === 'prev') { - return Math.max(0, prev - 1); - } - return Math.min(signatures.length - 1, prev + 1); - }); - }, - [signatures.length] - ); + useEffect(() => { + setActiveSharedIndex(prev => Math.min(prev, Math.max(groupedSignatures.shared.length - 1, 0))); + }, [groupedSignatures.shared.length]); + + useEffect(() => { + setActiveLocalStorageIndex(prev => Math.min(prev, Math.max(groupedSignatures.localStorage.length - 1, 0))); + }, [groupedSignatures.localStorage.length]); const renderPreview = (signature: SavedSignature) => { 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 ( @@ -234,67 +225,253 @@ export const SavedSignaturesSection = ({ {signatures.length === 0 ? ( emptyState ) : ( - - - - {translate('saved.carouselPosition', '{{current}} of {{total}}', { - current: activeIndex + 1, - total: signatures.length, - })} - - - handleNavigate('prev')} - disabled={disabled || activeIndex === 0} - > - - - handleNavigate('next')} - disabled={disabled || activeIndex >= signatures.length - 1} - > - - - - + + {/* Personal Signatures */} + {groupedSignatures.personal.length > 0 && activePersonalSignature && ( + + + + + {translate('saved.personalHeading', 'Personal Signatures')} + + + + {translate('saved.personalDescription', 'Only you can see these signatures.')} + - {activeSignature && ( - - - - - {typeLabel(activeSignature.type)} - - - onDeleteSignature(activeSignature)} - disabled={disabled} - > - - - + + + {translate('saved.carouselPosition', '{{current}} of {{total}}', { + current: activePersonalIndex + 1, + total: groupedSignatures.personal.length, + })} + + + setActivePersonalIndex(prev => Math.max(0, prev - 1))} + disabled={disabled || activePersonalIndex === 0} + > + + + setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))} + disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 1} + > + + + - {renderPreview(activeSignature)} + + + + + {typeLabel(activePersonalSignature.type)} + + + onUseSignature(activePersonalSignature)} + disabled={disabled} + > + + + + onDeleteSignature(activePersonalSignature)} + disabled={disabled} + > + + + + + + {renderPreview(activePersonalSignature)} + handleLabelChange(event, activePersonalSignature)} + onBlur={() => handleLabelBlur(activePersonalSignature)} + onKeyDown={event => handleLabelKeyDown(event, activePersonalSignature)} + disabled={disabled} + /> + + + + )} - handleLabelChange(event, activeSignature)} - onBlur={() => handleLabelBlur(activeSignature)} - onKeyDown={event => handleLabelKeyDown(event, activeSignature)} - disabled={disabled} - /> + {/* Shared Signatures */} + {groupedSignatures.shared.length > 0 && activeSharedSignature && ( + + + + + {translate('saved.sharedHeading', 'Shared Signatures')} + + + + {translate('saved.sharedDescription', 'All users can see and use these signatures.')} + - - + + + {translate('saved.carouselPosition', '{{current}} of {{total}}', { + current: activeSharedIndex + 1, + total: groupedSignatures.shared.length, + })} + + + setActiveSharedIndex(prev => Math.max(0, prev - 1))} + disabled={disabled || activeSharedIndex === 0} + > + + + setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))} + disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 1} + > + + + + + + + + + + {typeLabel(activeSharedSignature.type)} + + + onUseSignature(activeSharedSignature)} + disabled={disabled} + > + + + + onDeleteSignature(activeSharedSignature)} + disabled={disabled} + > + + + + + + {renderPreview(activeSharedSignature)} + handleLabelChange(event, activeSharedSignature)} + onBlur={() => handleLabelBlur(activeSharedSignature)} + onKeyDown={event => handleLabelKeyDown(event, activeSharedSignature)} + disabled={disabled} + /> + + + + )} + + {/* Browser Storage (localStorage) - Temporary */} + {groupedSignatures.localStorage.length > 0 && activeLocalStorageSignature && ( + + + + {translate( + 'saved.tempStorageDescription', + 'Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers.' + )} + + + + + + {translate('saved.carouselPosition', '{{current}} of {{total}}', { + current: activeLocalStorageIndex + 1, + total: groupedSignatures.localStorage.length, + })} + + + setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))} + disabled={disabled || activeLocalStorageIndex === 0} + > + + + setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))} + disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 1} + > + + + + + + + + + + {typeLabel(activeLocalStorageSignature.type)} + + + onUseSignature(activeLocalStorageSignature)} + disabled={disabled} + > + + + + onDeleteSignature(activeLocalStorageSignature)} + disabled={disabled} + > + + + + + + {renderPreview(activeLocalStorageSignature)} + handleLabelChange(event, activeLocalStorageSignature)} + onBlur={() => handleLabelBlur(activeLocalStorageSignature)} + onKeyDown={event => handleLabelKeyDown(event, activeLocalStorageSignature)} + disabled={disabled} + /> + + + )} )} diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index a506eeffb..6fb3238f8 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -16,6 +16,7 @@ import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; import { LocalIcon } from "@app/components/shared/LocalIcon"; import { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, MAX_SAVED_SIGNATURES, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures'; import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection'; +import { buildSignaturePreview } from '@app/utils/signaturePreview'; type SignatureDrafts = { canvas?: string; @@ -99,6 +100,7 @@ const SignSettings = ({ removeSignature, updateSignatureLabel, byTypeCounts, + storageType, } = useSavedSignatures(); const [signatureSource, setSignatureSource] = useState(() => { const paramSource = parameters.signatureType as SignatureSource; @@ -157,11 +159,11 @@ const SignSettings = ({ }, [canvasSignatureData, imageSignatureData, buildTextSignatureKey, parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]); const saveSignatureToLibrary = useCallback( - (payload: SavedSignaturePayload, type: SavedSignatureType): AddSignatureResult => { + async (payload: SavedSignaturePayload, type: SavedSignatureType, scope: 'personal' | 'shared'): Promise => { if (isSavedSignatureLimitReached) { return { success: false, reason: 'limit' }; } - return addSignature(payload, getDefaultSavedLabel(type)); + return await addSignature(payload, getDefaultSavedLabel(type), scope); }, [addSignature, getDefaultSavedLabel, isSavedSignatureLimitReached] ); @@ -176,40 +178,57 @@ const SignSettings = ({ [signatureKeysByType] ); - const handleSaveCanvasSignature = useCallback(() => { + const handleSaveCanvasSignature = useCallback(async (scope: 'personal' | 'shared') => { if (!canvasSignatureData) { return; } - const result = saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas'); + const result = await saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas', scope); if (result.success) { setLastSavedKeyForType('canvas'); } }, [canvasSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]); - const handleSaveImageSignature = useCallback(() => { + const handleSaveImageSignature = useCallback(async (scope: 'personal' | 'shared') => { if (!imageSignatureData) { return; } - const result = saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image'); + const result = await saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image', scope); if (result.success) { setLastSavedKeyForType('image'); } }, [imageSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]); - const handleSaveTextSignature = useCallback(() => { + const handleSaveTextSignature = useCallback(async (scope: 'personal' | 'shared') => { const signerName = (parameters.signerName ?? '').trim(); if (!signerName) { 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', + dataUrl: preview.dataUrl, signerName, fontFamily: parameters.fontFamily ?? 'Helvetica', fontSize: parameters.fontSize ?? 16, textColor: parameters.textColor ?? '#000000', }, - 'text' + 'text', + scope ); if (result.success) { setLastSavedKeyForType('text'); @@ -286,16 +305,21 @@ const SignSettings = ({ [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) { return null; } - const label = translate('saved.saveButton', 'Save signature'); const currentKey = signatureKeysByType[type]; const lastSavedKey = lastSavedSignatureKeys[type]; const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey); 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; if (!isReady) { tooltipMessage = translate('saved.saveUnavailable', 'Create a signature first to save it.'); @@ -312,11 +336,11 @@ const SignSettings = ({ size="xs" variant="outline" color={isSaved ? 'green' : undefined} - onClick={onClick} + onClick={() => onClick(scope)} disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges} - leftSection={} + leftSection={} > - {isSaved ? translate('saved.status.saved', 'Saved') : label} + {label} ); @@ -331,15 +355,38 @@ const SignSettings = ({ return button; }; - const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => { - const button = renderSaveButton(type, isReady, onClick); - if (!button) { + const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void) => { + if (!canUseSavedLibrary) { 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 ( - - {button} - + + {personalButton} + {sharedButton} + ); }; @@ -744,6 +791,7 @@ const SignSettings = ({ signatures={savedSignatures} disabled={disabled} isAtCapacity={isSavedSignatureLimitReached} + storageType={storageType} onUseSignature={handleUseSavedSignature} onDeleteSignature={handleDeleteSavedSignature} onRenameSignature={handleRenameSavedSignature} diff --git a/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts index 665f40814..655038a3f 100644 --- a/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts +++ b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts @@ -1,9 +1,10 @@ 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 type SavedSignatureType = 'canvas' | 'image' | 'text'; +export type SignatureScope = 'personal' | 'shared' | 'localStorage'; export type SavedSignaturePayload = | { @@ -16,6 +17,7 @@ export type SavedSignaturePayload = } | { type: 'text'; + dataUrl: string; signerName: string; fontFamily: string; fontSize: number; @@ -25,6 +27,7 @@ export type SavedSignaturePayload = export type SavedSignature = SavedSignaturePayload & { id: string; label: string; + scope: SignatureScope; createdAt: number; updatedAt: number; }; @@ -35,68 +38,6 @@ export type AddSignatureResult = 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 = () => { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); @@ -105,29 +46,61 @@ const generateId = () => { }; export const useSavedSignatures = () => { - const [savedSignatures, setSavedSignatures] = useState(() => readFromStorage()); + const [savedSignatures, setSavedSignatures] = useState([]); + const [storageType, setStorageType] = useState(null); + const [isLoading, setIsLoading] = useState(true); + // Load signatures and detect storage type on mount 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; } const syncFromStorage = () => { - setSavedSignatures(readFromStorage()); + signatureStorageService.loadSignatures().then(setSavedSignatures); }; window.addEventListener('storage', syncFromStorage); return () => window.removeEventListener('storage', syncFromStorage); - }, []); - - useEffect(() => { - writeToStorage(savedSignatures); - }, [savedSignatures]); + }, [storageType]); const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES; const addSignature = useCallback( - (payload: SavedSignaturePayload, label?: string): AddSignatureResult => { + async (payload: SavedSignaturePayload, label?: string, scope?: SignatureScope): Promise => { if ( (payload.type === 'text' && !payload.signerName.trim()) || ((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl) @@ -135,62 +108,85 @@ export const useSavedSignatures = () => { return { success: false, reason: 'invalid' }; } - let createdSignature: SavedSignature | null = null; - setSavedSignatures(prev => { - if (prev.length >= MAX_SAVED_SIGNATURES) { - return prev; - } + if (savedSignatures.length >= MAX_SAVED_SIGNATURES) { + return { success: false, reason: 'limit' }; + } - const timestamp = Date.now(); - const nextEntry: SavedSignature = { - ...payload, - id: generateId(), - label: (label || 'Signature').trim() || 'Signature', - createdAt: timestamp, - updatedAt: timestamp, - }; - createdSignature = nextEntry; - return [nextEntry, ...prev]; - }); + const timestamp = Date.now(); + const newSignature: SavedSignature = { + ...payload, + id: generateId(), + label: (label || 'Signature').trim() || 'Signature', + scope: scope || (storageType === 'backend' ? 'personal' : 'localStorage'), + createdAt: timestamp, + updatedAt: timestamp, + }; - return createdSignature - ? { success: true, signature: createdSignature } - : { success: false, reason: 'limit' }; + try { + await signatureStorageService.saveSignature(newSignature); + 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) => { - setSavedSignatures(prev => prev.filter(entry => entry.id !== id)); + const removeSignature = useCallback(async (id: string) => { + 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) => { - setSavedSignatures(prev => - prev.map(entry => - entry.id === id - ? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() } - : entry - ) - ); + const updateSignatureLabel = useCallback(async (id: string, nextLabel: string) => { + try { + await signatureStorageService.updateSignatureLabel(id, nextLabel); + setSavedSignatures(prev => + prev.map(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) => { - setSavedSignatures(prev => - prev.map(entry => - entry.id === id - ? { - ...entry, - ...payload, - updatedAt: Date.now(), - } - : entry - ) - ); - }, []); + const replaceSignature = useCallback( + async (id: string, payload: SavedSignaturePayload) => { + const existing = savedSignatures.find(s => s.id === id); + if (!existing) return; - const clearSignatures = useCallback(() => { - setSavedSignatures([]); - }, []); + const updated: SavedSignature = { + ...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(() => { return savedSignatures.reduce>( @@ -211,6 +207,8 @@ export const useSavedSignatures = () => { replaceSignature, clearSignatures, byTypeCounts, + storageType, + isLoading, }; }; diff --git a/frontend/src/core/services/signatureStorageService.ts b/frontend/src/core/services/signatureStorageService.ts new file mode 100644 index 000000000..365a0cbc6 --- /dev/null +++ b/frontend/src/core/services/signatureStorageService.ts @@ -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 | null = null; + private blobUrls: Set = new Set(); + + /** + * Detect if backend supports signature storage API + */ + async detectCapabilities(): Promise { + // 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 { + 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 { + const capabilities = await this.detectCapabilities(); + return capabilities.storageType; + } + + /** + * Load all signatures + */ + async loadSignatures(): Promise { + // 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 { + 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 { + 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 { + 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 { + try { + const response = await apiClient.get('/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(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 { + await apiClient.post('/api/v1/proprietary/signatures', signature); + } + + private async _deleteFromBackend(id: string): Promise { + 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();