import { STORAGE_KEY } from '../constants'; import { normalizeMediaItem } from '../lib/media'; import type { Album, MediaItem } from '../types'; import type { PersistedState, StorageAdapter } from './types'; const CURRENT_VERSION = 1; /** * Default zero-config adapter. Persists library metadata to localStorage and * (optionally) binary blobs to IndexedDB so imported files survive reloads. * * Safe under SSR: all browser APIs are guarded with `typeof window` checks. */ export function createLocalStorageAdapter(key: string = STORAGE_KEY): StorageAdapter { const hasWindow = typeof window !== 'undefined'; return { name: 'localStorage', async load(): Promise { if (!hasWindow) return null; try { const raw = window.localStorage.getItem(key); if (!raw) return null; const parsed = JSON.parse(raw) as PersistedState; if (!parsed || typeof parsed !== 'object') return null; // Field-level validation — never trust persisted data blindly. Every media // item is re-normalized (URL scheme allow-list, string coercion) so a // tampered localStorage record cannot inject unsafe values into the UI. const media: MediaItem[] = Array.isArray(parsed.media) ? parsed.media .map((m) => normalizeMediaItem(m)) .filter((m): m is MediaItem => m !== null) : []; const albums: Album[] = Array.isArray(parsed.albums) ? parsed.albums .filter((a): a is Album => Boolean(a) && typeof a === 'object') .map((a) => ({ ...a, name: typeof a.name === 'string' ? a.name : 'Album', mediaIds: Array.isArray(a.mediaIds) ? a.mediaIds.filter((id): id is string => typeof id === 'string') : [], })) : []; // Keep only string→string entries — never trust persisted data blindly. const labelAliases: Record = parsed.labelAliases && typeof parsed.labelAliases === 'object' ? Object.fromEntries( Object.entries(parsed.labelAliases).filter( ([k, v]) => typeof k === 'string' && typeof v === 'string', ), ) : {}; const deletedLabels: string[] = Array.isArray(parsed.deletedLabels) ? (parsed.deletedLabels as unknown[]).filter((l): l is string => typeof l === 'string') : []; return { media, albums, people: Array.isArray(parsed.people) ? parsed.people : [], labelAliases, deletedLabels, version: typeof parsed.version === 'number' ? parsed.version : CURRENT_VERSION, }; } catch { return null; } }, async save(state: PersistedState): Promise { if (!hasWindow) return; try { window.localStorage.setItem( key, JSON.stringify({ ...state, version: CURRENT_VERSION }), ); } catch { // Quota exceeded or storage disabled — fail silently, app still works in-memory. } }, async putBlob(id: string, blob: Blob): Promise { if (!hasWindow) return ''; const db = await openBlobDb(); if (!db) return URL.createObjectURL(blob); await new Promise((resolve, reject) => { const tx = db.transaction('blobs', 'readwrite'); tx.objectStore('blobs').put(blob, id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); return URL.createObjectURL(blob); }, async clear(): Promise { if (!hasWindow) return; try { window.localStorage.removeItem(key); } catch { /* noop */ } }, }; } let dbPromise: Promise | null = null; function openBlobDb(): Promise { if (typeof indexedDB === 'undefined') return Promise.resolve(null); if (dbPromise) return dbPromise; dbPromise = new Promise((resolve) => { const req = indexedDB.open('photo-gallery-sdk-blobs', 1); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains('blobs')) db.createObjectStore('blobs'); }; req.onsuccess = () => resolve(req.result); req.onerror = () => resolve(null); }); return dbPromise; }