1
0
Files
pawkey-sk/packages/backend/src/server/api/stream/Connection.ts
T
dakkar 77144b058c make the cap of activeRateLimitRequests match the rate limit
It's trivial to have more than 128 requests in flight: open a busy
timeline, scroll a bit down, wait for many notes to arrive, scroll to
the top. The frontend will send "subscribe to note" messages for each
new note that it accumulated, all at once. We don't want to shut down
the connection in those common cases!
2024-08-18 17:57:51 +01:00

362 lines
10 KiB
TypeScript

/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as WebSocket from 'ws';
import type { MiUser } from '@/models/User.js';
import type { MiAccessToken } from '@/models/AccessToken.js';
import type { Packed } from '@/misc/json-schema.js';
import type { NoteReadService } from '@/core/NoteReadService.js';
import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { MiFollowing, MiUserProfile } from '@/models/_.js';
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import type { ChannelsService } from './ChannelsService.js';
import type { EventEmitter } from 'events';
import type Channel from './channel.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
const MAX_CHANNELS_PER_CONNECTION = 32;
/**
* Main stream connection
*/
// eslint-disable-next-line import/no-default-export
export default class Connection {
public user?: MiUser;
public token?: MiAccessToken;
private rateLimiter?: () => Promise<boolean>;
private wsConnection: WebSocket.WebSocket;
public subscriber: StreamEventEmitter;
private channels: Channel[] = [];
private subscribingNotes: any = {};
private cachedNotes: Packed<'Note'>[] = [];
public userProfile: MiUserProfile | null = null;
public following: Record<string, Pick<MiFollowing, 'withReplies'> | undefined> = {};
public followingChannels: Set<string> = new Set();
public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoBlockingMe: Set<string> = new Set();
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
public userMutedInstances: Set<string> = new Set();
private fetchIntervalId: NodeJS.Timeout | null = null;
private activeRateLimitRequests: number = 0;
private closingConnection: boolean = false;
private logger: Logger;
constructor(
private channelsService: ChannelsService,
private noteReadService: NoteReadService,
private notificationService: NotificationService,
private cacheService: CacheService,
private channelFollowingService: ChannelFollowingService,
loggerService: LoggerService,
user: MiUser | null | undefined,
token: MiAccessToken | null | undefined,
private ip: string,
rateLimiter: () => Promise<boolean>,
) {
if (user) this.user = user;
if (token) this.token = token;
if (rateLimiter) this.rateLimiter = rateLimiter;
this.logger = loggerService.getLogger('streaming', 'coral', false);
}
@bindThis
public async fetch() {
if (this.user == null) return;
const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([
this.cacheService.userProfileCache.fetch(this.user.id),
this.cacheService.userFollowingsCache.fetch(this.user.id),
this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id),
this.cacheService.userMutingsCache.fetch(this.user.id),
this.cacheService.userBlockedCache.fetch(this.user.id),
this.cacheService.renoteMutingsCache.fetch(this.user.id),
]);
this.userProfile = userProfile;
this.following = following;
this.followingChannels = followingChannels;
this.userIdsWhoMeMuting = userIdsWhoMeMuting;
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
this.userMutedInstances = new Set(userProfile.mutedInstances);
}
@bindThis
public async init() {
if (this.user != null) {
await this.fetch();
if (!this.fetchIntervalId) {
this.fetchIntervalId = setInterval(this.fetch, 1000 * 10);
}
}
}
@bindThis
public async listen(subscriber: EventEmitter, wsConnection: WebSocket.WebSocket) {
this.subscriber = subscriber;
this.wsConnection = wsConnection;
this.wsConnection.on('message', this.onWsConnectionMessage);
this.subscriber.on('broadcast', data => {
this.onBroadcastMessage(data);
});
}
/**
* クライアントからメッセージ受信時
*/
@bindThis
private async onWsConnectionMessage(data: WebSocket.RawData) {
let obj: Record<string, any>;
if (this.closingConnection) return;
if (this.rateLimiter) {
// this 4096 should match the `max` of the `rateLimiter`, see
// StreamingApiServerService
if (this.activeRateLimitRequests <= 4096) {
this.activeRateLimitRequests++;
const shouldRateLimit = await this.rateLimiter();
this.activeRateLimitRequests--;
if (shouldRateLimit) return;
if (this.closingConnection) return;
} else {
let connectionInfo = `IP ${this.ip}`;
if (this.user) connectionInfo += `, user ID ${this.user.id}`;
this.logger.warn(`Closing a connection (${connectionInfo}) due to an excessive influx of messages.`);
this.closingConnection = true;
this.wsConnection.close(1008, 'Please stop spamming the streaming API.');
return;
}
}
try {
obj = JSON.parse(data.toString());
} catch (e) {
return;
}
const { type, body } = obj;
switch (type) {
case 'readNotification': this.onReadNotification(body); break;
case 'subNote': this.onSubscribeNote(body); break;
case 's': this.onSubscribeNote(body); break; // alias
case 'sr': this.onSubscribeNote(body); this.readNote(body); break;
case 'unsubNote': this.onUnsubscribeNote(body); break;
case 'un': this.onUnsubscribeNote(body); break; // alias
case 'connect': this.onChannelConnectRequested(body); break;
case 'disconnect': this.onChannelDisconnectRequested(body); break;
case 'channel': this.onChannelMessageRequested(body); break;
case 'ch': this.onChannelMessageRequested(body); break; // alias
}
}
@bindThis
private onBroadcastMessage(data: GlobalEvents['broadcast']['payload']) {
this.sendMessageToWs(data.type, data.body);
}
@bindThis
public cacheNote(note: Packed<'Note'>) {
const add = (note: Packed<'Note'>) => {
const existIndex = this.cachedNotes.findIndex(n => n.id === note.id);
if (existIndex > -1) {
this.cachedNotes[existIndex] = note;
return;
}
this.cachedNotes.unshift(note);
if (this.cachedNotes.length > 32) {
this.cachedNotes.splice(32);
}
};
add(note);
if (note.reply) add(note.reply);
if (note.renote) add(note.renote);
}
@bindThis
private readNote(body: any) {
const id = body.id;
const note = this.cachedNotes.find(n => n.id === id);
if (note == null) return;
if (this.user && (note.userId !== this.user.id)) {
this.noteReadService.read(this.user.id, [note]);
}
}
@bindThis
private onReadNotification(payload: any) {
this.notificationService.readAllNotification(this.user!.id);
}
/**
* 投稿購読要求時
*/
@bindThis
private onSubscribeNote(payload: any) {
if (!payload.id) return;
if (this.subscribingNotes[payload.id] == null) {
this.subscribingNotes[payload.id] = 0;
}
this.subscribingNotes[payload.id]++;
if (this.subscribingNotes[payload.id] === 1) {
this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage);
}
}
/**
* 投稿購読解除要求時
*/
@bindThis
private onUnsubscribeNote(payload: any) {
if (!payload.id) return;
this.subscribingNotes[payload.id]--;
if (this.subscribingNotes[payload.id] <= 0) {
delete this.subscribingNotes[payload.id];
this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage);
}
}
@bindThis
private async onNoteStreamMessage(data: GlobalEvents['note']['payload']) {
// we must not send to the frontend information about notes from
// users who blocked the logged-in user, even when they're replies
// to notes the logged-in user can see
if (data.type === 'replied') {
const noteUserId = data.body.body.userId;
if (noteUserId !== null) {
if (this.userIdsWhoBlockingMe.has(noteUserId)) {
return;
}
}
}
this.sendMessageToWs('noteUpdated', {
id: data.body.id,
type: data.type,
body: data.body.body,
});
}
/**
* チャンネル接続要求時
*/
@bindThis
private onChannelConnectRequested(payload: any) {
const { channel, id, params, pong } = payload;
this.connectChannel(id, params, channel, pong);
}
/**
* チャンネル切断要求時
*/
@bindThis
private onChannelDisconnectRequested(payload: any) {
const { id } = payload;
this.disconnectChannel(id);
}
/**
* クライアントにメッセージ送信
*/
@bindThis
public sendMessageToWs(type: string, payload: any) {
this.wsConnection.send(JSON.stringify({
type: type,
body: payload,
}));
}
/**
* チャンネルに接続
*/
@bindThis
public connectChannel(id: string, params: any, channel: string, pong = false) {
if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) {
return;
}
const channelService = this.channelsService.getChannelService(channel);
if (channelService.requireCredential && this.user == null) {
return;
}
if (this.token && ((channelService.kind && !this.token.permission.some(p => p === channelService.kind))
|| (!channelService.kind && channelService.requireCredential))) {
return;
}
// 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視
if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) {
return;
}
const ch: Channel = channelService.create(id, this);
this.channels.push(ch);
ch.init(params ?? {});
if (pong) {
this.sendMessageToWs('connected', {
id: id,
});
}
}
/**
* チャンネルから切断
* @param id チャンネルコネクションID
*/
@bindThis
public disconnectChannel(id: string) {
const channel = this.channels.find(c => c.id === id);
if (channel) {
if (channel.dispose) channel.dispose();
this.channels = this.channels.filter(c => c.id !== id);
}
}
/**
* チャンネルへメッセージ送信要求時
* @param data メッセージ
*/
@bindThis
private onChannelMessageRequested(data: any) {
const channel = this.channels.find(c => c.id === data.id);
if (channel != null && channel.onMessage != null) {
channel.onMessage(data.type, data.body);
}
}
/**
* ストリームが切れたとき
*/
@bindThis
public dispose() {
if (this.fetchIntervalId) clearInterval(this.fetchIntervalId);
for (const c of this.channels.filter(c => c.dispose)) {
if (c.dispose) c.dispose();
}
}
}