Files
gitmost/apps/server/src/core/search/search.service.ts
T
claude code agent 227 7c48bab1f2 test: add unit tests for 10 candidates from issue #139
Adds co-located unit tests for ten targets (client → vitest *.test.ts(x),
server → jest *.spec.ts), plus minimal behavior-preserving extractions/exports
where the issue required a pure function to test:

- encode-wav: WAV header + PCM16 clamping
- editor-ext embed-provider / utils (sanitizeUrl, isInternalFileUrl) / indent
  (export clampIndent)
- label.dto @Matches regex
- move-page.dto vs generateJitteredKeyBetween parity (bug locked via test.failing)
- new-note-button canCreatePage (extracted to can-create-page.ts)
- history-editor diff (extracted pure computeHistoryDiff into history-diff.ts)
- notification getTypesForTab + repo contract (direct-tab divergence locked via
  test.failing)
- search buildTsQuery (extracted + sanitizes operator inputs so adversarial
  queries no longer risk a to_tsquery 500)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 04:13:44 +03:00

278 lines
8.6 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import { SearchResponseDto } from './dto/search-response.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { sql } from 'kysely';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const tsquery = require('pg-tsquery')();
// Build a safe prefix tsquery string from a raw user query.
//
// The previous inline form `tsquery(query.trim() + '*')` passed user input
// (including to_tsquery operators like `&`, `|`, `!`, `<->`, `*`, backslashes)
// straight through. pg-tsquery would then emit operator fragments that
// `to_tsquery('english', ...)` can reject as a syntax error, turning a search
// into a 500. We strip everything that is not a letter, number or whitespace
// BEFORE handing the text to pg-tsquery, so adversarial input degrades to a
// neutral (possibly empty) query instead of throwing, while normal word queries
// (incl. accented / non-Latin words) are unaffected.
export function buildTsQuery(raw: string): string {
const cleaned = (raw ?? '')
.normalize('NFC')
// Keep Unicode letters/numbers and whitespace; drop everything else.
.replace(/[^\p{L}\p{N}\s]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return '';
return tsquery(cleaned + '*');
}
@Injectable()
export class SearchService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
private pagePermissionRepo: PagePermissionRepo,
) {}
async searchPage(
searchParams: SearchDTO,
opts: {
userId?: string;
workspaceId: string;
},
): Promise<{ items: SearchResponseDto[] }> {
const { query } = searchParams;
if (query.length < 1) {
return { items: [] };
}
const searchQuery = buildTsQuery(query);
let queryResults = this.db
.selectFrom('pages')
.select([
'id',
'slugId',
'title',
'icon',
'parentPageId',
'creatorId',
'createdAt',
'updatedAt',
sql<number>`ts_rank(tsv, to_tsquery('english', f_unaccent(${searchQuery})))`.as(
'rank',
),
sql<string>`ts_headline('english', text_content, to_tsquery('english', f_unaccent(${searchQuery})),'MinWords=9, MaxWords=10, MaxFragments=3')`.as(
'highlight',
),
])
.where(
'tsv',
'@@',
sql<string>`to_tsquery('english', f_unaccent(${searchQuery}))`,
)
.$if(Boolean(searchParams.creatorId), (qb) =>
qb.where('creatorId', '=', searchParams.creatorId),
)
.where('deletedAt', 'is', null)
.orderBy('rank', 'desc')
.limit(searchParams.limit || 25)
.offset(searchParams.offset || 0);
if (!searchParams.shareId) {
queryResults = queryResults.select((eb) => this.pageRepo.withSpace(eb));
}
if (searchParams.spaceId) {
// search by spaceId
queryResults = queryResults.where('spaceId', '=', searchParams.spaceId);
} else if (opts.userId && !searchParams.spaceId) {
// only search spaces the user is a member of
queryResults = queryResults
.where(
'spaceId',
'in',
this.spaceMemberRepo.getUserSpaceIdsQuery(opts.userId),
)
.where('workspaceId', '=', opts.workspaceId);
} else if (searchParams.shareId && !searchParams.spaceId && !opts.userId) {
// search in shares
const shareId = searchParams.shareId;
const share = await this.shareRepo.findById(shareId);
if (!share || share.workspaceId !== opts.workspaceId) {
return { items: [] };
}
const isRestricted =
await this.pagePermissionRepo.hasRestrictedAncestor(share.pageId);
if (isRestricted) {
return { items: [] };
}
const pageIdsToSearch = [];
if (share.includeSubPages) {
const pageList = await this.pageRepo.getPageAndDescendantsExcludingRestricted(
share.pageId,
{
includeContent: false,
},
);
pageIdsToSearch.push(...pageList.map((page) => page.id));
} else {
pageIdsToSearch.push(share.pageId);
}
if (pageIdsToSearch.length > 0) {
queryResults = queryResults
.where('id', 'in', pageIdsToSearch)
.where('workspaceId', '=', opts.workspaceId);
} else {
return { items: [] };
}
} else {
return { items: [] };
}
//@ts-ignore
let results: any[] = await queryResults.execute();
// Filter results by page-level permissions (if user is authenticated)
if (opts.userId && results.length > 0) {
const pageIds = results.map((r: any) => r.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId: opts.userId,
spaceId: searchParams.spaceId,
});
const accessibleSet = new Set(accessibleIds);
results = results.filter((r: any) => accessibleSet.has(r.id));
}
//@ts-ignore
const searchResults = results.map((result: SearchResponseDto) => {
if (result.highlight) {
result.highlight = result.highlight
.replace(/\r\n|\r|\n/g, ' ')
.replace(/\s+/g, ' ');
}
return result;
});
return { items: searchResults };
}
async searchSuggestions(
suggestion: SearchSuggestionDTO,
userId: string,
workspaceId: string,
) {
let users = [];
let groups = [];
let pages = [];
const limit = suggestion?.limit || 10;
const query = suggestion.query.toLowerCase().trim();
if (suggestion.includeUsers) {
const userQuery = this.db
.selectFrom('users')
.select(['id', 'name', 'email', 'avatarUrl'])
.where('workspaceId', '=', workspaceId)
.where('deletedAt', 'is', null)
.where((eb) =>
eb.or([
eb(
sql`LOWER(f_unaccent(users.name))`,
'like',
sql`LOWER(f_unaccent(${`%${query}%`}))`,
),
eb(sql`users.email`, 'ilike', sql`f_unaccent(${`%${query}%`})`),
]),
)
.limit(limit);
users = await userQuery.execute();
}
if (suggestion.includeGroups) {
groups = await this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) =>
eb(
sql`LOWER(f_unaccent(groups.name))`,
'like',
sql`LOWER(f_unaccent(${`%${query}%`}))`,
),
)
.where('workspaceId', '=', workspaceId)
.limit(limit)
.execute();
}
if (suggestion.includePages) {
let pageSearch = this.db
.selectFrom('pages')
.select(['id', 'slugId', 'title', 'icon', 'spaceId'])
.select((eb) => this.pageRepo.withSpace(eb))
.where((eb) =>
eb(
sql`LOWER(f_unaccent(pages.title))`,
'like',
sql`LOWER(f_unaccent(${`%${query}%`}))`,
),
)
.where('deletedAt', 'is', null)
.where('workspaceId', '=', workspaceId)
.limit(limit);
// Template picker: restrict to pages flagged as templates.
if (suggestion.onlyTemplates) {
pageSearch = pageSearch.where('isTemplate', '=', true);
}
// search all spaces the user has access to, prioritizing the current space
const userSpaceIds = await this.spaceMemberRepo.getUserSpaceIds(userId);
if (userSpaceIds?.length > 0) {
pageSearch = pageSearch.where('spaceId', 'in', userSpaceIds);
if (suggestion?.spaceId) {
pageSearch = pageSearch.orderBy(
sql`CASE WHEN pages."space_id" = ${suggestion.spaceId} THEN 0 ELSE 1 END`,
'asc',
);
}
pages = await pageSearch.execute();
}
// Filter by page-level permissions
if (pages.length > 0) {
const pageIds = pages.map((p) => p.id);
const accessibleIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessibleIds);
pages = pages.filter((p) => accessibleSet.has(p.id));
}
}
return { users, groups, pages };
}
}