fix(backend): fix streaming note hiding logic (#17248)

* fix(backend): fix streaming note hiding logic

* Update Changelog

* refactor: avoid using generator function

---------

Co-authored-by: Acid Chicken <root@acid-chicken.com>
This commit is contained in:
かっこかり
2026-03-20 14:01:27 +09:00
committed by GitHub
parent f16ef2ef56
commit 2904b5a342
11 changed files with 72 additions and 118 deletions

View File

@@ -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<HiddenLayer> };
/** 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<LockdownCheckResult> {
const hiddenLayers = new Set<HiddenLayer>();
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<HiddenLayer>,
): 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<Packed<'Note'> | 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;
}
}

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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)) {