Merge branch 'develop' into feature/2024.10

This commit is contained in:
dakkar 2024-12-09 09:43:55 +00:00
commit 1837ccc618
24 changed files with 1638 additions and 233 deletions

8
locales/index.d.ts vendored
View file

@ -7482,6 +7482,14 @@ export interface Locale extends ILocale {
* Testers
*/
"testers": string;
/**
* Misskey Contributors
*/
"misskeyContributors": string;
/**
* Our lovely Sponsors
*/
"ourLovelySponsors": string;
};
"_displayOfSensitiveMedia": {
/**

View file

@ -42,6 +42,13 @@ export default [
name: '__filename',
message: 'Not in ESModule. Use `import.meta.url` instead.',
}],
// https://typescript-eslint.io/rules/prefer-nullish-coalescing/
'@typescript-eslint/prefer-nullish-coalescing': ['warn', {
ignorePrimitives: {
// Without this, the rule breaks for nullable booleans
boolean: true,
},
}],
},
},
{

View file

@ -19,17 +19,18 @@ export async function server() {
logger: new NestLogger(),
});
const serverService = app.get(ServerService);
await serverService.launch();
if (process.env.NODE_ENV !== 'test') {
app.get(ChartManagementService).start();
await app.get(ChartManagementService).start();
}
if (!envOption.noDaemons) {
app.get(QueueStatsService).start();
app.get(ServerStatsService).start();
await app.get(QueueStatsService).start();
await app.get(ServerStatsService).start();
}
// Start server last so the other services can register hooks first
const serverService = app.get(ServerService);
await serverService.launch();
return app;
}
@ -38,8 +39,8 @@ export async function jobQueue() {
logger: new NestLogger(),
});
jobQueue.get(QueueProcessorService).start();
jobQueue.get(ChartManagementService).start();
await jobQueue.get(QueueProcessorService).start();
await jobQueue.get(ChartManagementService).start();
return jobQueue;
}

View file

@ -15,6 +15,8 @@ import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js';
import { WebhookTestService } from '@/core/WebhookTestService.js';
import { FlashService } from '@/core/FlashService.js';
import { TimeService } from '@/core/TimeService.js';
import { EnvService } from '@/core/EnvService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AnnouncementService } from './AnnouncementService.js';
@ -384,6 +386,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ChannelFollowingService,
RegistryApiService,
ReversiService,
TimeService,
EnvService,
ChartLoggerService,
FederationChart,
@ -685,6 +689,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ChannelFollowingService,
RegistryApiService,
ReversiService,
TimeService,
EnvService,
FederationChart,
NotesChart,

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
/**
* Provides access to the process environment variables.
* This exists for testing purposes, so that a test can mock the environment without corrupting state for other tests.
*/
@Injectable()
export class EnvService {
/**
* Passthrough to process.env
*/
public get env() {
return process.env;
}
}

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
/**
* Provides abstractions to access the current time.
* Exists for unit testing purposes, so that tests can "simulate" any given time for consistency.
*/
@Injectable()
export class TimeService {
/**
* Returns Date.now()
*/
public get now() {
return Date.now();
}
/**
* Returns a new Date instance.
*/
public get date() {
return new Date();
}
}

View file

@ -33,7 +33,7 @@ export class QueueStatsService implements OnApplicationShutdown {
* Report queue stats regularly
*/
@bindThis
public start(): void {
public async start(): Promise<void> {
const log = [] as any[];
ev.on('requestQueueStatsLog', x => {
@ -82,7 +82,7 @@ export class QueueStatsService implements OnApplicationShutdown {
activeInboxJobs = 0;
};
tick();
await tick();
this.intervalId = setInterval(tick, interval);
}

View file

@ -68,7 +68,7 @@ export class ServerStatsService implements OnApplicationShutdown {
if (log.length > 200) log.pop();
};
tick();
await tick();
this.intervalId = setInterval(tick, interval);
}

View file

@ -0,0 +1,146 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { FastifyReply } from 'fastify';
export type RateLimit = BucketRateLimit | LegacyRateLimit;
export type Keyed<T> = T & { key: string };
/**
* Rate limit based on "leaky bucket" logic.
* The bucket count increases with each call, and decreases gradually at a given rate.
* The subject is blocked until the bucket count drops below the limit.
*/
export interface BucketRateLimit {
/**
* Unique key identifying the particular resource (or resource group) being limited.
*/
key?: string;
/**
* Constant value identifying the type of rate limit.
*/
type: 'bucket';
/**
* Size of the bucket, in number of requests.
* The subject will be blocked when the number of calls exceeds this size.
*/
size: number;
/**
* How often the bucket should "drip" and reduce the counter, measured in milliseconds.
* Defaults to 1000 (1 second).
*/
dripRate?: number;
/**
* Amount to reduce the counter on each drip.
* Defaults to 1.
*/
dripSize?: number;
}
/**
* Legacy rate limit based on a "request window" with a maximum number of requests within a given time box.
* These will be translated into a bucket with linear drip rate.
*/
export interface LegacyRateLimit {
/**
* Unique key identifying the particular resource (or resource group) being limited.
*/
key?: string;
/**
* Constant value identifying the type of rate limit.
* Must be excluded or explicitly set to undefined
*/
type?: undefined;
/**
* Duration of the request window, in milliseconds.
* If present, then "max" must also be included.
*/
duration?: number;
/**
* Maximum number of requests allowed in the request window.
* If present, then "duration" must also be included.
*/
max?: number;
/**
* Optional minimum interval between consecutive requests.
* Will apply in addition to the primary rate limit.
*/
minInterval?: number;
}
/**
* Metadata about the current status of a rate limiter
*/
export interface LimitInfo {
/**
* True if the limit has been reached, and the call should be blocked.
*/
blocked: boolean;
/**
* Number of calls that can be made before the limit is triggered.
*/
remaining: number;
/**
* Time in seconds until the next call can be made, or zero if the next call can be made immediately.
* Rounded up to the nearest second.
*/
resetSec: number;
/**
* Time in milliseconds until the next call can be made, or zero if the next call can be made immediately.
* Rounded up to the nearest milliseconds.
*/
resetMs: number;
/**
* Time in seconds until the limit has fully reset.
* Rounded up to the nearest second.
*/
fullResetSec: number;
/**
* Time in milliseconds until the limit has fully reset.
* Rounded up to the nearest millisecond.
*/
fullResetMs: number;
}
export function isLegacyRateLimit(limit: RateLimit): limit is LegacyRateLimit {
return limit.type === undefined;
}
export function hasMinLimit(limit: LegacyRateLimit): limit is LegacyRateLimit & { minInterval: number } {
return !!limit.minInterval;
}
export function sendRateLimitHeaders(reply: FastifyReply, info: LimitInfo): void {
// Number of seconds until the limit has fully reset.
const clear = (info.fullResetMs / 1000).toFixed(3);
reply.header('X-RateLimit-Clear', clear);
// Number of calls that can be made before being limited.
const remaining = info.remaining.toString();
reply.header('X-RateLimit-Remaining', remaining);
if (info.blocked) {
// Number of seconds to wait before trying again. Left for backwards compatibility.
const retry = info.resetSec.toString();
reply.header('Retry-After', retry);
// Number of milliseconds to wait before trying again.
const reset = (info.resetMs / 1000).toFixed(3);
reply.header('X-RateLimit-Reset', reset);
}
}

View file

@ -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;
}

View file

@ -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,

View file

@ -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,32 +320,30 @@ 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であることが期待される
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,
}, err.info);
} else {
throw new TypeError('information must be a rate-limiter information.');
}
});
}
}
}
if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) {
if (user == null) {

View file

@ -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();

View file

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

View file

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

View 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;
}

View file

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

View file

@ -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>;
/**
*

View file

@ -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;

View file

@ -17,16 +17,24 @@ import { GlobalModule } from '@/GlobalModule.js';
import { DI } from '@/di-symbols.js';
import { CoreModule } from '@/core/CoreModule.js';
import { SigninWithPasskeyApiService } from '@/server/api/SigninWithPasskeyApiService.js';
import { RateLimiterService } from '@/server/api/RateLimiterService.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { SigninService } from '@/server/api/SigninService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
import { LimitInfo } from '@/misc/rate-limit-utils.js';
const moduleMocker = new ModuleMocker(global);
class FakeLimiter {
public async limit() {
return;
public async limit(): Promise<LimitInfo> {
return {
blocked: false,
remaining: Number.MAX_SAFE_INTEGER,
resetMs: 0,
resetSec: 0,
fullResetMs: 0,
fullResetSec: 0,
};
}
}
@ -90,7 +98,7 @@ describe('SigninWithPasskeyApiService', () => {
imports: [GlobalModule, CoreModule],
providers: [
SigninWithPasskeyApiService,
{ provide: RateLimiterService, useClass: FakeLimiter },
{ provide: SkRateLimiterService, useClass: FakeLimiter },
{ provide: SigninService, useClass: FakeSigninService },
],
}).useMocker((token) => {

View file

@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { jest } from '@jest/globals';
import { Mock } from 'jest-mock';
import type { FastifyReply } from 'fastify';
import { LimitInfo, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
/* eslint-disable @typescript-eslint/no-non-null-assertion */
describe(sendRateLimitHeaders, () => {
let mockHeader: Mock<((name: string, value: unknown) => void)> = null!;
let mockReply: FastifyReply = null!;
let fakeInfo: LimitInfo = null!;
beforeEach(() => {
mockHeader = jest.fn<((name: string, value: unknown) => void)>();
mockReply = {
header: mockHeader,
} as unknown as FastifyReply;
fakeInfo = {
blocked: false,
remaining: 1,
resetSec: 1,
resetMs: 567,
fullResetSec: 10,
fullResetMs: 9876,
};
});
it('should send X-RateLimit-Clear', () => {
sendRateLimitHeaders(mockReply, fakeInfo);
expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Clear', '9.876');
});
it('should send X-RateLimit-Remaining', () => {
sendRateLimitHeaders(mockReply, fakeInfo);
expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', '1');
});
describe('when limit is blocked', () => {
it('should send X-RateLimit-Reset', () => {
fakeInfo.blocked = true;
sendRateLimitHeaders(mockReply, fakeInfo);
expect(mockHeader).toHaveBeenCalledWith('X-RateLimit-Reset', '0.567');
});
it('should send Retry-After', () => {
fakeInfo.blocked = true;
sendRateLimitHeaders(mockReply, fakeInfo);
expect(mockHeader).toHaveBeenCalledWith('Retry-After', '1');
});
});
});

View file

@ -0,0 +1,857 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { KEYWORD } from 'color-convert/conversions.js';
import { jest } from '@jest/globals';
import type Redis from 'ioredis';
import { LimitCounter, SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { BucketRateLimit, Keyed, LegacyRateLimit } from '@/misc/rate-limit-utils.js';
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
describe(SkRateLimiterService, () => {
let mockTimeService: { now: number, date: Date } = null!;
let mockRedisGet: ((key: string) => string | null) | undefined = undefined;
let mockRedisSet: ((args: unknown[]) => void) | undefined = undefined;
let mockEnvironment: Record<string, string | undefined> = null!;
let serviceUnderTest: () => SkRateLimiterService = null!;
let loggedMessages: { level: string, data: unknown[] }[] = [];
beforeEach(() => {
mockTimeService = {
now: 0,
get date() {
return new Date(mockTimeService.now);
},
};
mockRedisGet = undefined;
mockRedisSet = undefined;
const mockRedisClient = {
get(key: string) {
if (mockRedisGet) return Promise.resolve(mockRedisGet(key));
else return Promise.resolve(null);
},
set(...args: unknown[]): Promise<void> {
if (mockRedisSet) mockRedisSet(args);
return Promise.resolve();
},
} as unknown as Redis.Redis;
mockEnvironment = Object.create(process.env);
mockEnvironment.NODE_ENV = 'production';
const mockEnvService = {
env: mockEnvironment,
};
loggedMessages = [];
const mockLogService = {
getLogger() {
return {
createSubLogger(context: string, color?: KEYWORD) {
return mockLogService.getLogger(context, color);
},
error(...data: unknown[]) {
loggedMessages.push({ level: 'error', data });
},
warn(...data: unknown[]) {
loggedMessages.push({ level: 'warn', data });
},
succ(...data: unknown[]) {
loggedMessages.push({ level: 'succ', data });
},
debug(...data: unknown[]) {
loggedMessages.push({ level: 'debug', data });
},
info(...data: unknown[]) {
loggedMessages.push({ level: 'info', data });
},
};
},
} as unknown as LoggerService;
let service: SkRateLimiterService | undefined = undefined;
serviceUnderTest = () => {
return service ??= new SkRateLimiterService(mockTimeService, mockRedisClient, mockLogService, mockEnvService);
};
});
function expectNoUnhandledErrors() {
const unhandledErrors = loggedMessages.filter(m => m.level === 'error');
if (unhandledErrors.length > 0) {
throw new Error(`Test failed: got unhandled errors ${unhandledErrors.join('\n')}`);
}
}
describe('limit', () => {
const actor = 'actor';
const key = 'test';
let counter: LimitCounter | undefined = undefined;
let minCounter: LimitCounter | undefined = undefined;
beforeEach(() => {
counter = undefined;
minCounter = undefined;
mockRedisGet = (key: string) => {
if (key === 'rl_actor_test_bucket' && counter) {
return JSON.stringify(counter);
}
if (key === 'rl_actor_test_min' && minCounter) {
return JSON.stringify(minCounter);
}
return null;
};
mockRedisSet = (args: unknown[]) => {
const [key, value] = args;
if (key === 'rl_actor_test_bucket') {
if (value == null) counter = undefined;
else if (typeof(value) === 'string') counter = JSON.parse(value);
else throw new Error('invalid redis call');
}
if (key === 'rl_actor_test_min') {
if (value == null) minCounter = undefined;
else if (typeof(value) === 'string') minCounter = JSON.parse(value);
else throw new Error('invalid redis call');
}
};
});
it('should bypass in non-production', async () => {
mockEnvironment.NODE_ENV = 'test';
const info = await serviceUnderTest().limit({ key: 'l', type: undefined, max: 0 }, 'actor');
expect(info.blocked).toBeFalsy();
expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
expect(info.resetSec).toBe(0);
expect(info.resetMs).toBe(0);
expect(info.fullResetSec).toBe(0);
expect(info.fullResetMs).toBe(0);
});
describe('with bucket limit', () => {
let limit: Keyed<BucketRateLimit> = null!;
beforeEach(() => {
limit = {
type: 'bucket',
key: 'test',
size: 1,
};
});
it('should allow when limit is not reached', async () => {
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
});
it('should not error when allowed', async () => {
await serviceUnderTest().limit(limit, actor);
expectNoUnhandledErrors();
});
it('should return correct info when allowed', async () => {
limit.size = 2;
counter = { c: 1, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.remaining).toBe(0);
expect(info.resetSec).toBe(1);
expect(info.resetMs).toBe(1000);
expect(info.fullResetSec).toBe(2);
expect(info.fullResetMs).toBe(2000);
});
it('should increment counter when called', async () => {
await serviceUnderTest().limit(limit, actor);
expect(counter).not.toBeUndefined();
expect(counter?.c).toBe(1);
});
it('should set timestamp when called', async () => {
mockTimeService.now = 1000;
await serviceUnderTest().limit(limit, actor);
expect(counter).not.toBeUndefined();
expect(counter?.t).toBe(1000);
});
it('should decrement counter when dripRate has passed', async () => {
counter = { c: 2, t: 0 };
mockTimeService.now = 2000;
await serviceUnderTest().limit(limit, actor);
expect(counter).not.toBeUndefined();
expect(counter?.c).toBe(1); // 2 (starting) - 2 (2x1 drip) + 1 (call) = 1
});
it('should decrement counter by dripSize', async () => {
counter = { c: 2, t: 0 };
limit.dripSize = 2;
mockTimeService.now = 1000;
await serviceUnderTest().limit(limit, actor);
expect(counter).not.toBeUndefined();
expect(counter?.c).toBe(1); // 2 (starting) - 2 (1x2 drip) + 1 (call) = 1
});
it('should maintain counter between calls over time', async () => {
limit.size = 5;
await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
mockTimeService.now += 1000; // 1 - 1 = 0
await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2
await serviceUnderTest().limit(limit, actor); // 2 + 1 = 3
mockTimeService.now += 1000; // 3 - 1 = 2
mockTimeService.now += 1000; // 2 - 1 = 1
await serviceUnderTest().limit(limit, actor); // 1 + 1 = 2
expect(counter?.c).toBe(2);
expect(counter?.t).toBe(3000);
});
it('should log error and continue when update fails', async () => {
mockRedisSet = () => {
throw new Error('test error');
};
await serviceUnderTest().limit(limit, actor);
const matchingError = loggedMessages
.find(m => m.level === 'error' && m.data
.some(d => typeof(d) === 'string' && d.includes('Failed to update limit')));
expect(matchingError).toBeTruthy();
});
it('should block when bucket is filled', async () => {
counter = { c: 1, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeTruthy();
});
it('should calculate correct info when blocked', async () => {
counter = { c: 1, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.resetSec).toBe(1);
expect(info.resetMs).toBe(1000);
expect(info.fullResetSec).toBe(1);
expect(info.fullResetMs).toBe(1000);
});
it('should allow when bucket is filled but should drip', async () => {
counter = { c: 1, t: 0 };
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
});
it('should scale limit by factor', async () => {
counter = { c: 1, t: 0 };
const i1 = await serviceUnderTest().limit(limit, actor, 0.5); // 1 + 1 = 2
const i2 = await serviceUnderTest().limit(limit, actor, 0.5); // 2 + 1 = 3
expect(i1.blocked).toBeFalsy();
expect(i2.blocked).toBeTruthy();
});
it('should set key expiration', async () => {
const mock = jest.fn(mockRedisSet);
mockRedisSet = mock;
await serviceUnderTest().limit(limit, actor);
expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
counter = { c: 1, t: 0 };
mockTimeService.now += 100;
await serviceUnderTest().limit(limit, actor);
expect(counter?.c).toBe(1);
expect(counter?.t).toBe(0);
});
it('should skip if factor is zero', async () => {
const info = await serviceUnderTest().limit(limit, actor, 0);
expect(info.blocked).toBeFalsy();
expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
});
it('should throw if factor is negative', async () => {
const promise = serviceUnderTest().limit(limit, actor, -1);
await expect(promise).rejects.toThrow(/factor is zero or negative/);
});
it('should throw if size is zero', async () => {
limit.size = 0;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/size is less than 1/);
});
it('should throw if size is negative', async () => {
limit.size = -1;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/size is less than 1/);
});
it('should throw if size is fraction', async () => {
limit.size = 0.5;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/size is less than 1/);
});
it('should throw if dripRate is zero', async () => {
limit.dripRate = 0;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/dripRate is less than 1/);
});
it('should throw if dripRate is negative', async () => {
limit.dripRate = -1;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/dripRate is less than 1/);
});
it('should throw if dripRate is fraction', async () => {
limit.dripRate = 0.5;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/dripRate is less than 1/);
});
it('should throw if dripSize is zero', async () => {
limit.dripSize = 0;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/dripSize is less than 1/);
});
it('should throw if dripSize is negative', async () => {
limit.dripSize = -1;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/dripSize is less than 1/);
});
it('should throw if dripSize is fraction', async () => {
limit.dripSize = 0.5;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/dripSize is less than 1/);
});
});
describe('with min interval', () => {
let limit: Keyed<LegacyRateLimit> = null!;
beforeEach(() => {
limit = {
type: undefined,
key,
minInterval: 1000,
};
});
it('should allow when limit is not reached', async () => {
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
});
it('should not error when allowed', async () => {
await serviceUnderTest().limit(limit, actor);
expectNoUnhandledErrors();
});
it('should calculate correct info when allowed', async () => {
const info = await serviceUnderTest().limit(limit, actor);
expect(info.remaining).toBe(0);
expect(info.resetSec).toBe(1);
expect(info.resetMs).toBe(1000);
expect(info.fullResetSec).toBe(1);
expect(info.fullResetMs).toBe(1000);
});
it('should increment counter when called', async () => {
await serviceUnderTest().limit(limit, actor);
expect(minCounter).not.toBeUndefined();
expect(minCounter?.c).toBe(1);
});
it('should set timestamp when called', async () => {
mockTimeService.now = 1000;
await serviceUnderTest().limit(limit, actor);
expect(minCounter).not.toBeUndefined();
expect(minCounter?.t).toBe(1000);
});
it('should decrement counter when minInterval has passed', async () => {
minCounter = { c: 1, t: 0 };
mockTimeService.now = 1000;
await serviceUnderTest().limit(limit, actor);
expect(minCounter).not.toBeUndefined();
expect(minCounter?.c).toBe(1); // 1 (starting) - 1 (interval) + 1 (call) = 1
});
it('should reset counter entirely', async () => {
minCounter = { c: 2, t: 0 };
mockTimeService.now = 1000;
await serviceUnderTest().limit(limit, actor);
expect(minCounter).not.toBeUndefined();
expect(minCounter?.c).toBe(1); // 2 (starting) - 2 (interval) + 1 (call) = 1
});
it('should maintain counter between calls over time', async () => {
await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
mockTimeService.now += 1000; // 1 - 1 = 0
await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
await serviceUnderTest().limit(limit, actor); // blocked
await serviceUnderTest().limit(limit, actor); // blocked
mockTimeService.now += 1000; // 1 - 1 = 0
mockTimeService.now += 1000; // 0 - 1 = 0
await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
expect(minCounter?.c).toBe(1);
expect(minCounter?.t).toBe(3000);
});
it('should log error and continue when update fails', async () => {
mockRedisSet = () => {
throw new Error('test error');
};
await serviceUnderTest().limit(limit, actor);
const matchingError = loggedMessages
.find(m => m.level === 'error' && m.data
.some(d => typeof(d) === 'string' && d.includes('Failed to update limit')));
expect(matchingError).toBeTruthy();
});
it('should block when interval exceeded', async () => {
minCounter = { c: 1, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeTruthy();
});
it('should calculate correct info when blocked', async () => {
minCounter = { c: 1, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.resetSec).toBe(1);
expect(info.resetMs).toBe(1000);
expect(info.fullResetSec).toBe(1);
expect(info.fullResetMs).toBe(1000);
});
it('should allow when bucket is filled but interval has passed', async () => {
minCounter = { c: 1, t: 0 };
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
});
it('should scale interval by factor', async () => {
minCounter = { c: 1, t: 0 };
mockTimeService.now += 500;
const info = await serviceUnderTest().limit(limit, actor, 0.5);
expect(info.blocked).toBeFalsy();
});
it('should set key expiration', async () => {
const mock = jest.fn(mockRedisSet);
mockRedisSet = mock;
await serviceUnderTest().limit(limit, actor);
expect(mock).toHaveBeenCalledWith(['rl_actor_test_min', '{"t":0,"c":1}', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
minCounter = { c: 1, t: 0 };
mockTimeService.now += 100;
await serviceUnderTest().limit(limit, actor);
expect(minCounter?.c).toBe(1);
expect(minCounter?.t).toBe(0);
});
it('should skip if factor is zero', async () => {
const info = await serviceUnderTest().limit(limit, actor, 0);
expect(info.blocked).toBeFalsy();
expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
});
it('should throw if factor is negative', async () => {
const promise = serviceUnderTest().limit(limit, actor, -1);
await expect(promise).rejects.toThrow(/factor is zero or negative/);
});
it('should skip if minInterval is zero', async () => {
limit.minInterval = 0;
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
});
it('should throw if minInterval is negative', async () => {
limit.minInterval = -1;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/minInterval is negative/);
});
});
describe('with legacy limit', () => {
let limit: Keyed<LegacyRateLimit> = null!;
beforeEach(() => {
limit = {
type: undefined,
key,
max: 1,
duration: 1000,
};
});
it('should allow when limit is not reached', async () => {
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
});
it('should not error when allowed', async () => {
await serviceUnderTest().limit(limit, actor);
expectNoUnhandledErrors();
});
it('should infer dripRate from duration', async () => {
limit.max = 10;
limit.duration = 10000;
counter = { c: 10, t: 0 };
const i1 = await serviceUnderTest().limit(limit, actor);
mockTimeService.now += 1000;
const i2 = await serviceUnderTest().limit(limit, actor);
mockTimeService.now += 2000;
const i3 = await serviceUnderTest().limit(limit, actor);
const i4 = await serviceUnderTest().limit(limit, actor);
const i5 = await serviceUnderTest().limit(limit, actor);
mockTimeService.now += 2000;
const i6 = await serviceUnderTest().limit(limit, actor);
expect(i1.blocked).toBeTruthy();
expect(i2.blocked).toBeFalsy();
expect(i3.blocked).toBeFalsy();
expect(i4.blocked).toBeFalsy();
expect(i5.blocked).toBeTruthy();
expect(i6.blocked).toBeFalsy();
});
it('should calculate correct info when allowed', async () => {
limit.max = 10;
limit.duration = 10000;
counter = { c: 10, t: 0 };
mockTimeService.now += 2000;
const info = await serviceUnderTest().limit(limit, actor);
expect(info.remaining).toBe(1);
expect(info.resetSec).toBe(0);
expect(info.resetMs).toBe(0);
expect(info.fullResetSec).toBe(9);
expect(info.fullResetMs).toBe(9000);
});
it('should calculate correct info when blocked', async () => {
limit.max = 10;
limit.duration = 10000;
counter = { c: 10, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.remaining).toBe(0);
expect(info.resetSec).toBe(1);
expect(info.resetMs).toBe(1000);
expect(info.fullResetSec).toBe(10);
expect(info.fullResetMs).toBe(10000);
});
it('should allow when bucket is filled but interval has passed', async () => {
counter = { c: 10, t: 0 };
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeTruthy();
});
it('should scale limit by factor', async () => {
counter = { c: 10, t: 0 };
const info = await serviceUnderTest().limit(limit, actor, 0.5); // 10 + 1 = 11
expect(info.blocked).toBeTruthy();
});
it('should set key expiration', async () => {
const mock = jest.fn(mockRedisSet);
mockRedisSet = mock;
await serviceUnderTest().limit(limit, actor);
expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
counter = { c: 1, t: 0 };
mockTimeService.now += 100;
await serviceUnderTest().limit(limit, actor);
expect(counter?.c).toBe(1);
expect(counter?.t).toBe(0);
});
it('should not allow dripRate to be lower than 0', async () => {
// real-world case; taken from StreamingApiServerService
limit.max = 4096;
limit.duration = 2000;
counter = { c: 4096, t: 0 };
const i1 = await serviceUnderTest().limit(limit, actor);
mockTimeService.now = 1;
const i2 = await serviceUnderTest().limit(limit, actor);
expect(i1.blocked).toBeTruthy();
expect(i2.blocked).toBeFalsy();
});
it('should skip if factor is zero', async () => {
const info = await serviceUnderTest().limit(limit, actor, 0);
expect(info.blocked).toBeFalsy();
expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
});
it('should throw if factor is negative', async () => {
const promise = serviceUnderTest().limit(limit, actor, -1);
await expect(promise).rejects.toThrow(/factor is zero or negative/);
});
it('should throw if max is zero', async () => {
limit.max = 0;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/size is less than 1/);
});
it('should throw if max is negative', async () => {
limit.max = -1;
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/size is less than 1/);
});
});
describe('with legacy limit and min interval', () => {
let limit: Keyed<LegacyRateLimit> = null!;
beforeEach(() => {
limit = {
type: undefined,
key,
max: 5,
duration: 5000,
minInterval: 1000,
};
});
it('should allow when limit is not reached', async () => {
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
});
it('should not error when allowed', async () => {
await serviceUnderTest().limit(limit, actor);
expectNoUnhandledErrors();
});
it('should block when limit exceeded', async () => {
counter = { c: 5, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeTruthy();
});
it('should block when minInterval exceeded', async () => {
minCounter = { c: 1, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeTruthy();
});
it('should calculate correct info when allowed', async () => {
counter = { c: 1, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.remaining).toBe(0);
expect(info.resetSec).toBe(1);
expect(info.resetMs).toBe(1000);
expect(info.fullResetSec).toBe(2);
expect(info.fullResetMs).toBe(2000);
});
it('should calculate correct info when blocked by limit', async () => {
counter = { c: 5, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.remaining).toBe(0);
expect(info.resetSec).toBe(1);
expect(info.resetMs).toBe(1000);
expect(info.fullResetSec).toBe(5);
expect(info.fullResetMs).toBe(5000);
});
it('should calculate correct info when blocked by minInterval', async () => {
minCounter = { c: 1, t: 0 };
const info = await serviceUnderTest().limit(limit, actor);
expect(info.remaining).toBe(0);
expect(info.resetSec).toBe(1);
expect(info.resetMs).toBe(1000);
expect(info.fullResetSec).toBe(1);
expect(info.fullResetMs).toBe(1000);
});
it('should allow when counter is filled but interval has passed', async () => {
counter = { c: 5, t: 0 };
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
});
it('should allow when minCounter is filled but interval has passed', async () => {
minCounter = { c: 1, t: 0 };
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeFalsy();
});
it('should scale limit and interval by factor', async () => {
counter = { c: 5, t: 0 };
minCounter = { c: 1, t: 0 };
mockTimeService.now += 500;
const info = await serviceUnderTest().limit(limit, actor, 0.5);
expect(info.blocked).toBeFalsy();
});
it('should set key expiration', async () => {
const mock = jest.fn(mockRedisSet);
mockRedisSet = mock;
await serviceUnderTest().limit(limit, actor);
expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]);
expect(mock).toHaveBeenCalledWith(['rl_actor_test_min', '{"t":0,"c":1}', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
counter = { c: 5, t: 0 };
minCounter = { c: 1, t: 0 };
mockTimeService.now += 100;
await serviceUnderTest().limit(limit, actor);
expect(counter?.c).toBe(5);
expect(counter?.t).toBe(0);
expect(minCounter?.c).toBe(1);
expect(minCounter?.t).toBe(0);
});
});
});
});

View file

@ -74,108 +74,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormLink>
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._aboutMisskey.projectMembers }}</template>
<FormSection v-for="section in everyone" :key="section.heading">
<template #label>{{ section.heading }}</template>
<div :class="$style.contributors" style="margin-bottom: 8px;">
<a href="https://activitypub.software/dakkar" target="_blank" :class="$style.contributor">
<img src="https://secure.gravatar.com/avatar/c71b315eed7c63ff94c42b1b3e8dbad1?s=192&d=identicon" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@dakkar</span>
</a>
<a href="https://activitypub.software/supakaity" target="_blank" :class="$style.contributor">
<img src="https://activitypub.software/uploads/-/system/user/avatar/65/avatar.png?width=40" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@supakaity</span>
</a>
<a href="https://activitypub.software/julia" target="_blank" :class="$style.contributor">
<img src="https://activitypub.software/uploads/-/system/user/avatar/41/avatar.png?width=40" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@julia</span>
</a>
<a href="https://activitypub.software/Leah" target="_blank" :class="$style.contributor">
<img src="https://secure.gravatar.com/avatar/3b35b921b284ccfd1fe348508f6f705b?s=80&d=identicon" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@Leah</span>
</a>
<a href="https://activitypub.software/fEmber" target="_blank" :class="$style.contributor">
<img src="https://secure.gravatar.com/avatar/ea0ea6451fdb74311efad369bdce018e?s=80&d=identicon" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@fEmber</span>
</a>
<a href="https://activitypub.software/tess" target="_blank" :class="$style.contributor">
<img src="https://activitypub.software/uploads/-/system/user/avatar/132/avatar.png?width=128" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@tess</span>
</a>
<a href="https://activitypub.software/marie" target="_blank" :class="$style.contributor">
<img src="https://activitypub.software/uploads/-/system/user/avatar/2/avatar.png?width=128" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@Marie</span>
</a>
<a href="https://activitypub.software/luna" target="_blank" :class="$style.contributor">
<img src="https://secure.gravatar.com/avatar/4faf37df86a3d93a6c19ed6abf8588eade4efb837410dbbc53021b4fd12eaae7?s=80&d=identicon" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@Luna</span>
<a v-for="person in section.people" :key="person.handle" :href="person.link" target="_blank" :class="$style.contributor">
<img :src="person.avatar" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">{{ person.handle }}</span>
</a>
</div>
<template #description><MkLink url="https://activitypub.software/TransFem-org/Sharkey/-/graphs/develop">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._aboutMisskey.testers }}</template>
<div :class="$style.contributors" style="margin-bottom: 8px;">
<a href="https://antani.cyou/@lucent" target="_blank" :class="$style.contributor">
<img src="https://antani.cyou/proxy/avatar.webp?url=https%3A%2F%2Fantani.cyou%2Ffiles%2Fa2944119-024c-4abd-86e5-64bf0d30b26f&avatar=1" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@lucent</span>
</a>
<a href="https://plasmatrap.com/@privateger" target="_blank" :class="$style.contributor">
<img src="https://mediaproxy.plasmatrap.com/?url=https%3A%2F%2Fplasmatrap.com%2Ffiles%2F2cf35a8f-6520-4d4c-9611-bf22ee983293&avatar=1" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@privateger</span>
</a>
<a href="https://thetransagenda.gay/@phoenix_fairy" target="_blank" :class="$style.contributor">
<img src="https://thetransagenda.gay/proxy/avatar.webp?url=https%3A%2F%2Fs3.us-east-005.backblazeb2.com%2Ftranssharkey%2Fnull%2Fd93ac6dc-2020-4b5a-bce7-84b41e97a0ac.png&avatar=1" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@phoenix_fairy</span>
</a>
</div>
</FormSection>
<FormSection>
<template #label>Misskey Contributors</template>
<div :class="$style.contributors" style="margin-bottom: 8px;">
<a href="https://github.com/syuilo" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/4439005?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@syuilo</span>
</a>
<a href="https://github.com/tamaina" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/7973572?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@tamaina</span>
</a>
<a href="https://github.com/acid-chicken" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/20679825?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@acid-chicken</span>
</a>
<a href="https://github.com/kakkokari-gtyih" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/67428053?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@kakkokari-gtyih</span>
</a>
<a href="https://github.com/tai-cha" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/40626578?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@tai-cha</span>
</a>
<a href="https://github.com/samunohito" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/46447427?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@samunohito</span>
</a>
<a href="https://github.com/anatawa12" target="_blank" :class="$style.contributor">
<img src="https://avatars.githubusercontent.com/u/22656849?v=4" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">@anatawa12</span>
</a>
</div>
</FormSection>
<FormSection v-if="sponsors[0].length > 0">
<template #label>Our lovely Sponsors</template>
<div :class="$style.contributors">
<span
v-for="sponsor in sponsors[0]"
:key="sponsor"
style="margin-bottom: 0.5rem;"
>
<a :href="sponsor.website || sponsor.profile" target="_blank" :class="$style.contributor">
<img :src="sponsor.image || `https://ui-avatars.com/api/?background=0D8ABC&color=fff&name=${sponsor.name}`" :class="$style.contributorAvatar">
<span :class="$style.contributorUsername">{{ sponsor.name }}</span>
</a>
</span>
</div>
<template v-if="section.link" #description><MkLink :url="section.link.url">{{ section.link.label }}</MkLink></template>
</FormSection>
</div>
</MkSpacer>
@ -201,15 +108,194 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
import { $i } from '@/account.js';
type Section = {
heading: string,
link?: {
label: string,
url: string,
},
people: {
handle: string,
avatar: string,
link?: string
}[],
};
const thereIsTreasure = ref($i && !claimedAchievements.includes('foundTreasure'));
let easterEggReady = false;
const easterEggEmojis = ref([]);
const easterEggEngine = ref(null);
const sponsors = ref([]);
const everyone = ref<Section[]>([
{
heading: i18n.ts._aboutMisskey.projectMembers,
link: {
label: i18n.ts._aboutMisskey.allContributors,
url: 'https://activitypub.software/TransFem-org/Sharkey/-/graphs/develop',
},
people: fisher_yates([
{
handle: '@CenTdemeern1',
avatar: 'https://secure.gravatar.com/avatar/e97dd57d32caf703cea556ace6304617b7420f17f5b1aac4a1eea8e4234735bb?s=128&d=identicon',
link: 'https://activitypub.software/CenTdemeern1',
},
{
handle: '@dakkar',
avatar: 'https://secure.gravatar.com/avatar/c71b315eed7c63ff94c42b1b3e8dbad1?s=128&d=identicon',
link: 'https://activitypub.software/dakkar',
},
{
handle: '@hazelnoot',
avatar: 'https://activitypub.software/uploads/-/system/user/avatar/5/avatar.png?width=128',
link: 'https://activitypub.software/fEmber',
},
{
handle: '@julia',
avatar: 'https://activitypub.software/uploads/-/system/user/avatar/41/avatar.png?width=128',
link: 'https://activitypub.software/julia',
},
{
handle: '@Leah',
avatar: 'https://secure.gravatar.com/avatar/3b35b921b284ccfd1fe348508f6f705b?s=128&d=identicon',
link: 'https://activitypub.software/Leah',
},
{
handle: '@Luna',
avatar: 'https://secure.gravatar.com/avatar/4faf37df86a3d93a6c19ed6abf8588eade4efb837410dbbc53021b4fd12eaae7?s=128&d=identicon',
link: 'https://activitypub.software/luna',
},
{
handle: '@Marie',
avatar: 'https://activitypub.software/uploads/-/system/user/avatar/2/avatar.png?width=128',
link: 'https://activitypub.software/marie',
},
{
handle: '@supakaity',
avatar: 'https://activitypub.software/uploads/-/system/user/avatar/65/avatar.png?width=128',
link: 'https://activitypub.software/supakaity',
},
{
handle: '@tess',
avatar: 'https://activitypub.software/uploads/-/system/user/avatar/132/avatar.png?width=128',
link: 'https://activitypub.software/tess',
},
]),
},
{
heading: i18n.ts._aboutMisskey.testers,
people: [
{
handle: '@lucent',
avatar: 'https://antani.cyou/proxy/avatar.webp?url=https%3A%2F%2Fantani.cyou%2Ffiles%2Fa2944119-024c-4abd-86e5-64bf0d30b26f&avatar=1',
link: 'https://antani.cyou/@lucent',
},
{
handle: '@privateger',
avatar: 'https://mediaproxy.plasmatrap.com/?url=https%3A%2F%2Fplasmatrap.com%2Ffiles%2F2cf35a8f-6520-4d4c-9611-bf22ee983293&avatar=1',
link: 'https://plasmatrap.com/@privateger',
},
{
handle: '@phoenix_fairy',
avatar: 'https://thetransagenda.gay/proxy/avatar.webp?url=https%3A%2F%2Fs3.us-east-005.backblazeb2.com%2Ftranssharkey%2Fnull%2Fd93ac6dc-2020-4b5a-bce7-84b41e97a0ac.png&avatar=1',
link: 'https://thetransagenda.gay/@phoenix_fairy',
},
],
},
{
heading: i18n.ts._aboutMisskey.misskeyContributors,
people: [
{
handle: '@syuilo',
avatar: 'https://avatars.githubusercontent.com/u/4439005?s=128&v=4',
link: 'https://github.com/syuilo',
},
{
handle: '@tamaina',
avatar: 'https://avatars.githubusercontent.com/u/7973572?s=128&v=4',
link: 'https://github.com/tamaina',
},
{
handle: '@acid-chicken',
avatar: 'https://avatars.githubusercontent.com/u/20679825?s=128&v=4',
link: 'https://github.com/acid-chicken',
},
{
handle: '@mei23',
avatar: 'https://avatars.githubusercontent.com/u/30769358?s=128&v=4',
link: 'https://github.com/mei23',
},
{
handle: '@AyaMorisawa',
avatar: 'https://avatars.githubusercontent.com/u/10798641?s=128&v=4',
link: 'https://github.com/AyaMorisawa',
},
{
handle: '@kakkokari-gtyih',
avatar: 'https://avatars.githubusercontent.com/u/67428053?s=128&v=4',
link: 'https://github.com/kakkokari-gtyih',
},
{
handle: '@tai-cha',
avatar: 'https://avatars.githubusercontent.com/u/40626578?s=128&v=4',
link: 'https://github.com/tai-cha',
},
{
handle: '@Johann150',
avatar: 'https://avatars.githubusercontent.com/u/20990607?s=128&v=4',
link: 'https://github.com/Johann150',
},
{
handle: '@anatawa12',
avatar: 'https://avatars.githubusercontent.com/u/22656849?s=128&v=4',
link: 'https://github.com/anatawa12',
},
{
handle: '@saschanaz',
avatar: 'https://avatars.githubusercontent.com/u/3396686?s=128&v=4',
link: 'https://github.com/saschanaz',
},
{
handle: '@zyoshoka',
avatar: 'https://avatars.githubusercontent.com/u/107108195?s=128&v=4',
link: 'https://github.com/zyoshoka',
},
{
handle: '@samunohito',
avatar: 'https://avatars.githubusercontent.com/u/46447427?s=128&v=4',
link: 'https://github.com/samunohito',
},
],
},
]);
const containerEl = shallowRef<HTMLElement>();
await misskeyApi('sponsors', { forceUpdate: false }).then((res) => sponsors.value.push(res.sponsor_data));
await misskeyApi('sponsors', { forceUpdate: false }).then((res) => {
const section: Section = {
heading: i18n.ts._aboutMisskey.ourLovelySponsors,
people: [],
};
for (const sponsor of res.sponsor_data) {
section.people.push({
handle: sponsor.name,
avatar: sponsor.image || `https://ui-avatars.com/api/?background=0D8ABC&color=fff&name=${sponsor.name}`,
link: sponsor.website || sponsor.profile,
});
}
everyone.value.push(section);
});
/**
* Based on the pseudocode description from Wikipedia:
* https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle
* Modifies the array in-place, but still returns it for the sake of convenience
*/
function fisher_yates<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
function iconLoaded() {
const emojis = defaultStore.state.reactions;

View file

@ -241,6 +241,8 @@ _aboutMisskey:
translation: "Translate Sharkey"
donate_sharkey: "Donate to Sharkey"
testers: "Testers"
misskeyContributors: "Misskey Contributors"
ourLovelySponsors: "Our lovely Sponsors"
_serverDisconnectedBehavior:
disabled: "Disable warning"
_channel: