Merge branch 'develop' into drive-bulk
This commit is contained in:
commit
8d1a1813a3
|
@ -23,8 +23,10 @@
|
||||||
- Fix: "時計"ウィジェット(Clock)において、Transparent設定が有効でも、その背景が透過されない問題を修正
|
- Fix: "時計"ウィジェット(Clock)において、Transparent設定が有効でも、その背景が透過されない問題を修正
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
- Enhance: チャットルームの最大メンバー数を30人から50人に調整
|
||||||
- Enhance: ノートのレスポンスにアンケートが添付されているかどうかを示すフラグ`hasPoll`を追加
|
- Enhance: ノートのレスポンスにアンケートが添付されているかどうかを示すフラグ`hasPoll`を追加
|
||||||
- Enhance: チャットルームのレスポンスに招待されているかどうかを示すフラグ`invitationExists`を追加
|
- Enhance: チャットルームのレスポンスに招待されているかどうかを示すフラグ`invitationExists`を追加
|
||||||
|
- Enhance: レートリミットの計算方法を調整 (#13997)
|
||||||
- Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正
|
- Fix: チャットルームが削除された場合・チャットルームから抜けた場合に、未読状態が残り続けることがあるのを修正
|
||||||
- Fix: ユーザ除外アンテナをインポートできない問題を修正
|
- Fix: ユーザ除外アンテナをインポートできない問題を修正
|
||||||
- Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正
|
- Fix: アンテナのセンシティブなチャンネルのノートを含むかどうかの情報がエクスポートされない問題を修正
|
||||||
|
|
|
@ -29,7 +29,7 @@ import { emojiRegex } from '@/misc/emoji-regex.js';
|
||||||
import { NotificationService } from '@/core/NotificationService.js';
|
import { NotificationService } from '@/core/NotificationService.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
|
|
||||||
const MAX_ROOM_MEMBERS = 30;
|
const MAX_ROOM_MEMBERS = 50;
|
||||||
const MAX_REACTIONS_PER_MESSAGE = 100;
|
const MAX_REACTIONS_PER_MESSAGE = 100;
|
||||||
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
|
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
|
||||||
|
|
||||||
|
|
|
@ -326,19 +326,15 @@ export class ApiCallService implements OnApplicationShutdown {
|
||||||
|
|
||||||
if (factor > 0) {
|
if (factor > 0) {
|
||||||
// Rate limit
|
// Rate limit
|
||||||
await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor).catch(err => {
|
const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable<string> }, limitActor, factor);
|
||||||
if ('info' in err) {
|
if (rateLimit != null) {
|
||||||
// errはLimiter.LimiterInfoであることが期待される
|
|
||||||
throw new ApiError({
|
throw new ApiError({
|
||||||
message: 'Rate limit exceeded. Please try again later.',
|
message: 'Rate limit exceeded. Please try again later.',
|
||||||
code: 'RATE_LIMIT_EXCEEDED',
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||||
httpStatusCode: 429,
|
httpStatusCode: 429,
|
||||||
}, err.info);
|
}, rateLimit.info);
|
||||||
} else {
|
|
||||||
throw new TypeError('information must be a rate-limiter information.');
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,14 @@ import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { IEndpointMeta } from './endpoints.js';
|
import type { IEndpointMeta } from './endpoints.js';
|
||||||
|
|
||||||
|
type RateLimitInfo = {
|
||||||
|
code: 'BRIEF_REQUEST_INTERVAL',
|
||||||
|
info: Limiter.LimiterInfo,
|
||||||
|
} | {
|
||||||
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
info: Limiter.LimiterInfo,
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RateLimiterService {
|
export class RateLimiterService {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
@ -31,77 +39,57 @@ export class RateLimiterService {
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1) {
|
private checkLimiter(options: Limiter.LimiterOption): Promise<Limiter.LimiterInfo> {
|
||||||
{
|
return new Promise<Limiter.LimiterInfo>((resolve, reject) => {
|
||||||
|
new Limiter(options).get((err, info) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err);
|
||||||
|
}
|
||||||
|
resolve(info);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async limit(limitation: IEndpointMeta['limit'] & { key: NonNullable<string> }, actor: string, factor = 1): Promise<RateLimitInfo | null> {
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
return Promise.resolve();
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short-term limit
|
// Short-term limit
|
||||||
const min = new Promise<void>((ok, reject) => {
|
if (limitation.minInterval != null) {
|
||||||
const minIntervalLimiter = new Limiter({
|
const info = await this.checkLimiter({
|
||||||
id: `${actor}:${limitation.key}:min`,
|
id: `${actor}:${limitation.key}:min`,
|
||||||
duration: limitation.minInterval! * factor,
|
duration: limitation.minInterval * factor,
|
||||||
max: 1,
|
max: 1,
|
||||||
db: this.redisClient,
|
db: this.redisClient,
|
||||||
});
|
});
|
||||||
|
|
||||||
minIntervalLimiter.get((err, info) => {
|
|
||||||
if (err) {
|
|
||||||
return reject({ code: 'ERR', info });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
|
this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`);
|
||||||
|
|
||||||
if (info.remaining === 0) {
|
if (info.remaining === 0) {
|
||||||
return reject({ code: 'BRIEF_REQUEST_INTERVAL', info });
|
// eslint-disable-next-line no-throw-literal
|
||||||
} else {
|
return { code: 'BRIEF_REQUEST_INTERVAL', info };
|
||||||
if (hasLongTermLimit) {
|
|
||||||
return max.then(ok, reject);
|
|
||||||
} else {
|
|
||||||
return ok();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Long term limit
|
// Long term limit
|
||||||
const max = new Promise<void>((ok, reject) => {
|
if (limitation.duration != null && limitation.max != null) {
|
||||||
const limiter = new Limiter({
|
const info = await this.checkLimiter({
|
||||||
id: `${actor}:${limitation.key}`,
|
id: `${actor}:${limitation.key}`,
|
||||||
duration: limitation.duration! * factor,
|
duration: limitation.duration,
|
||||||
max: limitation.max! / factor,
|
max: limitation.max / factor,
|
||||||
db: this.redisClient,
|
db: this.redisClient,
|
||||||
});
|
});
|
||||||
|
|
||||||
limiter.get((err, info) => {
|
|
||||||
if (err) {
|
|
||||||
return reject({ code: 'ERR', info });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
|
this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`);
|
||||||
|
|
||||||
if (info.remaining === 0) {
|
if (info.remaining === 0) {
|
||||||
return reject({ code: 'RATE_LIMIT_EXCEEDED', info });
|
// eslint-disable-next-line no-throw-literal
|
||||||
} else {
|
return { code: 'RATE_LIMIT_EXCEEDED', info };
|
||||||
return ok();
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasShortTermLimit = typeof limitation.minInterval === 'number';
|
return null;
|
||||||
|
|
||||||
const hasLongTermLimit =
|
|
||||||
typeof limitation.duration === 'number' &&
|
|
||||||
typeof limitation.max === 'number';
|
|
||||||
|
|
||||||
if (hasShortTermLimit) {
|
|
||||||
return min;
|
|
||||||
} else if (hasLongTermLimit) {
|
|
||||||
return max;
|
|
||||||
} else {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,10 +89,9 @@ export class SigninApiService {
|
||||||
return { error };
|
return { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
// not more than 1 attempt per second and not more than 10 attempts per hour
|
// 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));
|
const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip));
|
||||||
} catch (err) {
|
if (rateLimit != null) {
|
||||||
reply.code(429);
|
reply.code(429);
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
|
|
|
@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160">
|
<svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160">
|
||||||
<path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.animLine]"/>
|
<path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.animLine]"/>
|
||||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircleSuccess]"/>
|
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160">
|
<svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160">
|
||||||
<path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.animLine]"/>
|
<path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.animLine]"/>
|
||||||
|
@ -87,14 +87,6 @@ const props = defineProps<{
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.animCircleSuccess {
|
|
||||||
stroke-dasharray: var(--l);
|
|
||||||
stroke-dashoffset: var(--l);
|
|
||||||
animation: circleSuccess var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
|
||||||
animation-delay: var(--delay, 0s);
|
|
||||||
transform-origin: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animFade {
|
.animFade {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
||||||
|
@ -112,19 +104,6 @@ const props = defineProps<{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes circleSuccess {
|
|
||||||
0% {
|
|
||||||
stroke-dashoffset: var(--l);
|
|
||||||
opacity: 0;
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
stroke-dashoffset: 0;
|
|
||||||
opacity: 1;
|
|
||||||
transform: rotate(90deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
Loading…
Reference in New Issue