Save signatures to server (#5080)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
Reece Browne
2025-11-29 19:29:06 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent fde449e738
commit 651f17f1c6
19 changed files with 1674 additions and 425 deletions
@@ -0,0 +1,282 @@
import apiClient from '@app/services/apiClient';
import type { SavedSignature } from '@app/hooks/tools/sign/useSavedSignatures';
export type StorageType = 'backend' | 'localStorage';
interface SignatureStorageCapabilities {
supportsBackend: boolean;
storageType: StorageType;
}
/**
* Service to handle signature storage with adaptive backend/localStorage fallback
*/
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
*/
async detectCapabilities(): Promise<SignatureStorageCapabilities> {
// Return cached result if already detected
if (this.capabilities) {
return this.capabilities;
}
// Return in-flight detection if already running
if (this.detectionPromise) {
return this.detectionPromise;
}
// Start new detection
this.detectionPromise = this._performDetection();
this.capabilities = await this.detectionPromise;
this.detectionPromise = null;
return this.capabilities;
}
private async _performDetection(): Promise<SignatureStorageCapabilities> {
try {
// Probe the proprietary signatures endpoint (requires authentication)
await apiClient.get('/api/v1/proprietary/signatures', {
timeout: 3000,
});
// 200 = Backend available and accessible (authenticated)
console.log('[SignatureStorage] Backend signature API detected and accessible (authenticated)');
return {
supportsBackend: true,
storageType: 'backend',
};
} catch (error: any) {
// Check if it's an HTTP error with status code
if (error?.response?.status === 401 || error?.response?.status === 403) {
// Backend exists but needs auth - gracefully fall back to localStorage
console.log('[SignatureStorage] Backend signature API requires authentication, using localStorage');
} else if (error?.response?.status === 404) {
// Endpoint doesn't exist (not running proprietary mode)
console.log('[SignatureStorage] Backend signature API not available (not in proprietary mode), using localStorage');
} else {
// Network error, timeout, or other error
console.log('[SignatureStorage] Backend signature API not available, using localStorage');
}
return {
supportsBackend: false,
storageType: 'localStorage',
};
}
}
/**
* Get current storage type
*/
async getStorageType(): Promise<StorageType> {
const capabilities = await this.detectCapabilities();
return capabilities.storageType;
}
/**
* 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) {
return this._loadFromBackend();
} else {
return this._loadFromLocalStorage();
}
}
/**
* Save a signature
*/
async saveSignature(signature: SavedSignature): Promise<void> {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend && signature.scope !== 'localStorage') {
await this._saveToBackend(signature);
} else {
// Force scope to localStorage for browser storage
signature.scope = 'localStorage';
this._saveToLocalStorage(signature);
}
}
/**
* Delete a signature
*/
async deleteSignature(id: string): Promise<void> {
const capabilities = await this.detectCapabilities();
if (capabilities.supportsBackend) {
await this._deleteFromBackend(id);
} else {
this._deleteFromLocalStorage(id);
}
}
/**
* Update signature label
*/
async updateSignatureLabel(id: string, label: string): Promise<void> {
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;
} else {
this._updateLabelInLocalStorage(id, label);
}
}
// Backend methods
private async _loadFromBackend(): Promise<SavedSignature[]> {
try {
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
const signaturePromises = signatures.map(async (sig) => {
if (sig.dataUrl && sig.dataUrl.startsWith('/api/v1/general/signatures/')) {
try {
// Fetch image via apiClient (unified endpoint works for both authenticated and unauthenticated)
const imageResponse = await apiClient.get<ArrayBuffer>(sig.dataUrl, {
responseType: 'arraybuffer',
});
// Convert to blob URL
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 };
} catch (error) {
console.error(`[SignatureStorage] Failed to load image for ${sig.id}:`, error);
return sig; // Return original if image fetch fails
}
}
return sig;
});
return await Promise.all(signaturePromises);
} catch (error) {
console.error('[SignatureStorage] Failed to load from backend:', error);
return [];
}
}
private async _saveToBackend(signature: SavedSignature): Promise<void> {
await apiClient.post('/api/v1/proprietary/signatures', signature);
}
private async _deleteFromBackend(id: string): Promise<void> {
await apiClient.delete(`/api/v1/proprietary/signatures/${id}`);
}
// LocalStorage methods
private readonly STORAGE_KEY = 'stirling:saved-signatures:v1';
private _loadFromLocalStorage(): SavedSignature[] {
try {
const raw = localStorage.getItem(this.STORAGE_KEY);
if (!raw) return [];
const signatures = JSON.parse(raw);
// Ensure all localStorage signatures have the correct scope
return signatures.map((sig: SavedSignature) => ({
...sig,
scope: 'localStorage' as const,
}));
} catch {
return [];
}
}
private _saveToLocalStorage(signature: SavedSignature): void {
const signatures = this._loadFromLocalStorage();
const index = signatures.findIndex(s => s.id === signature.id);
if (index >= 0) {
signatures[index] = signature;
} else {
signatures.unshift(signature);
}
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
}
private _deleteFromLocalStorage(id: string): void {
const signatures = this._loadFromLocalStorage();
const filtered = signatures.filter(s => s.id !== id);
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(filtered));
}
private _updateLabelInLocalStorage(id: string, label: string): void {
const signatures = this._loadFromLocalStorage();
const signature = signatures.find(s => s.id === id);
if (signature) {
signature.label = label;
signature.updatedAt = Date.now();
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(signatures));
}
}
/**
* Migrate signatures from localStorage to backend
*/
async migrateToBackend(): Promise<{ migrated: number; failed: number }> {
const capabilities = await this.detectCapabilities();
if (!capabilities.supportsBackend) {
return { migrated: 0, failed: 0 };
}
const localSignatures = this._loadFromLocalStorage();
if (localSignatures.length === 0) {
return { migrated: 0, failed: 0 };
}
let migrated = 0;
let failed = 0;
for (const signature of localSignatures) {
try {
await this._saveToBackend(signature);
migrated++;
} catch (error) {
console.error(`[SignatureStorage] Failed to migrate signature ${signature.id}:`, error);
failed++;
}
}
// Clear localStorage after successful migration
if (migrated > 0 && failed === 0) {
localStorage.removeItem(this.STORAGE_KEY);
console.log(`[SignatureStorage] Successfully migrated ${migrated} signatures to backend`);
}
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();