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:
Reece Browne
2025-12-02 22:48:29 +00:00
committed by GitHub
co-authored by Dario Ghunney Ware James Brunton
parent f3cc30d0c2
commit f2f4bd5230
9 changed files with 280 additions and 74 deletions
@@ -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(
@@ -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;
}
} }
@@ -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) {
+2 -2
View File
@@ -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";
} }
+2 -2
View File
@@ -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();