From 8c2ab25e5f2040fcbc81bc2a02a279fed40e1c11 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 9 May 2025 17:40:08 +0900 Subject: Feat: No websocket mode (#15851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * wip * wip * Update MkTimeline.vue * wip * wip * wip * Update MkTimeline.vue * Update use-pagination.ts * wip * wip * Update MkTimeline.vue * Update MkTimeline.vue * wip * wip * Update MkTimeline.vue * Update MkTimeline.vue * Update MkTimeline.vue * wip * Update use-pagination.ts * wip * Update use-pagination.ts * Update MkNotifications.vue * Update MkNotifications.vue * wip * wip * wip * Update use-note-capture.ts * Update use-note-capture.ts * Update use-note-capture.ts * wip * wip * wip * wip * Update MkNoteDetailed.vue * wip * wip * Update MkTimeline.vue * wip * fix * Update MkTimeline.vue * wip * test * Revert "test" This reverts commit 3375619396c54dcda5e564eb1da444c2391208c9. * Update use-pagination.ts * test * Revert "test" This reverts commit 42c53c830e28485d2fb49061fa7cdeee31bc6a22. * test * Revert "test" This reverts commit c4f8cda4aa1cec9d1eb97557145f3ad3d2d0e469. * Update style.scss * Update MkTimeline.vue * Update MkTimeline.vue * Update MkTimeline.vue * ✌️ * Update MkTimeline.vue * wip * wip * test * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * Update MkTimeline.vue * wip * tweak navbar * wip * wip * wip * wip * wip * wip * wip * Update home.vue * wip * refactor * wip * wip * Update note.vue * Update navbar.vue * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * Update MkPullToRefresh.vue * wip * Update MkStreamingNotificationsTimeline.vue * Update use-pagination.ts * wip * improve perf * wip * Update MkNotesTimeline.vue * wip * megre * Update use-pagination.ts * Update use-pagination.ts * Update MkStreamingNotesTimeline.vue * Update use-pagination.ts * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md --- .../api/endpoints/notes/show-partial-bulk.ts | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts (limited to 'packages/backend/src/server/api/endpoints/notes') diff --git a/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts new file mode 100644 index 0000000000..87b368e17e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts @@ -0,0 +1,47 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteIds: { type: 'array', items: { type: 'string', format: 'misskey:id' }, maxItems: 100, minItems: 1 }, + }, + required: ['noteIds'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private noteEntityService: NoteEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + return await this.noteEntityService.fetchDiffs(ps.noteIds); + }); + } +} -- cgit v1.2.3-freya From ebf291084f9490f0345a488592e2b4cd2ffb9d91 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 9 May 2025 17:44:35 +0900 Subject: Feat: UGCの公開範囲設定機能 (#15938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * Update CHANGELOG.md * wip * wip * Update show.ts --- CHANGELOG.md | 5 ++++- locales/index.d.ts | 26 ++++++++++++++++++++++ locales/ja-JP.yml | 8 +++++++ ...eUserGeneratedContentsForNonLoggedInVisitors.js | 16 +++++++++++++ packages/backend/src/models/Meta.ts | 6 +++++ .../backend/src/server/api/endpoints/admin/meta.ts | 6 +++++ .../src/server/api/endpoints/admin/update-meta.ts | 8 +++++++ .../backend/src/server/api/endpoints/notes/show.ts | 15 ++++++++++++- .../backend/src/server/api/endpoints/users/show.ts | 17 +++++++++++++- .../backend/src/server/web/ClientServerService.ts | 15 +++++++++++-- packages/frontend/src/pages/admin/moderation.vue | 24 +++++++++++++++++++- packages/misskey-js/src/autogen/types.ts | 4 ++++ 12 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 packages/backend/migration/1746330901644-visibleUserGeneratedContentsForNonLoggedInVisitors.js (limited to 'packages/backend/src/server/api/endpoints/notes') diff --git a/CHANGELOG.md b/CHANGELOG.md index efde2ed192..dd5ede9be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ ## Unreleased ### General -- +- Feat: 非ログインでサーバーを閲覧された際に、サーバー内のコンテンツを非公開にすることができるようになりました + - モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます + - 「全て公開(今までの挙動)」「ローカルのコンテンツだけ公開(=サーバー内で受信されたリモートのコンテンツは公開しない)」「何も公開しない」から選択できます + - デフォルト値は「ローカルのコンテンツだけ公開」になっています ### Client - Feat: Websocket接続を行わずにMisskeyを利用するNo Websocketモードが実装されました(beta) diff --git a/locales/index.d.ts b/locales/index.d.ts index 2b92a0ae7c..e10506bfe3 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -6412,6 +6412,32 @@ export interface Locale extends ILocale { * 脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。 */ "deliverSuspendedSoftwareDescription": string; + /** + * 非利用者に対するユーザー作成コンテンツの公開範囲 + */ + "userGeneratedContentsVisibilityForVisitor": string; + /** + * モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます。 + */ + "userGeneratedContentsVisibilityForVisitor_description": string; + /** + * サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。 + */ + "userGeneratedContentsVisibilityForVisitor_description2": string; + "_userGeneratedContentsVisibilityForVisitor": { + /** + * 全て公開 + */ + "all": string; + /** + * ローカルコンテンツのみ公開し、リモートコンテンツは非公開 + */ + "localOnly": string; + /** + * 全て非公開 + */ + "none": string; + }; }; "_accountMigration": { /** diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 6c5306dfa3..aeaff7e875 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1629,6 +1629,14 @@ _serverSettings: thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。" deliverSuspendedSoftware: "配信停止中のソフトウェア" deliverSuspendedSoftwareDescription: "脆弱性などの理由で、サーバーのソフトウェアの名前及びバージョンの範囲を指定して配信を停止できます。このバージョン情報はサーバーが提供したものであり、信頼性は保証されません。バージョン指定には semver の範囲指定が使用できますが、>= 2024.3.1 と指定すると 2024.3.1-custom.0 のようなカスタムバージョンが含まれないため、>= 2024.3.1-0 のように prerelease の指定を行うことを推奨します。" + userGeneratedContentsVisibilityForVisitor: "非利用者に対するユーザー作成コンテンツの公開範囲" + userGeneratedContentsVisibilityForVisitor_description: "モデレーションが行き届きにくい不適切なリモートコンテンツなどが、自サーバー経由で図らずもインターネットに公開されてしまうことによるトラブル防止などに役立ちます。" + userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受信したリモートのコンテンツを含め、サーバー内の全てのコンテンツを無条件でインターネットに公開することはリスクが伴います。特に、分散型の特性を知らない閲覧者にとっては、リモートのコンテンツであってもサーバー内で作成されたコンテンツであると誤って認識してしまう可能性があるため、注意が必要です。" + + _userGeneratedContentsVisibilityForVisitor: + all: "全て公開" + localOnly: "ローカルコンテンツのみ公開し、リモートコンテンツは非公開" + none: "全て非公開" _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" diff --git a/packages/backend/migration/1746330901644-visibleUserGeneratedContentsForNonLoggedInVisitors.js b/packages/backend/migration/1746330901644-visibleUserGeneratedContentsForNonLoggedInVisitors.js new file mode 100644 index 0000000000..115698a420 --- /dev/null +++ b/packages/backend/migration/1746330901644-visibleUserGeneratedContentsForNonLoggedInVisitors.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644 { + name = 'VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "ugcVisibilityForVisitor" character varying(128) NOT NULL DEFAULT 'local'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ugcVisibilityForVisitor"`); + } +} diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 46f3b2e3c0..95d19c8075 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -659,6 +659,12 @@ export class MiMeta { }) public federationHosts: string[]; + @Column('varchar', { + length: 128, + default: 'local', + }) + public ugcVisibilityForVisitor: 'all' | 'local' | 'none'; + @Column('varchar', { length: 64, nullable: true, diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 4a106e7175..cb48a1bc95 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -546,6 +546,11 @@ export const meta = { }, }, }, + ugcVisibilityForVisitor: { + type: 'string', + enum: ['all', 'local', 'none'], + optional: false, nullable: false, + }, }, }, } as const; @@ -691,6 +696,7 @@ export default class extends Endpoint { // eslint- federation: instance.federation, federationHosts: instance.federationHosts, deliverSuspendedSoftware: instance.deliverSuspendedSoftware, + ugcVisibilityForVisitor: instance.ugcVisibilityForVisitor, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 31eeaa5e38..fc6b890b69 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -196,6 +196,10 @@ export const paramDef = { required: ['software', 'versionRange'], }, }, + ugcVisibilityForVisitor: { + type: 'string', + enum: ['all', 'local', 'none'], + }, }, required: [], } as const; @@ -690,6 +694,10 @@ export default class extends Endpoint { // eslint- set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); } + if (ps.ugcVisibilityForVisitor !== undefined) { + set.ugcVisibilityForVisitor = ps.ugcVisibilityForVisitor; + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index 11839bce36..b93c73b0c5 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -3,10 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { MiMeta } from '@/models/Meta.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -46,6 +48,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private noteEntityService: NoteEntityService, private getterService: GetterService, ) { @@ -59,6 +64,14 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.signinRequired); } + if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) { + throw new ApiError(meta.errors.signinRequired); + } + + if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) { + throw new ApiError(meta.errors.signinRequired); + } + return await this.noteEntityService.pack(note, me, { detail: true, }); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 062326e28d..431869d47f 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -5,7 +5,7 @@ import { In, IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsersRepository } 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'; @@ -82,6 +82,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -92,6 +95,10 @@ export default class extends Endpoint { // eslint- private apiLoggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => { + if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) { + throw new ApiError(meta.errors.noSuchUser); + } + let user; const isModerator = await this.roleService.isModerator(me); @@ -123,6 +130,10 @@ export default class extends Endpoint { // eslint- } else { // Lookup user if (typeof ps.host === 'string' && typeof ps.username === 'string') { + if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) { + throw new ApiError(meta.errors.noSuchUser); + } + user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => { this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`); throw new ApiError(meta.errors.failedToResolveRemoteUser); @@ -139,6 +150,10 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchUser); } + if (this.serverSettings.ugcVisibilityForVisitor === 'local' && user.host != null && me == null) { + throw new ApiError(meta.errors.noSuchUser); + } + if (user.host == null) { if (me == null && ip != null) { this.perUserPvChart.commitByVisitor(user, ip); diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 30a911088e..9a33d27d86 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -513,7 +513,12 @@ export class ClientServerService { vary(reply.raw, 'Accept'); - if (user != null) { + if ( + user != null && ( + this.meta.ugcVisibilityForVisitor === 'all' || + (this.meta.ugcVisibilityForVisitor === 'local' && user.host == null) + ) + ) { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const me = profile.fields ? profile.fields @@ -577,7 +582,13 @@ export class ClientServerService { relations: ['user'], }); - if (note && !note.user!.requireSigninToViewContents) { + if ( + note && + !note.user!.requireSigninToViewContents && + (this.meta.ugcVisibilityForVisitor === 'all' || + (this.meta.ugcVisibilityForVisitor === 'local' && note.userHost == null) + ) + ) { const _note = await this.noteEntityService.pack(note); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); reply.header('Cache-Control', 'public, max-age=15'); diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index 2157b4ca14..819f229c10 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -17,9 +17,20 @@ SPDX-License-Identifier: AGPL-3.0-only - + + + + + + + + + {{ i18n.ts.serverRules }} @@ -137,9 +148,11 @@ import { definePage } from '@/page.js'; import MkButton from '@/components/MkButton.vue'; import FormLink from '@/components/form/link.vue'; import MkFolder from '@/components/MkFolder.vue'; +import MkSelect from '@/components/MkSelect.vue'; const enableRegistration = ref(false); const emailRequiredForSignup = ref(false); +const ugcVisibilityForVisitor = ref('all'); const sensitiveWords = ref(''); const prohibitedWords = ref(''); const prohibitedWordsForNameOfUser = ref(''); @@ -153,6 +166,7 @@ async function init() { const meta = await misskeyApi('admin/meta'); enableRegistration.value = !meta.disableRegistration; emailRequiredForSignup.value = meta.emailRequiredForSignup; + ugcVisibilityForVisitor.value = meta.ugcVisibilityForVisitor; sensitiveWords.value = meta.sensitiveWords.join('\n'); prohibitedWords.value = meta.prohibitedWords.join('\n'); prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n'); @@ -189,6 +203,14 @@ function onChange_emailRequiredForSignup(value: boolean) { }); } +function onChange_ugcVisibilityForVisitor(value: string) { + os.apiWithDialog('admin/update-meta', { + ugcVisibilityForVisitor: value, + }).then(() => { + fetchInstance(true); + }); +} + function save_preservedUsernames() { os.apiWithDialog('admin/update-meta', { preservedUsernames: preservedUsernames.value.split('\n'), diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 6db66477f7..4a71dd5465 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -8780,6 +8780,8 @@ export type operations = { software: string; versionRange: string; }[]; + /** @enum {string} */ + ugcVisibilityForVisitor: 'all' | 'local' | 'none'; }; }; }; @@ -11450,6 +11452,8 @@ export type operations = { software: string; versionRange: string; }[]; + /** @enum {string} */ + ugcVisibilityForVisitor?: 'all' | 'local' | 'none'; }; }; }; -- cgit v1.2.3-freya From ed3a844f5d7a9fa8e041a7c761e80d1e2bc316f2 Mon Sep 17 00:00:00 2001 From: zyoshoka <107108195+zyoshoka@users.noreply.github.com> Date: Sun, 25 May 2025 08:38:45 +0900 Subject: fix(backend): add response schema for `notes/show-partial-bulk` endpoint (#16093) --- .../server/api/endpoints/notes/show-partial-bulk.ts | 21 ++++++++++++++++++++- packages/misskey-js/src/autogen/types.ts | 10 +++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/notes') diff --git a/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts index 87b368e17e..e102bc1d4a 100644 --- a/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts +++ b/packages/backend/src/server/api/endpoints/notes/show-partial-bulk.ts @@ -19,7 +19,26 @@ export const meta = { optional: false, nullable: false, items: { type: 'object', - optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + reactions: { + type: 'object', + optional: false, nullable: false, + additionalProperties: { + type: 'number', + }, + }, + reactionEmojis: { + type: 'object', + optional: false, nullable: false, + additionalProperties: { + type: 'string', + }, + }, + }, }, }, diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c0d1242aaf..abd3a42628 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -25919,7 +25919,15 @@ export type operations = { /** @description OK (with results) */ 200: { content: { - 'application/json': Record[]; + 'application/json': { + id: string; + reactions: { + [key: string]: number; + }; + reactionEmojis: { + [key: string]: string; + }; + }[]; }; }; /** @description Client error */ -- cgit v1.2.3-freya From d27075c5f536a86a6c81cb3c3cec92b302663e2e Mon Sep 17 00:00:00 2001 From: zyoshoka <107108195+zyoshoka@users.noreply.github.com> Date: Tue, 27 May 2025 08:57:09 +0900 Subject: fix(backend): correct invalid schema format specifying only `required` for `anyOf` (#16089) * fix(backend): correct invalid schema format specifying only `required` for `anyOf` * refactor(backend): make types derived from `allOf` or `anyOf` more strong --- CONTRIBUTING.md | 21 ----- packages/backend/src/misc/json-schema.ts | 19 ++++- .../server/api/endpoints/admin/drive/show-file.ts | 35 ++++---- .../src/server/api/endpoints/admin/emoji/update.ts | 67 +++++++++------ .../src/server/api/endpoints/drive/files/show.ts | 41 +++++---- .../src/server/api/endpoints/i/revoke-token.ts | 23 +++-- .../server/api/endpoints/notes/search-by-tag.ts | 75 ++++++++++------- .../backend/src/server/api/endpoints/pages/show.ts | 27 +++--- .../src/server/api/endpoints/users/followers.ts | 51 ++++++++---- .../src/server/api/endpoints/users/following.ts | 54 +++++++----- .../src/server/api/endpoints/users/relation.ts | 2 +- .../endpoints/users/search-by-username-and-host.ts | 41 ++++++--- .../src/server/api/endpoints/users/show.test.ts | 23 +++++ .../backend/src/server/api/endpoints/users/show.ts | 63 +++++++++----- .../backend/src/server/api/openapi/gen-spec.ts | 3 +- packages/misskey-js/src/autogen/types.ts | 97 +++++++++++++--------- 16 files changed, 392 insertions(+), 250 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/users/show.test.ts (limited to 'packages/backend/src/server/api/endpoints/notes') diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8776f8ca24..8e10806686 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -581,27 +581,6 @@ pnpm dlx typeorm migration:generate -d ormconfig.js -o - 生成後、ファイルをmigration下に移してください - 作成されたスクリプトは不必要な変更を含むため除去してください -### JSON SchemaのobjectでanyOfを使うとき -JSON Schemaで、objectに対してanyOfを使う場合、anyOfの中でpropertiesを定義しないこと。 -バリデーションが効かないため。(SchemaTypeもそのように作られており、objectのanyOf内のpropertiesは捨てられます) -https://github.com/misskey-dev/misskey/pull/10082 - -テキストhogeおよびfugaについて、片方を必須としつつ両方の指定もありうる場合: - -```ts -export const paramDef = { - type: 'object', - properties: { - hoge: { type: 'string', minLength: 1 }, - fuga: { type: 'string', minLength: 1 }, - }, - anyOf: [ - { required: ['hoge'] }, - { required: ['fuga'] }, - ], -} as const; -``` - ### コネクションには`markRaw`せよ **Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。 diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 23f6b692a7..5e5d7041b9 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -218,7 +218,17 @@ type NullOrUndefined

= // https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection // Get intersection from union type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; -type PartialIntersection = Partial>; + +type ArrayToIntersection> = + T extends readonly [infer Head, ...infer Tail] + ? Head extends Schema + ? Tail extends ReadonlyArray + ? Tail extends [] + ? SchemaType + : SchemaType & ArrayToIntersection + : never + : never + : never; // https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552 // To get union, we use `Foo extends any ? Hoge : never` @@ -236,8 +246,8 @@ type ObjectSchemaTypeDef

= : never : ObjType> : - p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md - p['allOf'] extends ReadonlyArray ? UnionToIntersection> : + p['anyOf'] extends ReadonlyArray ? UnionSchemaType : + p['allOf'] extends ReadonlyArray ? ArrayToIntersection : p['additionalProperties'] extends true ? Record : p['additionalProperties'] extends Schema ? p['additionalProperties'] extends infer AdditionalProperties ? @@ -277,7 +287,8 @@ export type SchemaTypeDef

= p['items'] extends NonNullable ? SchemaType[] : any[] ) : - p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : + p['anyOf'] extends ReadonlyArray ? UnionSchemaType : + p['allOf'] extends ReadonlyArray ? ArrayToIntersection : p['oneOf'] extends ReadonlyArray ? UnionSchemaType : any; diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index a7136d8c8c..b84a5c73f9 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -162,14 +162,21 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - fileId: { type: 'string', format: 'misskey:id' }, - url: { type: 'string' }, - }, anyOf: [ - { required: ['fileId'] }, - { required: ['url'] }, + { + type: 'object', + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + }, + required: ['fileId'], + }, + { + type: 'object', + properties: { + url: { type: 'string' }, + }, + required: ['url'], + }, ], } as const; @@ -186,15 +193,11 @@ export default class extends Endpoint { // eslint- private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - const file = ps.fileId ? await this.driveFilesRepository.findOneBy({ id: ps.fileId }) : await this.driveFilesRepository.findOne({ - where: [{ - url: ps.url, - }, { - thumbnailUrl: ps.url, - }, { - webpublicUrl: ps.url, - }], - }); + const file = await this.driveFilesRepository.findOneBy( + 'fileId' in ps + ? { id: ps.fileId } + : [{ url: ps.url }, { thumbnailUrl: ps.url }, { webpublicUrl: ps.url }], + ); if (file == null) { throw new ApiError(meta.errors.noSuchFile); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 6834a6d213..7bde10af46 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -37,29 +37,45 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - id: { type: 'string', format: 'misskey:id' }, - name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, - fileId: { type: 'string', format: 'misskey:id' }, - category: { - type: 'string', - nullable: true, - description: 'Use `null` to reset the category.', + allOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + id: { type: 'string', format: 'misskey:id' }, + }, + required: ['id'], + }, + { + type: 'object', + properties: { + name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, + }, + required: ['name'], + }, + ], + }, + { + type: 'object', + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + category: { + type: 'string', + nullable: true, + description: 'Use `null` to reset the category.', + }, + aliases: { type: 'array', items: { + type: 'string', + } }, + license: { type: 'string', nullable: true }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { + type: 'string', + } }, + }, }, - aliases: { type: 'array', items: { - type: 'string', - } }, - license: { type: 'string', nullable: true }, - isSensitive: { type: 'boolean' }, - localOnly: { type: 'boolean' }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { - type: 'string', - } }, - }, - anyOf: [ - { required: ['id'] }, - { required: ['name'] }, ], } as const; @@ -78,10 +94,9 @@ export default class extends Endpoint { // eslint- if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); } - // JSON schemeのanyOfの型変換がうまくいっていないらしい - const required = { id: ps.id, name: ps.name } as - | { id: MiEmoji['id']; name?: string } - | { id?: MiEmoji['id']; name: string }; + const required = 'id' in ps + ? { id: ps.id, name: 'name' in ps ? ps.name as string : undefined } + : { name: ps.name }; const error = await this.customEmojiService.update({ ...required, diff --git a/packages/backend/src/server/api/endpoints/drive/files/show.ts b/packages/backend/src/server/api/endpoints/drive/files/show.ts index e8f4539d61..9a2e2c73e8 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/show.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/show.ts @@ -43,14 +43,21 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - fileId: { type: 'string', format: 'misskey:id' }, - url: { type: 'string' }, - }, anyOf: [ - { required: ['fileId'] }, - { required: ['url'] }, + { + type: 'object', + properties: { + fileId: { type: 'string', format: 'misskey:id' }, + }, + required: ['fileId'], + }, + { + type: 'object', + properties: { + url: { type: 'string' }, + }, + required: ['url'], + }, ], } as const; @@ -64,21 +71,11 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - let file: MiDriveFile | null = null; - - if (ps.fileId) { - file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); - } else if (ps.url) { - file = await this.driveFilesRepository.findOne({ - where: [{ - url: ps.url, - }, { - webpublicUrl: ps.url, - }, { - thumbnailUrl: ps.url, - }], - }); - } + const file = await this.driveFilesRepository.findOneBy( + 'fileId' in ps + ? { id: ps.fileId } + : [{ url: ps.url }, { webpublicUrl: ps.url }, { thumbnailUrl: ps.url }], + ); if (file == null) { throw new ApiError(meta.errors.noSuchFile); diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index c05ee93c6f..08f5e3a7a1 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -15,14 +15,21 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - tokenId: { type: 'string', format: 'misskey:id' }, - token: { type: 'string', nullable: true }, - }, anyOf: [ - { required: ['tokenId'] }, - { required: ['token'] }, + { + type: 'object', + properties: { + tokenId: { type: 'string', format: 'misskey:id' }, + }, + required: ['tokenId'], + }, + { + type: 'object', + properties: { + token: { type: 'string', nullable: true }, + }, + required: ['token'], + }, ], } as const; @@ -33,7 +40,7 @@ export default class extends Endpoint { // eslint- private accessTokensRepository: AccessTokensRepository, ) { super(meta, paramDef, async (ps, me) => { - if (ps.tokenId) { + if ('tokenId' in ps) { const tokenExist = await this.accessTokensRepository.exists({ where: { id: ps.tokenId } }); if (tokenExist) { diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index d0781bd8dd..3e41b33d03 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -28,38 +28,53 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - reply: { type: 'boolean', nullable: true, default: null }, - renote: { type: 'boolean', nullable: true, default: null }, - withFiles: { - type: 'boolean', - default: false, - description: 'Only show notes that have attached files.', + allOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + tag: { type: 'string', minLength: 1 }, + }, + required: ['tag'], + }, + { + type: 'object', + properties: { + query: { + type: 'array', + description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.', + items: { + type: 'array', + items: { + type: 'string', + minLength: 1, + }, + minItems: 1, + }, + minItems: 1, + }, + }, + required: ['query'], + }, + ], }, - poll: { type: 'boolean', nullable: true, default: null }, - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - - tag: { type: 'string', minLength: 1 }, - query: { - type: 'array', - description: 'The outer arrays are chained with OR, the inner arrays are chained with AND.', - items: { - type: 'array', - items: { - type: 'string', - minLength: 1, + { + type: 'object', + properties: { + reply: { type: 'boolean', nullable: true, default: null }, + renote: { type: 'boolean', nullable: true, default: null }, + withFiles: { + type: 'boolean', + default: false, + description: 'Only show notes that have attached files.', }, - minItems: 1, + poll: { type: 'boolean', nullable: true, default: null }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, - minItems: 1, }, - }, - anyOf: [ - { required: ['tag'] }, - { required: ['query'] }, ], } as const; @@ -87,12 +102,12 @@ export default class extends Endpoint { // eslint- if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); try { - if (ps.tag) { + if ('tag' in ps) { if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection'); query.andWhere(':tag <@ note.tags', { tag: [normalizeForSearch(ps.tag)] }); } else { query.andWhere(new Brackets(qb => { - for (const tags of ps.query!) { + for (const tags of ps.query) { qb.orWhere(new Brackets(qb => { for (const tag of tags) { if (!safeForSql(normalizeForSearch(tag))) throw new Error('Injection'); diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts index e08b832a3f..8427bab2d5 100644 --- a/packages/backend/src/server/api/endpoints/pages/show.ts +++ b/packages/backend/src/server/api/endpoints/pages/show.ts @@ -33,15 +33,22 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - pageId: { type: 'string', format: 'misskey:id' }, - name: { type: 'string' }, - username: { type: 'string' }, - }, anyOf: [ - { required: ['pageId'] }, - { required: ['name', 'username'] }, + { + type: 'object', + properties: { + pageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['pageId'], + }, + { + type: 'object', + properties: { + name: { type: 'string' }, + username: { type: 'string' }, + }, + required: ['name', 'username'], + }, ], } as const; @@ -59,9 +66,9 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { let page: MiPage | null = null; - if (ps.pageId) { + if ('pageId' in ps) { page = await this.pagesRepository.findOneBy({ id: ps.pageId }); - } else if (ps.name && ps.username) { + } else { const author = await this.usersRepository.findOneBy({ host: IsNull(), usernameLower: ps.username.toLowerCase(), diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index a8b4319a61..bb8d4c49e9 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -47,23 +47,38 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - - userId: { type: 'string', format: 'misskey:id' }, - username: { type: 'string' }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', + allOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + type: 'object', + properties: { + username: { type: 'string' }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, + }, + required: ['username', 'host'], + }, + ], + }, + { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, }, - }, - anyOf: [ - { required: ['userId'] }, - { required: ['username', 'host'] }, ], } as const; @@ -85,9 +100,9 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy(ps.userId != null + const user = await this.usersRepository.findOneBy('userId' in ps ? { id: ps.userId } - : { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); + : { usernameLower: ps.username.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); if (user == null) { throw new ApiError(meta.errors.noSuchUser); diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index feda5bb353..1fc87151b2 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -54,25 +54,39 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - sinceId: { type: 'string', format: 'misskey:id' }, - untilId: { type: 'string', format: 'misskey:id' }, - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - - userId: { type: 'string', format: 'misskey:id' }, - username: { type: 'string' }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', + allOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + type: 'object', + properties: { + username: { type: 'string' }, + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, + }, + required: ['username', 'host'], + }, + ], + }, + { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + birthday: { ...birthdaySchema, nullable: true }, + }, }, - - birthday: { ...birthdaySchema, nullable: true }, - }, - anyOf: [ - { required: ['userId'] }, - { required: ['username', 'host'] }, ], } as const; @@ -94,9 +108,9 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy(ps.userId != null + const user = await this.usersRepository.findOneBy('userId' in ps ? { id: ps.userId } - : { usernameLower: ps.username!.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); + : { usernameLower: ps.username.toLowerCase(), host: this.utilityService.toPunyNullable(ps.host) ?? IsNull() }); if (user == null) { throw new ApiError(meta.errors.noSuchUser); diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index 1d75437b81..f146095cf1 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -114,7 +114,7 @@ export const paramDef = { type: 'object', properties: { userId: { - anyOf: [ + oneOf: [ { type: 'string', format: 'misskey:id' }, { type: 'array', 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 134f1a8e87..d1d6354d53 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 @@ -26,17 +26,32 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - detail: { type: 'boolean', default: true }, - - username: { type: 'string', nullable: true }, - host: { type: 'string', nullable: true }, - }, - anyOf: [ - { required: ['username'] }, - { required: ['host'] }, + allOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + username: { type: 'string', nullable: true }, + }, + required: ['username'], + }, + { + type: 'object', + properties: { + host: { type: 'string', nullable: true }, + }, + required: ['host'], + }, + ], + }, + { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + detail: { type: 'boolean', default: true }, + }, + }, ], } as const; @@ -47,8 +62,8 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, (ps, me) => { return this.userSearchService.searchByUsernameAndHost({ - username: ps.username, - host: ps.host, + username: 'username' in ps ? ps.username : undefined, + host: 'host' in ps ? ps.host : undefined, }, { limit: ps.limit, detail: ps.detail, diff --git a/packages/backend/src/server/api/endpoints/users/show.test.ts b/packages/backend/src/server/api/endpoints/users/show.test.ts new file mode 100644 index 0000000000..068ffd8bc9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/show.test.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { getValidator } from '../../../../../test/prelude/get-api-validator.js'; +import { paramDef } from './show.js'; + +const VALID = true; +const INVALID = false; + +describe('api:users/show', () => { + describe('validation', () => { + const v = getValidator(paramDef); + + test('Reject empty', () => expect(v({})).toBe(INVALID)); + test('Reject host only', () => expect(v({ host: 'misskey.test' })).toBe(INVALID)); + test('Accept userId only', () => expect(v({ userId: '1' })).toBe(VALID)); + test('Accept username and host', () => expect(v({ username: 'alice', host: 'misskey.test' })).toBe(VALID)); + }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 431869d47f..d57db42e6d 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -59,23 +59,44 @@ export const meta = { } as const; export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - userIds: { type: 'array', uniqueItems: true, items: { - type: 'string', format: 'misskey:id', - } }, - username: { type: 'string' }, - host: { - type: 'string', - nullable: true, - description: 'The local host is represented with `null`.', + allOf: [ + { + anyOf: [ + { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + type: 'object', + properties: { + userIds: { type: 'array', uniqueItems: true, items: { + type: 'string', format: 'misskey:id', + } }, + }, + required: ['userIds'], + }, + { + type: 'object', + properties: { + username: { type: 'string' }, + }, + required: ['username'], + }, + ], + }, + { + type: 'object', + properties: { + host: { + type: 'string', + nullable: true, + description: 'The local host is represented with `null`.', + }, + }, }, - }, - anyOf: [ - { required: ['userId'] }, - { required: ['userIds'] }, - { required: ['username'] }, ], } as const; @@ -102,9 +123,11 @@ export default class extends Endpoint { // eslint- let user; const isModerator = await this.roleService.isModerator(me); - ps.username = ps.username?.trim(); + if ('username' in ps) { + ps.username = ps.username.trim(); + } - if (ps.userIds) { + if ('userIds' in ps) { if (ps.userIds.length === 0) { return []; } @@ -129,7 +152,7 @@ export default class extends Endpoint { // eslint- return _users.map(u => _userMap.get(u.id)!); } else { // Lookup user - if (typeof ps.host === 'string' && typeof ps.username === 'string') { + if (typeof ps.host === 'string' && 'username' in ps) { if (this.serverSettings.ugcVisibilityForVisitor === 'local' && me == null) { throw new ApiError(meta.errors.noSuchUser); } @@ -139,7 +162,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.failedToResolveRemoteUser); }); } else { - const q: FindOptionsWhere = ps.userId != null + const q: FindOptionsWhere = 'userId' in ps ? { id: ps.userId } : { usernameLower: ps.username!.toLowerCase(), host: IsNull() }; diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index ea64e32ee6..e1dead07cf 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -89,7 +89,8 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { schema.required = undefined; } - const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1); + const hasBody = (schema.type === 'object' && schema.properties && Object.keys(schema.properties).length >= 1) + || ['allOf', 'oneOf', 'anyOf'].some(o => (Array.isArray(schema[o]) && schema[o].length >= 0)); const info = { operationId: endpoint.name.replaceAll('/', '___'), // NOTE: スラッシュは使えない diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index abd3a42628..fb03629287 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -7306,8 +7306,9 @@ export type operations = { content: { 'application/json': { /** Format: misskey:id */ - fileId?: string; - url?: string; + fileId: string; + } | { + url: string; }; }; }; @@ -8093,10 +8094,12 @@ export type operations = { admin___emoji___update: { requestBody: { content: { - 'application/json': { + 'application/json': ({ /** Format: misskey:id */ - id?: string; - name?: string; + id: string; + } | { + name: string; + }) & ({ /** Format: misskey:id */ fileId?: string; /** @description Use `null` to reset the category. */ @@ -8106,7 +8109,7 @@ export type operations = { isSensitive?: boolean; localOnly?: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction?: string[]; - }; + }); }; }; responses: { @@ -16996,8 +16999,9 @@ export type operations = { content: { 'application/json': { /** Format: misskey:id */ - fileId?: string; - url?: string; + fileId: string; + } | { + url: string; }; }; }; @@ -23096,9 +23100,10 @@ export type operations = { content: { 'application/json': { /** Format: misskey:id */ - tokenId?: string; - token?: string | null; - }; + tokenId: string; + } | ({ + token: string | null; + }); }; }; responses: { @@ -25784,7 +25789,12 @@ export type operations = { 'notes___search-by-tag': { requestBody: { content: { - 'application/json': { + 'application/json': ({ + tag: string; + } | { + /** @description The outer arrays are chained with OR, the inner arrays are chained with AND. */ + query: string[][]; + }) & ({ /** @default null */ reply?: boolean | null; /** @default null */ @@ -25802,10 +25812,7 @@ export type operations = { untilId?: string; /** @default 10 */ limit?: number; - tag?: string; - /** @description The outer arrays are chained with OR, the inner arrays are chained with AND. */ - query?: string[][]; - }; + }); }; }; responses: { @@ -26890,9 +26897,10 @@ export type operations = { content: { 'application/json': { /** Format: misskey:id */ - pageId?: string; - name?: string; - username?: string; + pageId: string; + } | { + name: string; + username: string; }; }; }; @@ -28979,18 +28987,20 @@ export type operations = { users___followers: { requestBody: { content: { - 'application/json': { + 'application/json': ({ + /** Format: misskey:id */ + userId: string; + } | ({ + username: string; + /** @description The local host is represented with `null`. */ + host: string | null; + })) & { /** Format: misskey:id */ sinceId?: string; /** Format: misskey:id */ untilId?: string; /** @default 10 */ limit?: number; - /** Format: misskey:id */ - userId?: string; - username?: string; - /** @description The local host is represented with `null`. */ - host?: string | null; }; }; }; @@ -29042,20 +29052,22 @@ export type operations = { users___following: { requestBody: { content: { - 'application/json': { + 'application/json': ({ + /** Format: misskey:id */ + userId: string; + } | ({ + username: string; + /** @description The local host is represented with `null`. */ + host: string | null; + })) & ({ /** Format: misskey:id */ sinceId?: string; /** Format: misskey:id */ untilId?: string; /** @default 10 */ limit?: number; - /** Format: misskey:id */ - userId?: string; - username?: string; - /** @description The local host is represented with `null`. */ - host?: string | null; birthday?: string | null; - }; + }); }; }; responses: { @@ -30337,13 +30349,15 @@ export type operations = { 'users___search-by-username-and-host': { requestBody: { content: { - 'application/json': { + 'application/json': (({ + username: string | null; + }) | ({ + host: string | null; + })) & { /** @default 10 */ limit?: number; /** @default true */ detail?: boolean; - username?: string | null; - host?: string | null; }; }; }; @@ -30395,14 +30409,17 @@ export type operations = { users___show: { requestBody: { content: { - 'application/json': { + 'application/json': ({ /** Format: misskey:id */ - userId?: string; - userIds?: string[]; - username?: string; + userId: string; + } | { + userIds: string[]; + } | { + username: string; + }) & ({ /** @description The local host is represented with `null`. */ host?: string | null; - }; + }); }; }; responses: { -- cgit v1.2.3-freya From 367dac4edd4e25281b2978e7d509e9a57a002bdb Mon Sep 17 00:00:00 2001 From: anatawa12 Date: Thu, 29 May 2025 13:13:07 +0900 Subject: Fix: ミュート対象ユーザーが引用されているノートがRNされたときにミュートを貫通してしまう問題 (#16009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: change 3rd parameter of generateMutedUserQueryForNotes to options * chore: allow specifying note column for note/block query * chore: check for mute / block for renote of note with DB query * chore: check for mute / block for renote of note with FTT * refactor: ミュート・ブロックのためのクエリ呼び出しを一つの関数にまとめる * docs(changelog): ミュート対象ユーザーが引用されているノートがRNされたときにミュートを貫通してしまう問題を修正 * fix missing default parameter * Update is-user-related.ts * test: add tests for mutes --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 1 + .../src/core/FanoutTimelineEndpointService.ts | 2 + packages/backend/src/core/QueryService.ts | 100 +++++++++++++++---- packages/backend/src/core/SearchService.ts | 5 +- packages/backend/src/misc/is-user-related.ts | 19 +++- .../src/server/api/endpoints/antennas/notes.ts | 5 +- .../src/server/api/endpoints/channels/timeline.ts | 7 +- .../src/server/api/endpoints/clips/notes.ts | 2 + .../src/server/api/endpoints/notes/children.ts | 7 +- .../server/api/endpoints/notes/global-timeline.ts | 7 +- .../server/api/endpoints/notes/hybrid-timeline.ts | 5 +- .../server/api/endpoints/notes/local-timeline.ts | 5 +- .../src/server/api/endpoints/notes/mentions.ts | 5 +- .../src/server/api/endpoints/notes/renotes.ts | 5 +- .../src/server/api/endpoints/notes/replies.ts | 5 +- .../server/api/endpoints/notes/search-by-tag.ts | 5 +- .../src/server/api/endpoints/notes/timeline.ts | 5 +- .../api/endpoints/notes/user-list-timeline.ts | 5 +- .../src/server/api/endpoints/roles/notes.ts | 5 +- .../src/server/api/endpoints/users/notes.ts | 10 +- .../server/api/endpoints/users/recommendation.ts | 1 + packages/backend/test/e2e/timelines.ts | 111 +++++++++++++++++++++ 22 files changed, 232 insertions(+), 90 deletions(-) (limited to 'packages/backend/src/server/api/endpoints/notes') diff --git a/CHANGELOG.md b/CHANGELOG.md index cf652c0723..ee7a9d4841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ - Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正 - Fix: ユーザ除外アンテナをインポートできない問題を修正 - Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正 +- Fix: ミュート対象ユーザーが引用されているノートがRNされたときにミュートを貫通してしまう問題を修正 #16009 - Fix: 連合モードが「なし」の場合に、生成されるHTML内のactivity jsonへのリンクタグを省略するように - Fix: コントロールパネルから招待コードを作成すると作成者の情報が記録されない問題を修正 diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 6253f792ed..97b617096a 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -120,6 +120,8 @@ export class FanoutTimelineEndpointService { filter = (note) => { if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; + if (isUserRelated(note.renote, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; + if (isUserRelated(note.renote, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false; if (isInstanceMuted(note, userMutedInstances)) return false; diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index b9cef5b0ec..d398e83230 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -77,9 +77,51 @@ export class QueryService { return q; } + /** + * ミュートやブロックのようにすべてのタイムラインで共通に使用するフィルターを定義します。 + * + * 特別な事情がない限り、各タイムラインはこの関数を呼び出してフィルターを適用してください。 + * + * Notes for future maintainers: + * 1) この関数で生成するクエリと同等の処理が FanoutTimelineEndpointService にあります。 + * この関数を変更した場合、FanoutTimelineEndpointService の方も変更する必要があります。 + * 2) 以下のエンドポイントでは特別な事情があるため queryService のそれぞれの関数を呼び出しています。 + * この関数を変更した場合、以下のエンドポイントの方も変更する必要があることがあります。 + * - packages/backend/src/server/api/endpoints/clips/notes.ts + */ + @bindThis + public generateBaseNoteFilteringQuery( + query: SelectQueryBuilder, + me: { id: MiUser['id'] } | null, + { + excludeUserFromMute, + excludeAuthor, + }: { + excludeUserFromMute?: MiUser['id'], + excludeAuthor?: boolean, + } = {}, + ): void { + this.generateBlockedHostQueryForNote(query, excludeAuthor); + this.generateSuspendedUserQueryForNote(query, excludeAuthor); + if (me) { + this.generateMutedUserQueryForNotes(query, me, { excludeUserFromMute }); + this.generateBlockedUserQueryForNotes(query, me); + this.generateMutedUserQueryForNotes(query, me, { noteColumn: 'renote', excludeUserFromMute }); + this.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' }); + } + } + // ここでいうBlockedは被Blockedの意 @bindThis - public generateBlockedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { + public generateBlockedUserQueryForNotes( + q: SelectQueryBuilder, + me: { id: MiUser['id'] }, + { + noteColumn = 'note', + }: { + noteColumn?: string, + } = {}, + ): void { const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') .select('blocking.blockerId') .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); @@ -88,16 +130,20 @@ export class QueryService { // 投稿の返信先の作者にブロックされていない かつ // 投稿の引用元の作者にブロックされていない q - .andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`) .andWhere(new Brackets(qb => { qb - .where('note.replyUserId IS NULL') - .orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`); + .where(`${noteColumn}.userId IS NULL`) + .orWhere(`${noteColumn}.userId NOT IN (${ blockingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { + qb + .where(`${noteColumn}.replyUserId IS NULL`) + .orWhere(`${noteColumn}.replyUserId NOT IN (${ blockingQuery.getQuery() })`); })) .andWhere(new Brackets(qb => { qb - .where('note.renoteUserId IS NULL') - .orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); + .where(`${noteColumn}.renoteUserId IS NULL`) + .orWhere(`${noteColumn}.renoteUserId NOT IN (${ blockingQuery.getQuery() })`); })); q.setParameters(blockingQuery.getParameters()); @@ -137,13 +183,23 @@ export class QueryService { } @bindThis - public generateMutedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { + public generateMutedUserQueryForNotes( + q: SelectQueryBuilder, + me: { id: MiUser['id'] }, + { + excludeUserFromMute, + noteColumn = 'note', + }: { + excludeUserFromMute?: MiUser['id'], + noteColumn?: string, + } = {}, + ): void { const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: me.id }); - if (exclude) { - mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); + if (excludeUserFromMute) { + mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: excludeUserFromMute }); } const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') @@ -154,32 +210,36 @@ export class QueryService { // 投稿の返信先の作者をミュートしていない かつ // 投稿の引用元の作者をミュートしていない q - .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) .andWhere(new Brackets(qb => { qb - .where('note.replyUserId IS NULL') - .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); + .where(`${noteColumn}.userId IS NULL`) + .orWhere(`${noteColumn}.userId NOT IN (${ mutingQuery.getQuery() })`); + })) + .andWhere(new Brackets(qb => { + qb + .where(`${noteColumn}.replyUserId IS NULL`) + .orWhere(`${noteColumn}.replyUserId NOT IN (${ mutingQuery.getQuery() })`); })) .andWhere(new Brackets(qb => { qb - .where('note.renoteUserId IS NULL') - .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); + .where(`${noteColumn}.renoteUserId IS NULL`) + .orWhere(`${noteColumn}.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); })) // mute instances .andWhere(new Brackets(qb => { qb - .andWhere('note.userHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); + .andWhere(`${noteColumn}.userHost IS NULL`) + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.userHost)`); })) .andWhere(new Brackets(qb => { qb - .where('note.replyUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); + .where(`${noteColumn}.replyUserHost IS NULL`) + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.replyUserHost)`); })) .andWhere(new Brackets(qb => { qb - .where('note.renoteUserHost IS NULL') - .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + .where(`${noteColumn}.renoteUserHost IS NULL`) + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.renoteUserHost)`); })); q.setParameters(mutingQuery.getParameters()); diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 20a776ded8..643a5f525d 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -234,10 +234,7 @@ export class SearchService { } this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); return query.limit(pagination.limit).getMany(); } diff --git a/packages/backend/src/misc/is-user-related.ts b/packages/backend/src/misc/is-user-related.ts index 862d6e6a38..6f72082733 100644 --- a/packages/backend/src/misc/is-user-related.ts +++ b/packages/backend/src/misc/is-user-related.ts @@ -3,7 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -export function isUserRelated(note: any, userIds: Set, ignoreAuthor = false): boolean { +import type { MiUser } from '@/models/_.js'; + +interface NoteLike { + userId: MiUser['id']; + reply?: NoteLike | null; + renote?: NoteLike | null; + replyUserId?: MiUser['id'] | null; + renoteUserId?: MiUser['id'] | null; +} + +export function isUserRelated(note: NoteLike | null | undefined, userIds: Set, ignoreAuthor = false): boolean { if (!note) { return false; } @@ -12,13 +22,16 @@ export function isUserRelated(note: any, userIds: Set, ignoreAuthor = fa return true; } - if (note.reply != null && note.reply.userId !== note.userId && userIds.has(note.reply.userId)) { + const replyUserId = note.replyUserId ?? note.reply?.userId; + if (replyUserId != null && replyUserId !== note.userId && userIds.has(replyUserId)) { return true; } - if (note.renote != null && note.renote.userId !== note.userId && userIds.has(note.renote.userId)) { + const renoteUserId = note.renoteUserId ?? note.renote?.userId; + if (renoteUserId != null && renoteUserId !== note.userId && userIds.has(renoteUserId)) { return true; } return false; } + diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index f37cdc6658..b2d9cea03c 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -111,11 +111,8 @@ export default class extends Endpoint { // eslint- // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQueryForNotes(query, me); - this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); const notes = await query.getMany(); if (sinceId != null && untilId == null) { diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 2401ab8208..46b050d4b4 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -121,12 +121,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser') .leftJoinAndSelect('note.channel', 'channel'); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - if (me) { - this.queryService.generateMutedUserQueryForNotes(query, me); - this.queryService.generateBlockedUserQueryForNotes(query, me); - } + this.queryService.generateBaseNoteFilteringQuery(query, me); //#endregion return await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 33f32d1d8a..4869ffd402 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -91,6 +91,8 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me, { noteColumn: 'renote' }); + this.queryService.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' }); } const notes = await query diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index 712a86eb13..d457ad1220 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -70,12 +70,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - if (me) { - this.queryService.generateMutedUserQueryForNotes(query, me); - this.queryService.generateBlockedUserQueryForNotes(query, me); - } + this.queryService.generateBaseNoteFilteringQuery(query, me); const notes = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 8d38bb1c65..1c73edf08e 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -78,11 +78,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - if (me) { - this.queryService.generateMutedUserQueryForNotes(query, me); - this.queryService.generateBlockedUserQueryForNotes(query, me); - this.queryService.generateMutedUserRenotesQueryForNotes(query, me); - } + this.queryService.generateBaseNoteFilteringQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 6a3ee817e4..2c8459525a 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -243,10 +243,7 @@ export default class extends Endpoint { // eslint- } this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - this.queryService.generateMutedUserQueryForNotes(query, me); - this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index d1dc22f233..ee61ab43da 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -156,10 +156,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index c3722b1b5a..7ffc349db5 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -72,11 +72,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); this.queryService.generateMutedNoteThreadQuery(query, me); - this.queryService.generateBlockedUserQueryForNotes(query, me); if (ps.visibility) { query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index ce2435b8eb..fa2306c1bf 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -72,10 +72,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); const renotes = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index f491cc38ab..9626947480 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -56,10 +56,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); const timeline = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 3e41b33d03..51255f0bbf 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -96,10 +96,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); try { if ('tag' in ps) { diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index e6d6a1b629..c76cca1518 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -199,10 +199,7 @@ export default class extends Endpoint { // eslint- })); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - this.queryService.generateMutedUserQueryForNotes(query, me); - this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index ec7c4b0f97..614cd9204d 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -184,10 +184,7 @@ export default class extends Endpoint { // eslint- })); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - this.queryService.generateMutedUserQueryForNotes(query, me); - this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 16b0783a01..e8a760e9f8 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -102,10 +102,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query); - this.queryService.generateSuspendedUserQueryForNote(query); - this.queryService.generateMutedUserQueryForNotes(query, me); - this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBaseNoteFilteringQuery(query, me); const notes = await query.getMany(); notes.sort((a, b) => a.id > b.id ? -1 : 1); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 0c64df569d..5832790a61 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -186,12 +186,10 @@ export default class extends Endpoint { // eslint- } this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateBlockedHostQueryForNote(query, true); - this.queryService.generateSuspendedUserQueryForNote(query, true); - if (me) { - this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId }); - this.queryService.generateBlockedUserQueryForNotes(query, me); - } + this.queryService.generateBaseNoteFilteringQuery(query, me, { + excludeAuthor: true, + excludeUserFromMute: ps.userId, + }); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index 5b1c6b514b..769a72d7a1 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -64,6 +64,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQueryForUsers(query, me); this.queryService.generateBlockQueryForUsers(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' }); const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('following.followeeId') diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index d6d2cb33f0..e53c3d8f34 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -345,6 +345,44 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); + test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、フォローしているユーザーによるリノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(1000); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、フォローしているユーザーによるリノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(1000); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); @@ -687,6 +725,42 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); + test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(1000); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(1000); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === daveNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1383,6 +1457,39 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); + test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによる引用ノートの、リノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(1000); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', renoteId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + test.concurrent('ミュートしているユーザーのノートの、関係のないユーザによるリプライの、リノートが含まれない', async () => { + const [alice, bob, carol, dave] = await Promise.all([signup(), signup(), signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await api('mute/create', { userId: carol.id }, alice); + await setTimeout(1000); + const carolNote = await post(carol, { text: 'hi' }); + const daveNote = await post(dave, { text: 'quote hi', replyId: carolNote.id }); + const bobNote = await post(bob, { renoteId: daveNote.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, limit: 100 }, alice); + + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1391,6 +1498,8 @@ describe('Timelines', () => { const bobNote1 = await post(bob, { text: 'hi' }); const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); const bobNote3 = await post(bob, { text: 'hi', renoteId: bobNote1.id }); + const bobNote4 = await post(bob, { renoteId: bobNote2.id }); + const bobNote5 = await post(bob, { renoteId: bobNote3.id }); await waitForPushToTl(); @@ -1399,6 +1508,8 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote3.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote4.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote5.id), true); }); test.concurrent('自身の visibility: specified なノートが含まれる', async () => { -- cgit v1.2.3-freya