diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb8250a3e..0d43754dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ ### Server - Fix: 自分の行ったフォロワー限定投稿または指名投稿に自分自身でリアクションなどを行った場合のイベントが流れない問題を修正 - Fix: 署名付きGETリクエストにおいてAcceptヘッダを署名の対象から除外(Acceptヘッダを正規化するCDNやリバースプロキシを使用している際に挙動がおかしくなる問題を修正) +- Fix: WebSocket接続におけるノートの非表示ロジックを修正 ## 2026.3.1 diff --git a/packages/backend/src/server/api/stream/NoteStreamingHidingService.ts b/packages/backend/src/server/api/stream/NoteStreamingHidingService.ts index 459fa30fa9..1b86c0ae20 100644 --- a/packages/backend/src/server/api/stream/NoteStreamingHidingService.ts +++ b/packages/backend/src/server/api/stream/NoteStreamingHidingService.ts @@ -6,16 +6,11 @@ import { Injectable } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { deepClone } from '@/misc/clone.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { Packed } from '@/misc/json-schema.js'; import type { MiUser } from '@/models/User.js'; -type HiddenLayer = 'note' | 'renote' | 'renoteRenote'; - -type LockdownCheckResult = - | { shouldSkip: true } - | { shouldSkip: false; hiddenLayers: Set }; - /** Streamにおいて、ノートを隠す(hideNote)を適用するためのService */ @Injectable() export class NoteStreamingHidingService { @@ -23,110 +18,52 @@ export class NoteStreamingHidingService { private noteEntityService: NoteEntityService, ) {} - /** - * ノートの可視性を判定する - * - * @param note - 判定対象のノート - * @param meId - 閲覧者のユーザーID(未ログインの場合はnull) - * @returns shouldSkip: true の場合はノートを流さない、false の場合は hiddenLayers に基づいて隠す - */ - @bindThis - public async shouldHide( - note: Packed<'Note'>, - meId: MiUser['id'] | null, - ): Promise { - const hiddenLayers = new Set(); + private collectRenoteChain(note: Packed<'Note'>): Packed<'Note'>[] { + const renoteChain: Packed<'Note'>[] = []; - // 1階層目: note自体 - const shouldHideThisNote = await this.noteEntityService.shouldHideNote(note, meId); - if (shouldHideThisNote) { - if (isRenotePacked(note) && isQuotePacked(note)) { - // 引用リノートの場合、内容を隠して流す - hiddenLayers.add('note'); - } else if (isRenotePacked(note)) { - // 純粋リノートの場合、流さない - return { shouldSkip: true }; - } else { - // 通常ノートの場合、内容を隠して流す - hiddenLayers.add('note'); - } + for (let current: Packed<'Note'> | null | undefined = note; current != null; current = current.renote) { + renoteChain.push(current); } - // 2階層目: note.renote - if (isRenotePacked(note) && note.renote) { - const shouldHideRenote = await this.noteEntityService.shouldHideNote(note.renote, meId); - if (shouldHideRenote) { - if (isQuotePacked(note)) { - // noteが引用リノートの場合、renote部分だけ隠す - hiddenLayers.add('renote'); - } else { - // noteが純粋リノートの場合、流さない - return { shouldSkip: true }; - } - } - } - - // 3階層目: note.renote.renote - if (isRenotePacked(note) && note.renote && - isRenotePacked(note.renote) && note.renote.renote) { - const shouldHideRenoteRenote = await this.noteEntityService.shouldHideNote(note.renote.renote, meId); - if (shouldHideRenoteRenote) { - if (isQuotePacked(note.renote)) { - // note.renoteが引用リノートの場合、renote.renote部分だけ隠す - hiddenLayers.add('renoteRenote'); - } else { - // note.renoteが純粋リノートの場合、note.renoteの意味がなくなるので流さない - return { shouldSkip: true }; - } - } - } - - return { shouldSkip: false, hiddenLayers }; + return renoteChain; } /** - * hiddenLayersに基づいてノートの内容を隠す。 + * ストリーミング配信用にノートの内容を隠す(あるいはそもそも送信しない)判定及び処理を行う。 * - * この処理は渡された `note` を直接変更します。 + * 隠す処理が必要な場合は元のノートをクローンして変更を適用したものを返し、 + * 送信すべきでない場合は `null` を返す。 + * 変更が不要な場合は元のノートの参照をそのまま返す。 * * @param note - 処理対象のノート - * @param hiddenLayers - 隠す階層のセット + * @param meId - 閲覧者のユーザー ID (未ログインの場合は `null`) + * @returns 配信するノートオブジェクト、または配信スキップの場合は `null` */ @bindThis - public applyHiding( - note: Packed<'Note'>, - hiddenLayers: Set, - ): void { - if (hiddenLayers.has('note')) { - this.noteEntityService.hideNote(note); - } - if (hiddenLayers.has('renote') && note.renote) { - this.noteEntityService.hideNote(note.renote); - } - if (hiddenLayers.has('renoteRenote') && note.renote && note.renote.renote) { - this.noteEntityService.hideNote(note.renote.renote); - } - } + public async filter(note: Packed<'Note'>, meId: MiUser['id'] | null): Promise | null> { + const renoteChain = this.collectRenoteChain(note); + const shouldHide = await Promise.all(renoteChain.map(n => this.noteEntityService.shouldHideNote(n, meId))); - /** - * ストリーミング配信用にノートを隠す(あるいはそもそも送信しない)の判定及び処理を行う。 - * - * この処理は渡された `note` を直接変更します。 - * - * @param note - 処理対象のノート(必要に応じて内容が隠される) - * @param meId - 閲覧者のユーザーID(未ログインの場合はnull) - * @returns shouldSkip: true の場合はノートを流さない - */ - @bindThis - public async processHiding( - note: Packed<'Note'>, - meId: MiUser['id'] | null, - ): Promise<{ shouldSkip: boolean }> { - const result = await this.shouldHide(note, meId); - if (result.shouldSkip) { - return { shouldSkip: true }; + if (!shouldHide.some(h => h)) { + // 隠す必要がない場合は元のノートをそのまま返す + return note; } - this.applyHiding(note, result.hiddenLayers); - return { shouldSkip: false }; + + if (renoteChain.some(n => isRenotePacked(n) && !isQuotePacked(n))) { + // 純粋リノートの場合は配信をスキップする + return null; + } + + const clonedNote = deepClone(note); + let currentCloned = clonedNote; + + for (let i = 0; i < renoteChain.length; i++) { + if (shouldHide[i]) { + this.noteEntityService.hideNote(currentCloned); + } + currentCloned = currentCloned.renote!; + } + + return clonedNote; } } diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 3fb1ad63d4..b7f863b355 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -62,13 +62,14 @@ export class AntennaChannel extends Channel { @bindThis private async onEvent(data: GlobalEvents['antenna']['payload']) { if (data.type === 'note') { - const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); + let note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); if (!this.isNoteVisibleForMe(note)) return; if (this.isNoteMutedOrBlocked(note)) return; - const { shouldSkip } = await this.noteStreamingHidingService.processHiding(note, this.user?.id ?? null); - if (shouldSkip) return; + const filtered = await this.noteStreamingHidingService.filter(note, this.user?.id ?? null); + if (!filtered) return; + note = filtered; if (this.user) { if (isRenotePacked(note) && !isQuotePacked(note)) { diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 80352df2c2..6b9159887b 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -53,8 +53,10 @@ export class ChannelChannel extends Channel { if (!this.isNoteVisibleForMe(note)) return; if (this.isNoteMutedOrBlocked(note)) return; - const { shouldSkip } = await this.noteStreamingHidingService.processHiding(note, this.user?.id ?? null); - if (shouldSkip) return; + const filtered = await this.noteStreamingHidingService.filter(note, this.user?.id ?? null); + if (!filtered) return; + // eslint-disable-next-line no-param-reassign -- これ以降元の Note オブジェクトは見てはいけないので、いっそ再代入した方が安全 + note = filtered; if (this.user) { if (isRenotePacked(note) && !isQuotePacked(note)) { diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 2b971d93db..7d310bd875 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -62,8 +62,10 @@ export class GlobalTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const { shouldSkip } = await this.noteStreamingHidingService.processHiding(note, this.user?.id ?? null); - if (shouldSkip) return; + const filtered = await this.noteStreamingHidingService.filter(note, this.user?.id ?? null); + if (!filtered) return; + // eslint-disable-next-line no-param-reassign -- これ以降元の Note オブジェクトは見てはいけないので、いっそ再代入した方が安全 + note = filtered; if (this.user) { if (isRenotePacked(note) && !isQuotePacked(note)) { diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 6ba8963044..ccbe6a610c 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -59,8 +59,10 @@ export class HashtagChannel extends Channel { if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; if (this.isNoteMutedOrBlocked(note)) return; - const { shouldSkip } = await this.noteStreamingHidingService.processHiding(note, this.user?.id ?? null); - if (shouldSkip) return; + const filtered = await this.noteStreamingHidingService.filter(note, this.user?.id ?? null); + if (!filtered) return; + // eslint-disable-next-line no-param-reassign -- これ以降元の Note オブジェクトは見てはいけないので、いっそ再代入した方が安全 + note = filtered; if (this.user) { if (isRenotePacked(note) && !isQuotePacked(note)) { diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 4742b0ed32..5b6dbb24b0 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -82,8 +82,10 @@ export class HomeTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const { shouldSkip } = await this.noteStreamingHidingService.processHiding(note, this.user?.id ?? null); - if (shouldSkip) return; + const filtered = await this.noteStreamingHidingService.filter(note, this.user?.id ?? null); + if (!filtered) return; + // eslint-disable-next-line no-param-reassign -- これ以降元の Note オブジェクトは見てはいけないので、いっそ再代入した方が安全 + note = filtered; if (this.user) { if (isRenotePacked(note) && !isQuotePacked(note)) { diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 7acd945f54..f81e880018 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -101,8 +101,10 @@ export class HybridTimelineChannel extends Channel { } } - const { shouldSkip } = await this.noteStreamingHidingService.processHiding(note, this.user?.id ?? null); - if (shouldSkip) return; + const filtered = await this.noteStreamingHidingService.filter(note, this.user?.id ?? null); + if (!filtered) return; + // eslint-disable-next-line no-param-reassign -- これ以降元の Note オブジェクトは見てはいけないので、いっそ再代入した方が安全 + note = filtered; if (this.user) { if (isRenotePacked(note) && !isQuotePacked(note)) { diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 115b3a1415..5df9b7902b 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -72,8 +72,10 @@ export class LocalTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const { shouldSkip } = await this.noteStreamingHidingService.processHiding(note, this.user?.id ?? null); - if (shouldSkip) return; + const filtered = await this.noteStreamingHidingService.filter(note, this.user?.id ?? null); + if (!filtered) return; + // eslint-disable-next-line no-param-reassign -- これ以降元の Note オブジェクトは見てはいけないので、いっそ再代入した方が安全 + note = filtered; if (this.user) { if (isRenotePacked(note) && !isQuotePacked(note)) { diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index a1be92a5c8..c0e054b3e7 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -44,7 +44,7 @@ export class RoleTimelineChannel extends Channel { @bindThis private async onEvent(data: GlobalEvents['roleTimeline']['payload']) { if (data.type === 'note') { - const note = data.body; + let note = data.body; if (!(await this.roleservice.isExplorable({ id: this.roleId }))) { return; @@ -56,8 +56,9 @@ export class RoleTimelineChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const { shouldSkip } = await this.noteStreamingHidingService.processHiding(note, this.user?.id ?? null); - if (shouldSkip) return; + const filtered = await this.noteStreamingHidingService.filter(note, this.user?.id ?? null); + if (!filtered) return; + note = filtered; if (this.user) { if (isRenotePacked(note) && !isQuotePacked(note)) { diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 6df7e32005..0a9d09d64a 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -117,8 +117,10 @@ export class UserListChannel extends Channel { if (this.isNoteMutedOrBlocked(note)) return; - const { shouldSkip } = await this.noteStreamingHidingService.processHiding(note, this.user?.id ?? null); - if (shouldSkip) return; + const filtered = await this.noteStreamingHidingService.filter(note, this.user?.id ?? null); + if (!filtered) return; + // eslint-disable-next-line no-param-reassign -- これ以降元の Note オブジェクトは見てはいけないので、いっそ再代入した方が安全 + note = filtered; if (this.user) { if (isRenotePacked(note) && !isQuotePacked(note)) {