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:
Reece Browne 2025-12-02 22:48:29 +00:00 committed by GitHub
parent f3cc30d0c2
commit f2f4bd5230
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 280 additions and 74 deletions

View File

@ -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(

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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";
}

View File

@ -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";
}

View File

@ -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)}

View File

@ -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}

View File

@ -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,
};
};

View File

@ -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();