mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Bug/v2/signature fixes (#5104)
Co-authored-by: Dario Ghunney Ware <[email protected]> Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
Dario Ghunney Ware
James Brunton
parent
f3cc30d0c2
commit
f2f4bd5230
@@ -179,7 +179,7 @@ public class SharedSignatureService {
|
|||||||
StandardOpenOption.TRUNCATE_EXISTING);
|
StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
|
||||||
// Store reference to image file
|
// Store reference to image file
|
||||||
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
|
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Saved signature {} for user {}", request.getId(), username);
|
log.info("Saved signature {} for user {}", request.getId(), username);
|
||||||
@@ -207,7 +207,7 @@ public class SharedSignatureService {
|
|||||||
sig.setLabel(id); // Use ID as label
|
sig.setLabel(id); // Use ID as label
|
||||||
sig.setType("image"); // Default type
|
sig.setType("image"); // Default type
|
||||||
sig.setScope("personal");
|
sig.setScope("personal");
|
||||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||||
sig.setCreatedAt(
|
sig.setCreatedAt(
|
||||||
Files.getLastModifiedTime(path).toMillis());
|
Files.getLastModifiedTime(path).toMillis());
|
||||||
sig.setUpdatedAt(
|
sig.setUpdatedAt(
|
||||||
@@ -238,7 +238,7 @@ public class SharedSignatureService {
|
|||||||
sig.setLabel(id); // Use ID as label
|
sig.setLabel(id); // Use ID as label
|
||||||
sig.setType("image"); // Default type
|
sig.setType("image"); // Default type
|
||||||
sig.setScope("shared");
|
sig.setScope("shared");
|
||||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||||
sig.setCreatedAt(
|
sig.setCreatedAt(
|
||||||
Files.getLastModifiedTime(path).toMillis());
|
Files.getLastModifiedTime(path).toMillis());
|
||||||
sig.setUpdatedAt(
|
sig.setUpdatedAt(
|
||||||
|
|||||||
+96
-3
@@ -1,7 +1,12 @@
|
|||||||
package stirling.software.proprietary.controller.api;
|
package stirling.software.proprietary.controller.api;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -18,6 +23,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.common.annotations.api.UserApi;
|
import stirling.software.common.annotations.api.UserApi;
|
||||||
|
import stirling.software.common.configuration.InstallationPathConfig;
|
||||||
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
|
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
|
||||||
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||||
import stirling.software.proprietary.security.service.UserService;
|
import stirling.software.proprietary.security.service.UserService;
|
||||||
@@ -38,6 +44,7 @@ public class SignatureController {
|
|||||||
|
|
||||||
private final SignatureService signatureService;
|
private final SignatureService signatureService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private static final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a new signature for the authenticated user. Enforces storage limits and authentication
|
* Save a new signature for the authenticated user. Enforces storage limits and authentication
|
||||||
@@ -84,19 +91,105 @@ public class SignatureController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a signature owned by the authenticated user. Users can only delete their own personal
|
* Update a signature label. Users can update labels for their own personal signatures and for
|
||||||
* signatures, not shared ones.
|
* shared signatures.
|
||||||
|
*/
|
||||||
|
@PostMapping("/{signatureId}/label")
|
||||||
|
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||||
|
public ResponseEntity<Void> updateSignatureLabel(
|
||||||
|
@PathVariable String signatureId, @RequestBody Map<String, String> body) {
|
||||||
|
try {
|
||||||
|
String username = userService.getCurrentUsername();
|
||||||
|
String newLabel = body.get("label");
|
||||||
|
|
||||||
|
if (newLabel == null || newLabel.trim().isEmpty()) {
|
||||||
|
log.warn("Invalid label update request");
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
signatureService.updateSignatureLabel(username, signatureId, newLabel);
|
||||||
|
log.info("User {} updated label for signature {}", username, signatureId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to update signature label: {}", e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a signature owned by the authenticated user. Users can delete their own personal
|
||||||
|
* signatures. Admins can also delete shared signatures.
|
||||||
*/
|
*/
|
||||||
@DeleteMapping("/{signatureId}")
|
@DeleteMapping("/{signatureId}")
|
||||||
|
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||||
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
|
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
|
||||||
try {
|
try {
|
||||||
String username = userService.getCurrentUsername();
|
String username = userService.getCurrentUsername();
|
||||||
|
boolean isAdmin = userService.isCurrentUserAdmin();
|
||||||
|
|
||||||
|
// Validate filename to prevent path traversal
|
||||||
|
if (signatureId.contains("..")
|
||||||
|
|| signatureId.contains("/")
|
||||||
|
|| signatureId.contains("\\")) {
|
||||||
|
log.warn("Invalid signature ID: {}", signatureId);
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to delete from personal folder first
|
||||||
|
try {
|
||||||
signatureService.deleteSignature(username, signatureId);
|
signatureService.deleteSignature(username, signatureId);
|
||||||
log.info("User {} deleted signature {}", username, signatureId);
|
log.info("User {} deleted personal signature {}", username, signatureId);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
|
} catch (IOException e) {
|
||||||
|
// If not found in personal folder, check if it's in shared folder
|
||||||
|
if (isAdmin) {
|
||||||
|
// Admin can delete from shared folder
|
||||||
|
if (deleteFromSharedFolder(signatureId)) {
|
||||||
|
log.info("Admin {} deleted shared signature {}", username, signatureId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not admin or not found in shared folder either, return 404
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
|
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a signature from the shared (ALL_USERS) folder. Only admins should call this method.
|
||||||
|
*/
|
||||||
|
private boolean deleteFromSharedFolder(String signatureId) throws IOException {
|
||||||
|
String signatureBasePath = InstallationPathConfig.getSignaturesPath();
|
||||||
|
Path sharedFolder = Paths.get(signatureBasePath, ALL_USERS_FOLDER);
|
||||||
|
boolean deleted = false;
|
||||||
|
|
||||||
|
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;
|
||||||
|
log.info("Deleted shared signature file: {}", file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also delete metadata file if it exists
|
||||||
|
Path metadataPath = sharedFolder.resolve(signatureId + ".json");
|
||||||
|
if (Files.exists(metadataPath)) {
|
||||||
|
Files.delete(metadataPath);
|
||||||
|
log.info("Deleted shared signature metadata: {}", metadataPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+96
-5
@@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
|
|||||||
|
|
||||||
import java.io.FileNotFoundException;
|
import java.io.FileNotFoundException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
@@ -13,6 +14,8 @@ import java.util.stream.Stream;
|
|||||||
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.common.configuration.InstallationPathConfig;
|
import stirling.software.common.configuration.InstallationPathConfig;
|
||||||
@@ -31,6 +34,7 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
|||||||
|
|
||||||
private final String SIGNATURE_BASE_PATH;
|
private final String SIGNATURE_BASE_PATH;
|
||||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
// Storage limits per user
|
// Storage limits per user
|
||||||
private static final int MAX_SIGNATURES_PER_USER = 20;
|
private static final int MAX_SIGNATURES_PER_USER = 20;
|
||||||
@@ -88,6 +92,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
|||||||
response.setCreatedAt(timestamp);
|
response.setCreatedAt(timestamp);
|
||||||
response.setUpdatedAt(timestamp);
|
response.setUpdatedAt(timestamp);
|
||||||
|
|
||||||
|
// Copy text signature properties if present
|
||||||
|
if ("text".equals(request.getType())) {
|
||||||
|
response.setSignerName(request.getSignerName());
|
||||||
|
response.setFontFamily(request.getFontFamily());
|
||||||
|
response.setFontSize(request.getFontSize());
|
||||||
|
response.setTextColor(request.getTextColor());
|
||||||
|
}
|
||||||
|
|
||||||
// Extract and save image data
|
// Extract and save image data
|
||||||
String dataUrl = request.getDataUrl();
|
String dataUrl = request.getDataUrl();
|
||||||
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
|
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
|
||||||
@@ -133,6 +145,19 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
|||||||
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
|
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save metadata JSON file
|
||||||
|
String metadataFileName = request.getId() + ".json";
|
||||||
|
Path metadataPath = targetFolder.resolve(metadataFileName);
|
||||||
|
verifyPathWithinDirectory(metadataPath, targetFolder);
|
||||||
|
|
||||||
|
String metadataJson = objectMapper.writeValueAsString(response);
|
||||||
|
Files.writeString(
|
||||||
|
metadataPath,
|
||||||
|
metadataJson,
|
||||||
|
StandardCharsets.UTF_8,
|
||||||
|
StandardOpenOption.CREATE,
|
||||||
|
StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
|
||||||
log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope);
|
log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
@@ -179,6 +204,13 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
|||||||
log.info("Deleted signature file: {}", file);
|
log.info("Deleted signature file: {}", file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also delete metadata file if it exists
|
||||||
|
Path metadataPath = personalFolder.resolve(signatureId + ".json");
|
||||||
|
if (Files.exists(metadataPath)) {
|
||||||
|
Files.delete(metadataPath);
|
||||||
|
log.info("Deleted signature metadata: {}", metadataPath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!deleted) {
|
if (!deleted) {
|
||||||
@@ -186,6 +218,50 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Update a signature label. */
|
||||||
|
public void updateSignatureLabel(String username, String signatureId, String newLabel)
|
||||||
|
throws IOException {
|
||||||
|
validateFileName(signatureId);
|
||||||
|
|
||||||
|
// Try personal folder first
|
||||||
|
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
|
||||||
|
Path metadataPath = personalFolder.resolve(signatureId + ".json");
|
||||||
|
|
||||||
|
if (Files.exists(metadataPath)) {
|
||||||
|
updateMetadataLabel(metadataPath, newLabel);
|
||||||
|
log.info("Updated label for personal signature {} (user: {})", signatureId, username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found in personal, try shared folder
|
||||||
|
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
|
||||||
|
Path sharedMetadataPath = sharedFolder.resolve(signatureId + ".json");
|
||||||
|
|
||||||
|
if (Files.exists(sharedMetadataPath)) {
|
||||||
|
updateMetadataLabel(sharedMetadataPath, newLabel);
|
||||||
|
log.info("Updated label for shared signature {}", signatureId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException("Signature metadata not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateMetadataLabel(Path metadataPath, String newLabel) throws IOException {
|
||||||
|
String metadataJson = Files.readString(metadataPath, StandardCharsets.UTF_8);
|
||||||
|
SavedSignatureResponse sig =
|
||||||
|
objectMapper.readValue(metadataJson, SavedSignatureResponse.class);
|
||||||
|
sig.setLabel(newLabel);
|
||||||
|
sig.setUpdatedAt(System.currentTimeMillis());
|
||||||
|
|
||||||
|
String updatedJson = objectMapper.writeValueAsString(sig);
|
||||||
|
Files.writeString(
|
||||||
|
metadataPath,
|
||||||
|
updatedJson,
|
||||||
|
StandardCharsets.UTF_8,
|
||||||
|
StandardOpenOption.CREATE,
|
||||||
|
StandardOpenOption.TRUNCATE_EXISTING);
|
||||||
|
}
|
||||||
|
|
||||||
// Private helper methods
|
// Private helper methods
|
||||||
|
|
||||||
private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
|
private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
|
||||||
@@ -245,16 +321,31 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
|||||||
String fileName = path.getFileName().toString();
|
String fileName = path.getFileName().toString();
|
||||||
String id = fileName.substring(0, fileName.lastIndexOf('.'));
|
String id = fileName.substring(0, fileName.lastIndexOf('.'));
|
||||||
|
|
||||||
SavedSignatureResponse sig = new SavedSignatureResponse();
|
// Try to load metadata from JSON file
|
||||||
|
Path metadataPath = folder.resolve(id + ".json");
|
||||||
|
SavedSignatureResponse sig;
|
||||||
|
|
||||||
|
if (Files.exists(metadataPath)) {
|
||||||
|
// Load from metadata file
|
||||||
|
String metadataJson =
|
||||||
|
Files.readString(
|
||||||
|
metadataPath, StandardCharsets.UTF_8);
|
||||||
|
sig =
|
||||||
|
objectMapper.readValue(
|
||||||
|
metadataJson, SavedSignatureResponse.class);
|
||||||
|
} else {
|
||||||
|
// Fallback for old signatures without metadata
|
||||||
|
sig = new SavedSignatureResponse();
|
||||||
sig.setId(id);
|
sig.setId(id);
|
||||||
sig.setLabel(id);
|
sig.setLabel(id);
|
||||||
sig.setType("image");
|
sig.setType("image");
|
||||||
sig.setScope(scope);
|
sig.setScope(scope);
|
||||||
sig.setCreatedAt(Files.getLastModifiedTime(path).toMillis());
|
sig.setCreatedAt(
|
||||||
sig.setUpdatedAt(Files.getLastModifiedTime(path).toMillis());
|
Files.getLastModifiedTime(path).toMillis());
|
||||||
|
sig.setUpdatedAt(
|
||||||
// Set unified URL path (works for both personal and shared)
|
Files.getLastModifiedTime(path).toMillis());
|
||||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||||
|
}
|
||||||
|
|
||||||
signatures.add(sig);
|
signatures.add(sig);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ http {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets (but not API endpoints)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^(?!/api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ http {
|
|||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets (but not API endpoints)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^(?!/api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,16 @@ 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 { SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||||
import type { StorageType } from '@app/services/signatureStorageService';
|
import type { StorageType } from '@app/services/signatureStorageService';
|
||||||
|
|
||||||
interface SavedSignaturesSectionProps {
|
interface SavedSignaturesSectionProps {
|
||||||
signatures: SavedSignature[];
|
signatures: SavedSignature[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isAtCapacity: boolean;
|
isAtCapacity: boolean;
|
||||||
|
maxLimit: number;
|
||||||
storageType?: StorageType | null;
|
storageType?: StorageType | null;
|
||||||
|
isAdmin?: boolean;
|
||||||
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;
|
||||||
@@ -26,7 +28,9 @@ export const SavedSignaturesSection = ({
|
|||||||
signatures,
|
signatures,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
isAtCapacity,
|
isAtCapacity,
|
||||||
|
maxLimit,
|
||||||
storageType: _storageType,
|
storageType: _storageType,
|
||||||
|
isAdmin = false,
|
||||||
onUseSignature,
|
onUseSignature,
|
||||||
onDeleteSignature,
|
onDeleteSignature,
|
||||||
onRenameSignature,
|
onRenameSignature,
|
||||||
@@ -155,7 +159,7 @@ export const SavedSignaturesSection = ({
|
|||||||
{translate(
|
{translate(
|
||||||
'saved.emptyDescription',
|
'saved.emptyDescription',
|
||||||
'Draw, upload, or type a signature above, then use "Save to library" to keep up to {{max}} favourites ready to use.',
|
'Draw, upload, or type a signature above, then use "Save to library" to keep up to {{max}} favourites ready to use.',
|
||||||
{ max: MAX_SAVED_SIGNATURES }
|
{ max: maxLimit }
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -216,7 +220,7 @@ export const SavedSignaturesSection = ({
|
|||||||
<Alert color="yellow" title={translate('saved.limitTitle', 'Limit reached')}>
|
<Alert color="yellow" title={translate('saved.limitTitle', 'Limit reached')}>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
{translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
||||||
max: MAX_SAVED_SIGNATURES,
|
max: maxLimit,
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -365,6 +369,7 @@ export const SavedSignaturesSection = ({
|
|||||||
>
|
>
|
||||||
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
|
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
|
{isAdmin && (
|
||||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -376,6 +381,7 @@ export const SavedSignaturesSection = ({
|
|||||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
{renderPreview(activeSharedSignature)}
|
{renderPreview(activeSharedSignature)}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
|||||||
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
|
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
|
||||||
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
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, 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';
|
import { buildSignaturePreview } from '@app/utils/signaturePreview';
|
||||||
|
|
||||||
@@ -96,11 +96,13 @@ const SignSettings = ({
|
|||||||
const {
|
const {
|
||||||
savedSignatures,
|
savedSignatures,
|
||||||
isAtCapacity: isSavedSignatureLimitReached,
|
isAtCapacity: isSavedSignatureLimitReached,
|
||||||
|
maxLimit,
|
||||||
addSignature,
|
addSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
updateSignatureLabel,
|
updateSignatureLabel,
|
||||||
byTypeCounts,
|
byTypeCounts,
|
||||||
storageType,
|
storageType,
|
||||||
|
isAdmin,
|
||||||
} = useSavedSignatures();
|
} = useSavedSignatures();
|
||||||
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
|
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
|
||||||
const paramSource = parameters.signatureType as SignatureSource;
|
const paramSource = parameters.signatureType as SignatureSource;
|
||||||
@@ -246,16 +248,19 @@ const SignSettings = ({
|
|||||||
(signature: SavedSignature) => {
|
(signature: SavedSignature) => {
|
||||||
setPlacementManuallyPaused(false);
|
setPlacementManuallyPaused(false);
|
||||||
|
|
||||||
|
// Use the data URL directly (already converted to base64 when loaded)
|
||||||
|
const dataUrlToUse = signature.dataUrl;
|
||||||
|
|
||||||
if (signature.type === 'canvas') {
|
if (signature.type === 'canvas') {
|
||||||
if (parameters.signatureType !== 'canvas') {
|
if (parameters.signatureType !== 'canvas') {
|
||||||
onParameterChange('signatureType', 'canvas');
|
onParameterChange('signatureType', 'canvas');
|
||||||
}
|
}
|
||||||
setCanvasSignatureData(signature.dataUrl);
|
setCanvasSignatureData(dataUrlToUse);
|
||||||
} else if (signature.type === 'image') {
|
} else if (signature.type === 'image') {
|
||||||
if (parameters.signatureType !== 'image') {
|
if (parameters.signatureType !== 'image') {
|
||||||
onParameterChange('signatureType', 'image');
|
onParameterChange('signatureType', 'image');
|
||||||
}
|
}
|
||||||
setImageSignatureData(signature.dataUrl);
|
setImageSignatureData(dataUrlToUse);
|
||||||
} else if (signature.type === 'text') {
|
} else if (signature.type === 'text') {
|
||||||
if (parameters.signatureType !== 'text') {
|
if (parameters.signatureType !== 'text') {
|
||||||
onParameterChange('signatureType', 'text');
|
onParameterChange('signatureType', 'text');
|
||||||
@@ -269,7 +274,7 @@ const SignSettings = ({
|
|||||||
const savedKey =
|
const savedKey =
|
||||||
signature.type === 'text'
|
signature.type === 'text'
|
||||||
? buildTextSignatureKey(signature.signerName, signature.fontSize, signature.fontFamily, signature.textColor)
|
? buildTextSignatureKey(signature.signerName, signature.fontSize, signature.fontFamily, signature.textColor)
|
||||||
: signature.dataUrl;
|
: dataUrlToUse;
|
||||||
setLastSavedKeyForType(signature.type, savedKey);
|
setLastSavedKeyForType(signature.type, savedKey);
|
||||||
|
|
||||||
const activate = () => onActivateSignaturePlacement?.();
|
const activate = () => onActivateSignaturePlacement?.();
|
||||||
@@ -326,8 +331,8 @@ const SignSettings = ({
|
|||||||
} else if (isSaved) {
|
} else if (isSaved) {
|
||||||
tooltipMessage = translate('saved.noChanges', 'Current signature is already saved.');
|
tooltipMessage = translate('saved.noChanges', 'Current signature is already saved.');
|
||||||
} else if (isSavedSignatureLimitReached) {
|
} else if (isSavedSignatureLimitReached) {
|
||||||
tooltipMessage = translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
tooltipMessage = translate('saved.limitDescription', 'You have reached the maximum limit of {{max}} saved signatures. Remove a saved signature before adding new ones.', {
|
||||||
max: MAX_SAVED_SIGNATURES,
|
max: maxLimit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,7 +796,9 @@ const SignSettings = ({
|
|||||||
signatures={savedSignatures}
|
signatures={savedSignatures}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
isAtCapacity={isSavedSignatureLimitReached}
|
isAtCapacity={isSavedSignatureLimitReached}
|
||||||
|
maxLimit={maxLimit}
|
||||||
storageType={storageType}
|
storageType={storageType}
|
||||||
|
isAdmin={isAdmin}
|
||||||
onUseSignature={handleUseSavedSignature}
|
onUseSignature={handleUseSavedSignature}
|
||||||
onDeleteSignature={handleDeleteSavedSignature}
|
onDeleteSignature={handleDeleteSavedSignature}
|
||||||
onRenameSignature={handleRenameSavedSignature}
|
onRenameSignature={handleRenameSavedSignature}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { signatureStorageService, type StorageType } from '@app/services/signatureStorageService';
|
import { signatureStorageService, type StorageType } from '@app/services/signatureStorageService';
|
||||||
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
|
||||||
export const MAX_SAVED_SIGNATURES = 10;
|
export const MAX_SAVED_SIGNATURES_BACKEND = 20; // Backend limit per user
|
||||||
|
export const MAX_SAVED_SIGNATURES_LOCALSTORAGE = 10; // LocalStorage limit
|
||||||
|
|
||||||
export type SavedSignatureType = 'canvas' | 'image' | 'text';
|
export type SavedSignatureType = 'canvas' | 'image' | 'text';
|
||||||
export type SignatureScope = 'personal' | 'shared' | 'localStorage';
|
export type SignatureScope = 'personal' | 'shared' | 'localStorage';
|
||||||
@@ -49,6 +51,8 @@ export const useSavedSignatures = () => {
|
|||||||
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
|
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
|
||||||
const [storageType, setStorageType] = useState<StorageType | null>(null);
|
const [storageType, setStorageType] = useState<StorageType | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const isAdmin = config?.isAdmin ?? false;
|
||||||
|
|
||||||
// Load signatures and detect storage type on mount
|
// Load signatures and detect storage type on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -97,7 +101,9 @@ export const useSavedSignatures = () => {
|
|||||||
return () => window.removeEventListener('storage', syncFromStorage);
|
return () => window.removeEventListener('storage', syncFromStorage);
|
||||||
}, [storageType]);
|
}, [storageType]);
|
||||||
|
|
||||||
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
|
// Different limits for backend vs localStorage
|
||||||
|
const maxLimit = storageType === 'backend' ? MAX_SAVED_SIGNATURES_BACKEND : MAX_SAVED_SIGNATURES_LOCALSTORAGE;
|
||||||
|
const isAtCapacity = savedSignatures.length >= maxLimit;
|
||||||
|
|
||||||
const addSignature = useCallback(
|
const addSignature = useCallback(
|
||||||
async (payload: SavedSignaturePayload, label?: string, scope?: SignatureScope): Promise<AddSignatureResult> => {
|
async (payload: SavedSignaturePayload, label?: string, scope?: SignatureScope): Promise<AddSignatureResult> => {
|
||||||
@@ -108,7 +114,7 @@ export const useSavedSignatures = () => {
|
|||||||
return { success: false, reason: 'invalid' };
|
return { success: false, reason: 'invalid' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (savedSignatures.length >= MAX_SAVED_SIGNATURES) {
|
if (isAtCapacity) {
|
||||||
return { success: false, reason: 'limit' };
|
return { success: false, reason: 'limit' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,6 +152,12 @@ export const useSavedSignatures = () => {
|
|||||||
const updateSignatureLabel = useCallback(async (id: string, nextLabel: string) => {
|
const updateSignatureLabel = useCallback(async (id: string, nextLabel: string) => {
|
||||||
try {
|
try {
|
||||||
await signatureStorageService.updateSignatureLabel(id, nextLabel);
|
await signatureStorageService.updateSignatureLabel(id, nextLabel);
|
||||||
|
// Reload signatures to get updated data from backend
|
||||||
|
if (storageType === 'backend') {
|
||||||
|
const signatures = await signatureStorageService.loadSignatures();
|
||||||
|
setSavedSignatures(signatures);
|
||||||
|
} else {
|
||||||
|
// For localStorage, update in place
|
||||||
setSavedSignatures(prev =>
|
setSavedSignatures(prev =>
|
||||||
prev.map(entry =>
|
prev.map(entry =>
|
||||||
entry.id === id
|
entry.id === id
|
||||||
@@ -153,10 +165,11 @@ export const useSavedSignatures = () => {
|
|||||||
: entry
|
: entry
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useSavedSignatures] Failed to update signature label:', error);
|
console.error('[useSavedSignatures] Failed to update signature label:', error);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [storageType]);
|
||||||
|
|
||||||
const replaceSignature = useCallback(
|
const replaceSignature = useCallback(
|
||||||
async (id: string, payload: SavedSignaturePayload) => {
|
async (id: string, payload: SavedSignaturePayload) => {
|
||||||
@@ -201,6 +214,7 @@ export const useSavedSignatures = () => {
|
|||||||
return {
|
return {
|
||||||
savedSignatures,
|
savedSignatures,
|
||||||
isAtCapacity,
|
isAtCapacity,
|
||||||
|
maxLimit,
|
||||||
addSignature,
|
addSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
updateSignatureLabel,
|
updateSignatureLabel,
|
||||||
@@ -209,6 +223,7 @@ export const useSavedSignatures = () => {
|
|||||||
byTypeCounts,
|
byTypeCounts,
|
||||||
storageType,
|
storageType,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isAdmin,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ interface SignatureStorageCapabilities {
|
|||||||
class SignatureStorageService {
|
class SignatureStorageService {
|
||||||
private capabilities: SignatureStorageCapabilities | null = null;
|
private capabilities: SignatureStorageCapabilities | null = null;
|
||||||
private detectionPromise: Promise<SignatureStorageCapabilities> | null = null;
|
private detectionPromise: Promise<SignatureStorageCapabilities> | null = null;
|
||||||
private blobUrls: Set<string> = new Set();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect if backend supports signature storage API
|
* Detect if backend supports signature storage API
|
||||||
@@ -83,9 +82,6 @@ class SignatureStorageService {
|
|||||||
* Load all signatures
|
* Load all signatures
|
||||||
*/
|
*/
|
||||||
async loadSignatures(): Promise<SavedSignature[]> {
|
async loadSignatures(): Promise<SavedSignature[]> {
|
||||||
// Clean up old blob URLs before loading new ones
|
|
||||||
this.cleanup();
|
|
||||||
|
|
||||||
const capabilities = await this.detectCapabilities();
|
const capabilities = await this.detectCapabilities();
|
||||||
|
|
||||||
if (capabilities.supportsBackend) {
|
if (capabilities.supportsBackend) {
|
||||||
@@ -130,9 +126,7 @@ class SignatureStorageService {
|
|||||||
const capabilities = await this.detectCapabilities();
|
const capabilities = await this.detectCapabilities();
|
||||||
|
|
||||||
if (capabilities.supportsBackend) {
|
if (capabilities.supportsBackend) {
|
||||||
// Backend only stores images - labels not supported for backend signatures
|
await this._updateLabelInBackend(id, label);
|
||||||
console.log('[SignatureStorage] Label updates not supported for backend signatures');
|
|
||||||
return;
|
|
||||||
} else {
|
} else {
|
||||||
this._updateLabelInLocalStorage(id, label);
|
this._updateLabelInLocalStorage(id, label);
|
||||||
}
|
}
|
||||||
@@ -144,7 +138,7 @@ class SignatureStorageService {
|
|||||||
const response = await apiClient.get<SavedSignature[]>('/api/v1/proprietary/signatures');
|
const response = await apiClient.get<SavedSignature[]>('/api/v1/proprietary/signatures');
|
||||||
const signatures = response.data;
|
const signatures = response.data;
|
||||||
|
|
||||||
// Fetch image data for each signature and convert to blob URLs
|
// Fetch image data for each signature and convert to data URLs
|
||||||
const signaturePromises = signatures.map(async (sig) => {
|
const signaturePromises = signatures.map(async (sig) => {
|
||||||
if (sig.dataUrl && sig.dataUrl.startsWith('/api/v1/general/signatures/')) {
|
if (sig.dataUrl && sig.dataUrl.startsWith('/api/v1/general/signatures/')) {
|
||||||
try {
|
try {
|
||||||
@@ -153,14 +147,20 @@ class SignatureStorageService {
|
|||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert to blob URL
|
// Convert to data URL (base64) for both display and use
|
||||||
const blob = new Blob([imageResponse.data], {
|
const blob = new Blob([imageResponse.data], {
|
||||||
type: imageResponse.headers['content-type'] || 'image/png',
|
type: imageResponse.headers['content-type'] || 'image/png',
|
||||||
});
|
});
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
this.blobUrls.add(blobUrl);
|
|
||||||
|
|
||||||
return { ...sig, dataUrl: blobUrl };
|
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use data URL for everything - more reliable than blob URLs
|
||||||
|
return { ...sig, dataUrl };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SignatureStorage] Failed to load image for ${sig.id}:`, error);
|
console.error(`[SignatureStorage] Failed to load image for ${sig.id}:`, error);
|
||||||
return sig; // Return original if image fetch fails
|
return sig; // Return original if image fetch fails
|
||||||
@@ -184,6 +184,10 @@ class SignatureStorageService {
|
|||||||
await apiClient.delete(`/api/v1/proprietary/signatures/${id}`);
|
await apiClient.delete(`/api/v1/proprietary/signatures/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _updateLabelInBackend(id: string, label: string): Promise<void> {
|
||||||
|
await apiClient.post(`/api/v1/proprietary/signatures/${id}/label`, { label });
|
||||||
|
}
|
||||||
|
|
||||||
// LocalStorage methods
|
// LocalStorage methods
|
||||||
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
|
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
|
||||||
|
|
||||||
@@ -267,16 +271,6 @@ class SignatureStorageService {
|
|||||||
|
|
||||||
return { migrated, failed };
|
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();
|
export const signatureStorageService = new SignatureStorageService();
|
||||||
|
|||||||
Reference in New Issue
Block a user