UNPKG

ng7-storage

Version:

An Angular service for browser session storage management with optional base64 encryption/decryption.

879 lines (872 loc) 30.2 kB
import * as i0 from '@angular/core'; import { InjectionToken, signal, computed, Optional, Inject, Injectable } from '@angular/core'; import { Subject, BehaviorSubject, distinctUntilChanged, filter, map } from 'rxjs'; // Injection Tokens const STORAGE_OPTIONS = new InjectionToken('StorageOptions'); const STORAGE_NAMED_OPTIONS = new InjectionToken('StorageNamedOptions'); const STORAGE_FLAGS = new InjectionToken('StorageFlags'); // Default Configuration const DEFAULT_STORAGE_CONFIG = { prefix: 'ng-storage', defaultTTL: 0, enableLogging: false, caseSensitive: false, storageType: 'sessionStorage', }; const DEFAULT_STORAGE_FLAGS = { autoCleanup: true, strictMode: false, enableMetrics: false, }; function provideNgStorage(optionsFactory, flags = {}) { return [ NgStorageService, { provide: STORAGE_OPTIONS, useFactory: optionsFactory, }, { provide: STORAGE_FLAGS, useValue: { ...DEFAULT_STORAGE_FLAGS, ...flags }, }, ]; } /** * Provides multiple named NgStorageService instances */ function provideNamedNgStorage(optionsFactory, flags = {}) { return [ { provide: STORAGE_NAMED_OPTIONS, useFactory: optionsFactory, }, { provide: STORAGE_FLAGS, useValue: { ...DEFAULT_STORAGE_FLAGS, ...flags }, }, ]; } class CryptoUtils { static { this.ALGORITHM = 'AES-GCM'; } static { this.KEY_LENGTH = 256; } static { this.IV_LENGTH = 12; } // 96 bits for GCM static { this.TAG_LENGTH = 128; } // 128 bits authentication tag static { this.encryptionKey = null; } static { this.keyPromise = null; } /** * Derives a consistent key from a password using PBKDF2 */ static async deriveKey(password, salt) { const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']); return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: salt, iterations: 100000, // OWASP recommended minimum hash: 'SHA-256', }, keyMaterial, { name: this.ALGORITHM, length: this.KEY_LENGTH, }, false, ['encrypt', 'decrypt']); } /** * Gets or creates the encryption key */ static async getEncryptionKey() { if (this.encryptionKey) { return this.encryptionKey; } if (this.keyPromise) { return this.keyPromise; } this.keyPromise = this.createEncryptionKey(); this.encryptionKey = await this.keyPromise; return this.encryptionKey; } /** * Creates a new encryption key or derives from stored salt */ static async createEncryptionKey() { const keyIdentifier = '__ng_storage_key_salt__'; let salt; // Try to get existing salt from localStorage try { const existingSalt = localStorage.getItem(keyIdentifier); if (existingSalt) { salt = new Uint8Array(JSON.parse(existingSalt)); } else { // Generate new salt salt = crypto.getRandomValues(new Uint8Array(32)); localStorage.setItem(keyIdentifier, JSON.stringify(Array.from(salt))); } } catch { // Fallback to generated salt if localStorage is not available salt = crypto.getRandomValues(new Uint8Array(32)); } // Use a default password - in production, you might want to make this configurable const password = 'ng-storage-default-key-2024'; return this.deriveKey(password, salt); } /** * Encrypts data using AES-GCM */ static async encrypt(data) { try { const key = await this.getEncryptionKey(); const encoder = new TextEncoder(); const dataBuffer = encoder.encode(data); // Generate random IV const iv = crypto.getRandomValues(new Uint8Array(this.IV_LENGTH)); // Encrypt the data const encryptedBuffer = await crypto.subtle.encrypt({ name: this.ALGORITHM, iv: iv, tagLength: this.TAG_LENGTH, }, key, dataBuffer); // Combine IV and encrypted data const encryptedArray = new Uint8Array(encryptedBuffer); const combined = new Uint8Array(iv.length + encryptedArray.length); combined.set(iv); combined.set(encryptedArray, iv.length); // Convert to base64 for storage return btoa(String.fromCharCode(...combined)); } catch (error) { throw new Error(`Encryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Decrypts data using AES-GCM */ static async decrypt(encryptedData) { try { const key = await this.getEncryptionKey(); // Convert from base64 const combined = new Uint8Array(atob(encryptedData) .split('') .map((char) => char.charCodeAt(0))); // Extract IV and encrypted data const iv = combined.slice(0, this.IV_LENGTH); const encryptedBuffer = combined.slice(this.IV_LENGTH); // Decrypt the data const decryptedBuffer = await crypto.subtle.decrypt({ name: this.ALGORITHM, iv: iv, tagLength: this.TAG_LENGTH, }, key, encryptedBuffer); // Convert back to string const decoder = new TextDecoder(); return decoder.decode(decryptedBuffer); } catch (error) { throw new Error(`Decryption failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Checks if Web Crypto API is available */ static isSupported() { return (typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined' && typeof crypto.subtle.encrypt === 'function'); } /** * Clears the cached encryption key (useful for testing or key rotation) */ static clearKey() { this.encryptionKey = null; this.keyPromise = null; } } /** * Simple provider for basic configuration */ function provideNgStorageConfig(config, flags = {}) { return provideNgStorage(() => config, flags); } class NgStorageService { constructor(options, flags) { // Reactive state management this._storageData = signal({}); this._changeSubject = new Subject(); this._keyChangeSubjects = new Map(); // Public reactive properties this.storageData = this._storageData.asReadonly(); this.changes$ = this._changeSubject.asObservable(); // Computed stats this.stats = computed(() => { const data = this._storageData(); return { itemCount: Object.keys(data).length, isEmpty: Object.keys(data).length === 0, keys: Object.keys(data), }; }); // Merge configurations this.config = { ...DEFAULT_STORAGE_CONFIG, ...options, }; this.flags = { ...DEFAULT_STORAGE_FLAGS, ...flags, }; // Set storage type and messages this.storageTypeName = this.config.storageType === 'localStorage' ? 'local storage' : 'session storage'; this.supportedMessage = `Your browser doesn't support ${this.storageTypeName}. Please update your browser.`; // Get the appropriate storage instance this.storage = this.getStorageInstance(); // Check if storage is supported this.isSupported = this.checkStorageSupport(); this.isCryptoSupported = CryptoUtils.isSupported(); if (!this.isSupported) { console.error(this.supportedMessage); if (this.flags.strictMode) { throw new Error(this.supportedMessage); } } if (!this.isCryptoSupported) { console.warn('[NgStorageService] Web Crypto API not supported. Encryption will be disabled.'); } // Initialize reactive state this.initializeReactiveState(); // Clean expired items on initialization if (this.flags.autoCleanup) { this.cleanupExpiredItems(); } // Set up periodic cleanup if (typeof window !== 'undefined' && this.flags.autoCleanup) { setInterval(() => this.cleanupExpiredItems(), 5 * 60 * 1000); // Every 5 minutes } } /** * Gets the appropriate storage instance based on configuration */ getStorageInstance() { if (typeof window === 'undefined') { throw new Error('Storage is not available in server-side rendering'); } switch (this.config.storageType) { case 'localStorage': return window.localStorage; case 'sessionStorage': return window.sessionStorage; default: throw new Error(`Unsupported storage type: ${this.config.storageType}`); } } /** * Checks if the configured storage is supported and available */ checkStorageSupport() { try { if (typeof window === 'undefined') { return false; } const testKey = '__ng_storage_test__'; this.storage.setItem(testKey, 'test'); this.storage.removeItem(testKey); return true; } catch (error) { return false; } } /** * Initializes reactive state from existing storage */ async initializeReactiveState() { try { const data = {}; const prefix = `${this.config.prefix}:`; for (let i = 0; i < this.storage.length; i++) { const key = this.storage.key(i); if (key?.startsWith(prefix)) { const originalKey = key.substring(prefix.length); try { const value = await this.getDataInternal(originalKey); if (value !== null) { data[originalKey] = value; } } catch (error) { this.log('Failed to load key during initialization', originalKey, error); } } } this._storageData.set(data); } catch (error) { this.log('Failed to initialize reactive state', '', error); } } /** * Generates a storage key with prefix */ generateKey(key) { if (!key || typeof key !== 'string') { throw new Error('Storage key must be a non-empty string'); } const processedKey = this.config.caseSensitive ? key : key.toLowerCase(); return `${this.config.prefix}:${processedKey}`; } /** * Logs debug information if logging is enabled */ log(action, key, data) { if (this.config.enableLogging) { console.log(`[NgStorageService] ${action}:`, { key, data }); } } /** * Encrypts data using AES-GCM encryption */ async encrypt(data) { if (!this.isCryptoSupported) { this.log('Crypto not supported, falling back to base64 encoding', '', null); return btoa(encodeURIComponent(data)); } try { return await CryptoUtils.encrypt(data); } catch (error) { this.log('Encryption failed, falling back to base64 encoding', '', error); return btoa(encodeURIComponent(data)); } } /** * Decrypts data using AES-GCM decryption */ async decrypt(encryptedData) { if (!this.isCryptoSupported) { try { return decodeURIComponent(atob(encryptedData)); } catch (error) { this.log('Base64 decoding failed', '', error); throw new Error('Failed to decode data'); } } try { return await CryptoUtils.decrypt(encryptedData); } catch (error) { // Fallback to base64 decoding for backward compatibility try { return decodeURIComponent(atob(encryptedData)); } catch (fallbackError) { this.log('Both decryption methods failed', '', { error, fallbackError, }); throw new Error('Failed to decrypt data'); } } } /** * Calculates the expiry timestamp */ calculateExpiry(ttlMinutes) { const ttl = ttlMinutes ?? this.config.defaultTTL; return ttl > 0 ? Date.now() + ttl * 60 * 1000 : undefined; } /** * Checks if an item has expired */ isExpired(item) { return item.expiry ? Date.now() > item.expiry : false; } /** * Emits change event */ emitChange(key, oldValue, newValue, action) { const event = { key, oldValue, newValue, action, timestamp: Date.now(), }; this._changeSubject.next(event); // Update key-specific subject const keySubject = this._keyChangeSubjects.get(key); if (keySubject) { keySubject.next(newValue); } this.log('Change emitted', key, event); } /** * Updates reactive state */ updateReactiveState(key, value, action) { const currentData = this._storageData(); const oldValue = currentData[key] ?? null; if (action === 'remove' || action === 'clear' || action === 'expire') { const newData = { ...currentData }; delete newData[key]; this._storageData.set(newData); this.emitChange(key, oldValue, null, action); } else { this._storageData.update((data) => ({ ...data, [key]: value })); this.emitChange(key, oldValue, value, action); } } /** * Internal get data method without reactive updates */ async getDataInternal(key, options = {}) { try { const storageKey = this.generateKey(key); const { decrypt = false, defaultValue = null } = options; const rawData = this.storage.getItem(storageKey); if (!rawData) { return defaultValue; } let parsedData; if (decrypt) { parsedData = await this.decrypt(rawData); } else { parsedData = rawData; } const item = JSON.parse(parsedData); // Check if item has expired if (this.isExpired(item)) { this.storage.removeItem(storageKey); return defaultValue; } return item.value; } catch (error) { this.log('Get data failed', key, error); return options.defaultValue ?? null; } } /** * Removes expired items from storage */ async cleanupExpiredItems() { try { const keysToRemove = []; const currentData = this._storageData(); for (let i = 0; i < this.storage.length; i++) { const key = this.storage.key(i); if (key?.startsWith(`${this.config.prefix}:`)) { try { const rawData = this.storage.getItem(key); if (rawData) { let parsedData = rawData; // Try to detect if encrypted try { JSON.parse(parsedData); } catch { try { parsedData = await this.decrypt(rawData); } catch { keysToRemove.push(key); continue; } } const item = JSON.parse(parsedData); if (this.isExpired(item)) { keysToRemove.push(key); } } } catch (error) { keysToRemove.push(key); } } } keysToRemove.forEach((storageKey) => { const originalKey = storageKey.substring(`${this.config.prefix}:`.length); this.storage.removeItem(storageKey); if (currentData[originalKey] !== undefined) { this.updateReactiveState(originalKey, null, 'expire'); } this.log('Removed expired item', originalKey); }); } catch (error) { this.log('Cleanup failed', '', error); } } /** * Stores data in storage with optional encryption and TTL */ async setData(key, value, options = {}) { try { if (!this.isSupported) { throw new Error(this.supportedMessage); } const storageKey = this.generateKey(key); const { encrypt = false, ttlMinutes } = options; const item = { value, timestamp: Date.now(), expiry: this.calculateExpiry(ttlMinutes), encrypted: encrypt, }; let serializedData = JSON.stringify(item); if (encrypt) { serializedData = await this.encrypt(serializedData); } this.storage.setItem(storageKey, serializedData); this.updateReactiveState(key, value, 'set'); this.log('Data stored', key, { value, options }); return true; } catch (error) { this.log('Set data failed', key, error); if (error instanceof Error && error.name === 'QuotaExceededError') { throw new Error('Storage quota exceeded. Try clearing some data.'); } throw error; } } /** * Retrieves data from storage with automatic decryption and expiry check */ async getData(key, options = {}) { // Try to get from reactive state first const reactiveValue = this._storageData()[key]; if (reactiveValue !== undefined) { return reactiveValue; } // Fallback to storage const value = await this.getDataInternal(key, options); // Update reactive state if value found if (value !== null) { this._storageData.update((data) => ({ ...data, [key]: value })); } return value; } /** * Creates a signal for a specific key */ async createSignal(key, defaultValue) { const initialValue = (await this.getData(key)) ?? defaultValue ?? null; const keySignal = signal(initialValue); // Subscribe to changes for this key this.watch(key).subscribe((value) => { keySignal.set(value); }); return keySignal.asReadonly(); } /** * Watch changes for a specific key */ watch(key) { if (!this._keyChangeSubjects.has(key)) { // Initialize with current value asynchronously this.getData(key).then((currentValue) => { if (!this._keyChangeSubjects.has(key)) { this._keyChangeSubjects.set(key, new BehaviorSubject(currentValue)); } }); // Create with null initially this._keyChangeSubjects.set(key, new BehaviorSubject(null)); } return this._keyChangeSubjects .get(key) .asObservable() .pipe(distinctUntilChanged()); } /** * Watch all changes */ watchAll() { return this.changes$; } /** * Watch changes for multiple keys */ watchKeys(keys) { return this.changes$.pipe(filter((event) => keys.includes(event.key)), map((event) => ({ key: event.key, value: event.newValue }))); } /** * Watch changes by pattern (simple glob-like matching) */ watchPattern(pattern) { const regex = new RegExp(pattern.replace(/\*/g, '.*')); return this.changes$.pipe(filter((event) => regex.test(event.key)), map((event) => ({ key: event.key, value: event.newValue }))); } /** * Removes data associated with the specified key */ removeData(key) { try { if (!this.isSupported) { throw new Error(this.supportedMessage); } const storageKey = this.generateKey(key); this.storage.removeItem(storageKey); this.updateReactiveState(key, null, 'remove'); this.log('Data removed', key); return true; } catch (error) { this.log('Remove data failed', key, error); return false; } } /** * Removes multiple items at once */ removeMultiple(keys) { const result = { success: [], failed: [] }; keys.forEach((key) => { if (this.removeData(key)) { result.success.push(key); } else { result.failed.push(key); } }); return result; } /** * Clears all storage data with the current prefix */ removeAll() { try { if (!this.isSupported) { throw new Error(this.supportedMessage); } const keysToRemove = []; const currentData = this._storageData(); for (let i = 0; i < this.storage.length; i++) { const key = this.storage.key(i); if (key?.startsWith(`${this.config.prefix}:`)) { keysToRemove.push(key); } } keysToRemove.forEach((key) => this.storage.removeItem(key)); // Clear reactive state and emit events Object.keys(currentData).forEach((key) => { this.updateReactiveState(key, null, 'clear'); }); this.log('All data cleared', '', { removedCount: keysToRemove.length }); return true; } catch (error) { this.log('Clear all failed', '', error); return false; } } /** * Checks if a key exists in storage */ async hasKey(key) { // Check reactive state first if (this._storageData()[key] !== undefined) { return true; } // Check storage try { if (!this.isSupported) { return false; } const value = await this.getDataInternal(key); const exists = value !== null; // Update reactive state if found if (exists) { this._storageData.update((data) => ({ ...data, [key]: value })); } return exists; } catch (error) { this.log('Has key check failed', key, error); return false; } } /** * Gets all keys managed by this service */ getKeys() { return Object.keys(this._storageData()); } /** * Gets storage statistics */ async getStorageStats() { const stats = { totalItems: 0, totalSize: 0, availableSpace: 0, items: [], }; try { if (!this.isSupported) { return stats; } const prefix = `${this.config.prefix}:`; for (let i = 0; i < this.storage.length; i++) { const key = this.storage.key(i); if (key?.startsWith(prefix)) { const data = this.storage.getItem(key); if (data) { const size = new Blob([data]).size; stats.totalSize += size; stats.totalItems++; try { let parsedData = data; try { JSON.parse(parsedData); } catch { parsedData = await this.decrypt(data); } const item = JSON.parse(parsedData); stats.items.push({ key: key.substring(prefix.length), size, timestamp: item.timestamp, hasExpiry: Boolean(item.expiry), }); } catch { stats.items.push({ key: key.substring(prefix.length), size, timestamp: 0, hasExpiry: false, }); } } } } const estimatedLimit = 5 * 1024 * 1024; // 5MB stats.availableSpace = Math.max(0, estimatedLimit - stats.totalSize); } catch (error) { this.log('Get storage stats failed', '', error); } return stats; } /** * Updates existing data if key exists */ async updateData(key, updateFn, options = {}) { try { const currentValue = await this.getData(key, { decrypt: options.encrypt, }); const newValue = updateFn(currentValue); return await this.setData(key, newValue, options); } catch (error) { this.log('Update data failed', key, error); return false; } } /** * Sets data only if key doesn't exist */ async setIfNotExists(key, value, options = {}) { if (!(await this.hasKey(key))) { return await this.setData(key, value, options); } return false; } /** * Gets storage configuration */ getConfig() { return { ...this.config }; } /** * Gets the current storage type */ getStorageType() { return this.config.storageType; } /** * Switches storage type (creates a new instance) */ static withStorageType(config) { return new NgStorageService(config); } /** * Creates a localStorage instance */ static localStorage(config = {}) { return new NgStorageService({ ...config, storageType: 'localStorage' }); } /** * Creates a sessionStorage instance */ static sessionStorage(config = {}) { return new NgStorageService({ ...config, storageType: 'sessionStorage' }); } /** * Checks if storage is supported */ isStorageSupported() { return this.isSupported; } /** * Checks if encryption is supported */ isEncryptionSupported() { return this.isCryptoSupported; } /** * Forces cleanup of expired items */ async cleanup() { const initialStats = await this.getStorageStats(); await this.cleanupExpiredItems(); const finalStats = await this.getStorageStats(); const removedCount = initialStats.totalItems - finalStats.totalItems; this.log('Manual cleanup completed', '', { removedCount }); return removedCount; } /** * Clears the encryption key (useful for key rotation) */ clearEncryptionKey() { CryptoUtils.clearKey(); this.log('Encryption key cleared', ''); } /** * Destroys all subscriptions and cleanup */ destroy() { this._changeSubject.complete(); this._keyChangeSubjects.forEach((subject) => subject.complete()); this._keyChangeSubjects.clear(); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: NgStorageService, deps: [{ token: STORAGE_OPTIONS, optional: true }, { token: STORAGE_FLAGS, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: NgStorageService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.3", ngImport: i0, type: NgStorageService, decorators: [{ type: Injectable, args: [{ providedIn: 'root', }] }], ctorParameters: () => [{ type: undefined, decorators: [{ type: Optional }, { type: Inject, args: [STORAGE_OPTIONS] }] }, { type: undefined, decorators: [{ type: Optional }, { type: Inject, args: [STORAGE_FLAGS] }] }] }); /** * Generated bundle index. Do not edit. */ export { DEFAULT_STORAGE_CONFIG, DEFAULT_STORAGE_FLAGS, NgStorageService, STORAGE_FLAGS, STORAGE_NAMED_OPTIONS, STORAGE_OPTIONS, provideNamedNgStorage, provideNgStorage, provideNgStorageConfig }; //# sourceMappingURL=ng7-storage.mjs.map