2023-07-27 05:31:52 +00:00
|
|
|
/*
|
|
|
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
|
|
|
* SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
*/
|
|
|
|
|
2023-05-04 23:52:14 +00:00
|
|
|
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';
|
2023-09-20 02:33:36 +00:00
|
|
|
import { MiNote } from '@/models/Note.js';
|
2023-09-15 05:28:29 +00:00
|
|
|
import { MiUser } from '@/models/_.js';
|
|
|
|
import type { NotesRepository } from '@/models/_.js';
|
2023-05-04 23:52:14 +00:00
|
|
|
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 } |
|
2023-08-20 04:39:37 +00:00
|
|
|
{ op: 'is null', k: K} |
|
|
|
|
{ op: 'is not null', k: K} |
|
2023-05-04 23:52:14 +00:00
|
|
|
{ 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 ') })`;
|
2023-08-20 04:39:37 +00:00
|
|
|
case 'is null': return `(${q.k} IS NULL)`;
|
|
|
|
case 'is not null': return `(${q.k} IS NOT NULL)`;
|
2023-05-04 23:52:14 +00:00
|
|
|
case 'not': return `(NOT ${compileQuery(q.q)})`;
|
|
|
|
default: throw new Error('unrecognized query operator');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
export class SearchService {
|
2023-07-15 00:59:19 +00:00
|
|
|
private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local';
|
2023-05-04 23:52:14 +00:00
|
|
|
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) {
|
2023-10-31 23:33:29 +00:00
|
|
|
this.meilisearchNoteIndex = meilisearch.index(`${this.config.meilisearch?.index}---notes`);
|
2023-05-04 23:52:14 +00:00
|
|
|
this.meilisearchNoteIndex.updateSettings({
|
|
|
|
searchableAttributes: [
|
|
|
|
'text',
|
|
|
|
'cw',
|
|
|
|
],
|
|
|
|
sortableAttributes: [
|
|
|
|
'createdAt',
|
|
|
|
],
|
|
|
|
filterableAttributes: [
|
|
|
|
'createdAt',
|
|
|
|
'userId',
|
|
|
|
'userHost',
|
|
|
|
'channelId',
|
2023-05-11 07:33:39 +00:00
|
|
|
'tags',
|
2023-05-04 23:52:14 +00:00
|
|
|
],
|
|
|
|
typoTolerance: {
|
|
|
|
enabled: false,
|
|
|
|
},
|
|
|
|
pagination: {
|
|
|
|
maxTotalHits: 10000,
|
|
|
|
},
|
|
|
|
});
|
|
|
|
}
|
2023-07-15 00:59:19 +00:00
|
|
|
|
2023-10-31 23:33:29 +00:00
|
|
|
if (this.config.meilisearch?.scope) {
|
|
|
|
this.meilisearchIndexScope = this.config.meilisearch.scope;
|
2023-07-15 00:59:19 +00:00
|
|
|
}
|
2023-05-04 23:52:14 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
@bindThis
|
2023-08-16 08:51:28 +00:00
|
|
|
public async indexNote(note: MiNote): Promise<void> {
|
2023-05-05 03:24:29 +00:00
|
|
|
if (note.text == null && note.cw == null) return;
|
|
|
|
if (!['home', 'public'].includes(note.visibility)) return;
|
|
|
|
|
2023-05-04 23:52:14 +00:00
|
|
|
if (this.meilisearch) {
|
2023-07-15 00:59:19 +00:00
|
|
|
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([{
|
2023-05-04 23:52:14 +00:00
|
|
|
id: note.id,
|
2023-10-16 01:45:22 +00:00
|
|
|
createdAt: this.idService.parse(note.id).date.getTime(),
|
2023-05-04 23:52:14 +00:00
|
|
|
userId: note.userId,
|
|
|
|
userHost: note.userHost,
|
|
|
|
channelId: note.channelId,
|
|
|
|
cw: note.cw,
|
|
|
|
text: note.text,
|
2023-05-11 07:33:39 +00:00
|
|
|
tags: note.tags,
|
2023-05-04 23:52:14 +00:00
|
|
|
}], {
|
|
|
|
primaryKey: 'id',
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-08 12:31:38 +00:00
|
|
|
@bindThis
|
2023-08-16 08:51:28 +00:00
|
|
|
public async unindexNote(note: MiNote): Promise<void> {
|
2023-07-08 12:31:38 +00:00
|
|
|
if (!['home', 'public'].includes(note.visibility)) return;
|
|
|
|
|
|
|
|
if (this.meilisearch) {
|
|
|
|
this.meilisearchNoteIndex!.deleteDocument(note.id);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-04 23:52:14 +00:00
|
|
|
@bindThis
|
2023-08-16 08:51:28 +00:00
|
|
|
public async searchNote(q: string, me: MiUser | null, opts: {
|
|
|
|
userId?: MiNote['userId'] | null;
|
|
|
|
channelId?: MiNote['channelId'] | null;
|
2023-05-07 02:59:06 +00:00
|
|
|
host?: string | null;
|
2023-10-21 20:03:19 +00:00
|
|
|
filetype?: string | null;
|
|
|
|
order?: string | null;
|
2023-10-21 22:13:08 +00:00
|
|
|
disableMeili?: boolean | null;
|
2023-05-04 23:52:14 +00:00
|
|
|
}, pagination: {
|
2023-08-16 08:51:28 +00:00
|
|
|
untilId?: MiNote['id'];
|
|
|
|
sinceId?: MiNote['id'];
|
2023-05-04 23:52:14 +00:00
|
|
|
limit?: number;
|
2023-08-16 08:51:28 +00:00
|
|
|
}): Promise<MiNote[]> {
|
2023-10-21 22:13:08 +00:00
|
|
|
if (this.meilisearch && !opts.disableMeili) {
|
2023-05-04 23:52:14 +00:00
|
|
|
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 });
|
2023-05-07 02:59:06 +00:00
|
|
|
if (opts.host) {
|
|
|
|
if (opts.host === '.') {
|
2023-08-20 04:39:37 +00:00
|
|
|
filter.qs.push({ op: 'is null', k: 'userHost' });
|
2023-05-07 02:59:06 +00:00
|
|
|
} else {
|
|
|
|
filter.qs.push({ op: '=', k: 'userHost', v: opts.host });
|
|
|
|
}
|
|
|
|
}
|
2023-05-04 23:52:14 +00:00
|
|
|
const res = await this.meilisearchNoteIndex!.search(q, {
|
2023-10-30 12:09:20 +00:00
|
|
|
sort: [`createdAt:${opts.order ? opts.order : 'desc'}`],
|
2023-05-04 23:52:14 +00:00
|
|
|
matchingStrategy: 'all',
|
|
|
|
attributesToRetrieve: ['id', 'createdAt'],
|
|
|
|
filter: compileQuery(filter),
|
|
|
|
limit: pagination.limit,
|
|
|
|
});
|
|
|
|
if (res.hits.length === 0) return [];
|
2023-05-06 03:49:49 +00:00
|
|
|
const notes = await this.notesRepository.findBy({
|
2023-05-04 23:52:14 +00:00
|
|
|
id: In(res.hits.map(x => x.id)),
|
|
|
|
});
|
2023-05-06 03:49:49 +00:00
|
|
|
return notes.sort((a, b) => a.id > b.id ? -1 : 1);
|
2023-05-04 23:52:14 +00:00
|
|
|
} 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');
|
|
|
|
|
2023-08-20 04:39:37 +00:00
|
|
|
if (opts.host) {
|
|
|
|
if (opts.host === '.') {
|
|
|
|
query.andWhere('user.host IS NULL');
|
|
|
|
} else {
|
|
|
|
query.andWhere('user.host = :host', { host: opts.host });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-10-21 22:13:08 +00:00
|
|
|
if (opts.filetype) {
|
2023-10-22 12:43:19 +00:00
|
|
|
/* this is very ugly, but the "correct" solution would
|
|
|
|
be `and exists (select 1 from
|
|
|
|
unnest(note."attachedFileTypes") x(t) where t like
|
|
|
|
:type)` and I can't find a way to get TypeORM to
|
|
|
|
generate that; this hack works because `~*` is
|
|
|
|
"regexp match, ignoring case" and the stringified
|
|
|
|
version of an array of varchars (which is what
|
|
|
|
`attachedFileTypes` is) looks like `{foo,bar}`, so
|
|
|
|
we're looking for opts.filetype as the first half of
|
|
|
|
a MIME type, either at start of the array (after the
|
|
|
|
`{`) or later (after a `,`) */
|
2023-10-22 12:35:11 +00:00
|
|
|
query.andWhere(`note."attachedFileTypes"::varchar ~* :type`, { type: `[{,]${opts.filetype}/` });
|
2023-10-21 22:13:08 +00:00
|
|
|
}
|
|
|
|
|
2023-05-04 23:52:14 +00:00
|
|
|
this.queryService.generateVisibilityQuery(query, me);
|
|
|
|
if (me) this.queryService.generateMutedUserQuery(query, me);
|
|
|
|
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
|
|
|
|
2023-07-08 07:53:07 +00:00
|
|
|
return await query.limit(pagination.limit).getMany();
|
2023-05-04 23:52:14 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|