Merge branch 'develop' into feature/2024.10
This commit is contained in:
commit
1837ccc618
24 changed files with 1638 additions and 233 deletions
|
|
@ -28,12 +28,12 @@ import { bindThis } from '@/decorators.js';
|
|||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||
import { correctFilename } from '@/misc/correct-filename.js';
|
||||
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
|
||||
import { RateLimiterService } from '@/server/api/RateLimiterService.js';
|
||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import { AuthenticateService } from '@/server/api/AuthenticateService.js';
|
||||
import type { IEndpointMeta } from '@/server/api/endpoints.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
|
||||
import { Keyed, RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||
import type Limiter from 'ratelimiter';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
|
@ -58,7 +58,8 @@ export class FileServerService {
|
|||
private internalStorageService: InternalStorageService,
|
||||
private loggerService: LoggerService,
|
||||
private authenticateService: AuthenticateService,
|
||||
private rateLimiterService: RateLimiterService,
|
||||
private rateLimiterService: SkRateLimiterService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('server', 'gray');
|
||||
|
||||
|
|
@ -625,48 +626,44 @@ export class FileServerService {
|
|||
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
|
||||
const [user] = await this.authenticateService.authenticate(token);
|
||||
const actor = user?.id ?? getIpHash(request.ip);
|
||||
const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
|
||||
|
||||
// Call both limits: the per-resource limit and the shared cross-resource limit
|
||||
return await this.checkResourceLimit(reply, actor, group, resource) && await this.checkSharedLimit(reply, actor, group);
|
||||
return await this.checkResourceLimit(reply, actor, group, resource, factor) && await this.checkSharedLimit(reply, actor, group, factor);
|
||||
}
|
||||
|
||||
private async checkResourceLimit(reply: FastifyReply, actor: string, group: string, resource: string): Promise<boolean> {
|
||||
const limit = {
|
||||
private async checkResourceLimit(reply: FastifyReply, actor: string, group: string, resource: string, factor = 1): Promise<boolean> {
|
||||
const limit: Keyed<RateLimit> = {
|
||||
// Group by resource
|
||||
key: `${group}${resource}`,
|
||||
type: 'bucket',
|
||||
|
||||
// Maximum of 10 requests / 10 minutes
|
||||
max: 10,
|
||||
duration: 1000 * 60 * 10,
|
||||
// Maximum of 10 requests, average rate of 1 per minute
|
||||
size: 10,
|
||||
dripRate: 1000 * 60,
|
||||
};
|
||||
|
||||
return await this.checkLimit(reply, actor, limit);
|
||||
return await this.checkLimit(reply, actor, limit, factor);
|
||||
}
|
||||
|
||||
private async checkSharedLimit(reply: FastifyReply, actor: string, group: string): Promise<boolean> {
|
||||
const limit = {
|
||||
private async checkSharedLimit(reply: FastifyReply, actor: string, group: string, factor = 1): Promise<boolean> {
|
||||
const limit: Keyed<RateLimit> = {
|
||||
key: group,
|
||||
type: 'bucket',
|
||||
|
||||
// Maximum of 3600 requests per hour, which is an average of 1 per second.
|
||||
max: 3600,
|
||||
duration: 1000 * 60 * 60,
|
||||
// Maximum of 3600 requests, average rate of 1 per second.
|
||||
size: 3600,
|
||||
};
|
||||
|
||||
return await this.checkLimit(reply, actor, limit);
|
||||
return await this.checkLimit(reply, actor, limit, factor);
|
||||
}
|
||||
|
||||
private async checkLimit(reply: FastifyReply, actor: string, limit: IEndpointMeta['limit'] & { key: NonNullable<string> }): Promise<boolean> {
|
||||
try {
|
||||
await this.rateLimiterService.limit(limit, actor);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// errはLimiter.LimiterInfoであることが期待される
|
||||
if (hasRateLimitInfo(err)) {
|
||||
const cooldownInSeconds = Math.ceil((err.info.resetMs - Date.now()) / 1000);
|
||||
// もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく
|
||||
reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10));
|
||||
}
|
||||
private async checkLimit(reply: FastifyReply, actor: string, limit: Keyed<RateLimit>, factor = 1): Promise<boolean> {
|
||||
const info = await this.rateLimiterService.limit(limit, actor, factor);
|
||||
|
||||
sendRateLimitHeaders(reply, info);
|
||||
|
||||
if (info.blocked) {
|
||||
reply.code(429);
|
||||
reply.send({
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
|
|
@ -676,9 +673,8 @@ export class FileServerService {
|
|||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function hasRateLimitInfo(err: unknown): err is { info: Limiter.LimiterInfo } {
|
||||
return err != null && typeof(err) === 'object' && 'info' in err;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EndpointsModule } from '@/server/api/EndpointsModule.js';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
|
||||
import { ApiCallService } from './api/ApiCallService.js';
|
||||
import { FileServerService } from './FileServerService.js';
|
||||
import { HealthServerService } from './HealthServerService.js';
|
||||
|
|
@ -73,6 +74,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
|
|||
ApiLoggerService,
|
||||
ApiServerService,
|
||||
AuthenticateService,
|
||||
SkRateLimiterService,
|
||||
// No longer used, but kept for backwards compatibility
|
||||
RateLimiterService,
|
||||
SigninApiService,
|
||||
SigninWithPasskeyApiService,
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ import { createTemp } from '@/misc/create-temp.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
||||
import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
|
||||
import { ApiError } from './error.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
import { ApiLoggerService } from './ApiLoggerService.js';
|
||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
|
@ -49,7 +50,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
private userIpsRepository: UserIpsRepository,
|
||||
|
||||
private authenticateService: AuthenticateService,
|
||||
private rateLimiterService: RateLimiterService,
|
||||
private rateLimiterService: SkRateLimiterService,
|
||||
private roleService: RoleService,
|
||||
private apiLoggerService: ApiLoggerService,
|
||||
) {
|
||||
|
|
@ -65,16 +66,6 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
let statusCode = err.httpStatusCode;
|
||||
if (err.httpStatusCode === 401) {
|
||||
reply.header('WWW-Authenticate', 'Bearer realm="Misskey"');
|
||||
} else if (err.code === 'RATE_LIMIT_EXCEEDED') {
|
||||
const info: unknown = err.info;
|
||||
const unixEpochInSeconds = Date.now();
|
||||
if (typeof(info) === 'object' && info && 'resetMs' in info && typeof(info.resetMs) === 'number') {
|
||||
const cooldownInSeconds = Math.ceil((info.resetMs - unixEpochInSeconds) / 1000);
|
||||
// もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく
|
||||
reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10));
|
||||
} else {
|
||||
this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`);
|
||||
}
|
||||
} else if (err.kind === 'client') {
|
||||
reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="invalid_request", error_description="${err.message}"`);
|
||||
statusCode = statusCode ?? 400;
|
||||
|
|
@ -168,7 +159,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
return;
|
||||
}
|
||||
this.authenticateService.authenticate(token).then(([user, app]) => {
|
||||
this.call(endpoint, user, app, body, null, request).then((res) => {
|
||||
this.call(endpoint, user, app, body, null, request, reply).then((res) => {
|
||||
if (request.method === 'GET' && endpoint.meta.cacheSec && !token && !user) {
|
||||
reply.header('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`);
|
||||
}
|
||||
|
|
@ -229,7 +220,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
this.call(endpoint, user, app, fields, {
|
||||
name: multipartData.filename,
|
||||
path: path,
|
||||
}, request).then((res) => {
|
||||
}, request, reply).then((res) => {
|
||||
this.send(reply, res);
|
||||
}).catch((err: ApiError) => {
|
||||
this.#sendApiError(reply, err);
|
||||
|
|
@ -304,6 +295,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
path: string;
|
||||
} | null,
|
||||
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const isSecure = user != null && token == null;
|
||||
|
||||
|
|
@ -312,7 +304,7 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
// For endpoints without a limit, the default is 10 calls per second
|
||||
const endpointLimit: IEndpointMeta['limit'] = ep.meta.limit ?? {
|
||||
const endpointLimit = ep.meta.limit ?? {
|
||||
duration: 1000,
|
||||
max: 10,
|
||||
};
|
||||
|
|
@ -328,30 +320,28 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
limitActor = getIpHash(request.ip);
|
||||
}
|
||||
|
||||
const limit = Object.assign({}, endpointLimit);
|
||||
|
||||
if (limit.key == null) {
|
||||
(limit as any).key = ep.name;
|
||||
}
|
||||
|
||||
// TODO: 毎リクエスト計算するのもあれだしキャッシュしたい
|
||||
const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1;
|
||||
|
||||
if (factor > 0) {
|
||||
const limit = {
|
||||
key: ep.name,
|
||||
...endpointLimit,
|
||||
};
|
||||
|
||||
// Rate limit
|
||||
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
|
||||
if ('info' in err) {
|
||||
// errはLimiter.LimiterInfoであることが期待される
|
||||
throw new ApiError({
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
httpStatusCode: 429,
|
||||
}, err.info);
|
||||
} else {
|
||||
throw new TypeError('information must be a rate-limiter information.');
|
||||
}
|
||||
});
|
||||
const info = await this.rateLimiterService.limit(limit, limitActor, factor);
|
||||
|
||||
sendRateLimitHeaders(reply, info);
|
||||
|
||||
if (info.blocked) {
|
||||
throw new ApiError({
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
httpStatusCode: 429,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import { DI } from '@/di-symbols.js';
|
|||
import type Logger from '@/logger.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { LegacyRateLimit } from '@/misc/rate-limit-utils.js';
|
||||
import type { IEndpointMeta } from './endpoints.js';
|
||||
|
||||
/** @deprecated Use SkRateLimiterService instead */
|
||||
@Injectable()
|
||||
export class RateLimiterService {
|
||||
private logger: Logger;
|
||||
|
|
@ -31,7 +33,7 @@ export class RateLimiterService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) {
|
||||
public limit(limitation: LegacyRateLimit & { key: NonNullable<string> }, actor: string, factor = 1) {
|
||||
return new Promise<void>((ok, reject) => {
|
||||
if (this.disabled) ok();
|
||||
|
||||
|
|
|
|||
|
|
@ -25,11 +25,13 @@ import { WebAuthnService } from '@/core/WebAuthnService.js';
|
|||
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||
import { CaptchaService } from '@/core/CaptchaService.js';
|
||||
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||
import type { MiMeta } from '@/models/_.js';
|
||||
import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
|
||||
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
||||
import { SigninService } from './SigninService.js';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||
|
||||
@Injectable()
|
||||
export class SigninApiService {
|
||||
|
|
@ -53,7 +55,7 @@ export class SigninApiService {
|
|||
private signinsRepository: SigninsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private rateLimiterService: RateLimiterService,
|
||||
private rateLimiterService: SkRateLimiterService,
|
||||
private signinService: SigninService,
|
||||
private userAuthService: UserAuthService,
|
||||
private webAuthnService: WebAuthnService,
|
||||
|
|
@ -92,10 +94,12 @@ export class SigninApiService {
|
|||
return { error };
|
||||
}
|
||||
|
||||
try {
|
||||
// not more than 1 attempt per second and not more than 10 attempts per hour
|
||||
await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
|
||||
} catch (err) {
|
||||
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
|
||||
|
||||
sendRateLimitHeaders(reply, rateLimit);
|
||||
|
||||
if (rateLimit.blocked) {
|
||||
reply.code(429);
|
||||
return {
|
||||
error: {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ import { WebAuthnService } from '@/core/WebAuthnService.js';
|
|||
import Logger from '@/logger.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
|
||||
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
||||
import { SigninService } from './SigninService.js';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
|
@ -43,7 +44,7 @@ export class SigninWithPasskeyApiService {
|
|||
private signinsRepository: SigninsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private rateLimiterService: RateLimiterService,
|
||||
private rateLimiterService: SkRateLimiterService,
|
||||
private signinService: SigninService,
|
||||
private webAuthnService: WebAuthnService,
|
||||
private loggerService: LoggerService,
|
||||
|
|
@ -84,11 +85,13 @@ export class SigninWithPasskeyApiService {
|
|||
return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' });
|
||||
};
|
||||
|
||||
try {
|
||||
// Not more than 1 API call per 250ms and not more than 100 attempts per 30min
|
||||
// NOTE: 1 Sign-in require 2 API calls
|
||||
await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
|
||||
} catch (err) {
|
||||
// Not more than 1 API call per 250ms and not more than 100 attempts per 30min
|
||||
// NOTE: 1 Sign-in require 2 API calls
|
||||
const rateLimit = await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip));
|
||||
|
||||
sendRateLimitHeaders(reply, rateLimit);
|
||||
|
||||
if (rateLimit.blocked) {
|
||||
reply.code(429);
|
||||
return {
|
||||
error: {
|
||||
|
|
|
|||
198
packages/backend/src/server/api/SkRateLimiterService.ts
Normal file
198
packages/backend/src/server/api/SkRateLimiterService.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { TimeService } from '@/core/TimeService.js';
|
||||
import { EnvService } from '@/core/EnvService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed } from '@/misc/rate-limit-utils.js';
|
||||
|
||||
@Injectable()
|
||||
export class SkRateLimiterService {
|
||||
private readonly logger: Logger;
|
||||
private readonly disabled: boolean;
|
||||
|
||||
constructor(
|
||||
@Inject(TimeService)
|
||||
private readonly timeService: TimeService,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private readonly redisClient: Redis.Redis,
|
||||
|
||||
@Inject(LoggerService)
|
||||
loggerService: LoggerService,
|
||||
|
||||
@Inject(EnvService)
|
||||
envService: EnvService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('limiter');
|
||||
this.disabled = envService.env.NODE_ENV !== 'production'; // TODO disable in TEST *only*
|
||||
}
|
||||
|
||||
public async limit(limit: Keyed<RateLimit>, actor: string, factor = 1): Promise<LimitInfo> {
|
||||
if (this.disabled || factor === 0) {
|
||||
return {
|
||||
blocked: false,
|
||||
remaining: Number.MAX_SAFE_INTEGER,
|
||||
resetSec: 0,
|
||||
resetMs: 0,
|
||||
fullResetSec: 0,
|
||||
fullResetMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (factor < 0) {
|
||||
throw new Error(`Rate limit factor is zero or negative: ${factor}`);
|
||||
}
|
||||
|
||||
if (isLegacyRateLimit(limit)) {
|
||||
return await this.limitLegacy(limit, actor, factor);
|
||||
} else {
|
||||
return await this.limitBucket(limit, actor, factor);
|
||||
}
|
||||
}
|
||||
|
||||
private async limitLegacy(limit: Keyed<LegacyRateLimit>, actor: string, factor: number): Promise<LimitInfo> {
|
||||
const promises: Promise<LimitInfo | null>[] = [];
|
||||
|
||||
// The "min" limit - if present - is handled directly.
|
||||
if (hasMinLimit(limit)) {
|
||||
promises.push(
|
||||
this.limitMin(limit, actor, factor),
|
||||
);
|
||||
}
|
||||
|
||||
// Convert the "max" limit into a leaky bucket with 1 drip / second rate.
|
||||
if (limit.max != null && limit.duration != null) {
|
||||
promises.push(
|
||||
this.limitBucket({
|
||||
type: 'bucket',
|
||||
key: limit.key,
|
||||
size: limit.max,
|
||||
dripRate: Math.max(Math.round(limit.duration / limit.max), 1),
|
||||
}, actor, factor),
|
||||
);
|
||||
}
|
||||
|
||||
const [lim1, lim2] = await Promise.all(promises);
|
||||
return {
|
||||
blocked: (lim1?.blocked || lim2?.blocked) ?? false,
|
||||
remaining: Math.min(lim1?.remaining ?? Number.MAX_SAFE_INTEGER, lim2?.remaining ?? Number.MAX_SAFE_INTEGER),
|
||||
resetSec: Math.max(lim1?.resetSec ?? 0, lim2?.resetSec ?? 0),
|
||||
resetMs: Math.max(lim1?.resetMs ?? 0, lim2?.resetMs ?? 0),
|
||||
fullResetSec: Math.max(lim1?.fullResetSec ?? 0, lim2?.fullResetSec ?? 0),
|
||||
fullResetMs: Math.max(lim1?.fullResetMs ?? 0, lim2?.fullResetMs ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
private async limitMin(limit: Keyed<LegacyRateLimit> & { minInterval: number }, actor: string, factor: number): Promise<LimitInfo | null> {
|
||||
if (limit.minInterval === 0) return null;
|
||||
if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`);
|
||||
|
||||
const counter = await this.getLimitCounter(limit, actor, 'min');
|
||||
const minInterval = Math.max(Math.ceil(limit.minInterval * factor), 0);
|
||||
|
||||
// Update expiration
|
||||
if (counter.c > 0) {
|
||||
const isCleared = this.timeService.now - counter.t >= minInterval;
|
||||
if (isCleared) {
|
||||
counter.c = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const blocked = counter.c > 0;
|
||||
if (!blocked) {
|
||||
counter.c++;
|
||||
counter.t = this.timeService.now;
|
||||
}
|
||||
|
||||
// Calculate limit status
|
||||
const resetMs = Math.max(Math.ceil(minInterval - (this.timeService.now - counter.t)), 0);
|
||||
const resetSec = Math.ceil(resetMs / 1000);
|
||||
const limitInfo: LimitInfo = { blocked, remaining: 0, resetSec, resetMs, fullResetSec: resetSec, fullResetMs: resetMs };
|
||||
|
||||
// Update the limit counter, but not if blocked
|
||||
if (!blocked) {
|
||||
// Don't await, or we will slow down the API.
|
||||
this.setLimitCounter(limit, actor, counter, resetSec, 'min')
|
||||
.catch(err => this.logger.error(`Failed to update limit ${limit.key}:min for ${actor}:`, err));
|
||||
}
|
||||
|
||||
return limitInfo;
|
||||
}
|
||||
|
||||
private async limitBucket(limit: Keyed<BucketRateLimit>, actor: string, factor: number): Promise<LimitInfo> {
|
||||
if (limit.size < 1) throw new Error(`Invalid rate limit ${limit.key}: size is less than 1 (${limit.size})`);
|
||||
if (limit.dripRate != null && limit.dripRate < 1) throw new Error(`Invalid rate limit ${limit.key}: dripRate is less than 1 (${limit.dripRate})`);
|
||||
if (limit.dripSize != null && limit.dripSize < 1) throw new Error(`Invalid rate limit ${limit.key}: dripSize is less than 1 (${limit.dripSize})`);
|
||||
|
||||
const counter = await this.getLimitCounter(limit, actor, 'bucket');
|
||||
const bucketSize = Math.max(Math.ceil(limit.size / factor), 1);
|
||||
const dripRate = Math.ceil(limit.dripRate ?? 1000);
|
||||
const dripSize = Math.ceil(limit.dripSize ?? 1);
|
||||
|
||||
// Update drips
|
||||
if (counter.c > 0) {
|
||||
const dripsSinceLastTick = Math.floor((this.timeService.now - counter.t) / dripRate) * dripSize;
|
||||
counter.c = Math.max(counter.c - dripsSinceLastTick, 0);
|
||||
}
|
||||
|
||||
const blocked = counter.c >= bucketSize;
|
||||
if (!blocked) {
|
||||
counter.c++;
|
||||
counter.t = this.timeService.now;
|
||||
}
|
||||
|
||||
// Calculate limit status
|
||||
const remaining = Math.max(bucketSize - counter.c, 0);
|
||||
const resetMs = remaining > 0 ? 0 : Math.max(dripRate - (this.timeService.now - counter.t), 0);
|
||||
const resetSec = Math.ceil(resetMs / 1000);
|
||||
const fullResetMs = Math.ceil(counter.c / dripSize) * dripRate;
|
||||
const fullResetSec = Math.ceil(fullResetMs / 1000);
|
||||
const limitInfo: LimitInfo = { blocked, remaining, resetSec, resetMs, fullResetSec, fullResetMs };
|
||||
|
||||
// Update the limit counter, but not if blocked
|
||||
if (!blocked) {
|
||||
// Don't await, or we will slow down the API.
|
||||
this.setLimitCounter(limit, actor, counter, fullResetSec, 'bucket')
|
||||
.catch(err => this.logger.error(`Failed to update limit ${limit.key} for ${actor}:`, err));
|
||||
}
|
||||
|
||||
return limitInfo;
|
||||
}
|
||||
|
||||
private async getLimitCounter(limit: Keyed<RateLimit>, actor: string, subject: string): Promise<LimitCounter> {
|
||||
const key = createLimitKey(limit, actor, subject);
|
||||
|
||||
const value = await this.redisClient.get(key);
|
||||
if (value == null) {
|
||||
return { t: 0, c: 0 };
|
||||
}
|
||||
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
private async setLimitCounter(limit: Keyed<RateLimit>, actor: string, counter: LimitCounter, expiration: number, subject: string): Promise<void> {
|
||||
const key = createLimitKey(limit, actor, subject);
|
||||
const value = JSON.stringify(counter);
|
||||
const expirationSec = Math.max(expiration, 1);
|
||||
await this.redisClient.set(key, value, 'EX', expirationSec);
|
||||
}
|
||||
}
|
||||
|
||||
function createLimitKey(limit: Keyed<RateLimit>, actor: string, subject: string): string {
|
||||
return `rl_${actor}_${limit.key}_${subject}`;
|
||||
}
|
||||
|
||||
export interface LimitCounter {
|
||||
/** Timestamp */
|
||||
t: number;
|
||||
|
||||
/** Counter */
|
||||
c: number;
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import { EventEmitter } from 'events';
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as WebSocket from 'ws';
|
||||
import proxyAddr from 'proxy-addr';
|
||||
import ms from 'ms';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository, MiAccessToken } from '@/models/_.js';
|
||||
import { NoteReadService } from '@/core/NoteReadService.js';
|
||||
|
|
@ -16,18 +18,15 @@ import { CacheService } from '@/core/CacheService.js';
|
|||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { UserService } from '@/core/UserService.js';
|
||||
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
|
||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||
import MainStreamConnection from './stream/Connection.js';
|
||||
import { ChannelsService } from './stream/ChannelsService.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import proxyAddr from 'proxy-addr';
|
||||
import ms from 'ms';
|
||||
import type * as http from 'node:http';
|
||||
import type { IEndpointMeta } from './endpoints.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
||||
@Injectable()
|
||||
export class StreamingApiServerService {
|
||||
|
|
@ -49,7 +48,7 @@ export class StreamingApiServerService {
|
|||
private notificationService: NotificationService,
|
||||
private usersService: UserService,
|
||||
private channelFollowingService: ChannelFollowingService,
|
||||
private rateLimiterService: RateLimiterService,
|
||||
private rateLimiterService: SkRateLimiterService,
|
||||
private roleService: RoleService,
|
||||
private loggerService: LoggerService,
|
||||
) {
|
||||
|
|
@ -73,9 +72,8 @@ export class StreamingApiServerService {
|
|||
if (factor <= 0) return false;
|
||||
|
||||
// Rate limit
|
||||
return await this.rateLimiterService.limit(limit, limitActor, factor)
|
||||
.then(() => { return false; })
|
||||
.catch(err => { return true; });
|
||||
const rateLimit = await this.rateLimiterService.limit(limit, limitActor, factor);
|
||||
return rateLimit.blocked;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { permissions } from 'misskey-js';
|
||||
import type { KeyOf, Schema } from '@/misc/json-schema.js';
|
||||
import type { RateLimit } from '@/misc/rate-limit-utils.js';
|
||||
|
||||
import * as ep___admin_abuseReport_notificationRecipient_list
|
||||
from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js';
|
||||
|
|
@ -859,30 +860,7 @@ interface IEndpointMetaBase {
|
|||
* エンドポイントのリミテーションに関するやつ
|
||||
* 省略した場合はリミテーションは無いものとして解釈されます。
|
||||
*/
|
||||
readonly limit?: {
|
||||
|
||||
/**
|
||||
* 複数のエンドポイントでリミットを共有したい場合に指定するキー
|
||||
*/
|
||||
readonly key?: string;
|
||||
|
||||
/**
|
||||
* リミットを適用する期間(ms)
|
||||
* このプロパティを設定する場合、max プロパティも設定する必要があります。
|
||||
*/
|
||||
readonly duration?: number;
|
||||
|
||||
/**
|
||||
* durationで指定した期間内にいくつまでリクエストできるのか
|
||||
* このプロパティを設定する場合、duration プロパティも設定する必要があります。
|
||||
*/
|
||||
readonly max?: number;
|
||||
|
||||
/**
|
||||
* 最低でもどれくらいの間隔を開けてリクエストしなければならないか(ms)
|
||||
*/
|
||||
readonly minInterval?: number;
|
||||
};
|
||||
readonly limit?: Readonly<RateLimit>;
|
||||
|
||||
/**
|
||||
* ファイルの添付を必要とするか否か
|
||||
|
|
|
|||
|
|
@ -29,10 +29,13 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
// 5 calls per second
|
||||
// 1000 max @ 1/10ms drip = 10/sec average.
|
||||
// Large bucket is ok because this is a fairly lightweight endpoint.
|
||||
limit: {
|
||||
duration: 1000,
|
||||
max: 5,
|
||||
type: 'bucket',
|
||||
|
||||
size: 1000,
|
||||
dripRate: 10,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue