1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-27 01:53:23 +00:00

Use filter service in web

This commit is contained in:
Matt Gibson
2025-03-11 09:21:43 -07:00
parent a137c99797
commit b58663000a
9 changed files with 175 additions and 171 deletions

View File

@@ -845,7 +845,6 @@ export default class MainBackground {
this.domainSettingsService,
this.apiService,
this.i18nService,
this.searchService,
this.stateService,
this.autofillSettingsService,
this.encryptService,

View File

@@ -59,11 +59,13 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SyncService } from "@bitwarden/common/platform/sync";
import { CipherId, CollectionId, OrganizationId, UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FilterService } from "@bitwarden/common/vault/search/filter.service";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons, ToastService } from "@bitwarden/components";
import {
@@ -114,7 +116,6 @@ import { VaultFilterComponent } from "./vault-filter/components/vault-filter.com
import { VaultFilterService } from "./vault-filter/services/abstractions/vault-filter.service";
import { RoutedVaultFilterBridgeService } from "./vault-filter/services/routed-vault-filter-bridge.service";
import { RoutedVaultFilterService } from "./vault-filter/services/routed-vault-filter.service";
import { createFilterFunction } from "./vault-filter/shared/models/filter-function";
import {
All,
RoutedVaultFilterModel,
@@ -259,6 +260,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private totpService: TotpService,
private eventCollectionService: EventCollectionService,
private searchService: SearchService,
private filterService: FilterService,
private searchPipe: SearchPipe,
private apiService: ApiService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
@@ -270,6 +272,7 @@ export class VaultComponent implements OnInit, OnDestroy {
private trialFlowService: TrialFlowService,
private organizationBillingService: OrganizationBillingServiceAbstraction,
private billingNotificationService: BillingNotificationService,
private folderService: FolderService,
) {}
async ngOnInit() {
@@ -342,30 +345,30 @@ export class VaultComponent implements OnInit, OnDestroy {
this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
const ciphers$ = combineLatest([
this.cipherService.cipherViews$(activeUserId).pipe(filter((c) => c !== null)),
filter$,
this.currentSearchText$,
]).pipe(
filter(([ciphers, filter]) => ciphers != undefined && filter != undefined),
concatMap(async ([ciphers, filter, searchText]) => {
const failedCiphers = await firstValueFrom(
this.cipherService.failedToDecryptCiphers$(activeUserId),
);
const filterFunction = createFilterFunction(filter);
// Append any failed to decrypt ciphers to the top of the cipher list
const allCiphers = [...failedCiphers, ...ciphers];
const context$ = this.accountService.activeAccount$.pipe(
switchMap((account) => {
return this.filterService.context$({
ciphers: this.cipherService.cipherViews$(account.id),
folders: this.folderService.folderViews$(account.id),
collections: this.collectionService.decryptedCollections$,
organizations: this.organizationService.organizations$(account.id),
});
}),
);
if (await this.searchService.isSearchable(activeUserId, searchText)) {
return await this.searchService.searchCiphers(
activeUserId,
searchText,
[filterFunction],
allCiphers,
);
const ciphers$ = combineLatest([this.currentSearchText$, context$]).pipe(
switchMap(([currentText, context]) => {
return this.filterService.filter(of([currentText, context]));
}),
// this.filterService.filter,
switchMap((result) => {
if (result.isError) {
// return all ciphers
return context$.pipe(map((c) => c.ciphers));
} else {
// return filtered ciphers
return of(result.ciphers);
}
return allCiphers.filter(filterFunction);
}),
shareReplay({ refCount: true, bufferSize: 1 }),
);
@@ -391,14 +394,15 @@ export class VaultComponent implements OnInit, OnDestroy {
collectionsToReturn = selectedCollection?.children.map((c) => c.node) ?? [];
}
if (await this.searchService.isSearchable(activeUserId, searchText)) {
collectionsToReturn = this.searchPipe.transform(
collectionsToReturn,
searchText,
(collection) => collection.name,
(collection) => collection.id,
);
}
// TODO: Not sure what this was doing, we probably want to reproduce this
// if (await this.searchService.isSearchable(activeUserId, searchText)) {
// collectionsToReturn = this.searchPipe.transform(
// collectionsToReturn,
// searchText,
// (collection) => collection.name,
// (collection) => collection.id,
// );
// }
return collectionsToReturn;
}),

View File

@@ -269,6 +269,7 @@ import {
} from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { DefaultFilterService, FilterService } from "@bitwarden/common/vault/search/filter.service";
import {
CipherAuthorizationService,
DefaultCipherAuthorizationService,
@@ -491,42 +492,12 @@ const safeProviders: SafeProvider[] = [
}),
safeProvider({
provide: CipherServiceAbstraction,
useFactory: (
keyService: KeyService,
domainSettingsService: DomainSettingsService,
apiService: ApiServiceAbstraction,
i18nService: I18nServiceAbstraction,
searchService: SearchServiceAbstraction,
stateService: StateServiceAbstraction,
autofillSettingsService: AutofillSettingsServiceAbstraction,
encryptService: EncryptService,
bulkEncryptService: BulkEncryptService,
fileUploadService: CipherFileUploadServiceAbstraction,
configService: ConfigService,
stateProvider: StateProvider,
accountService: AccountServiceAbstraction,
) =>
new CipherService(
keyService,
domainSettingsService,
apiService,
i18nService,
searchService,
stateService,
autofillSettingsService,
encryptService,
bulkEncryptService,
fileUploadService,
configService,
stateProvider,
accountService,
),
useClass: CipherService,
deps: [
KeyService,
DomainSettingsService,
ApiServiceAbstraction,
I18nServiceAbstraction,
SearchServiceAbstraction,
StateServiceAbstraction,
AutofillSettingsServiceAbstraction,
EncryptService,
@@ -1495,6 +1466,11 @@ const safeProviders: SafeProvider[] = [
useClass: MasterPasswordApiService,
deps: [ApiServiceAbstraction, LogService],
}),
safeProvider({
provide: FilterService,
useClass: DefaultFilterService,
deps: [],
}),
];
@NgModule({

View File

@@ -1,7 +1,8 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { BehaviorSubject, Subject, firstValueFrom, from, switchMap, takeUntil } from "rxjs";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { BehaviorSubject, Observable, Subject, firstValueFrom, map } from "rxjs";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
@@ -10,6 +11,8 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FilterService } from "@bitwarden/common/vault/search/filter.service";
import { ProcessInstructions } from "@bitwarden/common/vault/search/query.types";
@Directive()
export class VaultItemsComponent implements OnInit, OnDestroy {
@@ -31,6 +34,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
private searchTimeout: any = null;
private isSearchable: boolean = false;
private processInstructions$: Observable<ProcessInstructions>;
private _searchText$ = new BehaviorSubject<string>("");
get searchText() {
return this._searchText$.value;
@@ -40,6 +44,7 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
}
constructor(
protected filterService: FilterService,
protected searchService: SearchService,
protected cipherService: CipherService,
protected accountService: AccountService,
@@ -48,14 +53,12 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this._searchText$
.pipe(
switchMap((searchText) => from(this.searchService.isSearchable(this.userId, searchText))),
takeUntil(this.destroy$),
)
.subscribe((isSearchable) => {
this.isSearchable = isSearchable;
});
const parsed$ = this.filterService.parse(this._searchText$).pipe(takeUntilDestroyed());
this.processInstructions$ = parsed$.pipe(map((p) => p.processInstructions));
parsed$.subscribe(({ isError }) => {
this.isSearchable = !isError;
});
}
ngOnDestroy(): void {
@@ -135,11 +138,6 @@ export class VaultItemsComponent implements OnInit, OnDestroy {
indexedCiphers = [...failedCiphers, ...indexedCiphers];
}
this.ciphers = await this.searchService.searchCiphers(
this.userId,
this.searchText,
[this.filter, this.deletedFilter],
indexedCiphers,
);
this.ciphers = await firstValueFrom(this.filterService.filter(this.processInstructions$));
}
}

View File

@@ -34,6 +34,7 @@ export type AstNode =
| IsFavorite;
type AstNodeBase = {
d: object[];
type: AstNodeType;
start: number;
end: number;
@@ -41,7 +42,7 @@ type AstNodeBase = {
};
export type Search = AstNodeBase & {
type: "search";
d: Or;
contents: Or;
};
export function isSearch(x: AstNode): x is Search {

View File

@@ -1,90 +1,105 @@
import * as lunr from "lunr";
import {
combineLatest,
combineLatestWith,
map,
NEVER,
Observable,
of,
OperatorFunction,
pipe,
switchMap,
} from "rxjs";
import { combineLatest, map, Observable, of, OperatorFunction, pipe, switchMap } from "rxjs";
// eslint-disable-next-line no-restricted-imports -- temp poc, so we're ignoring the circular package dependency. We'll need to move this to the vault team's package
import { CollectionService } from "@bitwarden/admin-console/src/common/collections/abstractions";
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
import { AccountService } from "../../auth/abstractions/account.service";
import { UriMatchStrategy } from "../../models/domain/domain-service";
import { CipherService } from "../abstractions/cipher.service";
import { FolderService } from "../abstractions/folder/folder.service.abstraction";
import { CipherType, FieldType } from "../enums";
import { CipherView } from "../models/view/cipher.view";
import { parseQuery } from "./parse";
import { ProcessInstructions, SearchContext } from "./query.types";
import {
FilterResult,
ObservableSearchContextInput,
ParseResult,
SearchContext,
} from "./query.types";
export class FilterService {
export abstract class FilterService {
abstract readonly parse: OperatorFunction<string, ParseResult>;
abstract readonly filter: OperatorFunction<[string | ParseResult, SearchContext], FilterResult>;
abstract context$(context: ObservableSearchContextInput): Observable<SearchContext>;
}
export class DefaultFilterService implements FilterService {
private static registeredPipeline = false;
searchContext$: Observable<SearchContext>;
constructor(
accountService: AccountService,
cipherService: CipherService,
organizationService: OrganizationService,
folderService: FolderService,
collectionService: CollectionService,
) {
const activeAccount$ = accountService.activeAccount$.pipe(
switchMap((account) => {
return account ? of(account) : NEVER;
}),
);
const viewsAndIndex$ = activeAccount$.pipe(
switchMap((account) =>
cipherService
.cipherViews$(account.id)
.pipe(switchMap((views) => of([views, this.buildIndex(views)] as const))),
),
);
this.searchContext$ = activeAccount$.pipe(
switchMap((account) => {
const userId = account.id;
return combineLatest([
viewsAndIndex$,
organizationService.organizations$(userId),
folderService.folderViews$(userId),
collectionService.decryptedCollections$,
]);
}),
switchMap(([[ciphers, index], organizations, folders, collections]) => {
return of({
ciphers,
index,
organizations,
folders,
collections,
});
}),
);
constructor() {
// Currently have to ensure this is only done a single time. Lunr allows you to register a function
// multiple times but they will add a warning message to the console. The way they do that breaks when ran on a service worker.
if (!FilterService.registeredPipeline) {
FilterService.registeredPipeline = true;
if (!DefaultFilterService.registeredPipeline) {
DefaultFilterService.registeredPipeline = true;
//register lunr pipeline function
lunr.Pipeline.registerFunction(this.normalizeAccentsPipelineFunction, "normalizeAccents");
}
}
parse = map((query: string) => parseQuery(query));
context$(context: ObservableSearchContextInput): Observable<SearchContext> {
return combineLatest([
context.ciphers,
context.organizations,
context.folders,
context.collections,
]).pipe(
map(([ciphers, organizations, folders, collections]) => {
return {
ciphers,
organizations,
folders,
collections,
index: this.buildIndex(ciphers),
};
}),
);
}
filter(): OperatorFunction<ProcessInstructions, CipherView[]> {
get parse() {
return (query: Observable<string> | null | undefined) => {
if (query == null) {
return of({
isError: true as const,
processInstructions: null,
});
} else {
return query.pipe(
map((query: string) => {
try {
return {
isError: false as const,
processInstructions: parseQuery(query),
};
} catch {
return {
isError: true as const,
processInstructions: null,
};
}
}),
);
}
};
}
get filter(): OperatorFunction<[string | ParseResult, SearchContext], FilterResult> {
return pipe(
combineLatestWith(this.searchContext$),
map(([instructions, context]) => {
return instructions.filter(context).ciphers;
switchMap(([queryOrParsedQuery, context]) => {
if (queryOrParsedQuery == null || typeof queryOrParsedQuery === "string") {
// it's a string that needs parsing
return combineLatest([this.parse(of(queryOrParsedQuery as string)), of(context)]);
} else {
// It's a parsed query
return combineLatest([of(queryOrParsedQuery), of(context)]);
}
}),
map(([parseResult, context]) => {
if (parseResult.isError) {
return {
...parseResult,
ciphers: null,
};
} else {
return {
...parseResult,
ciphers: parseResult.processInstructions.filter(context).ciphers,
};
}
}),
);
}
@@ -154,7 +169,7 @@ export class FilterService {
const checkFields = fields.every((i: any) => searchableFields.includes(i));
if (checkFields) {
return FilterService.normalizeSearchQuery(token.toString());
return DefaultFilterService.normalizeSearchQuery(token.toString());
}
return token;

View File

@@ -20,23 +20,26 @@ import {
import grammar from "./bitwarden-query-grammar";
import { ProcessInstructions } from "./query.types";
export const PARSE_ERROR = new Error("Invalid search query");
export function parseQuery(query: string): ProcessInstructions {
const parser = new Parser(Grammar.fromCompiled(grammar));
parser.feed(query);
if (!parser.results) {
// TODO: Better error handling
// there should be some invalid token information
throw new Error("Invalid search query");
throw PARSE_ERROR;
}
const result = parser.results[0] as AstNode;
return handleNode(result);
const parsed = handleNode(result);
return parsed;
}
function handleNode(node: AstNode): ProcessInstructions {
if (isSearch(node)) {
return handleNode(node.d);
return handleNode(node.contents);
} else if (isOr(node)) {
const left = handleNode(node.left);
const right = handleNode(node.right);

View File

@@ -1,4 +1,5 @@
import lunr from "lunr";
import { Observable } from "rxjs";
import { CollectionView } from "@bitwarden/admin-console/common";
@@ -8,6 +9,28 @@ import { FolderView } from "../models/view/folder.view";
import { AstNodeType } from "./ast";
export type ParseResult =
| {
processInstructions: ProcessInstructions;
isError: false;
}
| {
processInstructions: null;
isError: true;
};
export type FilterResult =
| {
ciphers: CipherView[];
processInstructions: ProcessInstructions;
isError: false;
}
| {
ciphers: null;
processInstructions: null;
isError: true;
};
export type ProcessInstructions = {
filter: (context: SearchContext) => SearchContext;
sections: { start: number; end: number; type: AstNodeType }[];
@@ -20,3 +43,7 @@ export type SearchContext = {
organizations: Organization[];
index: lunr.Index;
};
export type ObservableSearchContextInput = {
[key in keyof Omit<SearchContext, "index">]: Observable<SearchContext[key]>;
};

View File

@@ -17,7 +17,6 @@ import { SemVer } from "semver";
import { KeyService } from "@bitwarden/key-management";
import { ApiService } from "../../abstractions/api.service";
import { SearchService } from "../../abstractions/search.service";
import { AccountService } from "../../auth/abstractions/account.service";
import { AutofillSettingsServiceAbstraction } from "../../autofill/services/autofill-settings.service";
import { DomainSettingsService } from "../../autofill/services/domain-settings.service";
@@ -102,7 +101,6 @@ export class CipherService implements CipherServiceAbstraction {
private domainSettingsService: DomainSettingsService,
private apiService: ApiService,
private i18nService: I18nService,
private searchService: SearchService,
private stateService: StateService,
private autofillSettingsService: AutofillSettingsServiceAbstraction,
private encryptService: EncryptService,
@@ -163,13 +161,6 @@ export class CipherService implements CipherServiceAbstraction {
if (value == null || value.length !== 0) {
await this.setDecryptedCiphers(value, userId);
}
if (this.searchService != null) {
if (value == null) {
await this.searchService.clearIndex(userId);
} else {
await this.searchService.indexCiphers(userId, value);
}
}
}
async setFailedDecryptedCiphers(cipherViews: CipherView[], userId: UserId) {
@@ -394,7 +385,6 @@ export class CipherService implements CipherServiceAbstraction {
async getAllDecrypted(userId: UserId): Promise<CipherView[]> {
const decCiphers = await this.getDecryptedCiphers(userId);
if (decCiphers != null && decCiphers.length !== 0) {
await this.reindexCiphers(userId);
return await this.getDecryptedCiphers(userId);
}
@@ -477,15 +467,6 @@ export class CipherService implements CipherServiceAbstraction {
);
}
private async reindexCiphers(userId: UserId) {
const reindexRequired =
this.searchService != null &&
((await firstValueFrom(this.searchService.indexedEntityId$(userId))) ?? userId) !== userId;
if (reindexRequired) {
await this.searchService.indexCiphers(userId, await this.getDecryptedCiphers(userId), userId);
}
}
async getAllDecryptedForGrouping(
groupingId: string,
userId: UserId,