mirror of
https://github.com/bitwarden/browser
synced 2025-12-26 05:03:33 +00:00
PS-813 Add memory storage to state service (#2892)
* Use abstract methods and generics in StorageService * Prepend `Abstract` to abstract classes * Create session browser storage service * Use memory storage service for state memory * Inject memory storage service * Maintain filename extensions to help ide formatting * Preserve state if it's still in memory * Use jslib's memory storage service * linter * Create prototypes on stored objects * standardize package scripts * Add type safety to `withPrototype` decorators * webpack notify manifest version * Fix desktop * linter * Fix script * Improve prototye application * do not change prototype if it already matches desired * fix error with object values prototype application * Handle null state * Apply prototypes to browser-specific state * Add angular language server to recommended extensions * Improve browser state service tests * Start testing state Service * Fix abstract returns * Move test setup files to not be picked up by default glob matchers * Add key generation service * Add low-dependency encrypt service * Back crypto service with encrypt service. We'll want to work items that don't require state over to encrypt service * Add new storage service and tests * Properly init more stored values * Fix reload issues when state service is recovering state from session storage Co-authored-by: Thomas Avery <Thomas-Avery@users.noreply.github.com> Co-authored-by: Justin Baur <admin@justinbaur.com> * Simplify encrypt service * Do not log mac failures for local-backed session storage * `content` changed to `main` in #2245 * Fix CLI * Remove loggin * PR feedback * Merge remote-tracking branch 'origin/master' into add-memory-storage-to-state-service * Fix desktop * Fix decrypt method signature * Minify if not development * Key is required Co-authored-by: Thomas Avery <Thomas-Avery@users.noreply.github.com> Co-authored-by: Justin Baur <admin@justinbaur.com>
This commit is contained in:
@@ -6,6 +6,7 @@ module.exports = {
|
||||
collectCoverage: true,
|
||||
coverageReporters: ["html", "lcov"],
|
||||
coverageDirectory: "coverage",
|
||||
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
|
||||
preset: "jest-preset-angular",
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, {
|
||||
prefix: "<rootDir>/",
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"build": "webpack",
|
||||
"build:mv3": "cross-env MANIFEST_VERSION=3 webpack",
|
||||
"build:watch": "webpack --watch",
|
||||
"build:watch:MV3": "cross-env MANIFEST_VERSION=3 webpack --watch",
|
||||
"build:watch:mv3": "cross-env MANIFEST_VERSION=3 webpack --watch",
|
||||
"build:prod": "cross-env NODE_ENV=production webpack",
|
||||
"build:prod:watch": "cross-env NODE_ENV=production webpack --watch",
|
||||
"dist": "npm run build:prod && gulp dist",
|
||||
"dist:chromeMV3": "cross-env MANIFEST_VERSION=3 npm run build:prod && gulp dist:chrome",
|
||||
"dist:chrome": "npm run build:prod && gulp dist:chrome",
|
||||
"dist:chrome:mv3": "cross-env MANIFEST_VERSION=3 npm run build:prod && gulp dist:chrome",
|
||||
"dist:firefox": "npm run build:prod && gulp dist:firefox",
|
||||
"dist:opera": "npm run build:prod && gulp dist:opera",
|
||||
"dist:safari": "npm run build:prod && gulp dist:safari",
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common
|
||||
import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service";
|
||||
import { SendService as SendServiceAbstraction } from "@bitwarden/common/abstractions/send.service";
|
||||
import { SettingsService as SettingsServiceAbstraction } from "@bitwarden/common/abstractions/settings.service";
|
||||
import { StorageService as StorageServiceAbstraction } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/abstractions/sync.service";
|
||||
import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/abstractions/system.service";
|
||||
import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/abstractions/token.service";
|
||||
@@ -47,12 +47,14 @@ import { CipherService } from "@bitwarden/common/services/cipher.service";
|
||||
import { CollectionService } from "@bitwarden/common/services/collection.service";
|
||||
import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service";
|
||||
import { ContainerService } from "@bitwarden/common/services/container.service";
|
||||
import { EncryptService } from "@bitwarden/common/services/encrypt.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/services/environment.service";
|
||||
import { EventService } from "@bitwarden/common/services/event.service";
|
||||
import { ExportService } from "@bitwarden/common/services/export.service";
|
||||
import { FileUploadService } from "@bitwarden/common/services/fileUpload.service";
|
||||
import { FolderService } from "@bitwarden/common/services/folder.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service";
|
||||
import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service";
|
||||
import { NotificationsService } from "@bitwarden/common/services/notifications.service";
|
||||
import { OrganizationService } from "@bitwarden/common/services/organization.service";
|
||||
import { PasswordGenerationService } from "@bitwarden/common/services/passwordGeneration.service";
|
||||
@@ -79,11 +81,13 @@ import { AutofillService as AutofillServiceAbstraction } from "../services/abstr
|
||||
import { StateService as StateServiceAbstraction } from "../services/abstractions/state.service";
|
||||
import AutofillService from "../services/autofill.service";
|
||||
import { BrowserCryptoService } from "../services/browserCrypto.service";
|
||||
import BrowserLocalStorageService from "../services/browserLocalStorage.service";
|
||||
import BrowserMessagingService from "../services/browserMessaging.service";
|
||||
import BrowserMessagingPrivateModeBackgroundService from "../services/browserMessagingPrivateModeBackground.service";
|
||||
import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service";
|
||||
import BrowserStorageService from "../services/browserStorage.service";
|
||||
import I18nService from "../services/i18n.service";
|
||||
import { KeyGenerationService } from "../services/keyGeneration.service";
|
||||
import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service";
|
||||
import { StateService } from "../services/state.service";
|
||||
import { VaultFilterService } from "../services/vaultFilter.service";
|
||||
import VaultTimeoutService from "../services/vaultTimeout.service";
|
||||
@@ -100,8 +104,9 @@ import WebRequestBackground from "./webRequest.background";
|
||||
|
||||
export default class MainBackground {
|
||||
messagingService: MessagingServiceAbstraction;
|
||||
storageService: StorageServiceAbstraction;
|
||||
secureStorageService: StorageServiceAbstraction;
|
||||
storageService: AbstractStorageService;
|
||||
secureStorageService: AbstractStorageService;
|
||||
memoryStorageService: AbstractStorageService;
|
||||
i18nService: I18nServiceAbstraction;
|
||||
platformUtilsService: PlatformUtilsServiceAbstraction;
|
||||
logService: LogServiceAbstraction;
|
||||
@@ -141,6 +146,7 @@ export default class MainBackground {
|
||||
twoFactorService: TwoFactorServiceAbstraction;
|
||||
vaultFilterService: VaultFilterService;
|
||||
usernameGenerationService: UsernameGenerationServiceAbstraction;
|
||||
encryptService: EncryptService;
|
||||
|
||||
onUpdatedRan: boolean;
|
||||
onReplacedRan: boolean;
|
||||
@@ -181,9 +187,17 @@ export default class MainBackground {
|
||||
this.messagingService = isPrivateMode
|
||||
? new BrowserMessagingPrivateModeBackgroundService()
|
||||
: new BrowserMessagingService();
|
||||
this.storageService = new BrowserStorageService();
|
||||
this.secureStorageService = new BrowserStorageService();
|
||||
this.logService = new ConsoleLogService(false);
|
||||
this.cryptoFunctionService = new WebCryptoFunctionService(window);
|
||||
this.storageService = new BrowserLocalStorageService();
|
||||
this.secureStorageService = new BrowserLocalStorageService();
|
||||
this.memoryStorageService =
|
||||
chrome.runtime.getManifest().manifest_version == 3
|
||||
? new LocalBackedSessionStorageService(
|
||||
new EncryptService(this.cryptoFunctionService, this.logService, false),
|
||||
new KeyGenerationService(this.cryptoFunctionService)
|
||||
)
|
||||
: new MemoryStorageService();
|
||||
this.stateMigrationService = new StateMigrationService(
|
||||
this.storageService,
|
||||
this.secureStorageService,
|
||||
@@ -192,6 +206,7 @@ export default class MainBackground {
|
||||
this.stateService = new StateService(
|
||||
this.storageService,
|
||||
this.secureStorageService,
|
||||
this.memoryStorageService,
|
||||
this.logService,
|
||||
this.stateMigrationService,
|
||||
new StateFactory(GlobalState, Account)
|
||||
@@ -219,9 +234,10 @@ export default class MainBackground {
|
||||
}
|
||||
);
|
||||
this.i18nService = new I18nService(BrowserApi.getUILanguage(window));
|
||||
this.cryptoFunctionService = new WebCryptoFunctionService(window);
|
||||
this.encryptService = new EncryptService(this.cryptoFunctionService, this.logService, true);
|
||||
this.cryptoService = new BrowserCryptoService(
|
||||
this.cryptoFunctionService,
|
||||
this.encryptService,
|
||||
this.platformUtilsService,
|
||||
this.logService,
|
||||
this.stateService
|
||||
|
||||
@@ -64,9 +64,7 @@
|
||||
"unlimitedStorage",
|
||||
"clipboardRead",
|
||||
"clipboardWrite",
|
||||
"idle",
|
||||
"webRequest",
|
||||
"declarativeNetRequest"
|
||||
"idle"
|
||||
],
|
||||
"optional_permissions": ["nativeMessaging"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"],
|
||||
@@ -30,12 +30,12 @@ export class PopupUtilsService {
|
||||
return this.privateMode;
|
||||
}
|
||||
|
||||
getContentScrollY(win: Window, scrollingContainer = "content"): number {
|
||||
getContentScrollY(win: Window, scrollingContainer = "main"): number {
|
||||
const content = win.document.getElementsByTagName(scrollingContainer)[0];
|
||||
return content.scrollTop;
|
||||
}
|
||||
|
||||
setContentScrollY(win: Window, scrollY: number, scrollingContainer = "content"): void {
|
||||
setContentScrollY(win: Window, scrollY: number, scrollingContainer = "main"): void {
|
||||
if (scrollY != null) {
|
||||
const content = win.document.getElementsByTagName(scrollingContainer)[0];
|
||||
content.scrollTop = scrollY;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LockGuard as BaseLockGuardService } from "@bitwarden/angular/guards/loc
|
||||
import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/guards/unauth.guard";
|
||||
import {
|
||||
JslibServicesModule,
|
||||
MEMORY_STORAGE,
|
||||
SECURE_STORAGE,
|
||||
} from "@bitwarden/angular/services/jslib-services.module";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -34,7 +35,7 @@ import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abs
|
||||
import { SendService } from "@bitwarden/common/abstractions/send.service";
|
||||
import { SettingsService } from "@bitwarden/common/abstractions/settings.service";
|
||||
import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service";
|
||||
import { StorageService as StorageServiceAbstraction } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { SyncService } from "@bitwarden/common/abstractions/sync.service";
|
||||
import { TokenService } from "@bitwarden/common/abstractions/token.service";
|
||||
import { TotpService } from "@bitwarden/common/abstractions/totp.service";
|
||||
@@ -185,8 +186,8 @@ function getBgService<T>(service: keyof MainBackground) {
|
||||
deps: [],
|
||||
},
|
||||
{
|
||||
provide: StorageServiceAbstraction,
|
||||
useFactory: getBgService<StorageServiceAbstraction>("storageService"),
|
||||
provide: AbstractStorageService,
|
||||
useFactory: getBgService<AbstractStorageService>("storageService"),
|
||||
deps: [],
|
||||
},
|
||||
{ provide: AppIdService, useFactory: getBgService<AppIdService>("appIdService"), deps: [] },
|
||||
@@ -249,9 +250,13 @@ function getBgService<T>(service: keyof MainBackground) {
|
||||
},
|
||||
{
|
||||
provide: SECURE_STORAGE,
|
||||
useFactory: getBgService<StorageServiceAbstraction>("secureStorageService"),
|
||||
useFactory: getBgService<AbstractStorageService>("secureStorageService"),
|
||||
deps: [],
|
||||
},
|
||||
{
|
||||
provide: MEMORY_STORAGE,
|
||||
useFactory: getBgService<AbstractStorageService>("memoryStorageService"),
|
||||
},
|
||||
{
|
||||
provide: StateServiceAbstraction,
|
||||
useFactory: getBgService<StateServiceAbstraction>("stateService"),
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { StorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
|
||||
export default class BrowserStorageService implements StorageService {
|
||||
private chromeStorageApi: any;
|
||||
|
||||
constructor() {
|
||||
this.chromeStorageApi = chrome.storage.local;
|
||||
}
|
||||
export default abstract class AbstractChromeStorageService implements AbstractStorageService {
|
||||
protected abstract chromeStorageApi: any;
|
||||
|
||||
async get<T>(key: string): Promise<T> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -23,7 +19,7 @@ export default class BrowserStorageService implements StorageService {
|
||||
return (await this.get(key)) != null;
|
||||
}
|
||||
|
||||
async save(key: string, obj: any): Promise<any> {
|
||||
async save(key: string, obj: any): Promise<void> {
|
||||
if (obj == null) {
|
||||
// Fix safari not liking null in set
|
||||
return new Promise<void>((resolve) => {
|
||||
@@ -45,7 +41,7 @@ export default class BrowserStorageService implements StorageService {
|
||||
});
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<any> {
|
||||
async remove(key: string): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
this.chromeStorageApi.remove(key, () => {
|
||||
resolve();
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
|
||||
|
||||
export interface AbstractKeyGenerationService {
|
||||
makeEphemeralKey(numBytes?: number): Promise<SymmetricCryptoKey>;
|
||||
}
|
||||
5
apps/browser/src/services/browserLocalStorage.service.ts
Normal file
5
apps/browser/src/services/browserLocalStorage.service.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import AbstractChromeStorageService from "./abstractChromeStorageApi.service";
|
||||
|
||||
export default class BrowserLocalStorageService extends AbstractChromeStorageService {
|
||||
protected chromeStorageApi: any = chrome.storage.local;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import AbstractChromeStorageService from "./abstractChromeStorageApi.service";
|
||||
|
||||
export default class BrowserMemoryStorageService extends AbstractChromeStorageService {
|
||||
protected chromeStorageApi: any = (chrome.storage as any).session;
|
||||
}
|
||||
20
apps/browser/src/services/keyGeneration.service.ts
Normal file
20
apps/browser/src/services/keyGeneration.service.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
|
||||
|
||||
import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service";
|
||||
|
||||
export class KeyGenerationService implements AbstractKeyGenerationService {
|
||||
constructor(private cryptoFunctionService: CryptoFunctionService) {}
|
||||
|
||||
async makeEphemeralKey(numBytes = 16): Promise<SymmetricCryptoKey> {
|
||||
const keyMaterial = await this.cryptoFunctionService.randomBytes(numBytes);
|
||||
const key = await this.cryptoFunctionService.hkdf(
|
||||
keyMaterial,
|
||||
"bitwarden-ephemeral",
|
||||
"ephemeral",
|
||||
64,
|
||||
"sha256"
|
||||
);
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { Utils } from "@bitwarden/common/misc/utils";
|
||||
import { EncString } from "@bitwarden/common/models/domain/encString";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
|
||||
import { EncryptService } from "@bitwarden/common/src/services/encrypt.service";
|
||||
|
||||
import BrowserLocalStorageService from "./browserLocalStorage.service";
|
||||
import BrowserMemoryStorageService from "./browserMemoryStorage.service";
|
||||
import { KeyGenerationService } from "./keyGeneration.service";
|
||||
import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service";
|
||||
|
||||
describe("Browser Session Storage Service", () => {
|
||||
let encryptService: SubstituteOf<EncryptService>;
|
||||
let keyGenerationService: SubstituteOf<KeyGenerationService>;
|
||||
|
||||
let cache: Map<string, any>;
|
||||
const testObj = { a: 1, b: 2 };
|
||||
|
||||
let localStorage: BrowserLocalStorageService;
|
||||
let sessionStorage: BrowserMemoryStorageService;
|
||||
|
||||
const key = new SymmetricCryptoKey(
|
||||
Utils.fromUtf8ToArray("00000000000000000000000000000000").buffer
|
||||
);
|
||||
let getSessionKeySpy: jest.SpyInstance;
|
||||
const mockEnc = (input: string) => Promise.resolve(new EncString("ENCRYPTED" + input));
|
||||
|
||||
let sut: LocalBackedSessionStorageService;
|
||||
|
||||
beforeEach(() => {
|
||||
encryptService = Substitute.for();
|
||||
keyGenerationService = Substitute.for();
|
||||
|
||||
sut = new LocalBackedSessionStorageService(encryptService, keyGenerationService);
|
||||
|
||||
cache = sut["cache"];
|
||||
localStorage = sut["localStorage"];
|
||||
sessionStorage = sut["sessionStorage"];
|
||||
getSessionKeySpy = jest.spyOn(sut, "getSessionEncKey");
|
||||
getSessionKeySpy.mockResolvedValue(key);
|
||||
});
|
||||
|
||||
it("should exist", () => {
|
||||
expect(sut).toBeInstanceOf(LocalBackedSessionStorageService);
|
||||
});
|
||||
|
||||
describe("get", () => {
|
||||
it("should return from cache", async () => {
|
||||
cache.set("test", testObj);
|
||||
const result = await sut.get("test");
|
||||
expect(result).toStrictEqual(testObj);
|
||||
});
|
||||
|
||||
describe("not in cache", () => {
|
||||
const session = { test: testObj };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(sut, "getSessionEncKey").mockResolvedValue(key);
|
||||
});
|
||||
|
||||
describe("no session retrieved", () => {
|
||||
let result: any;
|
||||
let spy: jest.SpyInstance;
|
||||
beforeEach(async () => {
|
||||
spy = jest.spyOn(sut, "getLocalSession").mockResolvedValue(null);
|
||||
result = await sut.get("test");
|
||||
});
|
||||
|
||||
it("should grab from session if not in cache", async () => {
|
||||
expect(spy).toHaveBeenCalledWith(key);
|
||||
});
|
||||
|
||||
it("should return null if session is null", async () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session retrieved from storage", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(session);
|
||||
});
|
||||
|
||||
it("should return null if session does not have the key", async () => {
|
||||
const result = await sut.get("DNE");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return the value retrieved from session", async () => {
|
||||
const result = await sut.get("test");
|
||||
expect(result).toEqual(session.test);
|
||||
});
|
||||
|
||||
it("should set retrieved values in cache", async () => {
|
||||
await sut.get("test");
|
||||
expect(cache.has("test")).toBe(true);
|
||||
expect(cache.get("test")).toEqual(session.test);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("has", () => {
|
||||
it("should be false if `get` returns null", async () => {
|
||||
const spy = jest.spyOn(sut, "get");
|
||||
spy.mockResolvedValue(null);
|
||||
expect(await sut.has("test")).toBe(false);
|
||||
expect(spy).toHaveBeenCalledWith("test");
|
||||
});
|
||||
|
||||
it("should be true if `get` returns non-null", async () => {
|
||||
const spy = jest.spyOn(sut, "get");
|
||||
spy.mockResolvedValue({});
|
||||
expect(await sut.has("test")).toBe(true);
|
||||
expect(spy).toHaveBeenCalledWith("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should save null", async () => {
|
||||
const spy = jest.spyOn(sut, "save");
|
||||
spy.mockResolvedValue(null);
|
||||
await sut.remove("test");
|
||||
expect(spy).toHaveBeenCalledWith("test", null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save", () => {
|
||||
describe("caching", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(localStorage, "get").mockResolvedValue(null);
|
||||
jest.spyOn(sessionStorage, "get").mockResolvedValue(null);
|
||||
jest.spyOn(localStorage, "save").mockResolvedValue();
|
||||
jest.spyOn(sessionStorage, "save").mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should remove key from cache if value is null", async () => {
|
||||
cache.set("test", {});
|
||||
const deleteSpy = jest.spyOn(cache, "delete");
|
||||
expect(cache.has("test")).toBe(true);
|
||||
await sut.save("test", null);
|
||||
expect(cache.has("test")).toBe(false);
|
||||
expect(deleteSpy).toHaveBeenCalledWith("test");
|
||||
});
|
||||
|
||||
it("should set cache if value is non-null", async () => {
|
||||
expect(cache.has("test")).toBe(false);
|
||||
const setSpy = jest.spyOn(cache, "set");
|
||||
await sut.save("test", testObj);
|
||||
expect(cache.get("test")).toBe(testObj);
|
||||
expect(setSpy).toHaveBeenCalledWith("test", testObj);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local storing", () => {
|
||||
let setSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
setSpy = jest.spyOn(sut, "setLocalSession").mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should store a new session", async () => {
|
||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(null);
|
||||
await sut.save("test", testObj);
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key);
|
||||
});
|
||||
|
||||
it("should update an existing session", async () => {
|
||||
const existingObj = { test: testObj };
|
||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj);
|
||||
await sut.save("test2", testObj);
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith({ test2: testObj, ...existingObj }, key);
|
||||
});
|
||||
|
||||
it("should overwrite an existing item in session", async () => {
|
||||
const existingObj = { test: {} };
|
||||
jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj);
|
||||
await sut.save("test", testObj);
|
||||
|
||||
expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSessionKey", () => {
|
||||
beforeEach(() => {
|
||||
getSessionKeySpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should return the stored symmetric crypto key", async () => {
|
||||
jest.spyOn(sessionStorage, "get").mockResolvedValue({ ...key });
|
||||
const result = await sut.getSessionEncKey();
|
||||
|
||||
expect(result).toStrictEqual(key);
|
||||
});
|
||||
|
||||
describe("new key creation", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(sessionStorage, "get").mockResolvedValue(null);
|
||||
keyGenerationService.makeEphemeralKey().resolves(key);
|
||||
jest.spyOn(sut, "setSessionEncKey").mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should create a symmetric crypto key", async () => {
|
||||
const result = await sut.getSessionEncKey();
|
||||
|
||||
expect(result).toStrictEqual(key);
|
||||
keyGenerationService.received(1).makeEphemeralKey();
|
||||
});
|
||||
|
||||
it("should store a symmetric crypto key if it makes one", async () => {
|
||||
const spy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue();
|
||||
await sut.getSessionEncKey();
|
||||
|
||||
expect(spy).toBeCalledWith(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocalSession", () => {
|
||||
it("should return null if session is null", async () => {
|
||||
const spy = jest.spyOn(localStorage, "get").mockResolvedValue(null);
|
||||
const result = await sut.getLocalSession(key);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(spy).toBeCalledWith("session");
|
||||
});
|
||||
|
||||
describe("non-null sessions", () => {
|
||||
const session = { test: "test" };
|
||||
const encSession = new EncString(JSON.stringify(session));
|
||||
const decryptedSession = JSON.stringify(session);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(localStorage, "get").mockResolvedValue(encSession.encryptedString);
|
||||
});
|
||||
|
||||
it("should decrypt returned sessions", async () => {
|
||||
encryptService.decryptToUtf8(encSession, key).resolves(decryptedSession);
|
||||
await sut.getLocalSession(key);
|
||||
encryptService.received(1).decryptToUtf8(encSession, key);
|
||||
});
|
||||
|
||||
it("should parse session", async () => {
|
||||
encryptService.decryptToUtf8(encSession, key).resolves(decryptedSession);
|
||||
const result = await sut.getLocalSession(key);
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should remove state if decryption fails", async () => {
|
||||
encryptService.decryptToUtf8(Arg.any(), Arg.any()).resolves(null);
|
||||
const setSessionEncKeySpy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue();
|
||||
const removeLocalSessionSpy = jest.spyOn(localStorage, "remove").mockResolvedValue();
|
||||
|
||||
const result = await sut.getLocalSession(key);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(setSessionEncKeySpy).toHaveBeenCalledWith(null);
|
||||
expect(removeLocalSessionSpy).toHaveBeenCalledWith("session");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setLocalSession", () => {
|
||||
const testSession = { test: "a" };
|
||||
const testJSON = JSON.stringify(testSession);
|
||||
|
||||
it("should encrypt a stringified session", async () => {
|
||||
encryptService.encrypt(Arg.any(), Arg.any()).mimicks(mockEnc);
|
||||
jest.spyOn(localStorage, "save").mockResolvedValue();
|
||||
await sut.setLocalSession(testSession, key);
|
||||
|
||||
encryptService.received(1).encrypt(testJSON, key);
|
||||
});
|
||||
|
||||
it("should remove local session if null", async () => {
|
||||
encryptService.encrypt(Arg.any(), Arg.any()).resolves(null);
|
||||
const spy = jest.spyOn(localStorage, "remove").mockResolvedValue();
|
||||
await sut.setLocalSession(null, key);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith("session");
|
||||
});
|
||||
|
||||
it("should save encrypted string", async () => {
|
||||
encryptService.encrypt(Arg.any(), Arg.any()).mimicks(mockEnc);
|
||||
const spy = jest.spyOn(localStorage, "save").mockResolvedValue();
|
||||
await sut.setLocalSession(testSession, key);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith("session", (await mockEnc(testJSON)).encryptedString);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionKey", () => {
|
||||
it("should remove if null", async () => {
|
||||
const spy = jest.spyOn(sessionStorage, "remove").mockResolvedValue();
|
||||
await sut.setSessionEncKey(null);
|
||||
expect(spy).toHaveBeenCalledWith("localEncryptionKey");
|
||||
});
|
||||
|
||||
it("should save key when not null", async () => {
|
||||
const spy = jest.spyOn(sessionStorage, "save").mockResolvedValue();
|
||||
await sut.setSessionEncKey(key);
|
||||
expect(spy).toHaveBeenCalledWith("localEncryptionKey", key);
|
||||
});
|
||||
});
|
||||
});
|
||||
107
apps/browser/src/services/localBackedSessionStorage.service.ts
Normal file
107
apps/browser/src/services/localBackedSessionStorage.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { EncString } from "@bitwarden/common/models/domain/encString";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey";
|
||||
|
||||
import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service";
|
||||
import BrowserLocalStorageService from "./browserLocalStorage.service";
|
||||
import BrowserMemoryStorageService from "./browserMemoryStorage.service";
|
||||
|
||||
const keys = {
|
||||
encKey: "localEncryptionKey",
|
||||
sessionKey: "session",
|
||||
};
|
||||
|
||||
export class LocalBackedSessionStorageService extends AbstractStorageService {
|
||||
private cache = new Map<string, any>();
|
||||
private localStorage = new BrowserLocalStorageService();
|
||||
private sessionStorage = new BrowserMemoryStorageService();
|
||||
|
||||
constructor(
|
||||
private encryptService: AbstractEncryptService,
|
||||
private keyGenerationService: AbstractKeyGenerationService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T> {
|
||||
if (this.cache.has(key)) {
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
const session = await this.getLocalSession(await this.getSessionEncKey());
|
||||
if (session == null || !Object.keys(session).includes(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.cache.set(key, session[key]);
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
async has(key: string): Promise<boolean> {
|
||||
return (await this.get(key)) != null;
|
||||
}
|
||||
|
||||
async save(key: string, obj: any): Promise<void> {
|
||||
if (obj == null) {
|
||||
this.cache.delete(key);
|
||||
} else {
|
||||
this.cache.set(key, obj);
|
||||
}
|
||||
|
||||
const sessionEncKey = await this.getSessionEncKey();
|
||||
const localSession = (await this.getLocalSession(sessionEncKey)) ?? {};
|
||||
localSession[key] = obj;
|
||||
await this.setLocalSession(localSession, sessionEncKey);
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
await this.save(key, null);
|
||||
}
|
||||
|
||||
async getLocalSession(encKey: SymmetricCryptoKey): Promise<any> {
|
||||
const local = await this.localStorage.get<string>(keys.sessionKey);
|
||||
|
||||
if (local == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionJson = await this.encryptService.decryptToUtf8(new EncString(local), encKey);
|
||||
if (sessionJson == null) {
|
||||
// Error with decryption -- session is lost, delete state and key and start over
|
||||
await this.setSessionEncKey(null);
|
||||
await this.localStorage.remove(keys.sessionKey);
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(sessionJson);
|
||||
}
|
||||
|
||||
async setLocalSession(session: any, key: SymmetricCryptoKey) {
|
||||
const jsonSession = JSON.stringify(session);
|
||||
const encSession = await this.encryptService.encrypt(jsonSession, key);
|
||||
|
||||
if (encSession == null) {
|
||||
return await this.localStorage.remove(keys.sessionKey);
|
||||
}
|
||||
await this.localStorage.save(keys.sessionKey, encSession.encryptedString);
|
||||
}
|
||||
|
||||
async getSessionEncKey(): Promise<SymmetricCryptoKey> {
|
||||
let storedKey = (await this.sessionStorage.get(keys.encKey)) as SymmetricCryptoKey;
|
||||
if (storedKey == null || Object.keys(storedKey).length == 0) {
|
||||
storedKey = await this.keyGenerationService.makeEphemeralKey();
|
||||
await this.setSessionEncKey(storedKey);
|
||||
}
|
||||
return SymmetricCryptoKey.initFromJson(
|
||||
Object.create(SymmetricCryptoKey.prototype, Object.getOwnPropertyDescriptors(storedKey))
|
||||
);
|
||||
}
|
||||
|
||||
async setSessionEncKey(input: SymmetricCryptoKey): Promise<void> {
|
||||
if (input == null) {
|
||||
await this.sessionStorage.remove(keys.encKey);
|
||||
} else {
|
||||
await this.sessionStorage.save(keys.encKey, input);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
apps/browser/src/services/state.service.spec.ts
Normal file
109
apps/browser/src/services/state.service.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { LogService } from "@bitwarden/common/abstractions/log.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service";
|
||||
import { SendType } from "@bitwarden/common/enums/sendType";
|
||||
import { StateFactory } from "@bitwarden/common/factories/stateFactory";
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
||||
import { State } from "@bitwarden/common/models/domain/state";
|
||||
import { SendView } from "@bitwarden/common/models/view/sendView";
|
||||
import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service";
|
||||
|
||||
import { Account } from "../models/account";
|
||||
import { BrowserComponentState } from "../models/browserComponentState";
|
||||
import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState";
|
||||
import { BrowserSendComponentState } from "../models/browserSendComponentState";
|
||||
|
||||
import { StateService } from "./state.service";
|
||||
|
||||
describe("Browser State Service", () => {
|
||||
let secureStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let diskStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let memoryStorageService: SubstituteOf<AbstractStorageService>;
|
||||
let logService: SubstituteOf<LogService>;
|
||||
let stateMigrationService: SubstituteOf<StateMigrationService>;
|
||||
let stateFactory: SubstituteOf<StateFactory<GlobalState, Account>>;
|
||||
let useAccountCache: boolean;
|
||||
|
||||
let state: State<GlobalState, Account>;
|
||||
const userId = "userId";
|
||||
|
||||
let sut: StateService;
|
||||
|
||||
beforeEach(() => {
|
||||
secureStorageService = Substitute.for();
|
||||
diskStorageService = Substitute.for();
|
||||
memoryStorageService = Substitute.for();
|
||||
logService = Substitute.for();
|
||||
stateMigrationService = Substitute.for();
|
||||
stateFactory = Substitute.for();
|
||||
useAccountCache = true;
|
||||
|
||||
state = new State(new GlobalState());
|
||||
state.accounts[userId] = new Account({
|
||||
profile: { userId: userId },
|
||||
});
|
||||
state.activeUserId = userId;
|
||||
const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state)));
|
||||
memoryStorageService.get("state").mimicks(stateGetter);
|
||||
|
||||
sut = new StateService(
|
||||
diskStorageService,
|
||||
secureStorageService,
|
||||
memoryStorageService,
|
||||
logService,
|
||||
stateMigrationService,
|
||||
stateFactory,
|
||||
useAccountCache
|
||||
);
|
||||
});
|
||||
|
||||
describe("getBrowserGroupingComponentState", () => {
|
||||
it("should return a BrowserGroupingsComponentState", async () => {
|
||||
state.accounts[userId].groupings = new BrowserGroupingsComponentState();
|
||||
|
||||
const actual = await sut.getBrowserGroupingComponentState();
|
||||
expect(actual).toBeInstanceOf(BrowserGroupingsComponentState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserCipherComponentState", () => {
|
||||
it("should return a BrowserComponentState", async () => {
|
||||
const componentState = new BrowserComponentState();
|
||||
componentState.scrollY = 0;
|
||||
componentState.searchText = "test";
|
||||
state.accounts[userId].ciphers = componentState;
|
||||
|
||||
const actual = await sut.getBrowserCipherComponentState();
|
||||
expect(actual).toStrictEqual(componentState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserSendComponentState", () => {
|
||||
it("should return a BrowserSendComponentState", async () => {
|
||||
const sendState = new BrowserSendComponentState();
|
||||
sendState.sends = [new SendView(), new SendView()];
|
||||
sendState.typeCounts = new Map<SendType, number>([
|
||||
[SendType.File, 3],
|
||||
[SendType.Text, 5],
|
||||
]);
|
||||
state.accounts[userId].send = sendState;
|
||||
|
||||
const actual = await sut.getBrowserSendComponentState();
|
||||
expect(actual).toBeInstanceOf(BrowserSendComponentState);
|
||||
expect(actual).toMatchObject(sendState);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBrowserSendTypeComponentState", () => {
|
||||
it("should return a BrowserComponentState", async () => {
|
||||
const componentState = new BrowserComponentState();
|
||||
componentState.scrollY = 0;
|
||||
componentState.searchText = "test";
|
||||
state.accounts[userId].sendType = componentState;
|
||||
|
||||
const actual = await sut.getBrowserSendTypeComponentState();
|
||||
expect(actual).toStrictEqual(componentState);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { GlobalState } from "@bitwarden/common/models/domain/globalState";
|
||||
import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions";
|
||||
import { StateService as BaseStateService } from "@bitwarden/common/services/state.service";
|
||||
import {
|
||||
StateService as BaseStateService,
|
||||
withPrototype,
|
||||
} from "@bitwarden/common/services/state.service";
|
||||
|
||||
import { Account } from "../models/account";
|
||||
import { BrowserComponentState } from "../models/browserComponentState";
|
||||
@@ -24,15 +27,17 @@ export class StateService
|
||||
// Check that there is an account in memory before considering the user authenticated
|
||||
return (
|
||||
(await super.getIsAuthenticated(options)) &&
|
||||
(await this.getAccount(this.defaultInMemoryOptions)) != null
|
||||
(await this.getAccount(await this.defaultInMemoryOptions())) != null
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototype(BrowserGroupingsComponentState)
|
||||
async getBrowserGroupingComponentState(
|
||||
options?: StorageOptions
|
||||
): Promise<BrowserGroupingsComponentState> {
|
||||
return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions)))
|
||||
?.groupings;
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.groupings;
|
||||
}
|
||||
|
||||
async setBrowserGroupingComponentState(
|
||||
@@ -40,15 +45,20 @@ export class StateService
|
||||
options?: StorageOptions
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, this.defaultInMemoryOptions)
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.groupings = value;
|
||||
await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions));
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototype(BrowserComponentState)
|
||||
async getBrowserCipherComponentState(options?: StorageOptions): Promise<BrowserComponentState> {
|
||||
return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions)))
|
||||
?.ciphers;
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.ciphers;
|
||||
}
|
||||
|
||||
async setBrowserCipherComponentState(
|
||||
@@ -56,15 +66,20 @@ export class StateService
|
||||
options?: StorageOptions
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, this.defaultInMemoryOptions)
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.ciphers = value;
|
||||
await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions));
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototype(BrowserSendComponentState)
|
||||
async getBrowserSendComponentState(options?: StorageOptions): Promise<BrowserSendComponentState> {
|
||||
return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions)))
|
||||
?.send;
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.send;
|
||||
}
|
||||
|
||||
async setBrowserSendComponentState(
|
||||
@@ -72,14 +87,20 @@ export class StateService
|
||||
options?: StorageOptions
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, this.defaultInMemoryOptions)
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.send = value;
|
||||
await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions));
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
}
|
||||
|
||||
@withPrototype(BrowserComponentState)
|
||||
async getBrowserSendTypeComponentState(options?: StorageOptions): Promise<BrowserComponentState> {
|
||||
return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions)))
|
||||
?.sendType;
|
||||
return (
|
||||
await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))
|
||||
)?.sendType;
|
||||
}
|
||||
|
||||
async setBrowserSendTypeComponentState(
|
||||
@@ -87,9 +108,12 @@ export class StateService
|
||||
options?: StorageOptions
|
||||
): Promise<void> {
|
||||
const account = await this.getAccount(
|
||||
this.reconcileOptions(options, this.defaultInMemoryOptions)
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
account.sendType = value;
|
||||
await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions));
|
||||
await this.saveAccount(
|
||||
account,
|
||||
this.reconcileOptions(options, await this.defaultInMemoryOptions())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
26
apps/browser/test.setup.ts
Normal file
26
apps/browser/test.setup.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Add chrome storage api
|
||||
const get = jest.fn();
|
||||
const set = jest.fn();
|
||||
const has = jest.fn();
|
||||
const remove = jest.fn();
|
||||
const QUOTA_BYTES = 10;
|
||||
const getBytesInUse = jest.fn();
|
||||
const clear = jest.fn();
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
set,
|
||||
get,
|
||||
remove,
|
||||
QUOTA_BYTES,
|
||||
getBytesInUse,
|
||||
clear,
|
||||
},
|
||||
session: {
|
||||
set,
|
||||
get,
|
||||
has,
|
||||
remove,
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
@@ -11,6 +11,9 @@ if (process.env.NODE_ENV == null) {
|
||||
process.env.NODE_ENV = "development";
|
||||
}
|
||||
const ENV = (process.env.ENV = process.env.NODE_ENV);
|
||||
const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2;
|
||||
|
||||
console.log(`Building Manifest Version ${manifestVersion} app`);
|
||||
|
||||
const moduleRules = [
|
||||
{
|
||||
@@ -72,8 +75,8 @@ const plugins = [
|
||||
}),
|
||||
new CopyWebpackPlugin({
|
||||
patterns: [
|
||||
process.env.MANIFEST_VERSION == 3
|
||||
? { from: "./src/manifest.json.v3", to: "manifest.json" }
|
||||
manifestVersion == 3
|
||||
? { from: "./src/manifest.v3.json", to: "manifest.json" }
|
||||
: "./src/manifest.json",
|
||||
{ from: "./src/_locales", to: "_locales" },
|
||||
{ from: "./src/images", to: "images" },
|
||||
@@ -123,7 +126,7 @@ const config = {
|
||||
"notification/bar": "./src/notification/bar.js",
|
||||
},
|
||||
optimization: {
|
||||
minimize: true,
|
||||
minimize: ENV !== "development",
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
exclude: [/content\/.*/, /notification\/.*/],
|
||||
|
||||
Reference in New Issue
Block a user