mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-01 20:10:35 +01:00
Bug/v2/signature fixes (#5104)
Co-authored-by: Dario Ghunney Ware <dariogware@gmail.com> Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
parent
f3cc30d0c2
commit
f2f4bd5230
@ -179,7 +179,7 @@ public class SharedSignatureService {
|
||||
StandardOpenOption.TRUNCATE_EXISTING);
|
||||
|
||||
// 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);
|
||||
@ -207,7 +207,7 @@ public class SharedSignatureService {
|
||||
sig.setLabel(id); // Use ID as label
|
||||
sig.setType("image"); // Default type
|
||||
sig.setScope("personal");
|
||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
@ -238,7 +238,7 @@ public class SharedSignatureService {
|
||||
sig.setLabel(id); // Use ID as label
|
||||
sig.setType("image"); // Default type
|
||||
sig.setScope("shared");
|
||||
sig.setDataUrl("/api/v1/general/sign/" + fileName);
|
||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
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.Map;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@ -18,6 +23,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
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.SavedSignatureResponse;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@ -38,6 +44,7 @@ public class SignatureController {
|
||||
|
||||
private final SignatureService signatureService;
|
||||
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
|
||||
@ -84,19 +91,105 @@ public class SignatureController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a signature owned by the authenticated user. Users can only delete their own personal
|
||||
* signatures, not shared ones.
|
||||
* Update a signature label. Users can update labels for their own personal signatures and for
|
||||
* 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}")
|
||||
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
|
||||
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
|
||||
try {
|
||||
String username = userService.getCurrentUsername();
|
||||
signatureService.deleteSignature(username, signatureId);
|
||||
log.info("User {} deleted signature {}", username, signatureId);
|
||||
return ResponseEntity.noContent().build();
|
||||
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);
|
||||
log.info("User {} deleted personal signature {}", username, signatureId);
|
||||
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) {
|
||||
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@ -13,6 +14,8 @@ import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
@ -31,6 +34,7 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// Storage limits per user
|
||||
private static final int MAX_SIGNATURES_PER_USER = 20;
|
||||
@ -88,6 +92,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
response.setCreatedAt(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
|
||||
String dataUrl = request.getDataUrl();
|
||||
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
|
||||
@ -133,6 +145,19 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
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);
|
||||
return response;
|
||||
}
|
||||
@ -179,6 +204,13 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
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) {
|
||||
@ -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 void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
|
||||
@ -245,16 +321,31 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
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());
|
||||
// Try to load metadata from JSON file
|
||||
Path metadataPath = folder.resolve(id + ".json");
|
||||
SavedSignatureResponse sig;
|
||||
|
||||
// Set unified URL path (works for both personal and shared)
|
||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||
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.setLabel(id);
|
||||
sig.setType("image");
|
||||
sig.setScope(scope);
|
||||
sig.setCreatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setUpdatedAt(
|
||||
Files.getLastModifiedTime(path).toMillis());
|
||||
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
|
||||
}
|
||||
|
||||
signatures.add(sig);
|
||||
} catch (IOException e) {
|
||||
|
||||
@ -103,8 +103,8 @@ http {
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Cache static assets (but not API endpoints)
|
||||
location ~* ^(?!/api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
@ -106,8 +106,8 @@ http {
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Cache static assets (but not API endpoints)
|
||||
location ~* ^(?!/api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
@ -2,14 +2,16 @@ 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 { SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||
import type { StorageType } from '@app/services/signatureStorageService';
|
||||
|
||||
interface SavedSignaturesSectionProps {
|
||||
signatures: SavedSignature[];
|
||||
disabled?: boolean;
|
||||
isAtCapacity: boolean;
|
||||
maxLimit: number;
|
||||
storageType?: StorageType | null;
|
||||
isAdmin?: boolean;
|
||||
onUseSignature: (signature: SavedSignature) => void;
|
||||
onDeleteSignature: (signature: SavedSignature) => void;
|
||||
onRenameSignature: (id: string, label: string) => void;
|
||||
@ -26,7 +28,9 @@ export const SavedSignaturesSection = ({
|
||||
signatures,
|
||||
disabled = false,
|
||||
isAtCapacity,
|
||||
maxLimit,
|
||||
storageType: _storageType,
|
||||
isAdmin = false,
|
||||
onUseSignature,
|
||||
onDeleteSignature,
|
||||
onRenameSignature,
|
||||
@ -155,7 +159,7 @@ export const SavedSignaturesSection = ({
|
||||
{translate(
|
||||
'saved.emptyDescription',
|
||||
'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>
|
||||
</Stack>
|
||||
@ -216,7 +220,7 @@ export const SavedSignaturesSection = ({
|
||||
<Alert color="yellow" title={translate('saved.limitTitle', 'Limit reached')}>
|
||||
<Text size="sm">
|
||||
{translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
||||
max: MAX_SAVED_SIGNATURES,
|
||||
max: maxLimit,
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
@ -365,17 +369,19 @@ export const SavedSignaturesSection = ({
|
||||
>
|
||||
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={translate('saved.delete', 'Remove')}
|
||||
onClick={() => onDeleteSignature(activeSharedSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{isAdmin && (
|
||||
<Tooltip label={translate('saved.delete', 'Remove')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={translate('saved.delete', 'Remove')}
|
||||
onClick={() => onDeleteSignature(activeSharedSignature)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
{renderPreview(activeSharedSignature)}
|
||||
|
||||
@ -14,7 +14,7 @@ import { ImageUploader } from "@app/components/annotation/shared/ImageUploader";
|
||||
import { TextInputWithFont } from "@app/components/annotation/shared/TextInputWithFont";
|
||||
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 { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures';
|
||||
import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection';
|
||||
import { buildSignaturePreview } from '@app/utils/signaturePreview';
|
||||
|
||||
@ -96,11 +96,13 @@ const SignSettings = ({
|
||||
const {
|
||||
savedSignatures,
|
||||
isAtCapacity: isSavedSignatureLimitReached,
|
||||
maxLimit,
|
||||
addSignature,
|
||||
removeSignature,
|
||||
updateSignatureLabel,
|
||||
byTypeCounts,
|
||||
storageType,
|
||||
isAdmin,
|
||||
} = useSavedSignatures();
|
||||
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
|
||||
const paramSource = parameters.signatureType as SignatureSource;
|
||||
@ -246,16 +248,19 @@ const SignSettings = ({
|
||||
(signature: SavedSignature) => {
|
||||
setPlacementManuallyPaused(false);
|
||||
|
||||
// Use the data URL directly (already converted to base64 when loaded)
|
||||
const dataUrlToUse = signature.dataUrl;
|
||||
|
||||
if (signature.type === 'canvas') {
|
||||
if (parameters.signatureType !== 'canvas') {
|
||||
onParameterChange('signatureType', 'canvas');
|
||||
}
|
||||
setCanvasSignatureData(signature.dataUrl);
|
||||
setCanvasSignatureData(dataUrlToUse);
|
||||
} else if (signature.type === 'image') {
|
||||
if (parameters.signatureType !== 'image') {
|
||||
onParameterChange('signatureType', 'image');
|
||||
}
|
||||
setImageSignatureData(signature.dataUrl);
|
||||
setImageSignatureData(dataUrlToUse);
|
||||
} else if (signature.type === 'text') {
|
||||
if (parameters.signatureType !== 'text') {
|
||||
onParameterChange('signatureType', 'text');
|
||||
@ -269,7 +274,7 @@ const SignSettings = ({
|
||||
const savedKey =
|
||||
signature.type === 'text'
|
||||
? buildTextSignatureKey(signature.signerName, signature.fontSize, signature.fontFamily, signature.textColor)
|
||||
: signature.dataUrl;
|
||||
: dataUrlToUse;
|
||||
setLastSavedKeyForType(signature.type, savedKey);
|
||||
|
||||
const activate = () => onActivateSignaturePlacement?.();
|
||||
@ -326,8 +331,8 @@ const SignSettings = ({
|
||||
} else if (isSaved) {
|
||||
tooltipMessage = translate('saved.noChanges', 'Current signature is already saved.');
|
||||
} else if (isSavedSignatureLimitReached) {
|
||||
tooltipMessage = translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
|
||||
max: MAX_SAVED_SIGNATURES,
|
||||
tooltipMessage = translate('saved.limitDescription', 'You have reached the maximum limit of {{max}} saved signatures. Remove a saved signature before adding new ones.', {
|
||||
max: maxLimit,
|
||||
});
|
||||
}
|
||||
|
||||
@ -791,7 +796,9 @@ const SignSettings = ({
|
||||
signatures={savedSignatures}
|
||||
disabled={disabled}
|
||||
isAtCapacity={isSavedSignatureLimitReached}
|
||||
maxLimit={maxLimit}
|
||||
storageType={storageType}
|
||||
isAdmin={isAdmin}
|
||||
onUseSignature={handleUseSavedSignature}
|
||||
onDeleteSignature={handleDeleteSavedSignature}
|
||||
onRenameSignature={handleRenameSavedSignature}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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 SignatureScope = 'personal' | 'shared' | 'localStorage';
|
||||
@ -49,6 +51,8 @@ export const useSavedSignatures = () => {
|
||||
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
|
||||
const [storageType, setStorageType] = useState<StorageType | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { config } = useAppConfig();
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
|
||||
// Load signatures and detect storage type on mount
|
||||
useEffect(() => {
|
||||
@ -97,7 +101,9 @@ export const useSavedSignatures = () => {
|
||||
return () => window.removeEventListener('storage', syncFromStorage);
|
||||
}, [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(
|
||||
async (payload: SavedSignaturePayload, label?: string, scope?: SignatureScope): Promise<AddSignatureResult> => {
|
||||
@ -108,7 +114,7 @@ export const useSavedSignatures = () => {
|
||||
return { success: false, reason: 'invalid' };
|
||||
}
|
||||
|
||||
if (savedSignatures.length >= MAX_SAVED_SIGNATURES) {
|
||||
if (isAtCapacity) {
|
||||
return { success: false, reason: 'limit' };
|
||||
}
|
||||
|
||||
@ -146,17 +152,24 @@ export const useSavedSignatures = () => {
|
||||
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
|
||||
)
|
||||
);
|
||||
// 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 =>
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
}, [storageType]);
|
||||
|
||||
const replaceSignature = useCallback(
|
||||
async (id: string, payload: SavedSignaturePayload) => {
|
||||
@ -201,6 +214,7 @@ export const useSavedSignatures = () => {
|
||||
return {
|
||||
savedSignatures,
|
||||
isAtCapacity,
|
||||
maxLimit,
|
||||
addSignature,
|
||||
removeSignature,
|
||||
updateSignatureLabel,
|
||||
@ -209,6 +223,7 @@ export const useSavedSignatures = () => {
|
||||
byTypeCounts,
|
||||
storageType,
|
||||
isLoading,
|
||||
isAdmin,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -14,7 +14,6 @@ interface SignatureStorageCapabilities {
|
||||
class SignatureStorageService {
|
||||
private capabilities: SignatureStorageCapabilities | null = null;
|
||||
private detectionPromise: Promise<SignatureStorageCapabilities> | null = null;
|
||||
private blobUrls: Set<string> = new Set();
|
||||
|
||||
/**
|
||||
* Detect if backend supports signature storage API
|
||||
@ -83,9 +82,6 @@ class SignatureStorageService {
|
||||
* Load all signatures
|
||||
*/
|
||||
async loadSignatures(): Promise<SavedSignature[]> {
|
||||
// Clean up old blob URLs before loading new ones
|
||||
this.cleanup();
|
||||
|
||||
const capabilities = await this.detectCapabilities();
|
||||
|
||||
if (capabilities.supportsBackend) {
|
||||
@ -130,9 +126,7 @@ class SignatureStorageService {
|
||||
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;
|
||||
await this._updateLabelInBackend(id, label);
|
||||
} else {
|
||||
this._updateLabelInLocalStorage(id, label);
|
||||
}
|
||||
@ -144,7 +138,7 @@ class SignatureStorageService {
|
||||
const response = await apiClient.get<SavedSignature[]>('/api/v1/proprietary/signatures');
|
||||
const signatures = response.data;
|
||||
|
||||
// Fetch image data for each signature and convert to blob URLs
|
||||
// Fetch image data for each signature and convert to data URLs
|
||||
const signaturePromises = signatures.map(async (sig) => {
|
||||
if (sig.dataUrl && sig.dataUrl.startsWith('/api/v1/general/signatures/')) {
|
||||
try {
|
||||
@ -153,14 +147,20 @@ class SignatureStorageService {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
// Convert to blob URL
|
||||
// Convert to data URL (base64) for both display and use
|
||||
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 };
|
||||
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) {
|
||||
console.error(`[SignatureStorage] Failed to load image for ${sig.id}:`, error);
|
||||
return sig; // Return original if image fetch fails
|
||||
@ -184,6 +184,10 @@ class SignatureStorageService {
|
||||
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
|
||||
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
|
||||
|
||||
@ -267,16 +271,6 @@ class SignatureStorageService {
|
||||
|
||||
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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user