diff --git a/libs/common/src/vault/models/view/cipher.view.ts b/libs/common/src/vault/models/view/cipher.view.ts index 7ddba9e2ed5..7cbcd5d264e 100644 --- a/libs/common/src/vault/models/view/cipher.view.ts +++ b/libs/common/src/vault/models/view/cipher.view.ts @@ -6,6 +6,7 @@ import { InitializerKey } from "../../../platform/services/cryptography/initiali import { DeepJsonify } from "../../../types/deep-jsonify"; import { CipherType, LinkedIdType } from "../../enums"; import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; +import { searchable } from "../../search/searchable.decorator"; import { CipherPermissionsApi } from "../api/cipher-permissions.api"; import { LocalData } from "../data/local.data"; import { Cipher } from "../domain/cipher"; @@ -22,12 +23,17 @@ import { SshKeyView } from "./ssh-key.view"; export class CipherView implements View, InitializerMetadata { readonly initializerKey = InitializerKey.CipherView; + @searchable() id: string = null; organizationId: string = null; folderId: string = null; + @searchable() name: string = null; + @searchable() notes: string = null; + @searchable({ key: "type", strategy: { enum: CipherType } }) type: CipherType = null; + @searchable({ key: "favorite", strategy: { boolean: true } }) favorite = false; organizationUseTotp = false; permissions: CipherPermissionsApi = new CipherPermissionsApi(); @@ -46,6 +52,7 @@ export class CipherView implements View, InitializerMetadata { revisionDate: Date = null; creationDate: Date = null; deletedDate: Date = null; + @searchable({ key: "reprompt", strategy: { enum: CipherRepromptType } }) reprompt: CipherRepromptType = CipherRepromptType.None; /** diff --git a/libs/common/src/vault/models/view/login-uri.view.ts b/libs/common/src/vault/models/view/login-uri.view.ts index 315adb87c75..885cff89738 100644 --- a/libs/common/src/vault/models/view/login-uri.view.ts +++ b/libs/common/src/vault/models/view/login-uri.view.ts @@ -6,9 +6,11 @@ import { UriMatchStrategy, UriMatchStrategySetting } from "../../../models/domai import { View } from "../../../models/view/view"; import { SafeUrls } from "../../../platform/misc/safe-urls"; import { Utils } from "../../../platform/misc/utils"; +import { searchable } from "../../search/searchable.decorator"; import { LoginUri } from "../domain/login-uri"; export class LoginUriView implements View { + @searchable({ key: "match detection", strategy: { enum: UriMatchStrategy } }) match: UriMatchStrategySetting = null; private _uri: string = null; @@ -25,6 +27,7 @@ export class LoginUriView implements View { this.match = u.match; } + @searchable({ key: "website", strategy: "string" }) get uri(): string { return this._uri; } diff --git a/libs/common/src/vault/models/view/login.view.ts b/libs/common/src/vault/models/view/login.view.ts index 228f3a60c34..ea73c915daa 100644 --- a/libs/common/src/vault/models/view/login.view.ts +++ b/libs/common/src/vault/models/view/login.view.ts @@ -5,6 +5,7 @@ import { Utils } from "../../../platform/misc/utils"; import { DeepJsonify } from "../../../types/deep-jsonify"; import { LoginLinkedId as LinkedId } from "../../enums"; import { linkedFieldOption } from "../../linked-field-option.decorator"; +import { searchable } from "../../search/searchable.decorator"; import { Login } from "../domain/login"; import { Fido2CredentialView } from "./fido2-credential.view"; @@ -12,6 +13,7 @@ import { ItemView } from "./item.view"; import { LoginUriView } from "./login-uri.view"; export class LoginView extends ItemView { + @searchable() @linkedFieldOption(LinkedId.Username, { sortPosition: 0 }) username: string = null; @linkedFieldOption(LinkedId.Password, { sortPosition: 1 }) diff --git a/libs/common/src/vault/search/searchable.decorator.spec.ts b/libs/common/src/vault/search/searchable.decorator.spec.ts new file mode 100644 index 00000000000..9bc98183628 --- /dev/null +++ b/libs/common/src/vault/search/searchable.decorator.spec.ts @@ -0,0 +1,158 @@ +import { SearchableValueType, getSearchableFields, searchable } from "./searchable.decorator"; + +describe("Searchable Decorator", () => { + it("adds a searchable string", () => { + class TestClass { + @searchable() property = "test"; + } + const instance = new TestClass(); + const searchableFields = getSearchableFields(instance); + expect(searchableFields).toEqual([ + { fieldName: "property", type: SearchableValueType.String, getter: expect.any(Function) }, + ]); + expect(searchableFields[0].getter(instance)).toBe("test"); + }); + + it("adds a searchable enum", () => { + enum TestEnum { + Option1, + Option2, + } + class TestClass { + @searchable({ strategy: { enum: TestEnum } }) property = TestEnum.Option1; + } + const instance = new TestClass(); + const searchableFields = getSearchableFields(instance); + expect(searchableFields).toEqual([ + { fieldName: "property", type: SearchableValueType.Enum, getter: expect.any(Function) }, + ]); + expect(searchableFields[0].getter(instance)).toEqual({ + type: SearchableValueType.Enum, + enum: TestEnum, + value: TestEnum.Option1, + }); + }); + + it("add a searchable getter", () => { + class TestClass { + @searchable() get property() { + return "test"; + } + } + const instance = new TestClass(); + const searchableFields = getSearchableFields(instance); + expect(searchableFields).toEqual([ + { fieldName: "property", type: SearchableValueType.String, getter: expect.any(Function) }, + ]); + expect(searchableFields[0].getter(instance)).toBe("test"); + }); + + it("overrides the name of the searchable field", () => { + class TestClass { + @searchable({ key: "customName", strategy: "string" }) property = "test"; + } + const instance = new TestClass(); + const searchableFields = getSearchableFields(instance); + expect(searchableFields).toEqual([ + { fieldName: "customName", type: SearchableValueType.String, getter: expect.any(Function) }, + ]); + expect(searchableFields[0].getter(instance)).toBe("test"); + }); + + it("adds all fields to the searchable fields", () => { + class TestClass { + @searchable() property1 = "test"; + @searchable({ key: "customName", strategy: "string" }) property2 = "test"; + @searchable({ strategy: { enum: { Option1: 1, Option2: 2 } } }) property3 = 1; + @searchable() get property4() { + return "test"; + } + } + const instance = new TestClass(); + const searchableFields = getSearchableFields(instance); + expect(searchableFields).toEqual([ + { fieldName: "property1", type: SearchableValueType.String, getter: expect.any(Function) }, + { fieldName: "customName", type: SearchableValueType.String, getter: expect.any(Function) }, + { fieldName: "property3", type: SearchableValueType.Enum, getter: expect.any(Function) }, + { fieldName: "property4", type: SearchableValueType.String, getter: expect.any(Function) }, + ]); + expect(searchableFields[0].getter(instance)).toBe("test"); + expect(searchableFields[1].getter(instance)).toBe("test"); + expect(searchableFields[2].getter(instance)).toEqual({ + type: SearchableValueType.Enum, + enum: { Option1: 1, Option2: 2 }, + value: 1, + }); + expect(searchableFields[3].getter(instance)).toBe("test"); + }); +}); + +describe("getSearchableFields", () => { + it("returns the searchable fields", () => { + class TestClass { + @searchable() property1 = "test"; + @searchable() get property2() { + return "test"; + } + } + const instance = new TestClass(); + const searchableFields = getSearchableFields(instance); + expect(searchableFields).toEqual([ + { fieldName: "property1", type: SearchableValueType.String, getter: expect.any(Function) }, + { fieldName: "property2", type: SearchableValueType.String, getter: expect.any(Function) }, + ]); + expect(searchableFields[0].getter(instance)).toBe("test"); + expect(searchableFields[1].getter(instance)).toBe("test"); + }); + + it("throws when a target class is not searchable", () => { + class TestClass { + property1 = "test"; + } + const instance = new TestClass(); + expect(() => getSearchableFields(instance)).toThrow("Target is not searchable"); + }); + + it("recurses to get searchable fields from properties", () => { + class Inner { + @searchable() innerString = "innerVal"; + } + class Outer { + @searchable() outerString = "outerVal"; + inner = new Inner(); + } + + const instance = new Outer(); + const searchableFields = getSearchableFields(instance); + expect(searchableFields).toEqual([ + { fieldName: "outerString", type: SearchableValueType.String, getter: expect.any(Function) }, + { fieldName: "innerString", type: SearchableValueType.String, getter: expect.any(Function) }, + ]); + expect(searchableFields[0].getter(instance)).toBe("outerVal"); + expect(searchableFields[1].getter(instance)).toBe("innerVal"); + }); + + it("recurses to get searchable fields from arrays", () => { + class Inner { + @searchable() innerString; + constructor(innerVal?: string) { + this.innerString = innerVal ?? "innerVal"; + } + } + class Outer { + @searchable() outerString = "outerVal"; + innerArray: Inner[] = [new Inner(), new Inner("secondInnerVal")]; + } + + const instance = new Outer(); + const searchableFields = getSearchableFields(instance); + expect(searchableFields).toEqual([ + { fieldName: "outerString", type: SearchableValueType.String, getter: expect.any(Function) }, + { fieldName: "innerString", type: SearchableValueType.String, getter: expect.any(Function) }, + { fieldName: "innerString", type: SearchableValueType.String, getter: expect.any(Function) }, + ]); + expect(searchableFields[0].getter(instance)).toBe("outerVal"); + expect(searchableFields[1].getter(instance)).toBe("innerVal"); + expect(searchableFields[2].getter(instance)).toBe("secondInnerVal"); + }); +}); diff --git a/libs/common/src/vault/search/searchable.decorator.ts b/libs/common/src/vault/search/searchable.decorator.ts new file mode 100644 index 00000000000..817740e757a --- /dev/null +++ b/libs/common/src/vault/search/searchable.decorator.ts @@ -0,0 +1,193 @@ +const searchableFields = Symbol("searchable"); + +/** The types of values that are searchable */ +export enum SearchableValueType { + String, + Enum, + Boolean, +} + +/** + * The union type representing expected return data for each kind of searchable value. + * This is more complicated than a simple value because matchers for different types of values need + * different data to perform the search. + */ +export type SearchableData = string | EnumSearchableData | BooleanSearchableData; + +/** The packet of data used by the search logic to perform matching */ +type SearchableGetter = { + /** The search field */ + fieldName: string; + /** The type of search to perform */ + type: SearchableValueType; + /** A getter for the current value of this search field from a given instance */ + getter: (instance: unknown) => SearchableData; +}; + +type SearchableTypes = "string" | EnumMetadata | BooleanMetadata; + +export interface Searchable { + [searchableFields]: SearchableGetter[]; +} + +/** Guard function determining if an object contains Searchable fields */ +export function isSearchable(x: unknown): x is Searchable { + return typeof x === "object" && x !== null && searchableFields in x; +} + +/** + * Extract {@link SearchableGetter} array from a searchable object. This method recurses all properties to find any + * searchable field in the tree. + * @param target + * @returns + */ +export function getSearchableFields(target: unknown): SearchableGetter[] { + if (!isSearchable(target)) { + throw new Error("Target is not searchable"); + } + + return recurseSearchableFields(target, (ancestor) => ancestor); +} + +/** + * Traverses an object and returns all searchable fields. Getters are updated to traverse the object in the same way. + * + * Arrays containing searchable objects are also traversed. + * + * @param target The target searchable object + * @param traversal The breadcrumb traversal function to use to traverse an ancestor object to the current level. + * @returns + */ +function recurseSearchableFields( + target: Searchable, + traversal: (ancestor: unknown) => unknown, +): SearchableGetter[] { + const result = target[searchableFields].map((getterData) => ({ + ...getterData, + getter: (ancestor: unknown) => getterData.getter(traversal(ancestor)), + })); + + for (const [key, value] of Object.entries(target)) { + const newTraversal = (ancestor: unknown) => (traversal(ancestor) as any)[key]; + + if (Array.isArray(value)) { + value.forEach((item, i) => { + const arrayTraversal = (ancestor: unknown) => (newTraversal(ancestor) as any)[i]; + if (isSearchable(item)) { + result.push(...recurseSearchableFields(item, arrayTraversal)); + } + }); + } else if (isSearchable(value)) { + result.push(...recurseSearchableFields(value, newTraversal)); + } + } + return result; +} + +/** + * The data needed to set up enum searching + */ +type EnumMetadata = { + enum: { [name: string]: any }; +}; + +function isEnumMetadata(type: unknown): type is EnumMetadata { + return typeof type === "object" && type !== null && "enum" in type; +} + +/** + * The data needed to perform enum searching for a given field + */ +type EnumSearchableData = { + type: SearchableValueType.Enum; + enum: { [name: string]: any }; + value: any; +}; + +type BooleanMetadata = { + boolean: boolean; +}; + +function isBooleanMetadata(type: unknown): type is BooleanMetadata { + return typeof type === "object" && type !== null && "boolean" in type; +} + +type BooleanSearchableData = { + type: SearchableValueType.Boolean; + boolean: boolean; + value: boolean; +}; + +function getSearchableTypeValue(type: SearchableTypes): SearchableValueType { + if (type === "string") { + return SearchableValueType.String; + } + + if (isEnumMetadata(type)) { + return SearchableValueType.Enum; + } else if (isBooleanMetadata(type)) { + return SearchableValueType.Boolean; + } + + throw new Error("Invalid searchable type"); +} + +export type SearchableOptions = { + key?: string; + strategy: SearchableTypes; +}; + +/** + * Decorator indicating that a the decorated field or getter should be included in search results. + * + * Note: It is important that the strategy is set to the correct type. There is _no_ type validation to ensure this. + * It is undefined behavior to set the strategy to a type that does not match the field or to use this decorator on a + * type that it cannot process. + * + * @see {@link SearchableValueType} for the types of values that can be searched + * + * @param options.key Optional name of the searchable field. Defaults to the property name + * @param options.strategy The type of searchable value. Defaults to string if no options are given + */ +export function searchable(options: SearchableOptions = { strategy: "string" }) { + return (prototype: unknown, propertyKey: string) => { + if (options.key == null) { + options.key = propertyKey; + } + + const target = prototype as Searchable & Record; + target[searchableFields] ??= []; + + const type = getSearchableTypeValue(options.strategy); + + const getter = (instance: unknown) => { + const i = instance as Record; + switch (type) { + case SearchableValueType.Enum: { + return { + type, + enum: (options.strategy as EnumMetadata).enum, + value: i[propertyKey], + } as EnumSearchableData; + } + case SearchableValueType.String: + return i[propertyKey]; + case SearchableValueType.Boolean: { + return { + type, + boolean: (options.strategy as BooleanMetadata).boolean, + value: i[propertyKey], + } as BooleanSearchableData; + } + default: + throw new Error("Invalid searchable type"); + } + }; + + target[searchableFields].push({ + fieldName: options.key, + type, + getter, + }); + }; +} diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index def0c04dd16..59c777f9fd4 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -139,7 +139,6 @@ describe("Cipher Service", () => { domainSettingsService, apiService, i18nService, - searchService, stateService, autofillSettingsService, encryptService,