Skip to content

Commit b361a10

Browse files
kakkokari-gtyihjuliajohannesenkanarikanaru
authored
Merge commit from fork
* Tighten security in `HashtagChannel` * Add isNoteVisibleForMe in stream channel Co-Authored-By: Julia Johannesen <julia@insertdomain.name> * Tighten note visibility checks in WebSocket (No.1) * refactor * Fix main channel Co-Authored-By: Julia Johannesen <julia@insertdomain.name> * fix typo * fix missing lockdown (requireSigninToViewContents) checks * fix(backend): streamingでのロックダウン挙動修正 * fix: 引用リノートを無条件で隠していた問題を修正 * fix: 引用リノートを単純にリノート場合に内容が見えることがある問題を修正 * refac * fix * fix * fix * Update docs --------- Co-authored-by: Julia Johannesen <julia@insertdomain.name> Co-authored-by: KanariKanaru <93921745+kanarikanaru@users.noreply.github.com>
1 parent a07dc58 commit b361a10

15 files changed

Lines changed: 346 additions & 103 deletions

packages/backend/src/core/QueryService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ export class QueryService {
259259

260260
@bindThis
261261
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: MiUser['id'] } | null): void {
262-
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
262+
// This code must always be synchronized with the checks in NoteEntityService.isVisibleForMe and Stream abstract class Channel.isNoteVisibleForMe.
263263
if (me == null) {
264264
q.andWhere(new Brackets(qb => {
265265
qb

packages/backend/src/core/entities/NoteEntityService.ts

Lines changed: 48 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { DebounceLoader } from '@/misc/loader.js';
1717
import { IdService } from '@/core/IdService.js';
1818
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
1919
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
20+
import { CacheService } from '@/core/CacheService.js';
2021
import type { OnModuleInit } from '@nestjs/common';
2122
import type { CustomEmojiService } from '../CustomEmojiService.js';
2223
import type { ReactionService } from '../ReactionService.js';
@@ -66,6 +67,7 @@ export class NoteEntityService implements OnModuleInit {
6667
private reactionService: ReactionService;
6768
private reactionsBufferingService: ReactionsBufferingService;
6869
private idService: IdService;
70+
private cacheService: CacheService;
6971
private noteLoader = new DebounceLoader(this.findNoteOrFail);
7072

7173
constructor(
@@ -101,6 +103,7 @@ export class NoteEntityService implements OnModuleInit {
101103
//private reactionService: ReactionService,
102104
//private reactionsBufferingService: ReactionsBufferingService,
103105
//private idService: IdService,
106+
//private cacheService: CacheService,
104107
) {
105108
}
106109

@@ -111,6 +114,7 @@ export class NoteEntityService implements OnModuleInit {
111114
this.reactionService = this.moduleRef.get('ReactionService');
112115
this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService');
113116
this.idService = this.moduleRef.get('IdService');
117+
this.cacheService = this.moduleRef.get('CacheService');
114118
}
115119

116120
@bindThis
@@ -125,75 +129,65 @@ export class NoteEntityService implements OnModuleInit {
125129
}
126130

127131
@bindThis
128-
private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<void> {
129-
if (meId === packedNote.userId) return;
130-
132+
public async shouldHideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise<boolean> {
133+
if (meId === packedNote.userId) return false;
131134
// TODO: isVisibleForMe を使うようにしても良さそう(型違うけど)
132-
let hide = false;
133135

134136
if (packedNote.user.requireSigninToViewContents && meId == null) {
135-
hide = true;
137+
return true;
136138
}
137139

138-
if (!hide) {
139-
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
140-
if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
141-
hide = true;
142-
}
140+
const hiddenBefore = packedNote.user.makeNotesHiddenBefore;
141+
if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) {
142+
return true;
143143
}
144144

145145
// visibility が specified かつ自分が指定されていなかったら非表示
146-
if (!hide) {
147-
if (packedNote.visibility === 'specified') {
148-
if (meId == null) {
149-
hide = true;
150-
} else {
151-
// 指定されているかどうか
152-
const specified = packedNote.visibleUserIds!.some(id => meId === id);
146+
if (packedNote.visibility === 'specified') {
147+
if (meId == null) {
148+
return true;
149+
} else {
150+
// 指定されているかどうか
151+
const specified = packedNote.visibleUserIds!.some(id => meId === id);
153152

154-
if (!specified) {
155-
hide = true;
156-
}
153+
if (!specified) {
154+
return true;
157155
}
158156
}
159157
}
160158

161159
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
162-
if (!hide) {
163-
if (packedNote.visibility === 'followers') {
164-
if (meId == null) {
165-
hide = true;
166-
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
167-
// 自分の投稿に対するリプライ
168-
hide = false;
169-
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
170-
// 自分へのメンション
171-
hide = false;
172-
} else {
173-
// フォロワーかどうか
174-
// TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする
175-
const isFollowing = await this.followingsRepository.exists({
176-
where: {
177-
followeeId: packedNote.userId,
178-
followerId: meId,
179-
},
180-
});
181-
182-
hide = !isFollowing;
160+
if (packedNote.visibility === 'followers') {
161+
if (meId == null) {
162+
return true;
163+
} else if (packedNote.reply && (meId === packedNote.reply.userId)) {
164+
// 自分の投稿に対するリプライ
165+
return false;
166+
} else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) {
167+
// 自分へのメンション
168+
return false;
169+
} else {
170+
// フォロワーかどうか
171+
const followings = await this.cacheService.userFollowingsCache.fetch(meId);
172+
if (!Object.hasOwn(followings, packedNote.userId)) {
173+
return true;
183174
}
184175
}
185176
}
186177

187-
if (hide) {
188-
packedNote.visibleUserIds = undefined;
189-
packedNote.fileIds = [];
190-
packedNote.files = [];
191-
packedNote.text = null;
192-
packedNote.poll = undefined;
193-
packedNote.cw = null;
194-
packedNote.isHidden = true;
195-
// TODO: hiddenReason みたいなのを提供しても良さそう
196-
}
178+
return false;
179+
}
180+
181+
@bindThis
182+
public hideNote(packedNote: Packed<'Note'>): void {
183+
packedNote.visibleUserIds = undefined;
184+
packedNote.fileIds = [];
185+
packedNote.files = [];
186+
packedNote.text = null;
187+
packedNote.poll = undefined;
188+
packedNote.cw = null;
189+
packedNote.isHidden = true;
190+
// TODO: hiddenReason みたいなのを提供しても良さそう
197191
}
198192

199193
@bindThis
@@ -278,7 +272,7 @@ export class NoteEntityService implements OnModuleInit {
278272

279273
@bindThis
280274
public async isVisibleForMe(note: MiNote, meId: MiUser['id'] | null): Promise<boolean> {
281-
// This code must always be synchronized with the checks in generateVisibilityQuery.
275+
// This code must always be synchronized with the checks in QueryService.generateVisibilityQuery.
282276
// visibility が specified かつ自分が指定されていなかったら非表示
283277
if (note.visibility === 'specified') {
284278
if (meId == null) {
@@ -468,8 +462,8 @@ export class NoteEntityService implements OnModuleInit {
468462

469463
this.treatVisibility(packed);
470464

471-
if (!opts.skipHide) {
472-
await this.hideNote(packed, meId);
465+
if (!opts.skipHide && await this.shouldHideNote(packed, meId)) {
466+
this.hideNote(packed);
473467
}
474468

475469
return packed;

packages/backend/src/server/ServerModule.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { ChatUserChannel } from './api/stream/channels/chat-user.js';
4949
import { ChatRoomChannel } from './api/stream/channels/chat-room.js';
5050
import { ReversiChannel } from './api/stream/channels/reversi.js';
5151
import { ReversiGameChannel } from './api/stream/channels/reversi-game.js';
52+
import { NoteStreamingHidingService } from './api/stream/NoteStreamingHidingService.js';
5253
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
5354

5455
@Module({
@@ -98,6 +99,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
9899
QueueStatsChannel,
99100
ServerStatsChannel,
100101
UserListChannel,
102+
NoteStreamingHidingService,
101103
OpenApiServerService,
102104
OAuth2ProviderService,
103105
],
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*
2+
* SPDX-FileCopyrightText: syuilo and misskey-project
3+
* SPDX-License-Identifier: AGPL-3.0-only
4+
*/
5+
6+
import { Injectable } from '@nestjs/common';
7+
import { bindThis } from '@/decorators.js';
8+
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
9+
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
10+
import type { Packed } from '@/misc/json-schema.js';
11+
import type { MiUser } from '@/models/User.js';
12+
13+
type HiddenLayer = 'note' | 'renote' | 'renoteRenote';
14+
15+
type LockdownCheckResult =
16+
| { shouldSkip: true }
17+
| { shouldSkip: false; hiddenLayers: Set<HiddenLayer> };
18+
19+
/** Streamにおいて、ノートを隠す(hideNote)を適用するためのService */
20+
@Injectable()
21+
export class NoteStreamingHidingService {
22+
constructor(
23+
private noteEntityService: NoteEntityService,
24+
) {}
25+
26+
/**
27+
* ノートの可視性を判定する
28+
*
29+
* @param note - 判定対象のノート
30+
* @param meId - 閲覧者のユーザーID(未ログインの場合はnull)
31+
* @returns shouldSkip: true の場合はノートを流さない、false の場合は hiddenLayers に基づいて隠す
32+
*/
33+
@bindThis
34+
public async shouldHide(
35+
note: Packed<'Note'>,
36+
meId: MiUser['id'] | null,
37+
): Promise<LockdownCheckResult> {
38+
const hiddenLayers = new Set<HiddenLayer>();
39+
40+
// 1階層目: note自体
41+
const shouldHideThisNote = await this.noteEntityService.shouldHideNote(note, meId);
42+
if (shouldHideThisNote) {
43+
if (isRenotePacked(note) && isQuotePacked(note)) {
44+
// 引用リノートの場合、内容を隠して流す
45+
hiddenLayers.add('note');
46+
} else if (isRenotePacked(note)) {
47+
// 純粋リノートの場合、流さない
48+
return { shouldSkip: true };
49+
} else {
50+
// 通常ノートの場合、内容を隠して流す
51+
hiddenLayers.add('note');
52+
}
53+
}
54+
55+
// 2階層目: note.renote
56+
if (isRenotePacked(note) && note.renote) {
57+
const shouldHideRenote = await this.noteEntityService.shouldHideNote(note.renote, meId);
58+
if (shouldHideRenote) {
59+
if (isQuotePacked(note)) {
60+
// noteが引用リノートの場合、renote部分だけ隠す
61+
hiddenLayers.add('renote');
62+
} else {
63+
// noteが純粋リノートの場合、流さない
64+
return { shouldSkip: true };
65+
}
66+
}
67+
}
68+
69+
// 3階層目: note.renote.renote
70+
if (isRenotePacked(note) && note.renote &&
71+
isRenotePacked(note.renote) && note.renote.renote) {
72+
const shouldHideRenoteRenote = await this.noteEntityService.shouldHideNote(note.renote.renote, meId);
73+
if (shouldHideRenoteRenote) {
74+
if (isQuotePacked(note.renote)) {
75+
// note.renoteが引用リノートの場合、renote.renote部分だけ隠す
76+
hiddenLayers.add('renoteRenote');
77+
} else {
78+
// note.renoteが純粋リノートの場合、note.renoteの意味がなくなるので流さない
79+
return { shouldSkip: true };
80+
}
81+
}
82+
}
83+
84+
return { shouldSkip: false, hiddenLayers };
85+
}
86+
87+
/**
88+
* hiddenLayersに基づいてノートの内容を隠す。
89+
*
90+
* この処理は渡された `note` を直接変更します。
91+
*
92+
* @param note - 処理対象のノート
93+
* @param hiddenLayers - 隠す階層のセット
94+
*/
95+
@bindThis
96+
public applyHiding(
97+
note: Packed<'Note'>,
98+
hiddenLayers: Set<HiddenLayer>,
99+
): void {
100+
if (hiddenLayers.has('note')) {
101+
this.noteEntityService.hideNote(note);
102+
}
103+
if (hiddenLayers.has('renote') && note.renote) {
104+
this.noteEntityService.hideNote(note.renote);
105+
}
106+
if (hiddenLayers.has('renoteRenote') && note.renote && note.renote.renote) {
107+
this.noteEntityService.hideNote(note.renote.renote);
108+
}
109+
}
110+
111+
/**
112+
* ストリーミング配信用にノートを隠す(あるいはそもそも送信しない)の判定及び処理を行う。
113+
*
114+
* この処理は渡された `note` を直接変更します。
115+
*
116+
* @param note - 処理対象のノート(必要に応じて内容が隠される)
117+
* @param meId - 閲覧者のユーザーID(未ログインの場合はnull)
118+
* @returns shouldSkip: true の場合はノートを流さない
119+
*/
120+
@bindThis
121+
public async processHiding(
122+
note: Packed<'Note'>,
123+
meId: MiUser['id'] | null,
124+
): Promise<{ shouldSkip: boolean }> {
125+
const result = await this.shouldHide(note, meId);
126+
if (result.shouldSkip) {
127+
return { shouldSkip: true };
128+
}
129+
this.applyHiding(note, result.hiddenLayers);
130+
return { shouldSkip: false };
131+
}
132+
}

packages/backend/src/server/api/stream/channel.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,43 @@ export default abstract class Channel {
6464
return this.connection.subscriber;
6565
}
6666

67+
protected isNoteVisibleForMe(note: Packed<'Note'>): boolean {
68+
// This code must always be synchronized with the checks in QueryService.generateVisibilityQuery.
69+
const meId = this.connection.user?.id ?? null;
70+
71+
// visibility が specified かつ自分が指定されていなかったら非表示
72+
if (note.visibility === 'specified') {
73+
if (meId == null) {
74+
return false;
75+
} else if (meId === note.userId) {
76+
return true;
77+
} else {
78+
// 指定されているかどうか
79+
return note.visibleUserIds?.some(id => meId === id) ?? false;
80+
}
81+
}
82+
83+
// visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示
84+
if (note.visibility === 'followers') {
85+
if (meId == null) {
86+
return false;
87+
} else if (meId === note.userId) {
88+
return true;
89+
} else if (note.reply && (meId === note.reply.userId)) {
90+
// 自分の投稿に対するリプライ
91+
return true;
92+
} else if (note.mentions && note.mentions.some(id => meId === id)) {
93+
// 自分へのメンション
94+
return true;
95+
} else {
96+
// フォロワーかどうか
97+
return Object.hasOwn(this.following, note.userId);
98+
}
99+
}
100+
101+
return true;
102+
}
103+
67104
/*
68105
* ミュートとブロックされてるを処理する
69106
*/

0 commit comments

Comments
 (0)