diff --git a/README.md b/README.md index 0419b44..a96cae6 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,36 @@ -# WebStorage +# WebStorage Decorators +A Javascript library that adds property decorators to sync a class property with the local or session storage. -A Javascript library that adds property decorators to sync a class property with the local & session storage. It also includes crypto-js so that sensitive information being stored on the client is not stored in plain text. - -### Quick Setup - 1. Install with: `npm install --save webstorage-decorators crypto-js` - 2. Add the decorator to your property and you are done! - ```javascript - import {LocalStorage, SessionStorage} from 'webstorage-decorators'; +## Quick Setup + 1. Install with: `npm install --save webstorage-decorators` + 2. Add the decorator to your property and use as normal! + ```typescript +import {LocalStorage, SessionStorage} from 'webstorage-decorators'; - export class SomeComponent { - - @LocalStorage({ - fieldName: 'customName', - encryptionKey: settings.encryptionToken, - defaultValue: 123 - }) - someProperty; - - @SessionStorage(/* Accepts same optional paramters as the LocalStorage*/) user; - - constructor(user) { - // This property will get its vallue from the local storage or default to 123 if the property doesn't exist - console.log(this.someProperty) - - // Because of our SessionStorage decorator the user is automatically stored in the session storage - this.user = user - } - } +export class MyCustomClass { + @LocalStorage({key: 'site_theme', default: 'light_theme'}) theme: string; + @SessionStorage({encryptWith: config.entryptionKey}) thisUser: User; + + constructor() { + console.log(this.theme, localStorage.getItem('theme')); // Output: 'light_theme', 'light_theme' + console.log(this.user, localStorage.getItem('user')); // Output: null, undefined + user = {first: 'John', last: 'Smith', ...} + console.log(this.user, this.user == localStorage.getItem('user')); // Output: {first: 'John', last: 'Smith', ...}, true + } +} ``` + +## Caveats +Impure functions don't use the Object's setter preventing the storage from being updated. To prevent this use a pure +function or save it manually by reading the variable. (Reading triggers change detection & save if there are differences) +```typescript +@LocalStorage([1, 2]) example: number[]; +example.push(3) // Impure & won't update storage +console.log(localStorage.getItem('example')) // Output: [1, 2]; +example; // Trigger save +console.log(localStorage.getItem('example')) // Output: [1, 2, 3]; + +// OR + +example = example.concat([3]); // Pure function requires you to use the setter triggering automatic saving +``` diff --git a/lib/src/webstorage.d.ts b/lib/src/webstorage.d.ts index fffbe72..232ea20 100644 --- a/lib/src/webstorage.d.ts +++ b/lib/src/webstorage.d.ts @@ -5,6 +5,8 @@ export interface WebStorageOptions { /** Default value to provide if storage is empty */ default?: any; + /** Key to prevent plain text storage **/ + encryptWith?: string; /** Key to save under */ key?: string; } diff --git a/lib/src/webstorage.js b/lib/src/webstorage.js index d6906ae..b179d34 100644 --- a/lib/src/webstorage.js +++ b/lib/src/webstorage.js @@ -1,6 +1,7 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SessionStorage = exports.LocalStorage = void 0; +const crypto = require("crypto-js"); /** * Automatically syncs localStorage with the decorated property. * @@ -57,14 +58,18 @@ function storage(storage, opts) { opts.key = key; Object.defineProperty(target, key, { get: function () { - const storageVal = storage.getItem(opts.key); + let storageVal = storage.getItem(opts.key); if (storageVal == null || storageVal == 'null' || storageVal == 'undefined') return opts.default || null; + if (opts.encryptWith != null) + storageVal = crypto.AES.decrypt(JSON.parse(storageVal), opts.encryptWith).toString(crypto.enc.Utf8); return JSON.parse(storageVal); }, set: function (value) { if (value == null) storage.removeItem(opts.key); + if (opts.encryptWith != null) + value = crypto.AES.encrypt(JSON.stringify(value), opts.encryptWith).toString(); storage.setItem(opts.key, JSON.stringify(value)); } }); diff --git a/package.json b/package.json index ba1bf29..964819f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webstorage-decorators", - "version": "2.0.0", + "version": "3.0.0", "description": "Decorators to sync class properties to Local/Session storage", "main": "./lib/index.js", "typings": "./lib/index.d.ts", diff --git a/src/webstorage.ts b/src/webstorage.ts index 945d1c3..c98bbfc 100644 --- a/src/webstorage.ts +++ b/src/webstorage.ts @@ -1,12 +1,15 @@ +import * as crypto from 'crypto-js'; + /** * Options to be used with WebStorage decorators - * @category WebStorage */ export interface WebStorageOptions { /** Default value to provide if storage is empty */ - default?: any + default?: any; + /** Key to prevent plain text storage **/ + encryptWith?: string; /** Key to save under */ - key?: string + key?: string; } /** @@ -20,13 +23,12 @@ export interface WebStorageOptions { * } * ``` * - * @category WebStorage * @param defaultValue Default value to return if property does no exist inside localStorage. * @param opts Any additional options */ export function LocalStorage(defaultValue?: any, opts: WebStorageOptions = {}) { opts.default = defaultValue; - return storage(localStorage, opts); + return decoratorBuilder(localStorage, opts); } /** @@ -40,13 +42,27 @@ export function LocalStorage(defaultValue?: any, opts: WebStorageOptions = {}) { * } * ``` * - * @category WebStorage * @param defaultValue Default value to return if property does no exist inside sessionStorage. * @param opts Any additional options */ export function SessionStorage(defaultValue?, opts: WebStorageOptions = {}) { opts.default = defaultValue; - return storage(sessionStorage, opts); + return decoratorBuilder(sessionStorage, opts); +} + +/** + * **Internal use only** + * + * Fetch variable from storage & take care of any defaults, object definitions, encryption & serialization + * + * @param storage Web Storage API + * @param opts Any additional options + */ +function fromStorage(storage: Storage, opts: WebStorageOptions) { + let storedVal = storage.getItem(opts.key); + if(storedVal == null) return opts.default != null ? opts.default : null; + if(opts.encryptWith != null) storedVal = JSON.parse(crypto.AES.decrypt(JSON.parse(storedVal), opts.encryptWith).toString(crypto.enc.Utf8)); + return typeof storedVal == 'object' && !Array.isArray(storedVal) ? Object.assign(opts.default, storedVal) : storedVal; } /** @@ -54,22 +70,22 @@ export function SessionStorage(defaultValue?, opts: WebStorageOptions = {}) { * * Overrides the properties getter/setter methods to read/write from the provided storage endpoint. * - * @hidden - * @category WebStorage * @param storage Web Storage API * @param opts Any additional options */ -function storage(storage: Storage, opts: WebStorageOptions) { +function decoratorBuilder(storage: Storage, opts: WebStorageOptions) { return function(target: object, key: string) { if(!opts.key) opts.key = key; + let field = fromStorage(storage, opts); Object.defineProperty(target, key, { get: function() { - const storageVal = storage.getItem(opts.key); - if(storageVal == null || storageVal == 'null' || storageVal == 'undefined') return opts.default || null; - return JSON.parse(storageVal); + if(field != fromStorage(storage, opts)) target[key] = field; + return field; }, - set: function(value) { + set: function(value?) { + field = value; if(value == null) storage.removeItem(opts.key); + if(opts.encryptWith != null) value = crypto.AES.encrypt(JSON.stringify(value), opts.encryptWith).toString(); storage.setItem(opts.key, JSON.stringify(value)); } }); diff --git a/tests/webstorage.spec.ts b/tests/webstorage.spec.ts index f5981b8..c848d77 100644 --- a/tests/webstorage.spec.ts +++ b/tests/webstorage.spec.ts @@ -1,22 +1,32 @@ import {LocalStorage, SessionStorage} from "../src"; -const CUSTOM_KEY = '__MY_KEY' +const CUSTOM_KEY = '_MY_KEY' +const ENCRYPTION_KEY = 'abc123'; -class TestClass { + +class TestType { + constructor(public first: string, public last: string) { } + fullName() { return `${this.last}, ${this.first}`; } +} + +class TestStorage { @LocalStorage() localStorage: any; @LocalStorage({a: true, b: 'test', c: 3.14}) defaultedLocalStorage: any; @LocalStorage(null, {key: CUSTOM_KEY}) customLocalStorage: any; + @LocalStorage(null, {encryptWith: ENCRYPTION_KEY}) encryptedLocalStorage: any; + @LocalStorage(new TestType('John', 'Smith')) objectLocalStorage!: TestType; @SessionStorage() sessionStorage: any; @SessionStorage({a: true, b: 'test', c: 3.14}) defaultedSessionStorage: any; @SessionStorage(null, {key: CUSTOM_KEY}) customSessionStorage: any; + @SessionStorage(null, {encryptWith: ENCRYPTION_KEY}) encryptedSessionStorage: any; + @SessionStorage(new TestType('John', 'Smith')) objectSessionStorage!: TestType; } -describe('WebStorage', () => { - let testComponent: TestClass; - +describe('Webstorage Decorators', () => { + let testComponent: TestStorage; beforeEach(() => { localStorage.clear(); - testComponent = new TestClass(); + testComponent = new TestStorage(); }); describe('LocalStorage', () => { @@ -52,6 +62,26 @@ describe('WebStorage', () => { expect(localStorage.getItem(CUSTOM_KEY)).toBe(JSON.stringify(testValue)); expect(testComponent.customLocalStorage).toBe(testValue); }); + test('Encrypted', () => { + const testValue = Math.random(); + testComponent.encryptedLocalStorage = testValue; + expect(localStorage.getItem('encryptedLocalStorage')).not.toBe(JSON.stringify(testValue)); + expect(testComponent.encryptedLocalStorage).toBe(testValue); + }); + test('Impure Functions', () => { + testComponent.localStorage = [1]; + testComponent.localStorage.push(2); + expect(localStorage.getItem('localStorage')).toStrictEqual(JSON.stringify([1])); + testComponent.localStorage; // Trigger save + expect(localStorage.getItem('localStorage')).toStrictEqual(JSON.stringify([1, 2])); + expect(testComponent.localStorage).toStrictEqual([1, 2]); + }); + test('Object Functions', () => { + expect(testComponent.objectLocalStorage.fullName()).toEqual('Smith, John'); + testComponent.objectLocalStorage.last = 'Snow'; + testComponent.objectLocalStorage; // Trigger save + expect(testComponent.objectLocalStorage.fullName()).toEqual('Snow, John'); + }); }); describe('SessionStorage', () => { @@ -87,5 +117,25 @@ describe('WebStorage', () => { expect(sessionStorage.getItem(CUSTOM_KEY)).toBe(JSON.stringify(testValue)); expect(testComponent.customSessionStorage).toBe(testValue); }); + test('Encrypted', () => { + const testValue = Math.random(); + testComponent.encryptedSessionStorage = testValue; + expect(sessionStorage.getItem('encryptedSessionStorage')).not.toBe(JSON.stringify(testValue)); + expect(testComponent.encryptedSessionStorage).toBe(testValue); + }); + test('Impure Functions', () => { + testComponent.sessionStorage = [1]; + testComponent.sessionStorage.push(2); + expect(sessionStorage.getItem('sessionStorage')).toStrictEqual(JSON.stringify([1])); + testComponent.sessionStorage; // Trigger save + expect(sessionStorage.getItem('sessionStorage')).toStrictEqual(JSON.stringify([1, 2])); + expect(testComponent.sessionStorage).toStrictEqual([1, 2]); + }); + test('Object Functions', () => { + expect(testComponent.objectSessionStorage.fullName()).toEqual('Smith, John'); + testComponent.objectSessionStorage.last = 'Snow'; + testComponent.objectSessionStorage; // Trigger save + expect(testComponent.objectSessionStorage.fullName()).toEqual('Snow, John'); + }); }); });