summaryrefslogtreecommitdiff
path: root/packages/backend/src/server/api/endpoints/users/search.ts
blob: 3d6aa1ac9ebd1ad72ead661f321719f55c620427 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
/*
 * SPDX-FileCopyrightText: syuilo and other misskey contributors
 * SPDX-License-Identifier: AGPL-3.0-only
 */

import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, UserProfilesRepository } from '@/models/index.js';
import type { MiUser } from '@/models/entities/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';

export const meta = {
	tags: ['users'],

	requireCredential: false,

	description: 'Search for users.',

	res: {
		type: 'array',
		optional: false, nullable: false,
		items: {
			type: 'object',
			optional: false, nullable: false,
			ref: 'User',
		},
	},
} as const;

export const paramDef = {
	type: 'object',
	properties: {
		query: { type: 'string' },
		offset: { type: 'integer', default: 0 },
		limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
		origin: { type: 'string', enum: ['local', 'remote', 'combined'], default: 'combined' },
		detail: { type: 'boolean', default: true },
	},
	required: ['query'],
} as const;

// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
	constructor(
		@Inject(DI.usersRepository)
		private usersRepository: UsersRepository,

		@Inject(DI.userProfilesRepository)
		private userProfilesRepository: UserProfilesRepository,

		private userEntityService: UserEntityService,
	) {
		super(meta, paramDef, async (ps, me) => {
			const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日

			ps.query = ps.query.trim();
			const isUsername = ps.query.startsWith('@');

			let users: MiUser[] = [];

			if (isUsername) {
				const usernameQuery = this.usersRepository.createQueryBuilder('user')
					.where('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' })
					.andWhere(new Brackets(qb => { qb
						.where('user.updatedAt IS NULL')
						.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
					}))
					.andWhere('user.isSuspended = FALSE');

				if (ps.origin === 'local') {
					usernameQuery.andWhere('user.host IS NULL');
				} else if (ps.origin === 'remote') {
					usernameQuery.andWhere('user.host IS NOT NULL');
				}

				users = await usernameQuery
					.orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
					.limit(ps.limit)
					.offset(ps.offset)
					.getMany();
			} else {
				const nameQuery = this.usersRepository.createQueryBuilder('user')
					.where(new Brackets(qb => {
						qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' });

						// Also search username if it qualifies as username
						if (this.userEntityService.validateLocalUsername(ps.query)) {
							qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' });
						}
					}))
					.andWhere(new Brackets(qb => { qb
						.where('user.updatedAt IS NULL')
						.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
					}))
					.andWhere('user.isSuspended = FALSE');

				if (ps.origin === 'local') {
					nameQuery.andWhere('user.host IS NULL');
				} else if (ps.origin === 'remote') {
					nameQuery.andWhere('user.host IS NOT NULL');
				}

				users = await nameQuery
					.orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
					.limit(ps.limit)
					.offset(ps.offset)
					.getMany();

				if (users.length < ps.limit) {
					const profQuery = this.userProfilesRepository.createQueryBuilder('prof')
						.select('prof.userId')
						.where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' });

					if (ps.origin === 'local') {
						profQuery.andWhere('prof.userHost IS NULL');
					} else if (ps.origin === 'remote') {
						profQuery.andWhere('prof.userHost IS NOT NULL');
					}

					const query = this.usersRepository.createQueryBuilder('user')
						.where(`user.id IN (${ profQuery.getQuery() })`)
						.andWhere(new Brackets(qb => { qb
							.where('user.updatedAt IS NULL')
							.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
						}))
						.andWhere('user.isSuspended = FALSE')
						.setParameters(profQuery.getParameters());

					users = users.concat(await query
						.orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
						.limit(ps.limit)
						.offset(ps.offset)
						.getMany(),
					);
				}
			}

			return await this.userEntityService.packMany(users, me, { detail: ps.detail });
		});
	}
}