enhance(backend): ミュートしているユーザーをユーザー検索の結果から除外するように

This commit is contained in:
syuilo 2025-03-27 17:12:23 +09:00
parent c29a5764d3
commit b95da9c9a4
4 changed files with 107 additions and 77 deletions

View File

@ -13,6 +13,7 @@
- メッセージにはリアクションも可能です
- Enhance: セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。
- Misskeyネイティブでダッシュボードを実装予定です
- Enhance: ミュートしているユーザーをユーザー検索の結果から除外するように
### Client
- Feat: 設定の管理が強化されました

View File

@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Brackets, SelectQueryBuilder } from 'typeorm';
import { DI } from '@/di-symbols.js';
import { type FollowingsRepository, MiUser, type UsersRepository } from '@/models/_.js';
import { type FollowingsRepository, MiUser, type MutingsRepository, type UserProfilesRepository, type UsersRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
import type { Config } from '@/config.js';
@ -22,10 +22,19 @@ export class UserSearchService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
private userEntityService: UserEntityService,
) {
}
@ -58,7 +67,7 @@ export class UserSearchService {
* @see {@link UserSearchService#buildSearchUserNoLoginQueries}
*/
@bindThis
public async search(
public async searchByUsernameAndHost(
params: {
username?: string | null,
host?: string | null,
@ -202,4 +211,91 @@ export class UserSearchService {
return userQuery;
}
@bindThis
public async search(query: string, meId: MiUser['id'] | null, options: Partial<{
limit: number;
offset: number;
origin: 'local' | 'remote' | 'combined';
}> = {}) {
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
const isUsername = query.startsWith('@') && !query.includes(' ') && query.indexOf('@', 1) === -1;
let users: MiUser[] = [];
const mutingQuery = meId == null ? null : this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: meId });
const nameQuery = this.usersRepository.createQueryBuilder('user')
.where(new Brackets(qb => {
qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' });
if (isUsername) {
qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(query.replace('@', '').toLowerCase()) + '%' });
} else if (this.userEntityService.validateLocalUsername(query)) { // Also search username if it qualifies as username
qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(query.toLowerCase()) + '%' });
}
}))
.andWhere(new Brackets(qb => {
qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}))
.andWhere('user.isSuspended = FALSE');
if (mutingQuery) {
nameQuery.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`);
nameQuery.setParameters(mutingQuery.getParameters());
}
if (options.origin === 'local') {
nameQuery.andWhere('user.host IS NULL');
} else if (options.origin === 'remote') {
nameQuery.andWhere('user.host IS NOT NULL');
}
users = await nameQuery
.orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
.limit(options.limit)
.offset(options.offset)
.getMany();
if (users.length < (options.limit ?? 30)) {
const profQuery = this.userProfilesRepository.createQueryBuilder('prof')
.select('prof.userId')
.where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' });
if (mutingQuery) {
profQuery.andWhere(`prof.userId NOT IN (${mutingQuery.getQuery()})`);
profQuery.setParameters(mutingQuery.getParameters());
}
if (options.origin === 'local') {
profQuery.andWhere('prof.userHost IS NULL');
} else if (options.origin === 'remote') {
profQuery.andWhere('prof.userHost IS NOT NULL');
}
const userQuery = 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 userQuery
.orderBy('user.updatedAt', 'DESC', 'NULLS LAST')
.limit(options.limit)
.offset(options.offset)
.getMany(),
);
}
return users;
}
}

View File

@ -46,7 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private userSearchService: UserSearchService,
) {
super(meta, paramDef, (ps, me) => {
return this.userSearchService.search({
return this.userSearchService.searchByUsernameAndHost({
username: ps.username,
host: ps.host,
}, {

View File

@ -3,14 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, UserProfilesRepository } from '@/models/_.js';
import type { MiUser } from '@/models/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';
import { UserSearchService } from '@/core/UserSearchService.js';
export const meta = {
tags: ['users'],
@ -45,79 +42,15 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private userSearchService: UserSearchService,
) {
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('@') && !ps.query.includes(' ') && ps.query.indexOf('@', 1) === -1;
let users: MiUser[] = [];
const nameQuery = this.usersRepository.createQueryBuilder('user')
.where(new Brackets(qb => {
qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' });
if (isUsername) {
qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' });
} else if (this.userEntityService.validateLocalUsername(ps.query)) { // Also search username if it qualifies as username
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(),
);
}
const users = await this.userSearchService.search(ps.query.trim(), me?.id ?? null, {
offset: ps.offset,
limit: ps.limit,
origin: ps.origin,
});
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});