diff --git a/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java b/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java index 6c349581d..7043118a4 100644 --- a/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java +++ b/app/core/src/main/java/stirling/software/SPDF/service/SharedSignatureService.java @@ -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( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java index f42b23a97..a073c2137 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/SignatureController.java @@ -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 updateSignatureLabel( + @PathVariable String signatureId, @RequestBody Map 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 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 stream = Files.list(sharedFolder)) { + List 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; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java index 6f849ebfe..63ee28512 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/SignatureService.java @@ -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) { diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf index 3be5ec900..ef74321ef 100644 --- a/docker/frontend/nginx.conf +++ b/docker/frontend/nginx.conf @@ -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"; } diff --git a/docker/unified/nginx.conf b/docker/unified/nginx.conf index 1e47b8619..cbd5fee81 100644 --- a/docker/unified/nginx.conf +++ b/docker/unified/nginx.conf @@ -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"; } diff --git a/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx b/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx index c2e8ca8f1..95a8a7e77 100644 --- a/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx +++ b/frontend/src/core/components/tools/sign/SavedSignaturesSection.tsx @@ -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 } )} @@ -216,7 +220,7 @@ export const SavedSignaturesSection = ({ {translate('saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', { - max: MAX_SAVED_SIGNATURES, + max: maxLimit, })} @@ -365,17 +369,19 @@ export const SavedSignaturesSection = ({ > - - onDeleteSignature(activeSharedSignature)} - disabled={disabled} - > - - - + {isAdmin && ( + + onDeleteSignature(activeSharedSignature)} + disabled={disabled} + > + + + + )} {renderPreview(activeSharedSignature)} diff --git a/frontend/src/core/components/tools/sign/SignSettings.tsx b/frontend/src/core/components/tools/sign/SignSettings.tsx index 6fb3238f8..233b87373 100644 --- a/frontend/src/core/components/tools/sign/SignSettings.tsx +++ b/frontend/src/core/components/tools/sign/SignSettings.tsx @@ -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(() => { 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} diff --git a/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts index 655038a3f..b878dcd0d 100644 --- a/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts +++ b/frontend/src/core/hooks/tools/sign/useSavedSignatures.ts @@ -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([]); const [storageType, setStorageType] = useState(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 => { @@ -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, }; }; diff --git a/frontend/src/core/services/signatureStorageService.ts b/frontend/src/core/services/signatureStorageService.ts index 365a0cbc6..63dabe909 100644 --- a/frontend/src/core/services/signatureStorageService.ts +++ b/frontend/src/core/services/signatureStorageService.ts @@ -14,7 +14,6 @@ interface SignatureStorageCapabilities { class SignatureStorageService { private capabilities: SignatureStorageCapabilities | null = null; private detectionPromise: Promise | null = null; - private blobUrls: Set = new Set(); /** * Detect if backend supports signature storage API @@ -83,9 +82,6 @@ class SignatureStorageService { * Load all signatures */ async loadSignatures(): Promise { - // Clean up old blob URLs before loading new ones - this.cleanup(); - const capabilities = await this.detectCapabilities(); if (capabilities.supportsBackend) { @@ -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('/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((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 { + 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();