/* * SPDX-FileCopyrightText: syuilo and other misskey contributors * SPDX-License-Identifier: AGPL-3.0-only */ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; import { Note } from '@/models/entities/Note.js'; import { User } from '@/models/index.js'; import type { NotesRepository } from '@/models/index.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; import { QueryService } from '@/core/QueryService.js'; import { IdService } from '@/core/IdService.js'; import type { Index, MeiliSearch } from 'meilisearch'; type K = string; type V = string | number | boolean; type Q = { op: '=', k: K, v: V } | { op: '!=', k: K, v: V } | { op: '>', k: K, v: number } | { op: '<', k: K, v: number } | { op: '>=', k: K, v: number } | { op: '<=', k: K, v: number } | { op: 'and', qs: Q[] } | { op: 'or', qs: Q[] } | { op: 'not', q: Q }; function compileValue(value: V): string { if (typeof value === 'string') { return `'${value}'`; // TODO: escape } else if (typeof value === 'number') { return value.toString(); } else if (typeof value === 'boolean') { return value.toString(); } throw new Error('unrecognized value'); } function compileQuery(q: Q): string { switch (q.op) { case '=': return `(${q.k} = ${compileValue(q.v)})`; case '!=': return `(${q.k} != ${compileValue(q.v)})`; case '>': return `(${q.k} > ${compileValue(q.v)})`; case '<': return `(${q.k} < ${compileValue(q.v)})`; case '>=': return `(${q.k} >= ${compileValue(q.v)})`; case '<=': return `(${q.k} <= ${compileValue(q.v)})`; case 'and': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' AND ') })`; case 'or': return q.qs.length === 0 ? '' : `(${ q.qs.map(_q => compileQuery(_q)).join(' OR ') })`; case 'not': return `(NOT ${compileQuery(q.q)})`; default: throw new Error('unrecognized query operator'); } } @Injectable() export class SearchService { private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local'; private meilisearchNoteIndex: Index | null = null; constructor( @Inject(DI.config) private config: Config, @Inject(DI.meilisearch) private meilisearch: MeiliSearch | null, @Inject(DI.notesRepository) private notesRepository: NotesRepository, private queryService: QueryService, private idService: IdService, ) { if (meilisearch) { this.meilisearchNoteIndex = meilisearch.index(`${config.meilisearch!.index}---notes`); this.meilisearchNoteIndex.updateSettings({ searchableAttributes: [ 'text', 'cw', ], sortableAttributes: [ 'createdAt', ], filterableAttributes: [ 'createdAt', 'userId', 'userHost', 'channelId', 'tags', ], typoTolerance: { enabled: false, }, pagination: { maxTotalHits: 10000, }, }); } if (config.meilisearch?.scope) { this.meilisearchIndexScope = config.meilisearch.scope; } } @bindThis public async indexNote(note: Note): Promise { if (note.text == null && note.cw == null) return; if (!['home', 'public'].includes(note.visibility)) return; if (this.meilisearch) { switch (this.meilisearchIndexScope) { case 'global': break; case 'local': if (note.userHost == null) break; return; default: { if (note.userHost == null) break; if (this.meilisearchIndexScope.includes(note.userHost)) break; return; } } await this.meilisearchNoteIndex?.addDocuments([{ id: note.id, createdAt: note.createdAt.getTime(), userId: note.userId, userHost: note.userHost, channelId: note.channelId, cw: note.cw, text: note.text, tags: note.tags, }], { primaryKey: 'id', }); } } @bindThis public async unindexNote(note: Note): Promise { if (!['home', 'public'].includes(note.visibility)) return; if (this.meilisearch) { this.meilisearchNoteIndex!.deleteDocument(note.id); } } @bindThis public async searchNote(q: string, me: User | null, opts: { userId?: Note['userId'] | null; channelId?: Note['channelId'] | null; host?: string | null; }, pagination: { untilId?: Note['id']; sinceId?: Note['id']; limit?: number; }): Promise { if (this.meilisearch) { const filter: Q = { op: 'and', qs: [], }; if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() }); if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() }); if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); if (opts.host) { if (opts.host === '.') { // TODO: Meilisearchが2023/05/07現在値がNULLかどうかのクエリが書けない } else { filter.qs.push({ op: '=', k: 'userHost', v: opts.host }); } } const res = await this.meilisearchNoteIndex!.search(q, { sort: ['createdAt:desc'], matchingStrategy: 'all', attributesToRetrieve: ['id', 'createdAt'], filter: compileQuery(filter), limit: pagination.limit, }); if (res.hits.length === 0) return []; const notes = await this.notesRepository.findBy({ id: In(res.hits.map(x => x.id)), }); return notes.sort((a, b) => a.id > b.id ? -1 : 1); } else { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); if (opts.userId) { query.andWhere('note.userId = :userId', { userId: opts.userId }); } else if (opts.channelId) { query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); } query .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }) .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); return await query.limit(pagination.limit).getMany(); } } }