This commit is contained in:
かっこかり 2026-01-25 23:36:23 +09:00 committed by GitHub
commit 47184f1087
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 284 additions and 81 deletions

View File

@ -29,6 +29,7 @@
- JSONによるClient Information Discoveryを行うには、レスポンスの`Content-Type`ヘッダーが`application/json`である必要があります
- 従来の実装12 February 2022版・HTML Microformat形式も引き続きサポートされます
- Enhance: メモリ使用量を削減
- Fix: リアルタイム更新時にロックダウン設定が考慮されていない問題を修正
## 2025.12.2

View File

@ -17,6 +17,7 @@ import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { CacheService } from '@/core/CacheService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js';
@ -101,6 +102,7 @@ export class NoteEntityService implements OnModuleInit {
//private reactionService: ReactionService,
//private reactionsBufferingService: ReactionsBufferingService,
//private idService: IdService,
private cacheService: CacheService,
) {
}
@ -125,75 +127,65 @@ export class NoteEntityService implements OnModuleInit {
}
@bindThis
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> {
if (meId === packedNote.userId) return;
public async shouldHideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<boolean> {
if (meId === packedNote.userId) return false;
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
let hide = false;
if (packedNote.user.requireSigninToViewContents && meId == null) {
hide = true;
return true;
}
if (!hide) {
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
hide = true;
}
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
return true;
}
// visibility が specified かつ自分が指定されていなかったら非表示
if (!hide) {
if (packedNote.visibility === 'specified') {
if (meId == null) {
hide = true;
} else {
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some(id => meId === id);
if (packedNote.visibility === 'specified') {
if (meId == null) {
return true;
} else {
// 指定されているかどうか
const specified = packedNote.visibleUserIds!.some(id => meId === id);
if (!specified) {
hide = true;
}
if (!specified) {
return true;
}
}
}
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
if (!hide) {
if (packedNote.visibility === 'followers') {
if (meId == null) {
hide = true;
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
// 自分の投稿に対するリプライ
hide = false;
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
// 自分へのメンション
hide = false;
} else {
// フォロワーかどうか
// TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: packedNote.userId,
followerId: meId,
},
});
hide = !isFollowing;
if (packedNote.visibility === 'followers') {
if (meId == null) {
return true;
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
// 自分の投稿に対するリプライ
return false;
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
// 自分へのメンション
return false;
} else {
// フォロワーかどうか
const followings = await this.cacheService.userFollowingsCache.fetch(meId);
if (!Object.hasOwn(followings, packedNote.userId)) {
return true;
}
}
}
if (hide) {
packedNote.visibleUserIds = undefined;
packedNote.fileIds = [];
packedNote.files = [];
packedNote.text = null;
packedNote.poll = undefined;
packedNote.cw = null;
packedNote.isHidden = true;
// TODO: hiddenReason みたいなのを提供しても良さそう
}
return false;
}
@bindThis
public hideNote(packedNote: Packed<'Note'>): void {
packedNote.visibleUserIds = undefined;
packedNote.fileIds = [];
packedNote.files = [];
packedNote.text = null;
packedNote.poll = undefined;
packedNote.cw = null;
packedNote.isHidden = true;
// TODO: hiddenReason みたいなのを提供しても良さそう
}
@bindThis
@ -468,8 +460,8 @@ export class NoteEntityService implements OnModuleInit {
this.treatVisibility(packed);
if (!opts.skipHide) {
await this.hideNote(packed, meId);
if (!opts.skipHide && await this.shouldHideNote(packed, meId)) {
this.hideNote(packed);
}
return packed;

View File

@ -22,6 +22,7 @@ import { SigninApiService } from './api/SigninApiService.js';
import { SigninService } from './api/SigninService.js';
import { SignupApiService } from './api/SignupApiService.js';
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
import { NoteStreamingLockdownService } from './api/stream/NoteStreamingLockdownService.js';
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
import { ClientServerService } from './web/ClientServerService.js';
import { HtmlTemplateService } from './web/HtmlTemplateService.js';
@ -80,6 +81,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
SigninService,
SignupApiService,
StreamingApiServerService,
NoteStreamingLockdownService,
MainChannel,
AdminChannel,
AntennaChannel,

View File

@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.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> };
@Injectable()
export class NoteStreamingLockdownService {
constructor(
private noteEntityService: NoteEntityService,
) {}
/**
*
*
*
* @param note -
* @param meId - IDnull
* @returns shouldSkip: true false hiddenLayers
*/
@bindThis
public async checkLockdown(
note: Packed<'Note'>,
meId: MiUser['id'] | null,
): Promise<LockdownCheckResult> {
const hiddenLayers = new Set<HiddenLayer>();
// 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');
}
}
// 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 };
}
/**
* hiddenLayersに基づいてートの内容を隠す
*
* @param note -
* @param hiddenLayers -
*/
@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);
}
}
/**
* 便
* checkLockdown + applyHiding
*
* @param note -
* @param meId - IDnull
* @returns shouldSkip: true
*/
@bindThis
public async processLockdown(
note: Packed<'Note'>,
meId: MiUser['id'] | null,
): Promise<{ shouldSkip: boolean }> {
const result = await this.checkLockdown(note, meId);
if (result.shouldSkip) {
return { shouldSkip: true };
}
this.applyHiding(note, result.hiddenLayers);
return { shouldSkip: false };
}
}

View File

@ -6,9 +6,11 @@
import { Inject, Injectable, Scope } from '@nestjs/common';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { NoteStreamingLockdownService } from '../NoteStreamingLockdownService.js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
@ -24,6 +26,7 @@ export class AntennaChannel extends Channel {
request: ChannelRequest,
private noteEntityService: NoteEntityService,
private noteStreamingFilterService: NoteStreamingLockdownService,
) {
super(request);
//this.onEvent = this.onEvent.bind(this);
@ -45,6 +48,18 @@ export class AntennaChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
const { shouldSkip: shouldSkipByLockdown } = await this.noteStreamingFilterService.processLockdown(note, this.user?.id ?? null);
if (shouldSkipByLockdown) return;
if (this.user) {
if (isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
}
this.send('note', note);
} else {
this.send(data.type, data.body);

View File

@ -12,6 +12,7 @@ import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { NoteStreamingLockdownService } from '../NoteStreamingLockdownService.js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
@ -26,6 +27,7 @@ export class ChannelChannel extends Channel {
request: ChannelRequest,
private noteEntityService: NoteEntityService,
private noteStreamingFilterService: NoteStreamingLockdownService,
) {
super(request);
//this.onNote = this.onNote.bind(this);
@ -50,10 +52,15 @@ export class ChannelChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
const { shouldSkip: shouldSkipByLockdown } = await this.noteStreamingFilterService.processLockdown(note, this.user?.id ?? null);
if (shouldSkipByLockdown) return;
if (this.user) {
if (isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
}

View File

@ -12,6 +12,7 @@ import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { NoteStreamingLockdownService } from '../NoteStreamingLockdownService.js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
@ -29,6 +30,7 @@ export class GlobalTimelineChannel extends Channel {
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
private noteStreamingFilterService: NoteStreamingLockdownService,
) {
super(request);
//this.onNote = this.onNote.bind(this);
@ -60,10 +62,15 @@ export class GlobalTimelineChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
const { shouldSkip: shouldSkipByLockdown } = await this.noteStreamingFilterService.processLockdown(note, this.user?.id ?? null);
if (shouldSkipByLockdown) return;
if (this.user) {
if (isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
}

View File

@ -11,6 +11,7 @@ import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { NoteStreamingLockdownService } from '../NoteStreamingLockdownService.js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
@ -25,6 +26,7 @@ export class HashtagChannel extends Channel {
request: ChannelRequest,
private noteEntityService: NoteEntityService,
private noteStreamingFilterService: NoteStreamingLockdownService,
) {
super(request);
//this.onNote = this.onNote.bind(this);
@ -48,10 +50,15 @@ export class HashtagChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
const { shouldSkip: shouldSkipByLockdown } = await this.noteStreamingFilterService.processLockdown(note, this.user?.id ?? null);
if (shouldSkipByLockdown) return;
if (this.user) {
if (isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
}

View File

@ -10,6 +10,7 @@ import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { NoteStreamingLockdownService } from '../NoteStreamingLockdownService.js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
@ -26,6 +27,7 @@ export class HomeTimelineChannel extends Channel {
request: ChannelRequest,
private noteEntityService: NoteEntityService,
private noteStreamingFilterService: NoteStreamingLockdownService,
) {
super(request);
//this.onNote = this.onNote.bind(this);
@ -84,10 +86,15 @@ export class HomeTimelineChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
const { shouldSkip: shouldSkipByLockdown } = await this.noteStreamingFilterService.processLockdown(note, this.user?.id ?? null);
if (shouldSkipByLockdown) return;
if (this.user) {
if (isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
}

View File

@ -12,6 +12,7 @@ import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { NoteStreamingLockdownService } from '../NoteStreamingLockdownService.js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
@ -31,6 +32,7 @@ export class HybridTimelineChannel extends Channel {
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
private noteStreamingFilterService: NoteStreamingLockdownService,
) {
super(request);
//this.onNote = this.onNote.bind(this);
@ -104,10 +106,15 @@ export class HybridTimelineChannel extends Channel {
}
}
if (this.user && note.renoteId && !note.text) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
const { shouldSkip: shouldSkipByLockdown } = await this.noteStreamingFilterService.processLockdown(note, this.user?.id ?? null);
if (shouldSkipByLockdown) return;
if (this.user) {
if (isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
}

View File

@ -12,6 +12,7 @@ import { RoleService } from '@/core/RoleService.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { NoteStreamingLockdownService } from '../NoteStreamingLockdownService.js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
@ -30,6 +31,7 @@ export class LocalTimelineChannel extends Channel {
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
private noteStreamingFilterService: NoteStreamingLockdownService,
) {
super(request);
//this.onNote = this.onNote.bind(this);
@ -70,10 +72,15 @@ export class LocalTimelineChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
const { shouldSkip: shouldSkipByLockdown } = await this.noteStreamingFilterService.processLockdown(note, this.user?.id ?? null);
if (shouldSkipByLockdown) return;
if (this.user) {
if (isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
}

View File

@ -7,9 +7,11 @@ import { Inject, Injectable, Scope } from '@nestjs/common';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { NoteStreamingLockdownService } from '../NoteStreamingLockdownService.js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
@ -25,6 +27,7 @@ export class RoleTimelineChannel extends Channel {
private noteEntityService: NoteEntityService,
private roleservice: RoleService,
private noteStreamingFilterService: NoteStreamingLockdownService,
) {
super(request);
//this.onNote = this.onNote.bind(this);
@ -50,6 +53,18 @@ export class RoleTimelineChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
const { shouldSkip: shouldSkipByLockdown } = await this.noteStreamingFilterService.processLockdown(note, this.user?.id ?? null);
if (shouldSkipByLockdown) return;
if (this.user) {
if (isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
}
this.send('note', note);
} else {
this.send(data.type, data.body);

View File

@ -12,6 +12,7 @@ import { bindThis } from '@/decorators.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import Channel, { type ChannelRequest } from '../channel.js';
import { NoteStreamingLockdownService } from '../NoteStreamingLockdownService.js';
import { REQUEST } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
@ -36,6 +37,7 @@ export class UserListChannel extends Channel {
request: ChannelRequest,
private noteEntityService: NoteEntityService,
private noteStreamingFilterService: NoteStreamingLockdownService,
) {
super(request);
//this.updateListUsers = this.updateListUsers.bind(this);
@ -117,10 +119,15 @@ export class UserListChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
if (this.user && isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
const { shouldSkip: shouldSkipByLockdown } = await this.noteStreamingFilterService.processLockdown(note, this.user?.id ?? null);
if (shouldSkipByLockdown) return;
if (this.user) {
if (isRenotePacked(note) && !isQuotePacked(note)) {
if (note.renote && Object.keys(note.renote.reactions).length > 0) {
const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id);
note.renote.myReaction = myRenoteReaction;
}
}
}