From 61fae45390283aee7ac582aa5303aae863de0f7a Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Sat, 8 Jun 2024 15:34:19 +0900 Subject: feat: 通報を受けた際にメールまたはWebhookで通知を送出出来るようにする (#13758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 通報を受けた際にメールまたはWebhookで通知を送出出来るようにする * モデログに対応&エンドポイントを単一オブジェクトでのサポートに変更(API経由で大量に作るシチュエーションもないと思うので) * fix spdx * fix migration * fix migration * fix models * add e2e webhook * tweak * fix modlog * fix bugs * add tests and fix bugs * add tests and fix bugs * add tests * fix path * regenerate locale * 混入除去 * 混入除去 * add abuseReportResolved * fix pnpm-lock.yaml * add abuseReportResolved test * fix bugs * fix ui * add tests * fix CHANGELOG.md * add tests * add RoleService.getModeratorIds tests * WebhookServiceをUserとSystemに分割 * fix CHANGELOG.md * fix test * insertOneを使う用に * fix * regenerate locales * revert version * separate webhook job queue * fix * :art: * Update QueueProcessorService.ts --------- Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- .../src/server/api/endpoints/users/report-abuse.ts | 54 ++++------------------ 1 file changed, 10 insertions(+), 44 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index 48e14b68cc..5ff6de37d2 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -3,17 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import sanitizeHtml from 'sanitize-html'; -import { Inject, Injectable } from '@nestjs/common'; -import type { AbuseUserReportsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { EmailService } from '@/core/EmailService.js'; -import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -57,60 +51,32 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, - - private idService: IdService, - private metaService: MetaService, - private emailService: EmailService, private getterService: GetterService, private roleService: RoleService, - private globalEventService: GlobalEventService, + private abuseReportService: AbuseReportService, ) { super(meta, paramDef, async (ps, me) => { // Lookup user - const user = await this.getterService.getUser(ps.userId).catch(err => { + const targetUser = await this.getterService.getUser(ps.userId).catch(err => { if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); throw err; }); - if (user.id === me.id) { + if (targetUser.id === me.id) { throw new ApiError(meta.errors.cannotReportYourself); } - if (await this.roleService.isAdministrator(user)) { + if (await this.roleService.isAdministrator(targetUser)) { throw new ApiError(meta.errors.cannotReportAdmin); } - const report = await this.abuseUserReportsRepository.insertOne({ - id: this.idService.gen(), - targetUserId: user.id, - targetUserHost: user.host, + await this.abuseReportService.report([{ + targetUserId: targetUser.id, + targetUserHost: targetUser.host, reporterId: me.id, reporterHost: null, comment: ps.comment, - }); - - // Publish event to moderators - setImmediate(async () => { - const moderators = await this.roleService.getModerators(); - - for (const moderator of moderators) { - this.globalEventService.publishAdminStream(moderator.id, 'newAbuseUserReport', { - id: report.id, - targetUserId: report.targetUserId, - reporterId: report.reporterId, - comment: report.comment, - }); - } - - const meta = await this.metaService.fetch(); - if (meta.email) { - this.emailService.sendEmail(meta.email, 'New abuse report', - sanitizeHtml(ps.comment), - sanitizeHtml(ps.comment)); - } - }); + }]); }); } } -- cgit v1.2.3-freya From d0ee0203e12d41515b05d7d99ae09f70eeae5874 Mon Sep 17 00:00:00 2001 From: Ryu jongheon Date: Tue, 18 Jun 2024 12:18:04 +0900 Subject: Fix(backend): Limit antenna/webhook/list to exact amount (#14036) ... not +1 * Update antennas/clips e2e test --- packages/backend/src/core/ClipService.ts | 4 ++-- packages/backend/src/core/UserListService.ts | 2 +- packages/backend/src/server/api/endpoints/antennas/create.ts | 2 +- packages/backend/src/server/api/endpoints/i/import-antennas.ts | 2 +- packages/backend/src/server/api/endpoints/i/webhooks/create.ts | 2 +- .../src/server/api/endpoints/users/lists/create-from-public.ts | 2 +- packages/backend/src/server/api/endpoints/users/lists/create.ts | 2 +- packages/backend/test/e2e/antennas.ts | 3 +-- packages/backend/test/e2e/clips.ts | 7 +++---- 9 files changed, 12 insertions(+), 14 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/packages/backend/src/core/ClipService.ts b/packages/backend/src/core/ClipService.ts index 9fd1ebad87..929a9db064 100644 --- a/packages/backend/src/core/ClipService.ts +++ b/packages/backend/src/core/ClipService.ts @@ -41,7 +41,7 @@ export class ClipService { const currentCount = await this.clipsRepository.countBy({ userId: me.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).clipLimit) { throw new ClipService.TooManyClipsError(); } @@ -102,7 +102,7 @@ export class ClipService { const currentCount = await this.clipNotesRepository.countBy({ clipId: clip.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) { throw new ClipService.TooManyClipNotesError(); } diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index bbdcfed738..6333356fe9 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -95,7 +95,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { const currentCount = await this.userListMembershipsRepository.countBy({ userListId: list.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { throw new UserListService.TooManyUsersError(); } diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index ec08198514..577b9e1b1f 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -93,7 +93,7 @@ export default class extends Endpoint { // eslint- const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id, }); - if (currentAntennasCount > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { + if (currentAntennasCount >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) { throw new ApiError(meta.errors.tooManyAntennas); } diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index b4661a93e2..bc46163e3d 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -78,7 +78,7 @@ export default class extends Endpoint { if (file.size === 0) throw new ApiError(meta.errors.emptyFile); const antennas: (_Antenna & { userListAccts: string[] | null })[] = JSON.parse(await this.downloadService.downloadTextFile(file.url)); const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id }); - if (currentAntennasCount + antennas.length > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { + if (currentAntennasCount + antennas.length >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) { throw new ApiError(meta.errors.tooManyAntennas); } this.queueService.createImportAntennasJob(me, antennas); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts index c692380288..9eb7f5b3a0 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -85,7 +85,7 @@ export default class extends Endpoint { // eslint- const currentWebhooksCount = await this.webhooksRepository.countBy({ userId: me.id, }); - if (currentWebhooksCount > (await this.roleService.getUserPolicies(me.id)).webhookLimit) { + if (currentWebhooksCount >= (await this.roleService.getUserPolicies(me.id)).webhookLimit) { throw new ApiError(meta.errors.tooManyWebhooks); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts index 8504da0209..7e44d501ab 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -100,7 +100,7 @@ export default class extends Endpoint { // eslint- const currentCount = await this.userListsRepository.countBy({ userId: me.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index 9378bde5cb..7daf05ba4e 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint- const currentCount = await this.userListsRepository.countBy({ userId: me.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index 101238b601..6ac14cd8dc 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -163,8 +163,7 @@ describe('アンテナ', () => { }); test('が上限いっぱいまで作成できること', async () => { - // antennaLimit + 1まで作れるのがキモ - const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit + 1)].map(() => successfulApiCall({ + const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit)].map(() => successfulApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam }, user: alice, diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index ba6f9d6a65..a229ec06f9 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -153,8 +153,7 @@ describe('クリップ', () => { }); test('の作成はポリシーで定められた数以上はできない。', async () => { - // ポリシー + 1まで作れるという所がミソ - const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clipLimit = DEFAULT_POLICIES.clipLimit; for (let i = 0; i < clipLimit; i++) { await create(); } @@ -327,7 +326,7 @@ describe('クリップ', () => { }); test('の一覧(clips/list)が取得できる(上限いっぱい)', async () => { - const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clipLimit = DEFAULT_POLICIES.clipLimit; const clips = await createMany({}, clipLimit); const res = await list({ parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる @@ -705,7 +704,7 @@ describe('クリップ', () => { // TODO: 17000msくらいかかる... test('をポリシーで定められた上限いっぱい(200)を超えて追加はできない。', async () => { - const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1; + const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit; const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, { text: `test ${i}`, }) as unknown)) as Misskey.entities.Note[]; -- cgit v1.2.3-freya From 6cd15275bbcd17498166cda3455370d5f009e0bb Mon Sep 17 00:00:00 2001 From: おさむのひと <46447427+samunohito@users.noreply.github.com> Date: Fri, 12 Jul 2024 21:14:09 +0900 Subject: fix: サジェストされるユーザのリストアップ方法を見直し (#14180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: サジェストされるユーザのリストアップ方法を見直し * fix comment * fix CHANGELOG.md * ノートの無いユーザ(updatedAtが無いユーザ)は含めないらしい * fix test --- CHANGELOG.md | 5 + packages/backend/src/core/CoreModule.ts | 6 + packages/backend/src/core/UserSearchService.ts | 205 ++++++++++++++++ .../endpoints/users/search-by-username-and-host.ts | 101 +------- packages/backend/test/unit/UserSearchService.ts | 265 +++++++++++++++++++++ 5 files changed, 492 insertions(+), 90 deletions(-) create mode 100644 packages/backend/src/core/UserSearchService.ts create mode 100644 packages/backend/test/unit/UserSearchService.ts (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f48684b9..060ddd0e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,11 @@ - Fix: リノートにリアクションできないように - Fix: ユーザー名の前後に空白文字列がある場合は省略するように - Fix: プロフィール編集時に名前を空白文字列のみにできる問題を修正 +- Fix: ユーザ名のサジェスト時に表示される内容と順番を調整(以下の順番になります) #14149 + 1. フォロー中かつアクティブなユーザ + 2. フォロー中かつ非アクティブなユーザ + 3. フォローしていないアクティブなユーザ + 4. フォローしていない非アクティブなユーザ ### Misskey.js - Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応) diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index b5b34487ec..0208540afa 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -12,6 +12,7 @@ import { } from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { UserSearchService } from '@/core/UserSearchService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -202,6 +203,7 @@ const $UserFollowingService: Provider = { provide: 'UserFollowingService', useEx const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; +const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; @@ -348,6 +350,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserSearchService, UserSuspendService, UserAuthService, VideoProcessingService, @@ -490,6 +493,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserSearchService, $UserSuspendService, $UserAuthService, $VideoProcessingService, @@ -633,6 +637,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserSearchService, UserSuspendService, UserAuthService, VideoProcessingService, @@ -774,6 +779,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserSearchService, $UserSuspendService, $UserAuthService, $VideoProcessingService, diff --git a/packages/backend/src/core/UserSearchService.ts b/packages/backend/src/core/UserSearchService.ts new file mode 100644 index 0000000000..0d03cf6ee0 --- /dev/null +++ b/packages/backend/src/core/UserSearchService.ts @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +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 { bindThis } from '@/decorators.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import type { Config } from '@/config.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; + +function defaultActiveThreshold() { + return new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); +} + +@Injectable() +export class UserSearchService { + constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + private userEntityService: UserEntityService, + ) { + } + + /** + * ユーザ名とホスト名によるユーザ検索を行う. + * + * - 検索結果には優先順位がつけられており、以下の順序で検索が行われる. + * 1. フォローしているユーザのうち、一定期間以内(※)に更新されたユーザ + * 2. フォローしているユーザのうち、一定期間以内に更新されていないユーザ + * 3. フォローしていないユーザのうち、一定期間以内に更新されたユーザ + * 4. フォローしていないユーザのうち、一定期間以内に更新されていないユーザ + * - ログインしていない場合は、以下の順序で検索が行われる. + * 1. 一定期間以内に更新されたユーザ + * 2. 一定期間以内に更新されていないユーザ + * - それぞれの検索結果はユーザ名の昇順でソートされる. + * - 動作的には先に登場した検索結果の登場位置が優先される(条件的にユーザIDが重複することはないが). + * (1で既にヒットしていた場合、2, 3, 4でヒットしても無視される) + * - ユーザ名とホスト名の検索条件はそれぞれ前方一致で検索される. + * - ユーザ名の検索は大文字小文字を区別しない. + * - ホスト名の検索は大文字小文字を区別しない. + * - 検索結果は最大で {@link opts.limit} 件までとなる. + * + * ※一定期間とは {@link params.activeThreshold} で指定された日時から現在までの期間を指す. + * + * @param params 検索条件. + * @param opts 関数の動作を制御するオプション. + * @param me 検索を実行するユーザの情報. 未ログインの場合は指定しない. + * @see {@link UserSearchService#buildSearchUserQueries} + * @see {@link UserSearchService#buildSearchUserNoLoginQueries} + */ + @bindThis + public async search( + params: { + username?: string | null, + host?: string | null, + activeThreshold?: Date, + }, + opts?: { + limit?: number, + detail?: boolean, + }, + me?: MiUser | null, + ): Promise[]> { + const queries = me ? this.buildSearchUserQueries(me, params) : this.buildSearchUserNoLoginQueries(params); + + let resultSet = new Set(); + const limit = opts?.limit ?? 10; + for (const query of queries) { + const ids = await query + .select('user.id') + .limit(limit - resultSet.size) + .orderBy('user.usernameLower', 'ASC') + .getRawMany<{ user_id: MiUser['id'] }>() + .then(res => res.map(x => x.user_id)); + + resultSet = new Set([...resultSet, ...ids]); + if (resultSet.size >= limit) { + break; + } + } + + return this.userEntityService.packMany<'UserLite' | 'UserDetailed'>( + [...resultSet].slice(0, limit), + me, + { schema: opts?.detail ? 'UserDetailed' : 'UserLite' }, + ); + } + + /** + * ログイン済みユーザによる検索実行時のクエリ一覧を構築する. + * @param me + * @param params + * @private + */ + @bindThis + private buildSearchUserQueries( + me: MiUser, + params: { + username?: string | null, + host?: string | null, + activeThreshold?: Date, + }, + ) { + // デフォルト30日以内に更新されたユーザーをアクティブユーザーとする + const activeThreshold = params.activeThreshold ?? defaultActiveThreshold(); + + const followingUserQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const activeFollowingUsersQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id IN (${followingUserQuery.getQuery()})`) + .andWhere('user.updatedAt > :activeThreshold', { activeThreshold }); + activeFollowingUsersQuery.setParameters(followingUserQuery.getParameters()); + + const inactiveFollowingUsersQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id IN (${followingUserQuery.getQuery()})`) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt <= :activeThreshold', { activeThreshold }); + })); + inactiveFollowingUsersQuery.setParameters(followingUserQuery.getParameters()); + + // 自分自身がヒットするとしたらここ + const activeUserQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`) + .andWhere('user.updatedAt > :activeThreshold', { activeThreshold }); + activeUserQuery.setParameters(followingUserQuery.getParameters()); + + const inactiveUserQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`) + .andWhere('user.updatedAt <= :activeThreshold', { activeThreshold }); + inactiveUserQuery.setParameters(followingUserQuery.getParameters()); + + return [activeFollowingUsersQuery, inactiveFollowingUsersQuery, activeUserQuery, inactiveUserQuery]; + } + + /** + * ログインしていないユーザによる検索実行時のクエリ一覧を構築する. + * @param params + * @private + */ + @bindThis + private buildSearchUserNoLoginQueries(params: { + username?: string | null, + host?: string | null, + activeThreshold?: Date, + }) { + // デフォルト30日以内に更新されたユーザーをアクティブユーザーとする + const activeThreshold = params.activeThreshold ?? defaultActiveThreshold(); + + const activeUserQuery = this.generateUserQueryBuilder(params) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold }); + })); + + const inactiveUserQuery = this.generateUserQueryBuilder(params) + .andWhere('user.updatedAt <= :activeThreshold', { activeThreshold }); + + return [activeUserQuery, inactiveUserQuery]; + } + + /** + * ユーザ検索クエリで共通する抽出条件をあらかじめ設定したクエリビルダを生成する. + * @param params + * @private + */ + @bindThis + private generateUserQueryBuilder(params: { + username?: string | null, + host?: string | null, + }): SelectQueryBuilder { + const userQuery = this.usersRepository.createQueryBuilder('user'); + + if (params.username) { + userQuery.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(params.username.toLowerCase()) + '%' }); + } + + if (params.host) { + if (params.host === this.config.hostname || params.host === '.') { + userQuery.andWhere('user.host IS NULL'); + } else { + userQuery.andWhere('user.host LIKE :host', { + host: sqlLikeEscape(params.host.toLowerCase()) + '%', + }); + } + } + + userQuery.andWhere('user.isSuspended = FALSE'); + + return userQuery; + } +} diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 7b3bdab327..8ff952dcb5 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -3,15 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type { MiUser } from '@/models/User.js'; +import { Injectable } from '@nestjs/common'; 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'], @@ -49,89 +43,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, + private userSearchService: UserSearchService, ) { - super(meta, paramDef, async (ps, me) => { - const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => { - if (ps.username) { - query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); - } - - if (ps.host) { - if (ps.host === this.config.hostname || ps.host === '.') { - query.andWhere('user.host IS NULL'); - } else { - query.andWhere('user.host LIKE :host', { - host: sqlLikeEscape(ps.host.toLowerCase()) + '%', - }); - } - } - - return query; - }; - - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - - let users: MiUser[] = []; - - if (me) { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - - const query = setUsernameAndHostQuery() - .andWhere(`user.id IN (${ followingQuery.getQuery() })`) - .andWhere('user.id != :meId', { meId: me.id }) - .andWhere('user.isSuspended = FALSE') - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })); - - query.setParameters(followingQuery.getParameters()); - - users = await query - .orderBy('user.usernameLower', 'ASC') - .limit(ps.limit) - .getMany(); - - if (users.length < ps.limit) { - const otherQuery = setUsernameAndHostQuery() - .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.updatedAt IS NOT NULL'); - - otherQuery.setParameters(followingQuery.getParameters()); - - const otherUsers = await otherQuery - .orderBy('user.updatedAt', 'DESC') - .limit(ps.limit - users.length) - .getMany(); - - users = users.concat(otherUsers); - } - } else { - const query = setUsernameAndHostQuery() - .andWhere('user.isSuspended = FALSE') - .andWhere('user.updatedAt IS NOT NULL'); - - users = await query - .orderBy('user.updatedAt', 'DESC') - .limit(ps.limit - users.length) - .getMany(); - } - - return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }); + super(meta, paramDef, (ps, me) => { + return this.userSearchService.search({ + username: ps.username, + host: ps.host, + }, { + limit: ps.limit, + detail: ps.detail, + }, me); }); } } diff --git a/packages/backend/test/unit/UserSearchService.ts b/packages/backend/test/unit/UserSearchService.ts new file mode 100644 index 0000000000..7ea325d420 --- /dev/null +++ b/packages/backend/test/unit/UserSearchService.ts @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { describe, jest, test } from '@jest/globals'; +import { In } from 'typeorm'; +import { UserSearchService } from '@/core/UserSearchService.js'; +import { FollowingsRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +describe('UserSearchService', () => { + let app: TestingModule; + let service: UserSearchService; + + let usersRepository: UsersRepository; + let followingsRepository: FollowingsRepository; + let idService: IdService; + let userProfilesRepository: UserProfilesRepository; + + let root: MiUser; + let alice: MiUser; + let alyce: MiUser; + let alycia: MiUser; + let alysha: MiUser; + let alyson: MiUser; + let alyssa: MiUser; + let bob: MiUser; + let bobbi: MiUser; + let bobbie: MiUser; + let bobby: MiUser; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createFollowings(follower: MiUser, followees: MiUser[]) { + for (const followee of followees) { + await followingsRepository.insert({ + id: idService.gen(), + followerId: follower.id, + followeeId: followee.id, + }); + } + } + + async function setActive(users: MiUser[]) { + for (const user of users) { + await usersRepository.update(user.id, { + updatedAt: new Date(), + }); + } + } + + async function setInactive(users: MiUser[]) { + for (const user of users) { + await usersRepository.update(user.id, { + updatedAt: new Date(0), + }); + } + } + + async function setSuspended(users: MiUser[]) { + for (const user of users) { + await usersRepository.update(user.id, { + isSuspended: true, + }); + } + } + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + UserSearchService, + { + provide: UserEntityService, useFactory: jest.fn(() => ({ + // とりあえずIDが返れば確認が出来るので + packMany: (value: any) => value, + })), + }, + IdService, + ], + }) + .compile(); + + await app.init(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + followingsRepository = app.get(DI.followingsRepository); + + service = app.get(UserSearchService); + idService = app.get(IdService); + }); + + beforeEach(async () => { + root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); + alice = await createUser({ username: 'Alice', usernameLower: 'alice' }); + alyce = await createUser({ username: 'Alyce', usernameLower: 'alyce' }); + alycia = await createUser({ username: 'Alycia', usernameLower: 'alycia' }); + alysha = await createUser({ username: 'Alysha', usernameLower: 'alysha' }); + alyson = await createUser({ username: 'Alyson', usernameLower: 'alyson', host: 'example.com' }); + alyssa = await createUser({ username: 'Alyssa', usernameLower: 'alyssa', host: 'example.com' }); + bob = await createUser({ username: 'Bob', usernameLower: 'bob' }); + bobbi = await createUser({ username: 'Bobbi', usernameLower: 'bobbi' }); + bobbie = await createUser({ username: 'Bobbie', usernameLower: 'bobbie', host: 'example.com' }); + bobby = await createUser({ username: 'Bobby', usernameLower: 'bobby', host: 'example.com' }); + }); + + afterEach(async () => { + await usersRepository.delete({}); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('search', () => { + test('フォロー中のアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setActive([alice, alyce, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alycia, alysha, alyson]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + // alycia, alysha, alysonは非アクティブなので後ろに行く + expect(result).toEqual([alice, alyce, alyssa, alycia, alysha, alyson].map(x => x.id)); + }); + + test('フォロー中の非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await createFollowings(root, [alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + // alice, alyceはフォローしていないので後ろに行く + expect(result).toEqual([alycia, alysha, alyson, alyssa, alice, alyce].map(x => x.id)); + }); + + test('フォローしていないアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alice, alyce, alycia]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + // alice, alyce, alyciaは非アクティブなので後ろに行く + expect(result).toEqual([alysha, alyson, alyssa, alice, alyce, alycia].map(x => x.id)); + }); + + test('フォローしていない非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + expect(result).toEqual([alice, alyce, alycia, alysha, alyson, alyssa].map(x => x.id)); + }); + + test('フォロー(アクティブ)、フォロー(非アクティブ)、非フォロー(アクティブ)、非フォロー(非アクティブ)混在時の優先順位度確認', async () => { + await createFollowings(root, [alyson, alyssa, bob, bobbi, bobbie]); + await setActive([root, alyssa, bob, bobbi, alyce, alycia]); + await setInactive([alyson, alice, alysha, bobbie, bobby]); + + const result = await service.search( + { }, + { limit: 100 }, + root, + ); + + // 見る用 + // const users = await usersRepository.findBy({ id: In(result) }).then(it => new Map(it.map(x => [x.id, x]))); + // console.log(result.map(x => users.get(x as any)).map(it => it?.username)); + + // フォローしててアクティブなので先頭: alyssa, bob, bobbi + // フォローしてて非アクティブなので次: alyson, bobbie + // フォローしてないけどアクティブなので次: alyce, alycia, root(アルファベット順的にここになる) + // フォローしてないし非アクティブなので最後: alice, alysha, bobby + expect(result).toEqual([alyssa, bob, bobbi, alyson, bobbie, alyce, alycia, root, alice, alysha, bobby].map(x => x.id)); + }); + + test('[非ログイン] アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alice, alyce, alycia]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + ); + + // alice, alyce, alyciaは非アクティブなので後ろに行く + expect(result).toEqual([alysha, alyson, alyssa, alice, alyce, alycia].map(x => x.id)); + }); + + test('[非ログイン] 非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + ); + + expect(result).toEqual([alice, alyce, alycia, alysha, alyson, alyssa].map(x => x.id)); + }); + + test('フォロー中のアクティブユーザのうち、"al"から始まり"example.com"にいる人が全員ヒットする', async () => { + await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al', host: 'exam' }, + { limit: 100 }, + root, + ); + + expect(result).toEqual([alyson, alyssa].map(x => x.id)); + }); + + test('サスペンド済みユーザは出ない', async () => { + await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setSuspended([alice, alyce, alycia]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + expect(result).toEqual([alysha, alyson, alyssa].map(x => x.id)); + }); + }); +}); -- cgit v1.2.3-freya From 4b9c60ad21704e7e75df830205cec2db7afc2baa Mon Sep 17 00:00:00 2001 From: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com> Date: Sun, 14 Jul 2024 20:24:29 +0900 Subject: fix(backend): ユーザーのリアクション一覧でミュート/ブロックが機能していなかった問題を修正 (#14100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: mute/block was not considered on users/reactions * docs(changelog): update changelog * chore: Apply suggestion from code review Co-authored-by: zyoshoka <107108195+zyoshoka@users.noreply.github.com> --------- Co-authored-by: zyoshoka <107108195+zyoshoka@users.noreply.github.com> --- CHANGELOG.md | 1 + packages/backend/src/misc/is-user-related.ts | 4 ++++ .../src/server/api/endpoints/users/reactions.ts | 19 +++++++++++++++++-- 3 files changed, 22 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/CHANGELOG.md b/CHANGELOG.md index 2156c81474..e50dbfec9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ 4. フォローしていない非アクティブなユーザ - Fix: 一般ユーザーから見たユーザーのバッジの一覧に公開されていないものが含まれることがある問題を修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/652) +- Fix: ユーザーのリアクション一覧でミュート/ブロックが機能していなかった問題を修正 ### Misskey.js - Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応) diff --git a/packages/backend/src/misc/is-user-related.ts b/packages/backend/src/misc/is-user-related.ts index 93c9b2b814..862d6e6a38 100644 --- a/packages/backend/src/misc/is-user-related.ts +++ b/packages/backend/src/misc/is-user-related.ts @@ -4,6 +4,10 @@ */ export function isUserRelated(note: any, userIds: Set, ignoreAuthor = false): boolean { + if (!note) { + return false; + } + if (userIds.has(note.userId) && !ignoreAuthor) { return true; } diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index aca883a052..7805ae3288 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { RoleService } from '@/core/RoleService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -74,6 +75,7 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { + const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set(); const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users if (!iAmModerator) { const user = await this.cacheService.findUserById(ps.userId); @@ -85,8 +87,15 @@ export default class extends Endpoint { // eslint- if ((me == null || me.id !== ps.userId) && !profile.publicReactions) { throw new ApiError(meta.errors.reactionsNotPublic); } + + // early return if me is blocked by requesting user + if (userIdsWhoBlockingMe.has(ps.userId)) { + return []; + } } + const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set(); + const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('reaction.userId = :userId', { userId: ps.userId }) @@ -94,9 +103,15 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); - const reactions = await query + const reactions = (await query .limit(ps.limit) - .getMany(); + .getMany()).filter(reaction => { + if (reaction.note?.userId === ps.userId) return true; // we can see reactions to note of requesting user + if (me && isUserRelated(reaction.note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(reaction.note, userIdsWhoMeMuting)) return false; + + return true; + }); return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true }); }); -- cgit v1.2.3-freya From d0b7c74fd15673604a682726c817c57475cfa5d4 Mon Sep 17 00:00:00 2001 From: Sayamame-beans <61457993+Sayamame-beans@users.noreply.github.com> Date: Tue, 30 Jul 2024 19:18:43 +0900 Subject: 検索からハッシュタグのページが開けるように、users/searchに`@`から始まる文字列が与えられた際の処理を修正 等 (#13858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(frontend): 検索からハッシュタグのページを開けるように * fix(frontend): 照会で入力が`#`のみの場合は`/tags/`に遷移しないように * docs(changelog): update changelog * enhance(frontend): ユーザー検索からもハッシュタグのページを開けるように * docs(changelog): update changelog * enhance(frontend): 検索範囲等が指定されている時は照会/ハッシュタグページを開かないように * enhance(frontend): 検索内容に空白が含まれている場合は照会/ハッシュタグページを開かないように * docs(changelog): update changelog * Revert "enhance(frontend): 検索範囲等が指定されている時は照会/ハッシュタグページを開かないように" This reverts commit f84eecea964b90e9b115eac19ed6f19a603a6bbc. * enhance(frontend): 検索から照会/ハッシュタグページを開くかどうか確認するように * docs(changelog): update changelog * chore: fix lint * docs(changelog): update changelog insertion position * enhance(frontend): 検索から`@user@host`の形式で照会出来るように * fix(frontend): 照会で入力が`@`のみの場合に`/@`に遷移しないように * fix(backend): `users/search`において`@`から始まるqueryに対する処理が正しくなかった問題を修正 * docs(changelog): update changelog * chore(backend): fix lint error * fix(backend): more improvements for users/search when query startswith `@` * chore: unify common conditions * docs(changelog): refine changelog * chore(backend): fix lint error * MkInputをpreventに対応させ、enterの意図せぬ伝搬を防ぐ * chore(frontend/search.user): use .prevent to prevent the propagation of enter instead of setTimeout --------- Co-authored-by: samunohito <46447427+samunohito@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> Co-authored-by: taichanne30 --- CHANGELOG.md | 8 ++ locales/index.d.ts | 8 ++ locales/ja-JP.yml | 2 + .../src/server/api/endpoints/users/search.ts | 108 ++++++++------------- packages/frontend/src/components/MkInput.vue | 4 +- packages/frontend/src/pages/search.note.vue | 54 ++++++++--- packages/frontend/src/pages/search.user.vue | 58 ++++++++--- packages/frontend/src/scripts/lookup.ts | 2 +- 8 files changed, 150 insertions(+), 94 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/users') diff --git a/CHANGELOG.md b/CHANGELOG.md index f074ed65e0..4051566651 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ - Enhance: AiScriptを0.19.0にアップデート - Enhance: Allow negative delay for MFM animation elements (`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`) - Enhance: センシティブなメディアを開く際に確認ダイアログを出せるように +- Enhance: 検索(ノート/ユーザー)で `#` から始まる文字列を入力すると、そのハッシュタグのノート/ユーザー一覧ページが表示できるように +- Enhance: 検索(ノート/ユーザー)において、入力に空白が含まれている場合は照会を行わないように +- Enhance: 検索(ノート/ユーザー)において、照会を行うかどうか、ハッシュタグのノート/ユーザー一覧ページを表示するかどうかの確認ダイアログを出すように +- Enhance: 検索(ノート/ユーザー)で `@` から始まる文字列(`@user@host`など)を入力すると、そのユーザーを照会できるように - Enhance: ドライブのファイル・フォルダをドラッグしなくても移動できるように (Cherry-picked from https://github.com/nafu-at/misskey/commit/b89c2af6945c6a9f9f10e83f54d2bcf0f240b0b4, https://github.com/nafu-at/misskey/commit/8a7d710c6acb83f50c83f050bd1423c764d60a99) - Enhance: デッキのアンテナ・リスト選択画面からそれぞれを新規作成できるように @@ -57,6 +61,8 @@ - Fix: ダイレクト投稿の"削除して編集"において、宛先が保持されていなかった問題を修正 - Fix: 投稿フォームへのURL貼り付けによる引用が下書きに保存されていなかった問題を修正 - Fix: "削除して編集"や下書きにおいて、リアクションの受け入れ設定が保持/保存されていなかった問題を修正 +- Fix: 照会に `#` から始まる文字列を入力してそのハッシュタグのページを表示する際、入力が `#` のみの場合に「指定されたURLに該当するページはありませんでした。」が表示されてしまう問題を修正 +- Fix: 照会に `@` から始まる文字列を入力してユーザーを照会する際、入力が `@` のみの場合に「問題が発生しました」が表示されてしまう問題を修正 - Fix: 投稿フォームにノートのURLを貼り付けて"引用として添付"した場合、投稿文を空にすることによるRenote化が出来なかった問題を修正 ### Server @@ -96,6 +102,8 @@ - Fix: リノートのミュートが適用されるまでに時間がかかることがある問題を修正 (Cherry-picked from https://github.com/Type4ny-Project/Type4ny/commit/e9601029b52e0ad43d9131b555b614e56c84ebc1) - Fix: Steaming APIが不正なデータを受けた場合の動作が不安定である問題 #14251 +- Fix: `users/search`において `@` から始まる文字列が与えられた際の処理が正しくなかった問題を修正 + - 名前や自己紹介に `@` から始まる文言が含まれるユーザーも検索できるようになります - Fix: 一部のMisskey以外のソフトウェアからファイルを受け取れない問題 (Cherry-picked from https://github.com/Secineralyr/misskey.dream/pull/73/commits/652eaff1e8aa00b890d71d2e1e52c263c1e67c76) - NOTE: `drive_file`の`url`, `uri`, `src`の上限が512から1024に変更されます diff --git a/locales/index.d.ts b/locales/index.d.ts index 14dc862745..aee0b6127e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4500,6 +4500,14 @@ export interface Locale extends ILocale { * ユーザー指定 */ "specifyUser": string; + /** + * 照会しますか? + */ + "lookupConfirm": string; + /** + * ハッシュタグのページを開きますか? + */ + "openTagPageConfirm": string; /** * ホスト指定 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index cd26b71d57..9b907f0971 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1121,6 +1121,8 @@ preventAiLearning: "生成AIによる学習を拒否" preventAiLearningDescription: "外部の文章生成AIや画像生成AIに対して、投稿したノートや画像などのコンテンツを学習の対象にしないように要求します。これはnoaiフラグをHTMLレスポンスに含めることによって実現されますが、この要求に従うかはそのAI次第であるため、学習を完全に防止するものではありません。" options: "オプション" specifyUser: "ユーザー指定" +lookupConfirm: "照会しますか?" +openTagPageConfirm: "ハッシュタグのページを開きますか?" specifyHost: "ホスト指定" failedToPreviewUrl: "プレビューできません" update: "更新" diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index df9d9f6312..0b0136066d 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -57,88 +57,66 @@ export default class extends Endpoint { // eslint- const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 ps.query = ps.query.trim(); - const isUsername = ps.query.startsWith('@'); + const isUsername = ps.query.startsWith('@') && !ps.query.includes(' ') && ps.query.indexOf('@', 1) === -1; 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'); + 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') { - usernameQuery.andWhere('user.host IS NULL'); + profQuery.andWhere('prof.userHost IS NULL'); } else if (ps.origin === 'remote') { - usernameQuery.andWhere('user.host IS NOT NULL'); + profQuery.andWhere('prof.userHost 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()) + '%' }); - } - })) + 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'); - - if (ps.origin === 'local') { - nameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - nameQuery.andWhere('user.host IS NOT NULL'); - } + .andWhere('user.isSuspended = FALSE') + .setParameters(profQuery.getParameters()); - users = await nameQuery + users = users.concat(await query .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(), - ); - } + .getMany(), + ); } return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }); diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 88ef4635e6..e695564f92 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -79,7 +79,7 @@ const props = defineProps<{ const emit = defineEmits<{ (ev: 'change', _ev: KeyboardEvent): void; (ev: 'keydown', _ev: KeyboardEvent): void; - (ev: 'enter'): void; + (ev: 'enter', _ev: KeyboardEvent): void; (ev: 'update:modelValue', value: string | number): void; }>(); @@ -111,7 +111,7 @@ const onKeydown = (ev: KeyboardEvent) => { emit('keydown', ev); if (ev.code === 'Enter') { - emit('enter'); + emit('enter', ev); } }; diff --git a/packages/frontend/src/pages/search.note.vue b/packages/frontend/src/pages/search.note.vue index 05dc77b94b..9cf7fbe8d8 100644 --- a/packages/frontend/src/pages/search.note.vue +++ b/packages/frontend/src/pages/search.note.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only