-
-
- {{ message }}
-
-
-
-
- {{ i18n.ts.continueOnRemote }}
-
-
-
-
-
-
-
- @
- @{{ host }}
-
-
-
-
-
-
-
-
-
- {{ signing ? i18n.ts.loggingIn : i18n.ts.login }}
-
-
-
-
{{ i18n.ts.useSecurityKey }}
-
- {{ i18n.ts.retry }}
-
-
-
-
-
- {{ i18n.ts.token }} ({{ i18n.ts['2fa'] }})
-
-
-
- {{ signing ? i18n.ts.loggingIn : i18n.ts.login }}
-
-
-
-
-
-
- {{ signing ? i18n.ts.loggingIn : i18n.ts.signinWithPasskey }}
-
-
{{ i18n.ts.useSecurityKey }}
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue
index d48780e9de..8351d7d5e0 100644
--- a/packages/frontend/src/components/MkSigninDialog.vue
+++ b/packages/frontend/src/components/MkSigninDialog.vue
@@ -4,26 +4,29 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
-
- {{ i18n.ts.login }}
-
-
-
-
-
+
+
+
{{ i18n.ts.login }}
+
+
+
+
+
+
+
+
+
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 5f4792eb74..9ad784c296 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -3040,7 +3040,7 @@ type Signin = components['schemas']['Signin'];
// @public (undocumented)
type SigninRequest = {
username: string;
- password: string;
+ password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string | null;
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 32646d28ed..3876a0bfe5 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -3782,16 +3782,13 @@ export type components = {
followingVisibility: 'public' | 'followers' | 'private';
/** @enum {string} */
followersVisibility: 'public' | 'followers' | 'private';
- /** @default false */
- twoFactorEnabled: boolean;
- /** @default false */
- usePasswordLessLogin: boolean;
- /** @default false */
- securityKeys: boolean;
roles: components['schemas']['RoleLite'][];
followedMessage?: string | null;
memo: string | null;
moderationNote?: string;
+ twoFactorEnabled?: boolean;
+ usePasswordLessLogin?: boolean;
+ securityKeys?: boolean;
isFollowing?: boolean;
isFollowed?: boolean;
hasPendingFollowRequestFromYou?: boolean;
@@ -3972,6 +3969,12 @@ export type components = {
}[];
loggedInDays: number;
policies: components['schemas']['RolePolicies'];
+ /** @default false */
+ twoFactorEnabled: boolean;
+ /** @default false */
+ usePasswordLessLogin: boolean;
+ /** @default false */
+ securityKeys: boolean;
email?: string | null;
emailVerified?: boolean | null;
securityKeysList?: {
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index 36b7f5bca3..98ac50e5a1 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -269,7 +269,7 @@ export type SignupPendingResponse = {
export type SigninRequest = {
username: string;
- password: string;
+ password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string | null;
--
cgit v1.2.3-freya
From ae3c155490d9b5a574c45309744ba2a0cbe78932 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 5 Oct 2024 12:03:47 +0900
Subject: fix: signin
の資格情報が足りないだけの場合はエラーにせず200を返すように (#14700)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: signin の資格情報が足りないだけの場合はエラーにせず200を返すように
* run api extractor
* fix
* fix
* fix test
* /signin -> /signin-flow
* fix
* fix lint
* rename
* fix
* fix
---
cypress/e2e/basic.cy.ts | 2 +-
cypress/support/commands.ts | 2 +-
.../backend/src/server/api/ApiServerService.ts | 2 +-
.../backend/src/server/api/SigninApiService.ts | 66 ++----
packages/backend/src/server/api/SigninService.ts | 6 +-
packages/backend/test/e2e/2fa.ts | 73 +++----
packages/backend/test/e2e/endpoints.ts | 8 +-
packages/frontend/src/components/MkSignin.vue | 236 +++++++++++----------
.../src/components/MkSignupDialog.form.vue | 11 +-
.../frontend/src/components/MkSignupDialog.vue | 4 +-
packages/misskey-js/etc/misskey-js.api.md | 24 ++-
packages/misskey-js/src/api.types.ts | 10 +-
packages/misskey-js/src/entities.ts | 22 +-
13 files changed, 231 insertions(+), 235 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts
index c9d7e0a24a..d2efbf709c 100644
--- a/cypress/e2e/basic.cy.ts
+++ b/cypress/e2e/basic.cy.ts
@@ -120,7 +120,7 @@ describe('After user signup', () => {
it('signin', () => {
cy.visitHome();
- cy.intercept('POST', '/api/signin').as('signin');
+ cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts
index ed5cda31b0..197ff963ac 100644
--- a/cypress/support/commands.ts
+++ b/cypress/support/commands.ts
@@ -55,7 +55,7 @@ Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
Cypress.Commands.add('login', (username, password) => {
cy.visitHome();
- cy.intercept('POST', '/api/signin').as('signin');
+ cy.intercept('POST', '/api/signin-flow').as('signin');
cy.get('[data-cy-signin]').click();
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index 356e145681..6b760c258b 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -133,7 +133,7 @@ export class ApiServerService {
'turnstile-response'?: string;
'm-captcha-response'?: string;
};
- }>('/signin', (request, reply) => this.signinApiService.signin(request, reply));
+ }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply));
fastify.post<{
Body: {
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index 81684beb3c..0d24ffa56a 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -5,8 +5,8 @@
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
-import * as OTPAuth from 'otpauth';
import { IsNull } from 'typeorm';
+import * as Misskey from 'misskey-js';
import { DI } from '@/di-symbols.js';
import type {
MiMeta,
@@ -26,27 +26,9 @@ import { CaptchaService } from '@/core/CaptchaService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
-import type { AuthenticationResponseJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/types';
+import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
import type { FastifyReply, FastifyRequest } from 'fastify';
-/**
- * next を指定すると、次にクライアント側で行うべき処理を指定できる。
- *
- * - `captcha`: パスワードと、(有効になっている場合は)CAPTCHAを求める
- * - `password`: パスワードを求める
- * - `totp`: ワンタイムパスワードを求める
- * - `passkey`: WebAuthn認証を求める(WebAuthnに対応していないブラウザの場合はワンタイムパスワード)
- */
-
-type SigninErrorResponse = {
- id: string;
- next?: 'captcha' | 'password' | 'totp';
-} | {
- id: string;
- next: 'passkey';
- authRequest: PublicKeyCredentialRequestOptionsJSON;
-};
-
@Injectable()
export class SigninApiService {
constructor(
@@ -101,7 +83,7 @@ export class SigninApiService {
const password = body['password'];
const token = body['token'];
- function error(status: number, error: SigninErrorResponse) {
+ function error(status: number, error: { id: string }) {
reply.code(status);
return { error };
}
@@ -152,21 +134,17 @@ export class SigninApiService {
const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1);
if (password == null) {
- reply.code(403);
+ reply.code(200);
if (profile.twoFactorEnabled) {
return {
- error: {
- id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
- next: 'password',
- },
- } satisfies { error: SigninErrorResponse };
+ finished: false,
+ next: 'password',
+ } satisfies Misskey.entities.SigninFlowResponse;
} else {
return {
- error: {
- id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
- next: 'captcha',
- },
- } satisfies { error: SigninErrorResponse };
+ finished: false,
+ next: 'captcha',
+ } satisfies Misskey.entities.SigninFlowResponse;
}
}
@@ -178,7 +156,7 @@ export class SigninApiService {
// Compare password
const same = await bcrypt.compare(password, profile.password!);
- const fail = async (status?: number, failure?: SigninErrorResponse) => {
+ const fail = async (status?: number, failure?: { id: string; }) => {
// Append signin history
await this.signinsRepository.insert({
id: this.idService.gen(),
@@ -268,27 +246,23 @@ export class SigninApiService {
const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
- reply.code(403);
+ reply.code(200);
return {
- error: {
- id: '06e661b9-8146-4ae3-bde5-47138c0ae0c4',
- next: 'passkey',
- authRequest,
- },
- } satisfies { error: SigninErrorResponse };
+ finished: false,
+ next: 'passkey',
+ authRequest,
+ } satisfies Misskey.entities.SigninFlowResponse;
} else {
if (!same || !profile.twoFactorEnabled) {
return await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
} else {
- reply.code(403);
+ reply.code(200);
return {
- error: {
- id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
- next: 'totp',
- },
- } satisfies { error: SigninErrorResponse };
+ finished: false,
+ next: 'totp',
+ } satisfies Misskey.entities.SigninFlowResponse;
}
}
// never get here
diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts
index 4b041f373f..640356b50c 100644
--- a/packages/backend/src/server/api/SigninService.ts
+++ b/packages/backend/src/server/api/SigninService.ts
@@ -4,6 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
+import * as Misskey from 'misskey-js';
import { DI } from '@/di-symbols.js';
import type { SigninsRepository, UserProfilesRepository } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
@@ -57,9 +58,10 @@ export class SigninService {
reply.code(200);
return {
+ finished: true,
id: user.id,
- i: user.token,
- };
+ i: user.token!,
+ } satisfies Misskey.entities.SigninFlowResponse;
}
}
diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts
index 88c32b4346..48e1bababb 100644
--- a/packages/backend/test/e2e/2fa.ts
+++ b/packages/backend/test/e2e/2fa.ts
@@ -136,7 +136,7 @@ describe('2要素認証', () => {
keyName: string,
credentialId: Buffer,
requestOptions: PublicKeyCredentialRequestOptionsJSON,
- }): misskey.entities.SigninRequest => {
+ }): misskey.entities.SigninFlowRequest => {
// AuthenticatorAssertionResponse.authenticatorData
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
const authenticatorData = Buffer.concat([
@@ -196,22 +196,21 @@ describe('2要素認証', () => {
}, alice);
assert.strictEqual(doneResponse.status, 200);
- const signinWithoutTokenResponse = await api('signin', {
+ const signinWithoutTokenResponse = await api('signin-flow', {
...signinParam(),
});
- assert.strictEqual(signinWithoutTokenResponse.status, 403);
+ assert.strictEqual(signinWithoutTokenResponse.status, 200);
assert.deepStrictEqual(signinWithoutTokenResponse.body, {
- error: {
- id: '144ff4f8-bd6c-41bc-82c3-b672eb09efbf',
- next: 'totp',
- },
+ finished: false,
+ next: 'totp',
});
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
token: otpToken(registerResponse.body.secret),
});
assert.strictEqual(signinResponse.status, 200);
+ assert.strictEqual(signinResponse.body.finished, true);
assert.notEqual(signinResponse.body.i, undefined);
// 後片付け
@@ -252,29 +251,23 @@ describe('2要素認証', () => {
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url'));
assert.strictEqual(keyDoneResponse.body.name, keyName);
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
});
- const signinResponseBody = signinResponse.body as unknown as {
- error: {
- id: string;
- next: 'passkey';
- authRequest: PublicKeyCredentialRequestOptionsJSON;
- };
- };
- assert.strictEqual(signinResponse.status, 403);
- assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4');
- assert.strictEqual(signinResponseBody.error.next, 'passkey');
- assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined);
- assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined);
- assert.strictEqual(signinResponseBody.error.authRequest.allowCredentials && signinResponseBody.error.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url'));
-
- const signinResponse2 = await api('signin', signinWithSecurityKeyParam({
+ assert.strictEqual(signinResponse.status, 200);
+ assert.strictEqual(signinResponse.body.finished, false);
+ assert.strictEqual(signinResponse.body.next, 'passkey');
+ assert.notEqual(signinResponse.body.authRequest.challenge, undefined);
+ assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined);
+ assert.strictEqual(signinResponse.body.authRequest.allowCredentials && signinResponse.body.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url'));
+
+ const signinResponse2 = await api('signin-flow', signinWithSecurityKeyParam({
keyName,
credentialId,
- requestOptions: signinResponseBody.error.authRequest,
+ requestOptions: signinResponse.body.authRequest,
}));
assert.strictEqual(signinResponse2.status, 200);
+ assert.strictEqual(signinResponse2.body.finished, true);
assert.notEqual(signinResponse2.body.i, undefined);
// 後片付け
@@ -320,32 +313,26 @@ describe('2要素認証', () => {
assert.strictEqual(iResponse.status, 200);
assert.strictEqual(iResponse.body.usePasswordLessLogin, true);
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
password: '',
});
- const signinResponseBody = signinResponse.body as unknown as {
- error: {
- id: string;
- next: 'passkey';
- authRequest: PublicKeyCredentialRequestOptionsJSON;
- };
- };
- assert.strictEqual(signinResponse.status, 403);
- assert.strictEqual(signinResponseBody.error.id, '06e661b9-8146-4ae3-bde5-47138c0ae0c4');
- assert.strictEqual(signinResponseBody.error.next, 'passkey');
- assert.notEqual(signinResponseBody.error.authRequest.challenge, undefined);
- assert.notEqual(signinResponseBody.error.authRequest.allowCredentials, undefined);
+ assert.strictEqual(signinResponse.status, 200);
+ assert.strictEqual(signinResponse.body.finished, false);
+ assert.strictEqual(signinResponse.body.next, 'passkey');
+ assert.notEqual(signinResponse.body.authRequest.challenge, undefined);
+ assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined);
- const signinResponse2 = await api('signin', {
+ const signinResponse2 = await api('signin-flow', {
...signinWithSecurityKeyParam({
keyName,
credentialId,
- requestOptions: signinResponseBody.error.authRequest,
+ requestOptions: signinResponse.body.authRequest,
} as any),
password: '',
});
assert.strictEqual(signinResponse2.status, 200);
+ assert.strictEqual(signinResponse2.body.finished, true);
assert.notEqual(signinResponse2.body.i, undefined);
// 後片付け
@@ -450,11 +437,12 @@ describe('2要素認証', () => {
assert.strictEqual(afterIResponse.status, 200);
assert.strictEqual(afterIResponse.body.securityKeys, false);
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
token: otpToken(registerResponse.body.secret),
});
assert.strictEqual(signinResponse.status, 200);
+ assert.strictEqual(signinResponse.body.finished, true);
assert.notEqual(signinResponse.body.i, undefined);
// 後片付け
@@ -485,10 +473,11 @@ describe('2要素認証', () => {
}, alice);
assert.strictEqual(unregisterResponse.status, 204);
- const signinResponse = await api('signin', {
+ const signinResponse = await api('signin-flow', {
...signinParam(),
});
assert.strictEqual(signinResponse.status, 200);
+ assert.strictEqual(signinResponse.body.finished, true);
assert.notEqual(signinResponse.body.i, undefined);
// 後片付け
diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts
index 5aaec7f6f9..b91d77c398 100644
--- a/packages/backend/test/e2e/endpoints.ts
+++ b/packages/backend/test/e2e/endpoints.ts
@@ -66,9 +66,9 @@ describe('Endpoints', () => {
});
});
- describe('signin', () => {
+ describe('signin-flow', () => {
test('間違ったパスワードでサインインできない', async () => {
- const res = await api('signin', {
+ const res = await api('signin-flow', {
username: 'test1',
password: 'bar',
});
@@ -77,7 +77,7 @@ describe('Endpoints', () => {
});
test('クエリをインジェクションできない', async () => {
- const res = await api('signin', {
+ const res = await api('signin-flow', {
username: 'test1',
// @ts-expect-error password must be string
password: {
@@ -89,7 +89,7 @@ describe('Endpoints', () => {
});
test('正しい情報でサインインできる', async () => {
- const res = await api('signin', {
+ const res = await api('signin-flow', {
username: 'test1',
password: 'test1',
});
diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue
index 03dd61f6c6..26e1ac516c 100644
--- a/packages/frontend/src/components/MkSignin.vue
+++ b/packages/frontend/src/components/MkSignin.vue
@@ -83,7 +83,7 @@ import type { AuthenticationPublicKeyCredential } from '@github/webauthn-json/br
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
const emit = defineEmits<{
- (ev: 'login', v: Misskey.entities.SigninResponse): void;
+ (ev: 'login', v: Misskey.entities.SigninFlowResponse): void;
}>();
const props = withDefaults(defineProps<{
@@ -212,23 +212,63 @@ async function onTotpSubmitted(token: string) {
}
}
-async function tryLogin(req: Partial
): Promise {
+async function tryLogin(req: Partial): Promise {
const _req = {
username: req.username ?? userInfo.value?.username,
...req,
};
- function assertIsSigninRequest(x: Partial): x is Misskey.entities.SigninRequest {
+ function assertIsSigninFlowRequest(x: Partial): x is Misskey.entities.SigninFlowRequest {
return x.username != null;
}
- if (!assertIsSigninRequest(_req)) {
+ if (!assertIsSigninFlowRequest(_req)) {
throw new Error('Invalid request');
}
- return await misskeyApi('signin', _req).then(async (res) => {
- emit('login', res);
- await onLoginSucceeded(res);
+ return await misskeyApi('signin-flow', _req).then(async (res) => {
+ if (res.finished) {
+ emit('login', res);
+ await onLoginSucceeded(res);
+ } else {
+ switch (res.next) {
+ case 'captcha': {
+ needCaptcha.value = true;
+ page.value = 'password';
+ break;
+ }
+ case 'password': {
+ needCaptcha.value = false;
+ page.value = 'password';
+ break;
+ }
+ case 'totp': {
+ page.value = 'totp';
+ break;
+ }
+ case 'passkey': {
+ if (webAuthnSupported()) {
+ credentialRequest.value = parseRequestOptionsFromJSON({
+ publicKey: res.authRequest,
+ });
+ page.value = 'passkey';
+ } else {
+ page.value = 'totp';
+ }
+ break;
+ }
+ }
+
+ if (doingPasskeyFromInputPage.value === true) {
+ doingPasskeyFromInputPage.value = false;
+ page.value = 'input';
+ password.value = '';
+ }
+ passwordPageEl.value?.resetCaptcha();
+ nextTick(() => {
+ waiting.value = false;
+ });
+ }
return res;
}).catch((err) => {
onSigninApiError(err);
@@ -236,7 +276,7 @@ async function tryLogin(req: Partial): Promise();
@@ -269,14 +269,19 @@ async function onSubmit(): Promise {
});
emit('signupEmailPending');
} else {
- const res = await misskeyApi('signin', {
+ const res = await misskeyApi('signin-flow', {
username: username.value,
password: password.value,
});
emit('signup', res);
- if (props.autoSet) {
+ if (props.autoSet && res.finished) {
return login(res.i);
+ } else {
+ os.alert({
+ type: 'error',
+ text: i18n.ts.somethingHappened,
+ });
}
}
} catch {
diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue
index 97310d32a6..4cccd99492 100644
--- a/packages/frontend/src/components/MkSignupDialog.vue
+++ b/packages/frontend/src/components/MkSignupDialog.vue
@@ -47,7 +47,7 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
- (ev: 'done', res: Misskey.entities.SigninResponse): void;
+ (ev: 'done', res: Misskey.entities.SigninFlowResponse): void;
(ev: 'closed'): void;
}>();
@@ -55,7 +55,7 @@ const dialog = shallowRef>();
const isAcceptedServerRule = ref(false);
-function onSignup(res: Misskey.entities.SigninResponse) {
+function onSignup(res: Misskey.entities.SigninFlowResponse) {
emit('done', res);
dialog.value?.close();
}
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 9ad784c296..732352abd8 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1158,9 +1158,9 @@ export type Endpoints = Overwrite> = T[keyof T];
--
cgit v1.2.3-freya
From d8bf1ff7e9ab4d39b2e924bf7eae010e9b9e21f0 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Sat, 5 Oct 2024 13:47:50 +0900
Subject: #14675 レビューの修正 (#14705)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/backend/src/server/api/ApiServerService.ts | 2 +-
packages/frontend/src/components/MkFukidashi.vue | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts
index 6b760c258b..be63635efe 100644
--- a/packages/backend/src/server/api/ApiServerService.ts
+++ b/packages/backend/src/server/api/ApiServerService.ts
@@ -125,7 +125,7 @@ export class ApiServerService {
fastify.post<{
Body: {
username: string;
- password: string;
+ password?: string;
token?: string;
credential?: AuthenticationResponseJSON;
'hcaptcha-response'?: string;
diff --git a/packages/frontend/src/components/MkFukidashi.vue b/packages/frontend/src/components/MkFukidashi.vue
index ba82eb442f..09825487bf 100644
--- a/packages/frontend/src/components/MkFukidashi.vue
+++ b/packages/frontend/src/components/MkFukidashi.vue
@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[
$style.root,
tail === 'left' ? $style.left : $style.right,
- negativeMargin === true && $style.negativeMergin,
+ negativeMargin === true && $style.negativeMargin,
shadow === true && $style.shadow,
]"
>
@@ -54,7 +54,7 @@ withDefaults(defineProps<{
&.left {
padding-left: calc(var(--fukidashi-radius) * .13);
- &.negativeMergin {
+ &.negativeMargin {
margin-left: calc(calc(var(--fukidashi-radius) * .13) * -1);
}
}
@@ -62,7 +62,7 @@ withDefaults(defineProps<{
&.right {
padding-right: calc(var(--fukidashi-radius) * .13);
- &.negativeMergin {
+ &.negativeMargin {
margin-right: calc(calc(var(--fukidashi-radius) * .13) * -1);
}
}
--
cgit v1.2.3-freya
From 0d7d1091c8970d9979e8efb02f0accd6dcd39422 Mon Sep 17 00:00:00 2001
From: おさむのひと <46447427+samunohito@users.noreply.github.com>
Date: Sat, 5 Oct 2024 14:37:52 +0900
Subject: enhance: 人気のPlayを10件以上表示できるように (#14443)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com>
---
CHANGELOG.md | 1 +
packages/backend/src/core/CoreModule.ts | 5 +
packages/backend/src/core/FlashService.ts | 40 ++++++
.../src/core/entities/FlashEntityService.ts | 41 ++++--
packages/backend/src/models/Flash.ts | 5 +-
.../src/server/api/endpoints/flash/featured.ts | 22 +--
packages/backend/test/unit/FlashService.ts | 152 +++++++++++++++++++++
packages/frontend/src/pages/flash/flash-index.vue | 3 +-
packages/misskey-js/etc/misskey-js.api.md | 4 +
packages/misskey-js/src/autogen/endpoint.ts | 3 +-
packages/misskey-js/src/autogen/entities.ts | 1 +
packages/misskey-js/src/autogen/types.ts | 10 ++
12 files changed, 262 insertions(+), 25 deletions(-)
create mode 100644 packages/backend/src/core/FlashService.ts
create mode 100644 packages/backend/test/unit/FlashService.ts
(limited to 'packages/backend/src/server/api')
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04acc11ac3..6a9143ea1b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@
- Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました
- Enhance: 依存関係の更新
- Enhance: l10nの更新
+- Enhance: Playの「人気」タブで10件以上表示可能に #14399
- Fix: 連合のホワイトリストが正常に登録されない問題を修正
### Client
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 3b3c35f976..734d135648 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -14,6 +14,7 @@ import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationSe
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 { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
@@ -217,6 +218,7 @@ const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useEx
const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService };
const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
+const $FlashService: Provider = { provide: 'FlashService', useExisting: FlashService };
const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService };
@@ -367,6 +369,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookTestService,
UtilityService,
FileInfoService,
+ FlashService,
SearchService,
ClipService,
FeaturedService,
@@ -513,6 +516,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$WebhookTestService,
$UtilityService,
$FileInfoService,
+ $FlashService,
$SearchService,
$ClipService,
$FeaturedService,
@@ -660,6 +664,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
WebhookTestService,
UtilityService,
FileInfoService,
+ FlashService,
SearchService,
ClipService,
FeaturedService,
diff --git a/packages/backend/src/core/FlashService.ts b/packages/backend/src/core/FlashService.ts
new file mode 100644
index 0000000000..2a98225382
--- /dev/null
+++ b/packages/backend/src/core/FlashService.ts
@@ -0,0 +1,40 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import { type FlashsRepository } from '@/models/_.js';
+
+/**
+ * MisskeyPlay関係のService
+ */
+@Injectable()
+export class FlashService {
+ constructor(
+ @Inject(DI.flashsRepository)
+ private flashRepository: FlashsRepository,
+ ) {
+ }
+
+ /**
+ * 人気のあるPlay一覧を取得する.
+ */
+ public async featured(opts?: { offset?: number, limit: number }) {
+ const builder = this.flashRepository.createQueryBuilder('flash')
+ .andWhere('flash.likedCount > 0')
+ .andWhere('flash.visibility = :visibility', { visibility: 'public' })
+ .addOrderBy('flash.likedCount', 'DESC')
+ .addOrderBy('flash.updatedAt', 'DESC')
+ .addOrderBy('flash.id', 'DESC');
+
+ if (opts?.offset) {
+ builder.skip(opts.offset);
+ }
+
+ builder.take(opts?.limit ?? 10);
+
+ return await builder.getMany();
+ }
+}
diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts
index 4aa7104c1e..0cdcf3310a 100644
--- a/packages/backend/src/core/entities/FlashEntityService.ts
+++ b/packages/backend/src/core/entities/FlashEntityService.ts
@@ -5,10 +5,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
-import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js';
-import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { FlashLikesRepository, FlashsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
-import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiFlash } from '@/models/Flash.js';
import { bindThis } from '@/decorators.js';
@@ -20,10 +18,8 @@ export class FlashEntityService {
constructor(
@Inject(DI.flashsRepository)
private flashsRepository: FlashsRepository,
-
@Inject(DI.flashLikesRepository)
private flashLikesRepository: FlashLikesRepository,
-
private userEntityService: UserEntityService,
private idService: IdService,
) {
@@ -34,25 +30,36 @@ export class FlashEntityService {
src: MiFlash['id'] | MiFlash,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
- packedUser?: Packed<'UserLite'>
+ packedUser?: Packed<'UserLite'>,
+ likedFlashIds?: MiFlash['id'][],
},
): Promise> {
const meId = me ? me.id : null;
const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src });
- return await awaitAll({
+ // { schema: 'UserDetailed' } すると無限ループするので注意
+ const user = hint?.packedUser ?? await this.userEntityService.pack(flash.user ?? flash.userId, me);
+
+ let isLiked = false;
+ if (meId) {
+ isLiked = hint?.likedFlashIds
+ ? hint.likedFlashIds.includes(flash.id)
+ : await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } });
+ }
+
+ return {
id: flash.id,
createdAt: this.idService.parse(flash.id).date.toISOString(),
updatedAt: flash.updatedAt.toISOString(),
userId: flash.userId,
- user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意
+ user: user,
title: flash.title,
summary: flash.summary,
script: flash.script,
visibility: flash.visibility,
likedCount: flash.likedCount,
- isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined,
- });
+ isLiked: isLiked,
+ };
}
@bindThis
@@ -63,7 +70,19 @@ export class FlashEntityService {
const _users = flashes.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
- return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) })));
+ const _likedFlashIds = me
+ ? await this.flashLikesRepository.createQueryBuilder('flashLike')
+ .select('flashLike.flashId')
+ .where('flashLike.userId = :userId', { userId: me.id })
+ .getRawMany<{ flashLike_flashId: string }>()
+ .then(likes => [...new Set(likes.map(like => like.flashLike_flashId))])
+ : [];
+ return Promise.all(
+ flashes.map(flash => this.pack(flash, me, {
+ packedUser: _userMap.get(flash.userId),
+ likedFlashIds: _likedFlashIds,
+ })),
+ );
}
}
diff --git a/packages/backend/src/models/Flash.ts b/packages/backend/src/models/Flash.ts
index a1469a0d94..5db7dca992 100644
--- a/packages/backend/src/models/Flash.ts
+++ b/packages/backend/src/models/Flash.ts
@@ -7,6 +7,9 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typ
import { id } from './util/id.js';
import { MiUser } from './User.js';
+export const flashVisibility = ['public', 'private'] as const;
+export type FlashVisibility = typeof flashVisibility[number];
+
@Entity('flash')
export class MiFlash {
@PrimaryColumn(id())
@@ -63,5 +66,5 @@ export class MiFlash {
@Column('varchar', {
length: 512, default: 'public',
})
- public visibility: 'public' | 'private';
+ public visibility: FlashVisibility;
}
diff --git a/packages/backend/src/server/api/endpoints/flash/featured.ts b/packages/backend/src/server/api/endpoints/flash/featured.ts
index c2d6ab5085..9a0cb461f2 100644
--- a/packages/backend/src/server/api/endpoints/flash/featured.ts
+++ b/packages/backend/src/server/api/endpoints/flash/featured.ts
@@ -8,6 +8,7 @@ import type { FlashsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
import { DI } from '@/di-symbols.js';
+import { FlashService } from '@/core/FlashService.js';
export const meta = {
tags: ['flash'],
@@ -27,26 +28,25 @@ export const meta = {
export const paramDef = {
type: 'object',
- properties: {},
+ properties: {
+ offset: { type: 'integer', minimum: 0, default: 0 },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ },
required: [],
} as const;
@Injectable()
export default class extends Endpoint { // eslint-disable-line import/no-default-export
constructor(
- @Inject(DI.flashsRepository)
- private flashsRepository: FlashsRepository,
-
+ private flashService: FlashService,
private flashEntityService: FlashEntityService,
) {
super(meta, paramDef, async (ps, me) => {
- const query = this.flashsRepository.createQueryBuilder('flash')
- .andWhere('flash.likedCount > 0')
- .orderBy('flash.likedCount', 'DESC');
-
- const flashs = await query.limit(10).getMany();
-
- return await this.flashEntityService.packMany(flashs, me);
+ const result = await this.flashService.featured({
+ offset: ps.offset,
+ limit: ps.limit,
+ });
+ return await this.flashEntityService.packMany(result, me);
});
}
}
diff --git a/packages/backend/test/unit/FlashService.ts b/packages/backend/test/unit/FlashService.ts
new file mode 100644
index 0000000000..12ffaf3421
--- /dev/null
+++ b/packages/backend/test/unit/FlashService.ts
@@ -0,0 +1,152 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Test, TestingModule } from '@nestjs/testing';
+import { FlashService } from '@/core/FlashService.js';
+import { IdService } from '@/core/IdService.js';
+import { FlashsRepository, MiFlash, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { GlobalModule } from '@/GlobalModule.js';
+
+describe('FlashService', () => {
+ let app: TestingModule;
+ let service: FlashService;
+
+ // --------------------------------------------------------------------------------------
+
+ let flashsRepository: FlashsRepository;
+ let usersRepository: UsersRepository;
+ let userProfilesRepository: UserProfilesRepository;
+ let idService: IdService;
+
+ // --------------------------------------------------------------------------------------
+
+ let root: MiUser;
+ let alice: MiUser;
+ let bob: MiUser;
+
+ // --------------------------------------------------------------------------------------
+
+ async function createFlash(data: Partial) {
+ return flashsRepository.insert({
+ id: idService.gen(),
+ updatedAt: new Date(),
+ userId: root.id,
+ title: 'title',
+ summary: 'summary',
+ script: 'script',
+ permissions: [],
+ likedCount: 0,
+ ...data,
+ }).then(x => flashsRepository.findOneByOrFail(x.identifiers[0]));
+ }
+
+ async function createUser(data: Partial = {}) {
+ const user = await usersRepository
+ .insert({
+ id: idService.gen(),
+ ...data,
+ })
+ .then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
+
+ await userProfilesRepository.insert({
+ userId: user.id,
+ });
+
+ return user;
+ }
+
+ // --------------------------------------------------------------------------------------
+
+ beforeEach(async () => {
+ app = await Test.createTestingModule({
+ imports: [
+ GlobalModule,
+ ],
+ providers: [
+ FlashService,
+ IdService,
+ ],
+ }).compile();
+
+ service = app.get(FlashService);
+
+ flashsRepository = app.get(DI.flashsRepository);
+ usersRepository = app.get(DI.usersRepository);
+ userProfilesRepository = app.get(DI.userProfilesRepository);
+ idService = app.get(IdService);
+
+ root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true });
+ alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false });
+ bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false });
+ });
+
+ afterEach(async () => {
+ await usersRepository.delete({});
+ await userProfilesRepository.delete({});
+ await flashsRepository.delete({});
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ // --------------------------------------------------------------------------------------
+
+ describe('featured', () => {
+ test('should return featured flashes', async () => {
+ const flash1 = await createFlash({ likedCount: 1 });
+ const flash2 = await createFlash({ likedCount: 2 });
+ const flash3 = await createFlash({ likedCount: 3 });
+
+ const result = await service.featured({
+ offset: 0,
+ limit: 10,
+ });
+
+ expect(result).toEqual([flash3, flash2, flash1]);
+ });
+
+ test('should return featured flashes public visibility only', async () => {
+ const flash1 = await createFlash({ likedCount: 1, visibility: 'public' });
+ const flash2 = await createFlash({ likedCount: 2, visibility: 'public' });
+ const flash3 = await createFlash({ likedCount: 3, visibility: 'private' });
+
+ const result = await service.featured({
+ offset: 0,
+ limit: 10,
+ });
+
+ expect(result).toEqual([flash2, flash1]);
+ });
+
+ test('should return featured flashes with offset', async () => {
+ const flash1 = await createFlash({ likedCount: 1 });
+ const flash2 = await createFlash({ likedCount: 2 });
+ const flash3 = await createFlash({ likedCount: 3 });
+
+ const result = await service.featured({
+ offset: 1,
+ limit: 10,
+ });
+
+ expect(result).toEqual([flash2, flash1]);
+ });
+
+ test('should return featured flashes with limit', async () => {
+ const flash1 = await createFlash({ likedCount: 1 });
+ const flash2 = await createFlash({ likedCount: 2 });
+ const flash3 = await createFlash({ likedCount: 3 });
+
+ const result = await service.featured({
+ offset: 0,
+ limit: 2,
+ });
+
+ expect(result).toEqual([flash3, flash2]);
+ });
+ });
+});
diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue
index f63a799365..2b85489706 100644
--- a/packages/frontend/src/pages/flash/flash-index.vue
+++ b/packages/frontend/src/pages/flash/flash-index.vue
@@ -55,7 +55,8 @@ const tab = ref('featured');
const featuredFlashsPagination = {
endpoint: 'flash/featured' as const,
- noPaging: true,
+ limit: 5,
+ offsetMode: true,
};
const myFlashsPagination = {
endpoint: 'flash/my' as const,
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 732352abd8..de52be3a61 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1680,6 +1680,7 @@ declare namespace entities {
FlashCreateRequest,
FlashCreateResponse,
FlashDeleteRequest,
+ FlashFeaturedRequest,
FlashFeaturedResponse,
FlashLikeRequest,
FlashShowRequest,
@@ -1929,6 +1930,9 @@ type FlashCreateResponse = operations['flash___create']['responses']['200']['con
// @public (undocumented)
type FlashDeleteRequest = operations['flash___delete']['requestBody']['content']['application/json'];
+// @public (undocumented)
+type FlashFeaturedRequest = operations['flash___featured']['requestBody']['content']['application/json'];
+
// @public (undocumented)
type FlashFeaturedResponse = operations['flash___featured']['responses']['200']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 42c74599a5..bf61c20628 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -465,6 +465,7 @@ import type {
FlashCreateRequest,
FlashCreateResponse,
FlashDeleteRequest,
+ FlashFeaturedRequest,
FlashFeaturedResponse,
FlashLikeRequest,
FlashShowRequest,
@@ -889,7 +890,7 @@ export type Endpoints = {
'pages/update': { req: PagesUpdateRequest; res: EmptyResponse };
'flash/create': { req: FlashCreateRequest; res: FlashCreateResponse };
'flash/delete': { req: FlashDeleteRequest; res: EmptyResponse };
- 'flash/featured': { req: EmptyRequest; res: FlashFeaturedResponse };
+ 'flash/featured': { req: FlashFeaturedRequest; res: FlashFeaturedResponse };
'flash/like': { req: FlashLikeRequest; res: EmptyResponse };
'flash/show': { req: FlashShowRequest; res: FlashShowResponse };
'flash/unlike': { req: FlashUnlikeRequest; res: EmptyResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index 87ed653d44..72c7c35ed4 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -468,6 +468,7 @@ export type PagesUpdateRequest = operations['pages___update']['requestBody']['co
export type FlashCreateRequest = operations['flash___create']['requestBody']['content']['application/json'];
export type FlashCreateResponse = operations['flash___create']['responses']['200']['content']['application/json'];
export type FlashDeleteRequest = operations['flash___delete']['requestBody']['content']['application/json'];
+export type FlashFeaturedRequest = operations['flash___featured']['requestBody']['content']['application/json'];
export type FlashFeaturedResponse = operations['flash___featured']['responses']['200']['content']['application/json'];
export type FlashLikeRequest = operations['flash___like']['requestBody']['content']['application/json'];
export type FlashShowRequest = operations['flash___show']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 3876a0bfe5..0938973481 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -23799,6 +23799,16 @@ export type operations = {
* **Credential required**: *No*
*/
flash___featured: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** @default 0 */
+ offset?: number;
+ /** @default 10 */
+ limit?: number;
+ };
+ };
+ };
responses: {
/** @description OK (with results) */
200: {
--
cgit v1.2.3-freya
From d8cb7305ef4d5ad6398d9eb57ece2f3ba7ca73eb Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sat, 5 Oct 2024 16:20:15 +0900
Subject: feat: 通報の強化 (#14704)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* wip
* Update CHANGELOG.md
* lint
* Update types.ts
* wip
* :v:
* Update MkAbuseReport.vue
* tweak
---
CHANGELOG.md | 3 +
locales/index.d.ts | 55 +++++++--
locales/ja-JP.yml | 15 ++-
.../1728085812127-refine-abuse-user-report.js | 18 +++
packages/backend/src/core/AbuseReportService.ts | 80 ++++++++++---
packages/backend/src/core/WebhookTestService.ts | 2 +
.../core/entities/AbuseUserReportEntityService.ts | 2 +
packages/backend/src/models/AbuseUserReport.ts | 18 +++
packages/backend/src/server/api/EndpointsModule.ts | 8 ++
packages/backend/src/server/api/endpoints.ts | 4 +
.../endpoints/admin/forward-abuse-user-report.ts | 55 +++++++++
.../endpoints/admin/resolve-abuse-user-report.ts | 4 +-
.../endpoints/admin/update-abuse-user-report.ts | 58 ++++++++++
packages/backend/src/types.ts | 15 ++-
packages/backend/test/e2e/synalio/abuse-report.ts | 6 -
packages/frontend/src/components/MkAbuseReport.vue | 74 +++++++++---
packages/frontend/src/pages/admin-user.vue | 3 +-
packages/frontend/src/pages/admin/abuses.vue | 11 +-
.../frontend/src/pages/admin/modlog.ModLog.vue | 5 +
packages/frontend/src/pages/instance-info.vue | 1 +
packages/frontend/src/pages/user/home.vue | 3 +-
packages/frontend/src/store.ts | 4 +
packages/misskey-js/etc/misskey-js.api.md | 16 ++-
packages/misskey-js/src/autogen/apiClientJSDoc.ts | 22 ++++
packages/misskey-js/src/autogen/endpoint.ts | 4 +
packages/misskey-js/src/autogen/entities.ts | 2 +
packages/misskey-js/src/autogen/types.ts | 127 ++++++++++++++++++++-
packages/misskey-js/src/consts.ts | 15 ++-
packages/misskey-js/src/entities.ts | 6 +
29 files changed, 574 insertions(+), 62 deletions(-)
create mode 100644 packages/backend/migration/1728085812127-refine-abuse-user-report.js
create mode 100644 packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts
create mode 100644 packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts
(limited to 'packages/backend/src/server/api')
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a9143ea1b..3fd1b7f899 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,9 @@
### General
- Feat: サーバー初期設定時に初期パスワードを設定できるように
+- Feat: 通報にモデレーションノートを残せるように
+- Feat: 通報の解決種別を設定できるように
+- Enhance: 通報の解決と転送を個別に行えるように
- Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました
- Enhance: 依存関係の更新
- Enhance: l10nの更新
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 1a0547ebc6..d502c5b432 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1834,6 +1834,10 @@ export interface Locale extends ILocale {
* モデレーションノート
*/
"moderationNote": string;
+ /**
+ * モデレーター間でだけ共有されるメモを記入することができます。
+ */
+ "moderationNoteDescription": string;
/**
* モデレーションノートを追加する
*/
@@ -2894,22 +2898,10 @@ export interface Locale extends ILocale {
* 通報元
*/
"reporterOrigin": string;
- /**
- * リモートサーバーに通報を転送する
- */
- "forwardReport": string;
- /**
- * リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。
- */
- "forwardReportIsAnonymous": string;
/**
* 送信
*/
"send": string;
- /**
- * 対応済みにする
- */
- "abuseMarkAsResolved": string;
/**
* 新しいタブで開く
*/
@@ -5170,6 +5162,37 @@ export interface Locale extends ILocale {
* フォロワーへのメッセージ
*/
"messageToFollower": string;
+ /**
+ * 対象
+ */
+ "target": string;
+ "_abuseUserReport": {
+ /**
+ * 転送
+ */
+ "forward": string;
+ /**
+ * 匿名のシステムアカウントとして、リモートサーバーに通報を転送します。
+ */
+ "forwardDescription": string;
+ /**
+ * 解決
+ */
+ "resolve": string;
+ /**
+ * 是認
+ */
+ "accept": string;
+ /**
+ * 否認
+ */
+ "reject": string;
+ /**
+ * 内容が正当である通報に対応した場合は「是認」を選択し、肯定的にケースが解決されたことをマークします。
+ * 内容が正当でない通報の場合は「否認」を選択し、否定的にケースが解決されたことをマークします。
+ */
+ "resolveTutorial": string;
+ };
"_delivery": {
/**
* 配信状態
@@ -9785,6 +9808,14 @@ export interface Locale extends ILocale {
* 通報を解決
*/
"resolveAbuseReport": string;
+ /**
+ * 通報を転送
+ */
+ "forwardAbuseReport": string;
+ /**
+ * 通報のモデレーションノート更新
+ */
+ "updateAbuseReportNote": string;
/**
* 招待コードを作成
*/
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 92014c8abc..678bc7e66b 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -454,6 +454,7 @@ totpDescription: "認証アプリを使ってワンタイムパスワードを
moderator: "モデレーター"
moderation: "モデレーション"
moderationNote: "モデレーションノート"
+moderationNoteDescription: "モデレーター間でだけ共有されるメモを記入することができます。"
addModerationNote: "モデレーションノートを追加する"
moderationLogs: "モデログ"
nUsersMentioned: "{n}人が投稿"
@@ -719,10 +720,7 @@ abuseReported: "内容が送信されました。ご報告ありがとうござ
reporter: "通報者"
reporteeOrigin: "通報先"
reporterOrigin: "通報元"
-forwardReport: "リモートサーバーに通報を転送する"
-forwardReportIsAnonymous: "リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。"
send: "送信"
-abuseMarkAsResolved: "対応済みにする"
openInNewTab: "新しいタブで開く"
openInSideView: "サイドビューで開く"
defaultNavigationBehaviour: "デフォルトのナビゲーション"
@@ -1288,6 +1286,15 @@ unknownWebAuthnKey: "登録されていないパスキーです。"
passkeyVerificationFailed: "パスキーの検証に失敗しました。"
passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。"
messageToFollower: "フォロワーへのメッセージ"
+target: "対象"
+
+_abuseUserReport:
+ forward: "転送"
+ forwardDescription: "匿名のシステムアカウントとして、リモートサーバーに通報を転送します。"
+ resolve: "解決"
+ accept: "是認"
+ reject: "否認"
+ resolveTutorial: "内容が正当である通報に対応した場合は「是認」を選択し、肯定的にケースが解決されたことをマークします。\n内容が正当でない通報の場合は「否認」を選択し、否定的にケースが解決されたことをマークします。"
_delivery:
status: "配信状態"
@@ -2593,6 +2600,8 @@ _moderationLogTypes:
markSensitiveDriveFile: "ファイルをセンシティブ付与"
unmarkSensitiveDriveFile: "ファイルをセンシティブ解除"
resolveAbuseReport: "通報を解決"
+ forwardAbuseReport: "通報を転送"
+ updateAbuseReportNote: "通報のモデレーションノート更新"
createInvitation: "招待コードを作成"
createAd: "広告を作成"
deleteAd: "広告を削除"
diff --git a/packages/backend/migration/1728085812127-refine-abuse-user-report.js b/packages/backend/migration/1728085812127-refine-abuse-user-report.js
new file mode 100644
index 0000000000..57cbfdcf6d
--- /dev/null
+++ b/packages/backend/migration/1728085812127-refine-abuse-user-report.js
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class RefineAbuseUserReport1728085812127 {
+ name = 'RefineAbuseUserReport1728085812127'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "moderationNote" character varying(8192) NOT NULL DEFAULT ''`);
+ await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "resolvedAs" character varying(128)`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "resolvedAs"`);
+ await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "moderationNote"`);
+ }
+}
diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts
index 69c51509ba..cddfe5eb81 100644
--- a/packages/backend/src/core/AbuseReportService.ts
+++ b/packages/backend/src/core/AbuseReportService.ts
@@ -20,8 +20,10 @@ export class AbuseReportService {
constructor(
@Inject(DI.abuseUserReportsRepository)
private abuseUserReportsRepository: AbuseUserReportsRepository,
+
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
+
private idService: IdService,
private abuseReportNotificationService: AbuseReportNotificationService,
private queueService: QueueService,
@@ -77,16 +79,16 @@ export class AbuseReportService {
* - SystemWebhook
*
* @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える
- * @param operator 通報を処理したユーザ
+ * @param moderator 通報を処理したユーザ
* @see AbuseReportNotificationService.notify
*/
@bindThis
public async resolve(
params: {
reportId: string;
- forward: boolean;
+ resolvedAs: MiAbuseUserReport['resolvedAs'];
}[],
- operator: MiUser,
+ moderator: MiUser,
) {
const paramsMap = new Map(params.map(it => [it.reportId, it]));
const reports = await this.abuseUserReportsRepository.findBy({
@@ -99,25 +101,15 @@ export class AbuseReportService {
await this.abuseUserReportsRepository.update(report.id, {
resolved: true,
- assigneeId: operator.id,
- forwarded: ps.forward && report.targetUserHost !== null,
+ assigneeId: moderator.id,
+ resolvedAs: ps.resolvedAs,
});
- if (ps.forward && report.targetUserHost != null) {
- const actor = await this.instanceActorService.getInstanceActor();
- const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId });
-
- // eslint-disable-next-line
- const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment);
- const contextAssignedFlag = this.apRendererService.addContext(flag);
- this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false);
- }
-
this.moderationLogService
- .log(operator, 'resolveAbuseReport', {
+ .log(moderator, 'resolveAbuseReport', {
reportId: report.id,
report: report,
- forwarded: ps.forward && report.targetUserHost !== null,
+ resolvedAs: ps.resolvedAs,
})
.then();
}
@@ -125,4 +117,58 @@ export class AbuseReportService {
return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) })
.then(reports => this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReportResolved'));
}
+
+ @bindThis
+ public async forward(
+ reportId: MiAbuseUserReport['id'],
+ moderator: MiUser,
+ ) {
+ const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
+
+ if (report.targetUserHost == null) {
+ throw new Error('The target user host is null.');
+ }
+
+ await this.abuseUserReportsRepository.update(report.id, {
+ forwarded: true,
+ });
+
+ const actor = await this.instanceActorService.getInstanceActor();
+ const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId });
+
+ const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment);
+ const contextAssignedFlag = this.apRendererService.addContext(flag);
+ this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false);
+
+ this.moderationLogService
+ .log(moderator, 'forwardAbuseReport', {
+ reportId: report.id,
+ report: report,
+ })
+ .then();
+ }
+
+ @bindThis
+ public async update(
+ reportId: MiAbuseUserReport['id'],
+ params: {
+ moderationNote?: MiAbuseUserReport['moderationNote'];
+ },
+ moderator: MiUser,
+ ) {
+ const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId });
+
+ await this.abuseUserReportsRepository.update(report.id, {
+ moderationNote: params.moderationNote,
+ });
+
+ if (params.moderationNote != null && report.moderationNote !== params.moderationNote) {
+ this.moderationLogService.log(moderator, 'updateAbuseReportNote', {
+ reportId: report.id,
+ report: report,
+ before: report.moderationNote,
+ after: params.moderationNote,
+ });
+ }
+ }
}
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index 149c753d4c..4c45b95a64 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -35,6 +35,8 @@ function generateAbuseReport(override?: Partial): AbuseUserRe
comment: 'This is a dummy report for testing purposes.',
targetUserHost: null,
reporterHost: null,
+ resolvedAs: null,
+ moderationNote: 'foo',
...override,
};
diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts
index a13c244c19..70ead890ab 100644
--- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts
+++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts
@@ -53,6 +53,8 @@ export class AbuseUserReportEntityService {
schema: 'UserDetailedNotMe',
}) : null,
forwarded: report.forwarded,
+ resolvedAs: report.resolvedAs,
+ moderationNote: report.moderationNote,
});
}
diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts
index 0615fd7eb5..cb5672e4ac 100644
--- a/packages/backend/src/models/AbuseUserReport.ts
+++ b/packages/backend/src/models/AbuseUserReport.ts
@@ -50,6 +50,9 @@ export class MiAbuseUserReport {
})
public resolved: boolean;
+ /**
+ * リモートサーバーに転送したかどうか
+ */
@Column('boolean', {
default: false,
})
@@ -60,6 +63,21 @@ export class MiAbuseUserReport {
})
public comment: string;
+ @Column('varchar', {
+ length: 8192, default: '',
+ })
+ public moderationNote: string;
+
+ /**
+ * accept 是認 ... 通報内容が正当であり、肯定的に対応された
+ * reject 否認 ... 通報内容が正当でなく、否定的に対応された
+ * null ... その他
+ */
+ @Column('varchar', {
+ length: 128, nullable: true,
+ })
+ public resolvedAs: 'accept' | 'reject' | null;
+
//#region Denormalized fields
@Index()
@Column('varchar', {
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 08a0468ab2..3557fa40a5 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -68,6 +68,8 @@ import * as ep___admin_relays_list from './endpoints/admin/relays/list.js';
import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js';
import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js';
import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js';
+import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js';
+import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js';
import * as ep___admin_sendEmail from './endpoints/admin/send-email.js';
import * as ep___admin_serverInfo from './endpoints/admin/server-info.js';
import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js';
@@ -453,6 +455,8 @@ const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass
const $admin_relays_remove: Provider = { provide: 'ep:admin/relays/remove', useClass: ep___admin_relays_remove.default };
const $admin_resetPassword: Provider = { provide: 'ep:admin/reset-password', useClass: ep___admin_resetPassword.default };
const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abuse-user-report', useClass: ep___admin_resolveAbuseUserReport.default };
+const $admin_forwardAbuseUserReport: Provider = { provide: 'ep:admin/forward-abuse-user-report', useClass: ep___admin_forwardAbuseUserReport.default };
+const $admin_updateAbuseUserReport: Provider = { provide: 'ep:admin/update-abuse-user-report', useClass: ep___admin_updateAbuseUserReport.default };
const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default };
const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default };
const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default };
@@ -842,6 +846,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_relays_remove,
$admin_resetPassword,
$admin_resolveAbuseUserReport,
+ $admin_forwardAbuseUserReport,
+ $admin_updateAbuseUserReport,
$admin_sendEmail,
$admin_serverInfo,
$admin_showModerationLogs,
@@ -1225,6 +1231,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_relays_remove,
$admin_resetPassword,
$admin_resolveAbuseUserReport,
+ $admin_forwardAbuseUserReport,
+ $admin_updateAbuseUserReport,
$admin_sendEmail,
$admin_serverInfo,
$admin_showModerationLogs,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 2462781f7b..49b07d6ced 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -74,6 +74,8 @@ import * as ep___admin_relays_list from './endpoints/admin/relays/list.js';
import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js';
import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js';
import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js';
+import * as ep___admin_forwardAbuseUserReport from './endpoints/admin/forward-abuse-user-report.js';
+import * as ep___admin_updateAbuseUserReport from './endpoints/admin/update-abuse-user-report.js';
import * as ep___admin_sendEmail from './endpoints/admin/send-email.js';
import * as ep___admin_serverInfo from './endpoints/admin/server-info.js';
import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js';
@@ -457,6 +459,8 @@ const eps = [
['admin/relays/remove', ep___admin_relays_remove],
['admin/reset-password', ep___admin_resetPassword],
['admin/resolve-abuse-user-report', ep___admin_resolveAbuseUserReport],
+ ['admin/forward-abuse-user-report', ep___admin_forwardAbuseUserReport],
+ ['admin/update-abuse-user-report', ep___admin_updateAbuseUserReport],
['admin/send-email', ep___admin_sendEmail],
['admin/server-info', ep___admin_serverInfo],
['admin/show-moderation-logs', ep___admin_showModerationLogs],
diff --git a/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts
new file mode 100644
index 0000000000..3e42c91fed
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { AbuseUserReportsRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { AbuseReportService } from '@/core/AbuseReportService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'write:admin:resolve-abuse-user-report',
+
+ errors: {
+ noSuchAbuseReport: {
+ message: 'No such abuse report.',
+ code: 'NO_SUCH_ABUSE_REPORT',
+ id: '8763e21b-d9bc-40be-acf6-54c1a6986493',
+ kind: 'server',
+ httpStatusCode: 404,
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ reportId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['reportId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.abuseUserReportsRepository)
+ private abuseUserReportsRepository: AbuseUserReportsRepository,
+ private abuseReportService: AbuseReportService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId });
+ if (!report) {
+ throw new ApiError(meta.errors.noSuchAbuseReport);
+ }
+
+ await this.abuseReportService.forward(report.id, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts
index 9b79100fcf..554d324ff2 100644
--- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts
+++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts
@@ -32,7 +32,7 @@ export const paramDef = {
type: 'object',
properties: {
reportId: { type: 'string', format: 'misskey:id' },
- forward: { type: 'boolean', default: false },
+ resolvedAs: { type: 'string', enum: ['accept', 'reject', null], nullable: true },
},
required: ['reportId'],
} as const;
@@ -50,7 +50,7 @@ export default class extends Endpoint { // eslint-
throw new ApiError(meta.errors.noSuchAbuseReport);
}
- await this.abuseReportService.resolve([{ reportId: report.id, forward: ps.forward }], me);
+ await this.abuseReportService.resolve([{ reportId: report.id, resolvedAs: ps.resolvedAs ?? null }], me);
});
}
}
diff --git a/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts
new file mode 100644
index 0000000000..73d4b843f0
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts
@@ -0,0 +1,58 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { AbuseUserReportsRepository } from '@/models/_.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '@/server/api/error.js';
+import { AbuseReportService } from '@/core/AbuseReportService.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+ kind: 'write:admin:resolve-abuse-user-report',
+
+ errors: {
+ noSuchAbuseReport: {
+ message: 'No such abuse report.',
+ code: 'NO_SUCH_ABUSE_REPORT',
+ id: '15f51cf5-46d1-4b1d-a618-b35bcbed0662',
+ kind: 'server',
+ httpStatusCode: 404,
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ reportId: { type: 'string', format: 'misskey:id' },
+ moderationNote: { type: 'string' },
+ },
+ required: ['reportId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.abuseUserReportsRepository)
+ private abuseUserReportsRepository: AbuseUserReportsRepository,
+ private abuseReportService: AbuseReportService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId });
+ if (!report) {
+ throw new ApiError(meta.errors.noSuchAbuseReport);
+ }
+
+ await this.abuseReportService.update(report.id, {
+ moderationNote: ps.moderationNote,
+ }, me);
+ });
+ }
+}
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 0389143daf..df3cfee171 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -99,6 +99,8 @@ export const moderationLogTypes = [
'markSensitiveDriveFile',
'unmarkSensitiveDriveFile',
'resolveAbuseReport',
+ 'forwardAbuseReport',
+ 'updateAbuseReportNote',
'createInvitation',
'createAd',
'updateAd',
@@ -267,7 +269,18 @@ export type ModerationLogPayloads = {
resolveAbuseReport: {
reportId: string;
report: any;
- forwarded: boolean;
+ forwarded?: boolean;
+ resolvedAs?: string | null;
+ };
+ forwardAbuseReport: {
+ reportId: string;
+ report: any;
+ };
+ updateAbuseReportNote: {
+ reportId: string;
+ report: any;
+ before: string;
+ after: string;
};
createInvitation: {
invitations: any[];
diff --git a/packages/backend/test/e2e/synalio/abuse-report.ts b/packages/backend/test/e2e/synalio/abuse-report.ts
index 6ce6e47781..c98d199f35 100644
--- a/packages/backend/test/e2e/synalio/abuse-report.ts
+++ b/packages/backend/test/e2e/synalio/abuse-report.ts
@@ -157,7 +157,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: webhookBody1.body.id,
- forward: false,
}, admin);
});
@@ -214,7 +213,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: abuseReportId,
- forward: false,
}, admin);
});
@@ -257,7 +255,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: webhookBody1.body.id,
- forward: false,
}, admin);
}).catch(e => e.message);
@@ -288,7 +285,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: abuseReportId,
- forward: false,
}, admin);
}).catch(e => e.message);
@@ -319,7 +315,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: abuseReportId,
- forward: false,
}, admin);
}).catch(e => e.message);
@@ -350,7 +345,6 @@ describe('[シナリオ] ユーザ通報', () => {
const webhookBody2 = await captureWebhook(async () => {
await resolveAbuseReport({
reportId: abuseReportId,
- forward: false,
}, admin);
}).catch(e => e.message);
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue
index c9c629046e..2f0e09fc4b 100644
--- a/packages/frontend/src/components/MkAbuseReport.vue
+++ b/packages/frontend/src/components/MkAbuseReport.vue
@@ -6,26 +6,33 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
+
+
(by )
{{ report.comment }}
-
+
- Target:
+ {{ i18n.ts.target }}:
#{{ report.targetUserId.toUpperCase() }}
@@ -36,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.details }}
-
+
@@ -51,6 +58,17 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+
+ {{ i18n.ts.moderationNote }}
+ {{ moderationNote.length > 0 ? '...' : i18n.ts.none }}
+
+
+ {{ i18n.ts.moderationNoteDescription }}
+
+
+
+
{{ i18n.ts.moderator }}:
@@ -60,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/pages/avatar-decorations.vue b/packages/frontend/src/pages/avatar-decorations.vue
index b97e7c0eea..a5cafb1678 100644
--- a/packages/frontend/src/pages/avatar-decorations.vue
+++ b/packages/frontend/src/pages/avatar-decorations.vue
@@ -5,92 +5,38 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
-
- {{ avatarDecoration.name }}
- {{ avatarDecoration.description }}
-
-
-
-
-
-
- {{ i18n.ts.name }}
-
-
- {{ i18n.ts.description }}
-
-
- {{ i18n.ts.imageUrl }}
-
-
- {{ i18n.ts.save }}
- {{ i18n.ts.delete }}
-
-
-
+
+
+
{{ avatarDecoration.name }}
+
-
+
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue
index f72a0b9383..3c9914b4e2 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.decoration.vue
@@ -10,12 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only
>
{{ decoration.name }}
-
+
diff --git a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
index 853e536ea3..aa899ac649 100644
--- a/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
+++ b/packages/frontend/src/pages/settings/avatar-decoration.dialog.vue
@@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.update }}
{{ i18n.ts.detach }}
- {{ i18n.ts.attach }}
+ {{ i18n.ts.attach }}
@@ -61,6 +61,7 @@ const props = defineProps<{
id: string;
url: string;
name: string;
+ roleIdsThatCanBeUsedThisDecoration: string[];
};
}>();
@@ -83,6 +84,7 @@ const emit = defineEmits<{
const dialog = shallowRef
>();
const exceeded = computed(() => ($i.policies.avatarDecorationLimit - $i.avatarDecorations.length) <= 0);
+const locked = computed(() => props.decoration.roleIdsThatCanBeUsedThisDecoration.length > 0 && !$i.roles.some(r => props.decoration.roleIdsThatCanBeUsedThisDecoration.includes(r.id)));
const angle = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].angle : null) ?? 0);
const flipH = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].flipH : null) ?? false);
const offsetX = ref((props.usingIndex != null ? $i.avatarDecorations[props.usingIndex].offsetX : null) ?? 0);
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 61de8b8c7e..061b533b72 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -121,6 +121,9 @@ type AdminAnnouncementsUpdateRequest = operations['admin___announcements___updat
// @public (undocumented)
type AdminAvatarDecorationsCreateRequest = operations['admin___avatar-decorations___create']['requestBody']['content']['application/json'];
+// @public (undocumented)
+type AdminAvatarDecorationsCreateResponse = operations['admin___avatar-decorations___create']['responses']['200']['content']['application/json'];
+
// @public (undocumented)
type AdminAvatarDecorationsDeleteRequest = operations['admin___avatar-decorations___delete']['requestBody']['content']['application/json'];
@@ -1253,6 +1256,7 @@ declare namespace entities {
AdminAnnouncementsListResponse,
AdminAnnouncementsUpdateRequest,
AdminAvatarDecorationsCreateRequest,
+ AdminAvatarDecorationsCreateResponse,
AdminAvatarDecorationsDeleteRequest,
AdminAvatarDecorationsListRequest,
AdminAvatarDecorationsListResponse,
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index d0367d8496..5e6bc0a99c 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -31,6 +31,7 @@ import type {
AdminAnnouncementsListResponse,
AdminAnnouncementsUpdateRequest,
AdminAvatarDecorationsCreateRequest,
+ AdminAvatarDecorationsCreateResponse,
AdminAvatarDecorationsDeleteRequest,
AdminAvatarDecorationsListRequest,
AdminAvatarDecorationsListResponse,
@@ -597,7 +598,7 @@ export type Endpoints = {
'admin/announcements/delete': { req: AdminAnnouncementsDeleteRequest; res: EmptyResponse };
'admin/announcements/list': { req: AdminAnnouncementsListRequest; res: AdminAnnouncementsListResponse };
'admin/announcements/update': { req: AdminAnnouncementsUpdateRequest; res: EmptyResponse };
- 'admin/avatar-decorations/create': { req: AdminAvatarDecorationsCreateRequest; res: EmptyResponse };
+ 'admin/avatar-decorations/create': { req: AdminAvatarDecorationsCreateRequest; res: AdminAvatarDecorationsCreateResponse };
'admin/avatar-decorations/delete': { req: AdminAvatarDecorationsDeleteRequest; res: EmptyResponse };
'admin/avatar-decorations/list': { req: AdminAvatarDecorationsListRequest; res: AdminAvatarDecorationsListResponse };
'admin/avatar-decorations/update': { req: AdminAvatarDecorationsUpdateRequest; res: EmptyResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index ced87c4c7e..f3ddf64481 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -34,6 +34,7 @@ export type AdminAnnouncementsListRequest = operations['admin___announcements___
export type AdminAnnouncementsListResponse = operations['admin___announcements___list']['responses']['200']['content']['application/json'];
export type AdminAnnouncementsUpdateRequest = operations['admin___announcements___update']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsCreateRequest = operations['admin___avatar-decorations___create']['requestBody']['content']['application/json'];
+export type AdminAvatarDecorationsCreateResponse = operations['admin___avatar-decorations___create']['responses']['200']['content']['application/json'];
export type AdminAvatarDecorationsDeleteRequest = operations['admin___avatar-decorations___delete']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsListRequest = operations['admin___avatar-decorations___list']['requestBody']['content']['application/json'];
export type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations___list']['responses']['200']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 560960f018..a5333d4f93 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -6324,9 +6324,22 @@ export type operations = {
};
};
responses: {
- /** @description OK (without any results) */
- 204: {
- content: never;
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': {
+ /** Format: id */
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ /** Format: date-time */
+ updatedAt: string | null;
+ name: string;
+ description: string;
+ url: string;
+ roleIdsThatCanBeUsedThisDecoration: string[];
+ };
+ };
};
/** @description Client error */
400: {
--
cgit v1.2.3-freya
From 8477909af208b7e0f3ce9357350be6b0a0fc783d Mon Sep 17 00:00:00 2001
From: Kio!
Date: Sun, 3 Nov 2024 19:50:25 +0000
Subject: Update report-abuse.ts
---
packages/backend/src/server/api/endpoints/users/report-abuse.ts | 4 ----
1 file changed, 4 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts
index 5ff6de37d2..38ded8ee1e 100644
--- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts
+++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts
@@ -66,10 +66,6 @@ export default class extends Endpoint { // eslint-
throw new ApiError(meta.errors.cannotReportYourself);
}
- if (await this.roleService.isAdministrator(targetUser)) {
- throw new ApiError(meta.errors.cannotReportAdmin);
- }
-
await this.abuseReportService.report([{
targetUserId: targetUser.id,
targetUserHost: targetUser.host,
--
cgit v1.2.3-freya
From b1c82213a320dd7c83f8b2e742406646ef18ff1c Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Wed, 6 Nov 2024 22:01:21 +0900
Subject: fix(backend):
FTT無効時にユーザーリストタイムラインが使用できない問題を修正 (#14878)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix: return getfromdb when FanoutTimeline is not enabled
* Update Changelog
* fix
---------
Co-authored-by: Lhc_fl
---
CHANGELOG.md | 2 ++
packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
(limited to 'packages/backend/src/server/api')
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0309f338f1..1740d0171e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -35,6 +35,8 @@
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/706)
- Fix: 連合への配信時に、acctの大小文字が区別されてしまい正しくメンションが処理されないことがある問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/711)
+- Fix: FTT無効時にユーザーリストタイムラインが使用できない問題を修正
+ (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/709)
### Misskey.js
- Fix: Stream初期化時、別途WebSocketを指定する場合の型定義を修正
diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
index 6c7185c9eb..87f9b322a6 100644
--- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
@@ -112,7 +112,7 @@ export default class extends Endpoint { // eslint-
this.activeUsersChart.read(me);
- await this.noteEntityService.packMany(timeline, me);
+ return await this.noteEntityService.packMany(timeline, me);
}
const timeline = await this.fanoutTimelineEndpointService.timeline({
--
cgit v1.2.3-freya
From bca690f256721815fb1c918c1f66a2172f4fcf40 Mon Sep 17 00:00:00 2001
From: 4ster1sk <146138447+4ster1sk@users.noreply.github.com>
Date: Thu, 7 Nov 2024 15:10:10 +0900
Subject: fix(backend): フォロワーへのメッセージの絵文字をemojisに含めるように
(#14904)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/backend/src/server/api/endpoints/i/update.ts | 6 ++++++
1 file changed, 6 insertions(+)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 2183beac7c..d91e2fef4b 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -465,6 +465,7 @@ export default class extends Endpoint { // eslint-
const newName = updates.name === undefined ? user.name : updates.name;
const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description;
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
+ const newFollowedMessage = profileUpdates.description === undefined ? profile.followedMessage : profileUpdates.followedMessage;
if (newName != null) {
let hasProhibitedWords = false;
@@ -494,6 +495,11 @@ export default class extends Endpoint { // eslint-
]);
}
+ if (newFollowedMessage != null) {
+ const tokens = mfm.parse(newFollowedMessage);
+ emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
+ }
+
updates.emojis = emojis;
updates.tags = tags;
--
cgit v1.2.3-freya
From 794cb9ffe205b1e2ca838978f80d2d6a35f17f77 Mon Sep 17 00:00:00 2001
From: 4ster1sk <146138447+4ster1sk@users.noreply.github.com>
Date: Thu, 7 Nov 2024 17:16:51 +0900
Subject: fix(backend): followedMessageではなくdescriptionになっていたのを修正
(#14908)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/backend/src/server/api/endpoints/i/update.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index d91e2fef4b..d3eeb75b27 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -465,7 +465,7 @@ export default class extends Endpoint { // eslint-
const newName = updates.name === undefined ? user.name : updates.name;
const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description;
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
- const newFollowedMessage = profileUpdates.description === undefined ? profile.followedMessage : profileUpdates.followedMessage;
+ const newFollowedMessage = profileUpdates.followedMessage === undefined ? profile.followedMessage : profileUpdates.followedMessage;
if (newName != null) {
let hasProhibitedWords = false;
--
cgit v1.2.3-freya
From ec875d9c40d517a788a450f3b68bd589adcde5ba Mon Sep 17 00:00:00 2001
From: dakkar
Date: Fri, 8 Nov 2024 16:09:02 +0000
Subject: fix merge mistakes in `admin/accounts/create.ts`
---
packages/backend/src/server/api/endpoints/admin/accounts/create.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
index d5d2e909a2..53b1c4c4ec 100644
--- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
+++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts
@@ -3,8 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import { Injectable } from '@nestjs/common';
+import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { UsersRepository } from '@/models/_.js';
import { MiAccessToken, MiUser } from '@/models/_.js';
import { SignupService } from '@/core/SignupService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
@@ -15,7 +16,6 @@ import type { Config } from '@/config.js';
import { ApiError } from '@/server/api/error.js';
import { Packed } from '@/misc/json-schema.js';
import { RoleService } from '@/core/RoleService.js';
-import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['admin'],
--
cgit v1.2.3-freya
From 0a15ffba55054bc4446339948198049ce7a54974 Mon Sep 17 00:00:00 2001
From: dakkar
Date: Fri, 8 Nov 2024 17:53:34 +0000
Subject: remove duplicate import
---
packages/backend/src/server/api/SigninApiService.ts | 1 -
1 file changed, 1 deletion(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index 1e7a2e80ef..2945384e14 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -30,7 +30,6 @@ 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';
-import type { MiMeta } from '@/models/_.js';
@Injectable()
export class SigninApiService {
--
cgit v1.2.3-freya
From 03559156b923ce5e337c998868f4fe12acfb7f14 Mon Sep 17 00:00:00 2001
From: Caramel
Date: Sat, 9 Nov 2024 00:32:03 +0100
Subject: Improve performance of notes/following API
---
.../src/server/api/endpoints/notes/following.ts | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/notes/following.ts b/packages/backend/src/server/api/endpoints/notes/following.ts
index b6604b9798..f8e9e5c4a1 100644
--- a/packages/backend/src/server/api/endpoints/notes/following.ts
+++ b/packages/backend/src/server/api/endpoints/notes/following.ts
@@ -103,6 +103,15 @@ export default class extends Endpoint { // eslint-
sub.andWhere('latest.is_quote = false');
}
+ // Select the appropriate collection of users
+ if (ps.list === 'followers') {
+ addFollower(sub);
+ } else if (ps.list === 'following') {
+ addFollowee(sub);
+ } else {
+ addMutual(sub);
+ }
+
return sub;
},
'latest',
@@ -118,15 +127,6 @@ export default class extends Endpoint { // eslint-
.leftJoinAndSelect('note.channel', 'channel')
;
- // Select the appropriate collection of users
- if (ps.list === 'followers') {
- addFollower(query);
- } else if (ps.list === 'following') {
- addFollowee(query);
- } else {
- addMutual(query);
- }
-
// Limit to files, if requested
if (ps.filesOnly) {
query.andWhere('note."fileIds" != \'{}\'');
--
cgit v1.2.3-freya
From c0d168260482caa974e3fc9e084b121fc32e8ec4 Mon Sep 17 00:00:00 2001
From: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
Date: Fri, 15 Nov 2024 17:30:54 +0900
Subject: feat: 送信したフォローリクエストを確認できるように (#14856)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* FEAT: Allow users to view pending follow requests they sent
This commit implements the `following/requests/sent` interface firstly
implemented on Firefish, and provides a UI interface to view the pending
follow requests users sent.
* ux: should not show follow requests tab when have no pending sent follow req
* fix default followreq tab
* fix default followreq tab
* restore missing hasPendingReceivedFollowRequest in navbar
* refactor
* use tabler icons
* tweak design
* Revert "ux: should not show follow requests tab when have no pending sent follow req"
This reverts commit e580b92c37f27c2849c6d27e22ca4c47086081bb.
* Update Changelog
* Update Changelog
* change tab titles
---------
Co-authored-by: Lhc_fl
Co-authored-by: Hazelnoot
---
CHANGELOG.md | 2 +
locales/index.d.ts | 10 ++
locales/ja-JP.yml | 4 +
packages/backend/src/server/api/EndpointsModule.ts | 3 +
packages/backend/src/server/api/endpoints.ts | 2 +
.../api/endpoints/following/requests/sent.ts | 77 +++++++++++++++
.../frontend/src/components/MkFollowButton.vue | 10 +-
packages/frontend/src/navbar.ts | 1 -
packages/frontend/src/pages/follow-requests.vue | 105 ++++++++++++++-------
packages/misskey-js/etc/misskey-js.api.md | 8 ++
packages/misskey-js/src/autogen/apiClientJSDoc.ts | 11 +++
packages/misskey-js/src/autogen/endpoint.ts | 3 +
packages/misskey-js/src/autogen/entities.ts | 2 +
packages/misskey-js/src/autogen/types.ts | 72 ++++++++++++++
14 files changed, 272 insertions(+), 38 deletions(-)
create mode 100644 packages/backend/src/server/api/endpoints/following/requests/sent.ts
(limited to 'packages/backend/src/server/api')
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fcbfed5900..232d52d7e1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,8 @@
- Enhance: カタルーニャ語 (ca-ES) に対応
- Enhance: 個別お知らせページではMetaタグを出力するように
- Enhance: ノート詳細画面にロールのバッジを表示
+- Enhance: 過去に送信したフォローリクエストを確認できるように
+ (Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663)
- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正
- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768)
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 440f24ac84..18fbfd15f0 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -10579,6 +10579,16 @@ export interface Locale extends ILocale {
*/
"description3": ParameterizedString<"link">;
};
+ "_followRequest": {
+ /**
+ * 受け取った申請
+ */
+ "recieved": string;
+ /**
+ * 送った申請
+ */
+ "sent": string;
+ };
}
declare const locales: {
[lang: string]: Locale;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 5d8e1a5e72..439ae708c5 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -2819,3 +2819,7 @@ _selfXssPrevention:
description1: "ここに何かを貼り付けると、悪意のあるユーザーにアカウントを乗っ取られたり、個人情報を盗まれたりする可能性があります。"
description2: "貼り付けようとしているものが何なのかを正確に理解していない場合は、%c今すぐ作業を中止してこのウィンドウを閉じてください。"
description3: "詳しくはこちらをご確認ください。 {link}"
+
+_followRequest:
+ recieved: "受け取った申請"
+ sent: "送った申請"
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 3557fa40a5..5bb194313d 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -187,6 +187,7 @@ import * as ep___following_invalidate from './endpoints/following/invalidate.js'
import * as ep___following_requests_accept from './endpoints/following/requests/accept.js';
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
import * as ep___following_requests_list from './endpoints/following/requests/list.js';
+import * as ep___following_requests_sent from './endpoints/following/requests/sent.js';
import * as ep___following_requests_reject from './endpoints/following/requests/reject.js';
import * as ep___gallery_featured from './endpoints/gallery/featured.js';
import * as ep___gallery_popular from './endpoints/gallery/popular.js';
@@ -574,6 +575,7 @@ const $following_invalidate: Provider = { provide: 'ep:following/invalidate', us
const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default };
const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default };
const $following_requests_list: Provider = { provide: 'ep:following/requests/list', useClass: ep___following_requests_list.default };
+const $following_requests_sent: Provider = { provide: 'ep:following/requests/sent', useClass: ep___following_requests_sent.default };
const $following_requests_reject: Provider = { provide: 'ep:following/requests/reject', useClass: ep___following_requests_reject.default };
const $gallery_featured: Provider = { provide: 'ep:gallery/featured', useClass: ep___gallery_featured.default };
const $gallery_popular: Provider = { provide: 'ep:gallery/popular', useClass: ep___gallery_popular.default };
@@ -965,6 +967,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$following_requests_accept,
$following_requests_cancel,
$following_requests_list,
+ $following_requests_sent,
$following_requests_reject,
$gallery_featured,
$gallery_popular,
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 49b07d6ced..15809b2678 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -193,6 +193,7 @@ import * as ep___following_invalidate from './endpoints/following/invalidate.js'
import * as ep___following_requests_accept from './endpoints/following/requests/accept.js';
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
import * as ep___following_requests_list from './endpoints/following/requests/list.js';
+import * as ep___following_requests_sent from './endpoints/following/requests/sent.js';
import * as ep___following_requests_reject from './endpoints/following/requests/reject.js';
import * as ep___gallery_featured from './endpoints/gallery/featured.js';
import * as ep___gallery_popular from './endpoints/gallery/popular.js';
@@ -578,6 +579,7 @@ const eps = [
['following/requests/accept', ep___following_requests_accept],
['following/requests/cancel', ep___following_requests_cancel],
['following/requests/list', ep___following_requests_list],
+ ['following/requests/sent', ep___following_requests_sent],
['following/requests/reject', ep___following_requests_reject],
['gallery/featured', ep___gallery_featured],
['gallery/popular', ep___gallery_popular],
diff --git a/packages/backend/src/server/api/endpoints/following/requests/sent.ts b/packages/backend/src/server/api/endpoints/following/requests/sent.ts
new file mode 100644
index 0000000000..6325f01bb8
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/following/requests/sent.ts
@@ -0,0 +1,77 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import { QueryService } from '@/core/QueryService.js';
+import type { FollowRequestsRepository } from '@/models/_.js';
+import { FollowRequestEntityService } from '@/core/entities/FollowRequestEntityService.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+ tags: ['following', 'account'],
+
+ requireCredential: true,
+
+ kind: 'read:following',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ },
+ follower: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'UserLite',
+ },
+ followee: {
+ type: 'object',
+ optional: false, nullable: false,
+ ref: 'UserLite',
+ },
+ },
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
+ },
+ required: [],
+} as const;
+
+@Injectable()
+export default class extends Endpoint { // eslint-disable-line import/no-default-export
+ constructor(
+ @Inject(DI.followRequestsRepository)
+ private followRequestsRepository: FollowRequestsRepository,
+
+ private followRequestEntityService: FollowRequestEntityService,
+ private queryService: QueryService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const query = this.queryService.makePaginationQuery(this.followRequestsRepository.createQueryBuilder('request'), ps.sinceId, ps.untilId)
+ .andWhere('request.followerId = :meId', { meId: me.id });
+
+ const requests = await query
+ .limit(ps.limit)
+ .getMany();
+
+ return await this.followRequestEntityService.packMany(requests, me);
+ });
+ }
+}
diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue
index cc07175907..c1dc67f776 100644
--- a/packages/frontend/src/components/MkFollowButton.vue
+++ b/packages/frontend/src/components/MkFollowButton.vue
@@ -91,7 +91,10 @@ async function onClick() {
text: i18n.tsx.unfollowConfirm({ name: props.user.name || props.user.username }),
});
- if (canceled) return;
+ if (canceled) {
+ wait.value = false;
+ return;
+ }
await misskeyApi('following/delete', {
userId: props.user.id,
@@ -125,7 +128,10 @@ async function onClick() {
});
hasPendingFollowRequestFromYou.value = true;
- if ($i == null) return;
+ if ($i == null) {
+ wait.value = false;
+ return;
+ }
claimAchievement('following1');
diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts
index ac730f8021..096d404a57 100644
--- a/packages/frontend/src/navbar.ts
+++ b/packages/frontend/src/navbar.ts
@@ -40,7 +40,6 @@ export const navbarItemDef = reactive({
followRequests: {
title: i18n.ts.followRequests,
icon: 'ti ti-user-plus',
- show: computed(() => $i != null && $i.isLocked),
indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
to: '/my/follow-requests',
},
diff --git a/packages/frontend/src/pages/follow-requests.vue b/packages/frontend/src/pages/follow-requests.vue
index a840d0d0b3..8688863c2c 100644
--- a/packages/frontend/src/pages/follow-requests.vue
+++ b/packages/frontend/src/pages/follow-requests.vue
@@ -5,69 +5,104 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
-
-
-
-
![]()
-
{{ i18n.ts.noFollowRequests }}
-
-
-
-
-
-
-
-
-
-
@{{ acct(req.follower) }}
-
-
-
{{ i18n.ts.accept }}
-
{{ i18n.ts.reject }}
+
+
+
+
+
+
![]()
+
{{ i18n.ts.noFollowRequests }}
+
+
+
+
+
+
+
+
+
+
@{{ acct(displayUser(req)) }}
+
+
+ {{ i18n.ts.accept }}
+ {{ i18n.ts.reject }}
+
+
+ {{ i18n.ts.cancel }}
+
+
-
-
-
-
+
+
+
+
diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue
new file mode 100644
index 0000000000..cf793c7110
--- /dev/null
+++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue
@@ -0,0 +1,60 @@
+
+
+
+
+ {{ i18n.ts.schedulePostList }}
+
+
+
+
+
![]()
+
{{ i18n.ts.nothing }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts
index e73aa77a3c..deb629d534 100644
--- a/packages/frontend/src/os.ts
+++ b/packages/frontend/src/os.ts
@@ -733,3 +733,4 @@ export function checkExistence(fileData: ArrayBuffer): Promise
{
});
});
}*/
+
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 1763db2323..5d896db98c 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -200,6 +200,25 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ {{ i18n.ts._role._options.scheduleNoteMax }}
+
+ {{ i18n.ts._role.useBaseValue }}
+ {{ role.policies.scheduleNoteMax.value }}
+
+
+
+
+ {{ i18n.ts._role.useBaseValue }}
+
+
+
+
+ {{ i18n.ts._role.priority }}
+
+
+
+
{{ i18n.ts._role._options.mentionMax }}
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 00a25446ab..036f18fe0d 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -70,6 +70,13 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ {{ i18n.ts._role._options.scheduleNoteMax }}
+ {{ policies.scheduleNoteMax }}
+
+
+
+
{{ i18n.ts._role._options.mentionMax }}
{{ policies.mentionLimit }}
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index 5af1a4112f..a74a4521e7 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -1684,6 +1684,10 @@ declare namespace entities {
NotesRenotesResponse,
NotesRepliesRequest,
NotesRepliesResponse,
+ NotesScheduleCreateRequest,
+ NotesScheduleDeleteRequest,
+ NotesScheduleListRequest,
+ NotesScheduleListResponse,
NotesSearchByTagRequest,
NotesSearchByTagResponse,
NotesSearchRequest,
@@ -2807,6 +2811,18 @@ type NotesRequest = operations['notes']['requestBody']['content']['application/j
// @public (undocumented)
type NotesResponse = operations['notes']['responses']['200']['content']['application/json'];
+// @public (undocumented)
+type NotesScheduleCreateRequest = operations['notes___schedule___create']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type NotesScheduleDeleteRequest = operations['notes___schedule___delete']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type NotesScheduleListRequest = operations['notes___schedule___list']['requestBody']['content']['application/json'];
+
+// @public (undocumented)
+type NotesScheduleListResponse = operations['notes___schedule___list']['responses']['200']['content']['application/json'];
+
// @public (undocumented)
type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
index 3fa67b7990..4b0e8173f8 100644
--- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts
+++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts
@@ -3385,6 +3385,39 @@ declare module '../api.js' {
credential?: string | null,
): Promise>;
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
+ */
+ request(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
+ */
+ request(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise>;
+
+ /**
+ * No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:notes-schedule*
+ */
+ request(
+ endpoint: E,
+ params: P,
+ credential?: string | null,
+ ): Promise>;
+
/**
* No description provided.
*
diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts
index 609c9f99d8..5caddb602b 100644
--- a/packages/misskey-js/src/autogen/endpoint.ts
+++ b/packages/misskey-js/src/autogen/endpoint.ts
@@ -450,6 +450,10 @@ import type {
NotesRenotesResponse,
NotesRepliesRequest,
NotesRepliesResponse,
+ NotesScheduleCreateRequest,
+ NotesScheduleDeleteRequest,
+ NotesScheduleListRequest,
+ NotesScheduleListResponse,
NotesSearchByTagRequest,
NotesSearchByTagResponse,
NotesSearchRequest,
@@ -900,6 +904,9 @@ export type Endpoints = {
'notes/like': { req: NotesLikeRequest; res: EmptyResponse };
'notes/renotes': { req: NotesRenotesRequest; res: NotesRenotesResponse };
'notes/replies': { req: NotesRepliesRequest; res: NotesRepliesResponse };
+ 'notes/schedule/create': { req: NotesScheduleCreateRequest; res: EmptyResponse };
+ 'notes/schedule/delete': { req: NotesScheduleDeleteRequest; res: EmptyResponse };
+ 'notes/schedule/list': { req: NotesScheduleListRequest; res: NotesScheduleListResponse };
'notes/search-by-tag': { req: NotesSearchByTagRequest; res: NotesSearchByTagResponse };
'notes/search': { req: NotesSearchRequest; res: NotesSearchResponse };
'notes/show': { req: NotesShowRequest; res: NotesShowResponse };
diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts
index 999dd4dd54..2da78f6a50 100644
--- a/packages/misskey-js/src/autogen/entities.ts
+++ b/packages/misskey-js/src/autogen/entities.ts
@@ -453,6 +453,10 @@ export type NotesRenotesRequest = operations['notes___renotes']['requestBody']['
export type NotesRenotesResponse = operations['notes___renotes']['responses']['200']['content']['application/json'];
export type NotesRepliesRequest = operations['notes___replies']['requestBody']['content']['application/json'];
export type NotesRepliesResponse = operations['notes___replies']['responses']['200']['content']['application/json'];
+export type NotesScheduleCreateRequest = operations['notes___schedule___create']['requestBody']['content']['application/json'];
+export type NotesScheduleDeleteRequest = operations['notes___schedule___delete']['requestBody']['content']['application/json'];
+export type NotesScheduleListRequest = operations['notes___schedule___list']['requestBody']['content']['application/json'];
+export type NotesScheduleListResponse = operations['notes___schedule___list']['responses']['200']['content']['application/json'];
export type NotesSearchByTagRequest = operations['notes___search-by-tag']['requestBody']['content']['application/json'];
export type NotesSearchByTagResponse = operations['notes___search-by-tag']['responses']['200']['content']['application/json'];
export type NotesSearchRequest = operations['notes___search']['requestBody']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index ad30f47f2e..2e6320c5be 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -2935,6 +2935,33 @@ export type paths = {
*/
post: operations['notes___replies'];
};
+ '/notes/schedule/create': {
+ /**
+ * notes/schedule/create
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
+ */
+ post: operations['notes___schedule___create'];
+ };
+ '/notes/schedule/delete': {
+ /**
+ * notes/schedule/delete
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
+ */
+ post: operations['notes___schedule___delete'];
+ };
+ '/notes/schedule/list': {
+ /**
+ * notes/schedule/list
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:notes-schedule*
+ */
+ post: operations['notes___schedule___list'];
+ };
'/notes/search-by-tag': {
/**
* notes/search-by-tag
@@ -5036,6 +5063,7 @@ export type components = {
canImportFollowing: boolean;
canImportMuting: boolean;
canImportUserLists: boolean;
+ scheduleNoteMax: number;
};
ReversiGameLite: {
/** Format: id */
@@ -24424,6 +24452,247 @@ export type operations = {
};
};
};
+ /**
+ * notes/schedule/create
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
+ */
+ notes___schedule___create: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /**
+ * @default public
+ * @enum {string}
+ */
+ visibility?: 'public' | 'home' | 'followers' | 'specified';
+ visibleUserIds?: string[];
+ cw?: string | null;
+ /**
+ * @default null
+ * @enum {string|null}
+ */
+ reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
+ /** @default false */
+ disableRightClick?: boolean;
+ /** @default false */
+ noExtractMentions?: boolean;
+ /** @default false */
+ noExtractHashtags?: boolean;
+ /** @default false */
+ noExtractEmojis?: boolean;
+ /** Format: misskey:id */
+ replyId?: string | null;
+ /** Format: misskey:id */
+ renoteId?: string | null;
+ text?: string | null;
+ fileIds?: string[];
+ mediaIds?: string[];
+ poll?: ({
+ choices: string[];
+ multiple?: boolean;
+ expiresAt?: number | null;
+ expiredAfter?: number | null;
+ }) | null;
+ event?: ({
+ title?: string;
+ start?: number;
+ end?: number | null;
+ metadata?: Record;
+ }) | null;
+ scheduleNote: {
+ scheduledAt?: number;
+ };
+ };
+ };
+ };
+ responses: {
+ /** @description OK (without any results) */
+ 204: {
+ content: never;
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description To many requests */
+ 429: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * notes/schedule/delete
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *write:notes-schedule*
+ */
+ notes___schedule___delete: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ noteId: string;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (without any results) */
+ 204: {
+ content: never;
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description To many requests */
+ 429: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
+ /**
+ * notes/schedule/list
+ * @description No description provided.
+ *
+ * **Credential required**: *Yes* / **Permission**: *read:notes-schedule*
+ */
+ notes___schedule___list: {
+ requestBody: {
+ content: {
+ 'application/json': {
+ /** Format: misskey:id */
+ sinceId?: string;
+ /** Format: misskey:id */
+ untilId?: string;
+ /** @default 10 */
+ limit?: number;
+ };
+ };
+ };
+ responses: {
+ /** @description OK (with results) */
+ 200: {
+ content: {
+ 'application/json': ({
+ /** Format: misskey:id */
+ id: string;
+ note: {
+ createdAt: string;
+ text?: string;
+ cw?: string | null;
+ fileIds: string[];
+ /** @enum {string} */
+ visibility: 'public' | 'home' | 'followers' | 'specified';
+ visibleUsers: components['schemas']['UserLite'][];
+ user: components['schemas']['User'];
+ /**
+ * @default null
+ * @enum {string|null}
+ */
+ reactionAcceptance: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
+ isSchedule: boolean;
+ };
+ userId: string;
+ scheduledAt: string;
+ })[];
+ };
+ };
+ /** @description Client error */
+ 400: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Authentication error */
+ 401: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Forbidden error */
+ 403: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description I'm Ai */
+ 418: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description To many requests */
+ 429: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ /** @description Internal server error */
+ 500: {
+ content: {
+ 'application/json': components['schemas']['Error'];
+ };
+ };
+ };
+ };
/**
* notes/search-by-tag
* @description No description provided.
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index c99b8f5570..d090a6b46f 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -42,6 +42,8 @@ export const permissions = [
'read:mutes',
'write:mutes',
'write:notes',
+ 'read:notes-schedule',
+ 'write:notes-schedule',
'read:notifications',
'write:notifications',
'read:reactions',
--
cgit v1.2.3-freya
From 4f58b8de20625da577a2b7a8055d065bbddb94d1 Mon Sep 17 00:00:00 2001
From: Marie
Date: Sun, 3 Nov 2024 03:39:19 +0100
Subject: fix: drive content not being loaded
---
.../server/api/endpoints/notes/schedule/create.ts | 28 +++-------------------
.../server/api/endpoints/notes/schedule/list.ts | 3 +++
packages/frontend/src/components/MkMediaList.vue | 2 +-
packages/frontend/src/components/MkNoteSimple.vue | 2 ++
.../src/components/MkSchedulePostListDialog.vue | 3 +++
packages/misskey-js/etc/misskey-js.api.md | 2 +-
packages/misskey-js/src/autogen/types.ts | 8 -------
7 files changed, 13 insertions(+), 35 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
index ecdfa4bf2e..c22c29ae31 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
@@ -6,7 +6,7 @@
import ms from 'ms';
import { In } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
-import { isPureRenote } from 'cherrypick-js/note.js';
+import { isPureRenote } from '@/misc/is-renote.js';
import type { MiUser } from '@/models/User.js';
import type {
UsersRepository,
@@ -19,7 +19,6 @@ import type {
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiNote } from '@/models/Note.js';
import type { MiChannel } from '@/models/Channel.js';
-import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { QueueService } from '@/core/QueueService.js';
@@ -129,7 +128,6 @@ export const paramDef = {
} },
cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
- disableRightClick: { type: 'boolean', default: false },
noExtractMentions: { type: 'boolean', default: false },
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },
@@ -141,7 +139,6 @@ export const paramDef = {
text: {
type: 'string',
minLength: 1,
- maxLength: MAX_NOTE_TEXT_LENGTH,
nullable: true,
},
fileIds: {
@@ -175,16 +172,6 @@ export const paramDef = {
},
required: ['choices'],
},
- event: {
- type: 'object',
- nullable: true,
- properties: {
- title: { type: 'string', minLength: 1, maxLength: 128, nullable: false },
- start: { type: 'integer', nullable: false },
- end: { type: 'integer', nullable: true },
- metadata: { type: 'object' },
- },
- },
scheduleNote: {
type: 'object',
nullable: false,
@@ -227,11 +214,9 @@ export default class extends Endpoint { // eslint-
private queueService: QueueService,
private roleService: RoleService,
- private idService: IdService,
+ private idService: IdService,
) {
- super({
- ...meta,
- }, paramDef, async (ps, me) => {
+ super(meta, paramDef, async (ps, me) => {
const scheduleNoteCount = await this.noteScheduleRepository.countBy({ userId: me.id });
const scheduleNoteMax = (await this.roleService.getUserPolicies(me.id)).scheduleNoteMax;
if (scheduleNoteCount >= scheduleNoteMax) {
@@ -358,13 +343,6 @@ export default class extends Endpoint { // eslint-
apMentions: ps.noExtractMentions ? [] : undefined,
apHashtags: ps.noExtractHashtags ? [] : undefined,
apEmojis: ps.noExtractEmojis ? [] : undefined,
- event: ps.event ? {
- start: new Date(ps.event.start!).toISOString(),
- end: ps.event.end ? new Date(ps.event.end).toISOString() : null,
- title: ps.event.title!,
- metadata: ps.event.metadata ?? {},
- } : undefined,
- disableRightClick: ps.disableRightClick,
};
if (ps.scheduleNote.scheduledAt) {
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
index 88da4f4043..4895733d4e 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/list.ts
@@ -9,6 +9,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import type { MiNote, MiNoteSchedule, NoteScheduleRepository } from '@/models/_.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { QueryService } from '@/core/QueryService.js';
import { Packed } from '@/misc/json-schema.js';
import { noteVisibilities } from '@/types.js';
@@ -81,6 +82,7 @@ export default class extends Endpoint { // eslint-
private noteScheduleRepository: NoteScheduleRepository,
private userEntityService: UserEntityService,
+ private driveFileEntityService: DriveFileEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -115,6 +117,7 @@ export default class extends Endpoint { // eslint-
reactionAcceptance: item.note.reactionAcceptance ?? null,
visibleUsers: item.note.visibleUsers ? await userEntityService.packMany(item.note.visibleUsers.map(u => u.id), me) : [],
fileIds: item.note.files ? item.note.files : [],
+ files: await this.driveFileEntityService.packManyByIds(item.note.files),
createdAt: item.scheduledAt.toISOString(),
isSchedule: true,
id: item.id,
diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue
index 5209489046..4ef929e81f 100644
--- a/packages/frontend/src/components/MkMediaList.vue
+++ b/packages/frontend/src/components/MkMediaList.vue
@@ -35,13 +35,13 @@ import * as Misskey from 'misskey-js';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
import PhotoSwipe from 'photoswipe';
import 'photoswipe/style.css';
+import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES } from '@@/js/const.js';
import XBanner from '@/components/MkMediaBanner.vue';
import XImage from '@/components/MkMediaImage.vue';
import XVideo from '@/components/MkMediaVideo.vue';
import XModPlayer from '@/components/SkModPlayer.vue';
import XFlashPlayer from '@/components/SkFlashPlayer.vue';
import * as os from '@/os.js';
-import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES, FILE_TYPE_FLASH_CONTENT, FILE_EXT_FLASH_CONTENT } from '@@/js/const.js';
import { defaultStore } from '@/store.js';
import { focusParent } from '@/scripts/focus.js';
diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue
index 7d2bbb31d3..48bf53fab5 100644
--- a/packages/frontend/src/components/MkNoteSimple.vue
+++ b/packages/frontend/src/components/MkNoteSimple.vue
@@ -33,6 +33,8 @@ import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import { defaultStore } from '@/store.js';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { i18n } from '@/i18n.js';
const props = defineProps<{
note: Misskey.entities.Note & {
diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue
index cf793c7110..8311981a75 100644
--- a/packages/frontend/src/components/MkSchedulePostListDialog.vue
+++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue
@@ -52,8 +52,11 @@ const paginationEl = ref();
const pagination: Paging = {
endpoint: 'notes/schedule/list',
limit: 10,
+ offsetMode: true,
};
+console.log(pagination);
+
function listUpdate() {
paginationEl.value.reload();
}
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index a74a4521e7..ca7a374a67 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -2953,7 +2953,7 @@ type PartialRolePolicyOverride = Partial<{
}>;
// @public (undocumented)
-export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
+export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notes-schedule", "write:notes-schedule", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:nsfw-user", "write:admin:unnsfw-user", "write:admin:silence-user", "write:admin:unsilence-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
// @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 2e6320c5be..c8d7194405 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -24475,8 +24475,6 @@ export type operations = {
*/
reactionAcceptance?: null | 'likeOnly' | 'likeOnlyForRemote' | 'nonSensitiveOnly' | 'nonSensitiveOnlyForLocalLikeOnlyForRemote';
/** @default false */
- disableRightClick?: boolean;
- /** @default false */
noExtractMentions?: boolean;
/** @default false */
noExtractHashtags?: boolean;
@@ -24495,12 +24493,6 @@ export type operations = {
expiresAt?: number | null;
expiredAfter?: number | null;
}) | null;
- event?: ({
- title?: string;
- start?: number;
- end?: number | null;
- metadata?: Record;
- }) | null;
scheduleNote: {
scheduledAt?: number;
};
--
cgit v1.2.3-freya
From fc9d777dc3161b40c5c62bb65cb03e2c7d8f4380 Mon Sep 17 00:00:00 2001
From: Marie
Date: Sun, 3 Nov 2024 17:59:50 +0100
Subject: upd: add notification for failures, add reasons for failure, apply
suggestions
---
locales/index.d.ts | 4 ++
.../src/core/entities/NotificationEntityService.ts | 5 +-
packages/backend/src/models/NoteSchedule.ts | 2 -
packages/backend/src/models/Notification.ts | 5 ++
.../backend/src/models/json-schema/notification.ts | 14 +++++
.../processors/ScheduleNotePostProcessorService.ts | 59 +++++++++++++++++-----
.../server/api/endpoints/notes/schedule/create.ts | 5 +-
packages/backend/src/types.ts | 1 +
packages/frontend-shared/js/const.ts | 1 +
.../frontend/src/components/MkNotification.vue | 8 ++-
packages/frontend/src/components/MkPostForm.vue | 5 +-
.../src/components/MkSchedulePostListDialog.vue | 1 +
packages/misskey-js/etc/misskey-js.api.md | 2 +-
packages/misskey-js/src/autogen/types.ts | 16 ++++--
packages/misskey-js/src/consts.ts | 2 +-
packages/sw/src/scripts/create-notification.ts | 7 +++
sharkey-locales/en-US.yml | 1 +
17 files changed, 110 insertions(+), 28 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/locales/index.d.ts b/locales/index.d.ts
index e181b13f33..4cfc220731 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -9558,6 +9558,10 @@ export interface Locale extends ILocale {
* Note got edited
*/
"edited": string;
+ /**
+ * Posting scheduled note failed
+ */
+ "scheduledNoteFailed": string;
};
"_deck": {
/**
diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts
index bbaf0cb7c8..27b8231854 100644
--- a/packages/backend/src/core/entities/NotificationEntityService.ts
+++ b/packages/backend/src/core/entities/NotificationEntityService.ts
@@ -20,7 +20,7 @@ import type { OnModuleInit } from '@nestjs/common';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';
-const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited'] as (typeof groupedNotificationTypes[number])[]);
+const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded', 'edited', 'scheduledNoteFailed'] as (typeof groupedNotificationTypes[number])[]);
@Injectable()
export class NotificationEntityService implements OnModuleInit {
@@ -169,6 +169,9 @@ export class NotificationEntityService implements OnModuleInit {
exportedEntity: notification.exportedEntity,
fileId: notification.fileId,
} : {}),
+ ...(notification.type === 'scheduledNoteFailed' ? {
+ reason: notification.reason,
+ } : {}),
...(notification.type === 'app' ? {
body: notification.customBody,
header: notification.customHeader,
diff --git a/packages/backend/src/models/NoteSchedule.ts b/packages/backend/src/models/NoteSchedule.ts
index 97ffe32ffa..dde0af6ad7 100644
--- a/packages/backend/src/models/NoteSchedule.ts
+++ b/packages/backend/src/models/NoteSchedule.ts
@@ -18,8 +18,6 @@ type MinimumUser = {
};
export type MiScheduleNoteType={
- /** Date.toISOString() */
- createdAt: string;
visibility: 'public' | 'home' | 'followers' | 'specified';
visibleUsers: MinimumUser[];
channel?: MiChannel['id'];
diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts
index c4f046c565..5003e02d96 100644
--- a/packages/backend/src/models/Notification.ts
+++ b/packages/backend/src/models/Notification.ts
@@ -122,6 +122,11 @@ export type MiNotification = {
createdAt: string;
notifierId: MiUser['id'];
noteId: MiNote['id'];
+} | {
+ type: 'scheduledNoteFailed';
+ id: string;
+ createdAt: string;
+ reason: string;
};
export type MiGroupedNotification = MiNotification | {
diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts
index 990e8957cf..69bd9531ec 100644
--- a/packages/backend/src/models/json-schema/notification.ts
+++ b/packages/backend/src/models/json-schema/notification.ts
@@ -369,6 +369,20 @@ export const packedNotificationSchema = {
optional: false, nullable: false,
},
},
+ }, {
+ type: 'object',
+ properties: {
+ ...baseSchema.properties,
+ type: {
+ type: 'string',
+ optional: false, nullable: false,
+ enum: ['scheduledNoteFailed'],
+ },
+ reason: {
+ type: 'string',
+ optional: false, nullable: false,
+ },
+ },
}, {
type: 'object',
properties: {
diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
index 62d527953d..59e23b865e 100644
--- a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
+++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
@@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
+import { NotificationService } from '@/core/NotificationService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { ScheduleNotePostJobData } from '../types.js';
@@ -32,6 +33,7 @@ export class ScheduleNotePostProcessorService {
private noteCreateService: NoteCreateService,
private queueLoggerService: QueueLoggerService,
+ private notificationService: NotificationService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('schedule-note-post');
}
@@ -50,8 +52,9 @@ export class ScheduleNotePostProcessorService {
const renote = note.reply ? await this.notesRepository.findOneBy({ id: note.renote }) : undefined;
const channel = note.channel ? await this.channelsRepository.findOneBy({ id: note.channel, isArchived: false }) : undefined;
let files: MiDriveFile[] = [];
- const fileIds = note.files ?? null;
- if (fileIds != null && fileIds.length > 0 && me) {
+ const fileIds = note.files;
+
+ if (fileIds.length > 0 && me) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id,
@@ -61,22 +64,52 @@ export class ScheduleNotePostProcessorService {
.setParameters({ fileIds })
.getMany();
}
- if (
- !data.userId ||
- !me ||
- (note.reply && !reply) ||
- (note.renote && !renote) ||
- (note.channel && !channel) ||
- (note.files.length !== files.length)
- ) {
- //キューに積んだときは有った物が消滅してたら予約投稿をキャンセルする
- this.logger.warn('cancel schedule note');
+
+ if (!data.userId || !me) {
+ this.logger.warn('Schedule Note Failed Reason: User Not Found');
+ await this.noteScheduleRepository.remove(data);
+ return;
+ }
+
+ if (note.files.length !== files.length) {
+ this.logger.warn('Schedule Note Failed Reason: files are missing in the user\'s drive');
+ this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+ reason: 'Some attached files on your scheduled note no longer exist',
+ });
+ await this.noteScheduleRepository.remove(data);
+ return;
+ }
+
+ if (note.reply && !reply) {
+ this.logger.warn('Schedule Note Failed Reason: parent note to reply does not exist');
+ this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+ reason: 'Replied to note on your scheduled note no longer exists',
+ });
await this.noteScheduleRepository.remove(data);
return;
}
+
+ if (note.renote && !renote) {
+ this.logger.warn('Schedule Note Failed Reason: attached quote note no longer exists');
+ this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+ reason: 'A quoted note from one of your scheduled notes no longer exists',
+ });
+ await this.noteScheduleRepository.remove(data);
+ return;
+ }
+
+ if (note.channel && !channel) {
+ this.logger.warn('Schedule Note Failed Reason: Channel does not exist');
+ this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+ reason: 'An attached channel on your scheduled note no longer exists',
+ });
+ await this.noteScheduleRepository.remove(data);
+ return;
+ }
+
await this.noteCreateService.create(me, {
...note,
- createdAt: new Date(note.createdAt), //typeORMのjsonbで何故かstringにされるから戻す
+ createdAt: new Date(),
files,
poll: note.poll ? {
choices: note.poll.choices,
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
index c22c29ae31..b8ae3f44a3 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
@@ -292,7 +292,7 @@ export default class extends Endpoint { // eslint-
// Check blocking
if (reply.userId !== me.id) {
- const blockExist = await this.blockingsRepository.exist({
+ const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: reply.userId,
blockeeId: me.id,
@@ -324,8 +324,7 @@ export default class extends Endpoint { // eslint-
} else {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
}
- const note:MiScheduleNoteType = {
- createdAt: new Date(ps.scheduleNote.scheduledAt!).toISOString(),
+ const note: MiScheduleNoteType = {
files: files.map(f => f.id),
poll: ps.poll ? {
choices: ps.poll.choices,
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 2aa4f279ea..7930129002 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -35,6 +35,7 @@ export const notificationTypes = [
'roleAssigned',
'achievementEarned',
'exportCompleted',
+ 'scheduledNoteFailed',
'app',
'test',
] as const;
diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts
index 882f19c7fd..5bc75db908 100644
--- a/packages/frontend-shared/js/const.ts
+++ b/packages/frontend-shared/js/const.ts
@@ -131,6 +131,7 @@ export const notificationTypes = [
'test',
'app',
'edited',
+ 'scheduledNoteFailed',
] as const;
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue
index 7bec9bdc65..3c4f56b537 100644
--- a/packages/frontend/src/components/MkNotification.vue
+++ b/packages/frontend/src/components/MkNotification.vue
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-
+
@@ -29,6 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
[$style.t_pollEnded]: notification.type === 'edited',
+ [$style.t_roleAssigned]: notification.type === 'scheduledNoteFailed',
}]"
>
@@ -46,6 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+
{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}
{{ notification.header }}
{{ i18n.ts._notification.edited }}
+ {{ i18n.ts._notification.scheduledNoteFailed }}
@@ -109,6 +112,9 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.showFile }}
+
+ {{ notification.reason }}
+
{{ i18n.ts.youGotNewFollower }}
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 443e9e7ee9..bbde7c65f9 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -1046,8 +1046,9 @@ function openAccountMenu(ev: MouseEvent) {
}
function toggleScheduleNote() {
- if (scheduleNote.value) scheduleNote.value = null;
- else {
+ if (scheduleNote.value) {
+ scheduleNote.value = null;
+ } else {
scheduleNote.value = {
scheduledAt: null,
};
diff --git a/packages/frontend/src/components/MkSchedulePostListDialog.vue b/packages/frontend/src/components/MkSchedulePostListDialog.vue
index cfae94951b..d0716ead79 100644
--- a/packages/frontend/src/components/MkSchedulePostListDialog.vue
+++ b/packages/frontend/src/components/MkSchedulePostListDialog.vue
@@ -48,6 +48,7 @@ const cancel = () => {
emit('cancel');
dialogEl.value.close();
};
+
const paginationEl = ref();
const pagination: Paging = {
endpoint: 'notes/schedule/list',
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index ca7a374a67..880be518fa 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -2890,7 +2890,7 @@ type Notification_2 = components['schemas']['Notification'];
type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];
// @public (undocumented)
-export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited"];
+export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned", "edited", "scheduledNoteFailed"];
// @public (undocumented)
export function nyaize(text: string): string;
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index c8d7194405..6eb1819037 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -4517,6 +4517,14 @@ export type components = {
/** Format: id */
userId: string;
note: components['schemas']['Note'];
+ } | {
+ /** Format: id */
+ id: string;
+ /** Format: date-time */
+ createdAt: string;
+ /** @enum {string} */
+ type: 'scheduledNoteFailed';
+ reason: string;
} | {
/** Format: id */
id: string;
@@ -19984,8 +19992,8 @@ export type operations = {
untilId?: string;
/** @default true */
markAsRead?: boolean;
- includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
- excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+ includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
+ excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'pollVote' | 'groupInvited')[];
};
};
};
@@ -20052,8 +20060,8 @@ export type operations = {
untilId?: string;
/** @default true */
markAsRead?: boolean;
- includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
- excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
+ includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
+ excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'edited' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'achievementEarned' | 'exportCompleted' | 'scheduledNoteFailed' | 'app' | 'test' | 'reaction:grouped' | 'renote:grouped' | 'pollVote' | 'groupInvited')[];
};
};
};
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index d090a6b46f..34fc7c1a03 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -16,7 +16,7 @@ import type {
UserLite,
} from './autogen/models.js';
-export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited'] as const;
+export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited', 'scheduledNoteFailed'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts
index 9c56e338c7..8442552e3b 100644
--- a/packages/sw/src/scripts/create-notification.ts
+++ b/packages/sw/src/scripts/create-notification.ts
@@ -258,6 +258,13 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
data,
}];
+ case 'scheduledNoteFailed':
+ return [i18n.ts._notification.scheduledNoteFailed, {
+ body: data.body.reason,
+ badge: iconUrl('bell'),
+ data,
+ }];
+
default:
return null;
}
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index 79c362cfd8..582f4eda0a 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -277,6 +277,7 @@ _notification:
youRenoted: "Boost from {name}"
renotedBySomeUsers: "Boosted by {n} users"
edited: "Note got edited"
+ scheduledNoteFailed: "Posting scheduled note failed"
_types:
renote: "Boosts"
edited: "Edits"
--
cgit v1.2.3-freya
From 152cc074831b784bb1e12267587184cea293a186 Mon Sep 17 00:00:00 2001
From: Marie
Date: Mon, 9 Dec 2024 05:58:25 +0100
Subject: Apply suggestions
---
.../src/queue/processors/ScheduleNotePostProcessorService.ts | 7 +++++++
packages/backend/src/server/api/endpoints/notes/schedule/create.ts | 2 +-
packages/frontend/src/components/MkPostForm.vue | 4 +---
3 files changed, 9 insertions(+), 4 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
index f281b0ed7b..ea43448ed0 100644
--- a/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
+++ b/packages/backend/src/queue/processors/ScheduleNotePostProcessorService.ts
@@ -10,6 +10,7 @@ import { NoteCreateService } from '@/core/NoteCreateService.js';
import type { ChannelsRepository, DriveFilesRepository, MiDriveFile, NoteScheduleRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { NotificationService } from '@/core/NotificationService.js';
+import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { ScheduleNotePostJobData } from '../types.js';
@@ -119,6 +120,12 @@ export class ScheduleNotePostProcessorService {
reply,
renote,
channel,
+ }).catch(async (err: IdentifiableError) => {
+ this.notificationService.createNotification(me.id, 'scheduledNoteFailed', {
+ reason: err.message,
+ });
+ await this.noteScheduleRepository.remove(data);
+ throw this.logger.error(`Schedule Note Failed Reason: ${err.message}`);
});
await this.noteScheduleRepository.remove(data);
this.notificationService.createNotification(me.id, 'scheduledNotePosted', {
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
index b8ae3f44a3..7d20b6b82a 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
@@ -360,7 +360,7 @@ export default class extends Endpoint { // eslint-
}, {
delay,
removeOnComplete: true,
- jobId: noteId,
+ jobId: `schedNote:${noteId}`,
});
}
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index bbde7c65f9..c7d5611847 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -821,7 +821,7 @@ async function post(ev?: MouseEvent) {
const filesData = toRaw(files.value);
const isMissingAltText = filesData.filter(
- file => file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/')
+ file => file.type.startsWith('image/') || file.type.startsWith('video/') || file.type.startsWith('audio/'),
).some(file => !file.comment);
if (isMissingAltText) {
@@ -914,8 +914,6 @@ async function post(ev?: MouseEvent) {
claimAchievement('notes1');
}
- poll.value = null;
-
const text = postData.text ?? '';
const lowerCase = text.toLowerCase();
if ((lowerCase.includes('love') || lowerCase.includes('❤')) && lowerCase.includes('sharkey')) {
--
cgit v1.2.3-freya
From f02d0994132c5774f3adf0d4a993f4ecca549b77 Mon Sep 17 00:00:00 2001
From: Marie
Date: Mon, 9 Dec 2024 06:10:32 +0100
Subject: fix deletion of scheduled note
---
packages/backend/src/server/api/endpoints/notes/schedule/delete.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
index df406f99f0..628fd89926 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/delete.ts
@@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint-
throw new ApiError(meta.errors.permissionDenied);
}
await this.noteScheduleRepository.delete({ id: ps.noteId });
- await this.queueService.ScheduleNotePostQueue.remove(ps.noteId);
+ await this.queueService.ScheduleNotePostQueue.remove(`schedNote:${ps.noteId}`);
});
}
}
--
cgit v1.2.3-freya
From c9f588276c90232e00d672e9e253e0f05e6e37e0 Mon Sep 17 00:00:00 2001
From: dakkar
Date: Mon, 9 Dec 2024 10:01:48 +0000
Subject: remove duplicate import
---
packages/backend/src/server/api/SigninApiService.ts | 1 -
1 file changed, 1 deletion(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index d1e58fb536..fa9155d82d 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -26,7 +26,6 @@ import { UserAuthService } from '@/core/UserAuthService.js';
import { CaptchaService } from '@/core/CaptchaService.js';
import { FastifyReplyError } from '@/misc/fastify-reply-error.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';
--
cgit v1.2.3-freya
From 9daafca155682281f567c9b4da8f3af3564aa281 Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Mon, 9 Dec 2024 19:04:06 -0500
Subject: fix rate limits under multi-node environments
---
.../backend/src/server/api/SkRateLimiterService.ts | 185 ++++---
.../unit/server/api/SkRateLimiterServiceTests.ts | 544 +++++++++++++--------
2 files changed, 456 insertions(+), 273 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/api/SkRateLimiterService.ts
index 6415ee905c..05166ed93c 100644
--- a/packages/backend/src/server/api/SkRateLimiterService.ts
+++ b/packages/backend/src/server/api/SkRateLimiterService.ts
@@ -5,16 +5,13 @@
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(
@@ -24,14 +21,10 @@ export class SkRateLimiterService {
@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*
+ this.disabled = envService.env.NODE_ENV !== 'production';
}
public async limit(limit: Keyed, actor: string, factor = 1): Promise {
@@ -50,10 +43,25 @@ export class SkRateLimiterService {
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);
+ return await this.tryLimit(limit, actor, factor);
+ }
+
+ private async tryLimit(limit: Keyed, actor: string, factor: number, retry = 1): Promise {
+ try {
+ if (isLegacyRateLimit(limit)) {
+ return await this.limitLegacy(limit, actor, factor);
+ } else {
+ return await this.limitBucket(limit, actor, factor);
+ }
+ } catch (err) {
+ // We may experience collision errors from optimistic locking.
+ // This is expected, so we should retry a few times before giving up.
+ // https://redis.io/docs/latest/develop/interact/transactions/#optimistic-locking-using-check-and-set
+ if (err instanceof TransactionError && retry < 3) {
+ return await this.tryLimit(limit, actor, factor, retry + 1);
+ }
+
+ throw err;
}
}
@@ -94,36 +102,30 @@ export class SkRateLimiterService {
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);
+ const expirationSec = Math.max(Math.ceil(minInterval / 1000), 1);
- // Update expiration
- if (counter.c > 0) {
- const isCleared = this.timeService.now - counter.t >= minInterval;
+ // Check for window clear
+ const counter = await this.getLimitCounter(limit, actor, 'min');
+ if (counter.counter > 0) {
+ const isCleared = this.timeService.now - counter.timestamp >= minInterval;
if (isCleared) {
- counter.c = 0;
+ counter.counter = 0;
}
}
- const blocked = counter.c > 0;
+ // Increment the limit, then synchronize with redis
+ const blocked = counter.counter > 0;
if (!blocked) {
- counter.c++;
- counter.t = this.timeService.now;
+ counter.counter++;
+ counter.timestamp = this.timeService.now;
+ await this.updateLimitCounter(limit, actor, 'min', expirationSec, counter);
}
// Calculate limit status
- const resetMs = Math.max(Math.ceil(minInterval - (this.timeService.now - counter.t)), 0);
+ const resetMs = Math.max(minInterval - (this.timeService.now - counter.timestamp), 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;
+ return { blocked, remaining: 0, resetSec, resetMs, fullResetSec: resetSec, fullResetMs: resetMs };
}
private async limitBucket(limit: Keyed, actor: string, factor: number): Promise {
@@ -131,68 +133,113 @@ export class SkRateLimiterService {
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);
+ const expirationSec = Math.max(Math.ceil(bucketSize / dripRate), 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);
+ // Simulate bucket drips
+ const counter = await this.getLimitCounter(limit, actor, 'bucket');
+ if (counter.counter > 0) {
+ const dripsSinceLastTick = Math.floor((this.timeService.now - counter.timestamp) / dripRate) * dripSize;
+ counter.counter = Math.max(counter.counter - dripsSinceLastTick, 0);
}
- const blocked = counter.c >= bucketSize;
+ // Increment the limit, then synchronize with redis
+ const blocked = counter.counter >= bucketSize;
if (!blocked) {
- counter.c++;
- counter.t = this.timeService.now;
+ counter.counter++;
+ counter.timestamp = this.timeService.now;
+ await this.updateLimitCounter(limit, actor, 'bucket', expirationSec, counter);
}
+ // Calculate how much time is needed to free up a bucket slot
+ const overflow = Math.max((counter.counter + 1) - bucketSize, 0);
+ const dripsNeeded = Math.ceil(overflow / dripSize);
+ const timeNeeded = Math.max((dripRate * dripsNeeded) - (this.timeService.now - counter.timestamp), 0);
+
// 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 remaining = Math.max(bucketSize - counter.counter, 0);
+ const resetMs = timeNeeded;
const resetSec = Math.ceil(resetMs / 1000);
- const fullResetMs = Math.ceil(counter.c / dripSize) * dripRate;
+ const fullResetMs = Math.ceil(counter.counter / dripSize) * dripRate;
const fullResetSec = Math.ceil(fullResetMs / 1000);
- const limitInfo: LimitInfo = { blocked, remaining, resetSec, resetMs, fullResetSec, fullResetMs };
+ return { 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));
- }
+ private async getLimitCounter(limit: Keyed, actor: string, subject: string): Promise {
+ const timestampKey = createLimitKey(limit, actor, subject, 't');
+ const counterKey = createLimitKey(limit, actor, subject, 'c');
+
+ const [timestamp, counter] = await this.executeRedis(
+ [
+ ['get', timestampKey],
+ ['get', counterKey],
+ ],
+ [
+ timestampKey,
+ counterKey,
+ ],
+ );
- return limitInfo;
+ return {
+ timestamp: timestamp ? parseInt(timestamp) : 0,
+ counter: counter ? parseInt(counter) : 0,
+ };
}
- private async getLimitCounter(limit: Keyed, actor: string, subject: string): Promise {
- const key = createLimitKey(limit, actor, subject);
+ private async updateLimitCounter(limit: Keyed, actor: string, subject: string, expirationSec: number, counter: LimitCounter): Promise {
+ const timestampKey = createLimitKey(limit, actor, subject, 't');
+ const counterKey = createLimitKey(limit, actor, subject, 'c');
+
+ await this.executeRedis(
+ [
+ ['set', timestampKey, counter.timestamp.toString(), 'EX', expirationSec],
+ ['set', counterKey, counter.counter.toString(), 'EX', expirationSec],
+ ],
+ [
+ timestampKey,
+ counterKey,
+ ],
+ );
+ }
+
+ private async executeRedis(batch: RedisBatch, watch: string[]): Promise> {
+ const results = await this.redisClient
+ .multi(batch)
+ .watch(watch)
+ .exec();
- const value = await this.redisClient.get(key);
- if (value == null) {
- return { t: 0, c: 0 };
+ // Transaction error
+ if (!results) {
+ throw new TransactionError('Redis error: transaction conflict');
}
- return JSON.parse(value);
- }
+ // The entire call failed
+ if (results.length !== batch.length) {
+ throw new Error('Redis error: failed to execute batch');
+ }
- private async setLimitCounter(limit: Keyed, actor: string, counter: LimitCounter, expiration: number, subject: string): Promise {
- 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);
+ // A particular command failed
+ const errors = results.map(r => r[0]).filter(e => e != null);
+ if (errors.length > 0) {
+ throw new AggregateError(errors, `Redis error: failed to execute command(s): '${errors.join('\', \'')}'`);
+ }
+
+ return results.map(r => r[1]) as RedisResults;
}
}
-function createLimitKey(limit: Keyed, actor: string, subject: string): string {
- return `rl_${actor}_${limit.key}_${subject}`;
+type RedisBatch = [string, ...unknown[]][] & { length: Num };
+type RedisResults = (string | null)[] & { length: Num };
+
+function createLimitKey(limit: Keyed, actor: string, subject: string, value: string): string {
+ return `rl_${actor}_${limit.key}_${subject}_${value}`;
}
-export interface LimitCounter {
- /** Timestamp */
- t: number;
+class TransactionError extends Error {}
- /** Counter */
- c: number;
+interface LimitCounter {
+ timestamp: number;
+ counter: number;
}
diff --git a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
index dbf7795fc6..871c9afa64 100644
--- a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
+++ b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
@@ -3,25 +3,19 @@
* 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 { SkRateLimiterService } from '@/server/api/SkRateLimiterService.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 mockRedis: Array<(command: [string, ...unknown[]]) => [Error | null, unknown] | null> = null!;
+ let mockRedisExec: (batch: [string, ...unknown[]][]) => Promise<[Error | null, unknown][] | null> = null!;
let mockEnvironment: Record = null!;
let serviceUnderTest: () => SkRateLimiterService = null!;
- let loggedMessages: { level: string, data: unknown[] }[] = [];
-
beforeEach(() => {
mockTimeService = {
now: 0,
@@ -30,16 +24,26 @@ describe(SkRateLimiterService, () => {
},
};
- mockRedisGet = undefined;
- mockRedisSet = undefined;
+ mockRedis = [];
+ mockRedisExec = (batch) => {
+ const results: [Error | null, unknown][] = batch.map(command => {
+ const handlerResults = mockRedis.map(handler => handler(command));
+ const finalResult = handlerResults.findLast(result => result != null);
+ return finalResult ?? [new Error('test error: no handler'), null];
+ });
+ return Promise.resolve(results);
+ };
const mockRedisClient = {
- get(key: string) {
- if (mockRedisGet) return Promise.resolve(mockRedisGet(key));
- else return Promise.resolve(null);
- },
- set(...args: unknown[]): Promise {
- if (mockRedisSet) mockRedisSet(args);
- return Promise.resolve();
+ multi(batch: [string, ...unknown[]][]) {
+ return {
+ watch() {
+ return {
+ exec() {
+ return mockRedisExec(batch);
+ },
+ };
+ },
+ };
},
} as unknown as Redis.Redis;
@@ -49,89 +53,77 @@ describe(SkRateLimiterService, () => {
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);
+ return service ??= new SkRateLimiterService(mockTimeService, mockRedisClient, 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;
+ let limitCounter: number | undefined = undefined;
+ let limitTimestamp: number | undefined = undefined;
+ let minCounter: number | undefined = undefined;
+ let minTimestamp: number | undefined = undefined;
beforeEach(() => {
- counter = undefined;
+ limitCounter = undefined;
+ limitTimestamp = undefined;
minCounter = undefined;
+ minTimestamp = undefined;
- mockRedisGet = (key: string) => {
- if (key === 'rl_actor_test_bucket' && counter) {
- return JSON.stringify(counter);
+ mockRedis.push(([command, ...args]) => {
+ if (command === 'set' && args[0] === 'rl_actor_test_bucket_t') {
+ limitTimestamp = parseInt(args[1] as string);
+ return [null, args[1]];
}
-
- if (key === 'rl_actor_test_min' && minCounter) {
- return JSON.stringify(minCounter);
+ if (command === 'get' && args[0] === 'rl_actor_test_bucket_t') {
+ return [null, limitTimestamp?.toString() ?? null];
}
-
- 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 (command === 'incr' && args[0] === 'rl_actor_test_bucket_c') {
+ // limitCounter = (limitCounter ?? 0) + 1;
+ // return [null, null];
+ // }
+ if (command === 'set' && args[0] === 'rl_actor_test_bucket_c') {
+ limitCounter = parseInt(args[1] as string);
+ return [null, args[1]];
+ }
+ if (command === 'get' && args[0] === 'rl_actor_test_bucket_c') {
+ return [null, limitCounter?.toString() ?? null];
}
- 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');
+ if (command === 'set' && args[0] === 'rl_actor_test_min_t') {
+ minTimestamp = parseInt(args[1] as string);
+ return [null, args[1]];
+ }
+ if (command === 'get' && args[0] === 'rl_actor_test_min_t') {
+ return [null, minTimestamp?.toString() ?? null];
+ }
+ // if (command === 'incr' && args[0] === 'rl_actor_test_min_c') {
+ // minCounter = (minCounter ?? 0) + 1;
+ // return [null, null];
+ // }
+ if (command === 'set' && args[0] === 'rl_actor_test_min_c') {
+ minCounter = parseInt(args[1] as string);
+ return [null, args[1]];
}
- };
+ if (command === 'get' && args[0] === 'rl_actor_test_min_c') {
+ return [null, minCounter?.toString() ?? null];
+ }
+ // if (command === 'expire') {
+ // return [null, null];
+ // }
+
+ return null;
+ });
});
it('should bypass in non-production', async () => {
mockEnvironment.NODE_ENV = 'test';
- const info = await serviceUnderTest().limit({ key: 'l', type: undefined, max: 0 }, 'actor');
+ const info = await serviceUnderTest().limit({ key: 'l', type: undefined, max: 0 }, actor);
expect(info.blocked).toBeFalsy();
expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
@@ -158,15 +150,10 @@ describe(SkRateLimiterService, () => {
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 };
+ limitCounter = 1;
+ limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -180,8 +167,7 @@ describe(SkRateLimiterService, () => {
it('should increment counter when called', async () => {
await serviceUnderTest().limit(limit, actor);
- expect(counter).not.toBeUndefined();
- expect(counter?.c).toBe(1);
+ expect(limitCounter).toBe(1);
});
it('should set timestamp when called', async () => {
@@ -189,29 +175,28 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(counter).not.toBeUndefined();
- expect(counter?.t).toBe(1000);
+ expect(limitTimestamp).toBe(1000);
});
it('should decrement counter when dripRate has passed', async () => {
- counter = { c: 2, t: 0 };
+ limitCounter = 2;
+ limitTimestamp = 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
+ expect(limitCounter).toBe(1); // 2 (starting) - 2 (2x1 drip) + 1 (call) = 1
});
it('should decrement counter by dripSize', async () => {
- counter = { c: 2, t: 0 };
+ limitCounter = 2;
+ limitTimestamp = 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
+ expect(limitCounter).toBe(1); // 2 (starting) - 2 (1x2 drip) + 1 (call) = 1
});
it('should maintain counter between calls over time', async () => {
@@ -226,25 +211,13 @@ describe(SkRateLimiterService, () => {
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();
+ expect(limitCounter).toBe(2);
+ expect(limitTimestamp).toBe(3000);
});
it('should block when bucket is filled', async () => {
- counter = { c: 1, t: 0 };
+ limitCounter = 1;
+ limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -252,7 +225,8 @@ describe(SkRateLimiterService, () => {
});
it('should calculate correct info when blocked', async () => {
- counter = { c: 1, t: 0 };
+ limitCounter = 1;
+ limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -263,7 +237,8 @@ describe(SkRateLimiterService, () => {
});
it('should allow when bucket is filled but should drip', async () => {
- counter = { c: 1, t: 0 };
+ limitCounter = 1;
+ limitTimestamp = 0;
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
@@ -272,7 +247,8 @@ describe(SkRateLimiterService, () => {
});
it('should scale limit by factor', async () => {
- counter = { c: 1, t: 0 };
+ limitCounter = 1;
+ limitTimestamp = 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
@@ -281,23 +257,39 @@ describe(SkRateLimiterService, () => {
expect(i2.blocked).toBeTruthy();
});
- it('should set key expiration', async () => {
- const mock = jest.fn(mockRedisSet);
- mockRedisSet = mock;
+ it('should set counter expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
await serviceUnderTest().limit(limit, actor);
- expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]);
+ expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_c', '1', 'EX', 1]);
+ });
+
+ it('should set timestamp expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_t', '0', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
- counter = { c: 1, t: 0 };
+ limitCounter = 1;
+ limitTimestamp = 0;
mockTimeService.now += 100;
await serviceUnderTest().limit(limit, actor);
- expect(counter?.c).toBe(1);
- expect(counter?.t).toBe(0);
+ expect(limitCounter).toBe(1);
+ expect(limitTimestamp).toBe(0);
});
it('should skip if factor is zero', async () => {
@@ -384,6 +376,48 @@ describe(SkRateLimiterService, () => {
await expect(promise).rejects.toThrow(/dripSize is less than 1/);
});
+
+ it('should retry when redis conflicts', async () => {
+ let numCalls = 0;
+ const realMockRedisExec = mockRedisExec;
+ mockRedisExec = () => {
+ if (numCalls > 0) {
+ mockRedisExec = realMockRedisExec;
+ }
+ numCalls++;
+ return Promise.resolve(null);
+ };
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(numCalls).toBe(2);
+ });
+
+ it('should bail out after 3 tries', async () => {
+ let numCalls = 0;
+ mockRedisExec = () => {
+ numCalls++;
+ return Promise.resolve(null);
+ };
+
+ const promise = serviceUnderTest().limit(limit, actor);
+
+ await expect(promise).rejects.toThrow(/transaction conflict/);
+ expect(numCalls).toBe(3);
+ });
+
+ it('should apply correction if extra calls slip through', async () => {
+ limitCounter = 2;
+
+ const info = await serviceUnderTest().limit(limit, actor);
+
+ expect(info.blocked).toBeTruthy();
+ expect(info.remaining).toBe(0);
+ expect(info.resetMs).toBe(2000);
+ expect(info.resetSec).toBe(2);
+ expect(info.fullResetMs).toBe(2000);
+ expect(info.fullResetSec).toBe(2);
+ });
});
describe('with min interval', () => {
@@ -403,12 +437,6 @@ describe(SkRateLimiterService, () => {
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);
@@ -423,7 +451,7 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
expect(minCounter).not.toBeUndefined();
- expect(minCounter?.c).toBe(1);
+ expect(minCounter).toBe(1);
});
it('should set timestamp when called', async () => {
@@ -432,27 +460,29 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
expect(minCounter).not.toBeUndefined();
- expect(minCounter?.t).toBe(1000);
+ expect(minTimestamp).toBe(1000);
});
it('should decrement counter when minInterval has passed', async () => {
- minCounter = { c: 1, t: 0 };
+ minCounter = 1;
+ minTimestamp = 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
+ expect(minCounter).toBe(1); // 1 (starting) - 1 (interval) + 1 (call) = 1
});
it('should reset counter entirely', async () => {
- minCounter = { c: 2, t: 0 };
+ minCounter = 2;
+ minTimestamp = 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
+ expect(minCounter).toBe(1); // 2 (starting) - 2 (interval) + 1 (call) = 1
});
it('should maintain counter between calls over time', async () => {
@@ -465,25 +495,13 @@ describe(SkRateLimiterService, () => {
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();
+ expect(minCounter).toBe(1);
+ expect(minTimestamp).toBe(3000);
});
it('should block when interval exceeded', async () => {
- minCounter = { c: 1, t: 0 };
+ minCounter = 1;
+ minTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -491,7 +509,8 @@ describe(SkRateLimiterService, () => {
});
it('should calculate correct info when blocked', async () => {
- minCounter = { c: 1, t: 0 };
+ minCounter = 1;
+ minTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -502,7 +521,8 @@ describe(SkRateLimiterService, () => {
});
it('should allow when bucket is filled but interval has passed', async () => {
- minCounter = { c: 1, t: 0 };
+ minCounter = 1;
+ minTimestamp = 0;
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
@@ -511,7 +531,8 @@ describe(SkRateLimiterService, () => {
});
it('should scale interval by factor', async () => {
- minCounter = { c: 1, t: 0 };
+ minCounter = 1;
+ minTimestamp = 0;
mockTimeService.now += 500;
const info = await serviceUnderTest().limit(limit, actor, 0.5);
@@ -519,23 +540,39 @@ describe(SkRateLimiterService, () => {
expect(info.blocked).toBeFalsy();
});
- it('should set key expiration', async () => {
- const mock = jest.fn(mockRedisSet);
- mockRedisSet = mock;
+ it('should set counter expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
await serviceUnderTest().limit(limit, actor);
- expect(mock).toHaveBeenCalledWith(['rl_actor_test_min', '{"t":0,"c":1}', 'EX', 1]);
+ expect(commands).toContainEqual(['set', 'rl_actor_test_min_c', '1', 'EX', 1]);
+ });
+
+ it('should set timestamp expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['set', 'rl_actor_test_min_t', '0', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
- minCounter = { c: 1, t: 0 };
+ minCounter = 1;
+ minTimestamp = 0;
mockTimeService.now += 100;
await serviceUnderTest().limit(limit, actor);
- expect(minCounter?.c).toBe(1);
- expect(minCounter?.t).toBe(0);
+ expect(minCounter).toBe(1);
+ expect(minTimestamp).toBe(0);
});
it('should skip if factor is zero', async () => {
@@ -567,6 +604,19 @@ describe(SkRateLimiterService, () => {
await expect(promise).rejects.toThrow(/minInterval is negative/);
});
+
+ it('should not apply correction to extra calls', async () => {
+ minCounter = 2;
+
+ const info = await serviceUnderTest().limit(limit, actor);
+
+ expect(info.blocked).toBeTruthy();
+ expect(info.remaining).toBe(0);
+ expect(info.resetMs).toBe(1000);
+ expect(info.resetSec).toBe(1);
+ expect(info.fullResetMs).toBe(1000);
+ expect(info.fullResetSec).toBe(1);
+ });
});
describe('with legacy limit', () => {
@@ -587,16 +637,11 @@ describe(SkRateLimiterService, () => {
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 };
+ limitCounter = 10;
+ limitTimestamp = 0;
const i1 = await serviceUnderTest().limit(limit, actor);
mockTimeService.now += 1000;
@@ -619,7 +664,8 @@ describe(SkRateLimiterService, () => {
it('should calculate correct info when allowed', async () => {
limit.max = 10;
limit.duration = 10000;
- counter = { c: 10, t: 0 };
+ limitCounter = 10;
+ limitTimestamp = 0;
mockTimeService.now += 2000;
const info = await serviceUnderTest().limit(limit, actor);
@@ -634,7 +680,8 @@ describe(SkRateLimiterService, () => {
it('should calculate correct info when blocked', async () => {
limit.max = 10;
limit.duration = 10000;
- counter = { c: 10, t: 0 };
+ limitCounter = 10;
+ limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -646,7 +693,8 @@ describe(SkRateLimiterService, () => {
});
it('should allow when bucket is filled but interval has passed', async () => {
- counter = { c: 10, t: 0 };
+ limitCounter = 10;
+ limitTimestamp = 0;
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
@@ -655,37 +703,55 @@ describe(SkRateLimiterService, () => {
});
it('should scale limit by factor', async () => {
- counter = { c: 10, t: 0 };
+ limitCounter = 10;
+ limitTimestamp = 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;
+ it('should set counter expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_c', '1', 'EX', 1]);
+ });
+
+ it('should set timestamp expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
await serviceUnderTest().limit(limit, actor);
- expect(mock).toHaveBeenCalledWith(['rl_actor_test_bucket', '{"t":0,"c":1}', 'EX', 1]);
+ expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_t', '0', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
- counter = { c: 1, t: 0 };
+ limitCounter = 1;
+ limitTimestamp = 0;
mockTimeService.now += 100;
await serviceUnderTest().limit(limit, actor);
- expect(counter?.c).toBe(1);
- expect(counter?.t).toBe(0);
+ expect(limitCounter).toBe(1);
+ expect(limitTimestamp).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 };
+ limitCounter = 4096;
+ limitTimestamp = 0;
const i1 = await serviceUnderTest().limit(limit, actor);
mockTimeService.now = 1;
@@ -723,6 +789,19 @@ describe(SkRateLimiterService, () => {
await expect(promise).rejects.toThrow(/size is less than 1/);
});
+
+ it('should apply correction if extra calls slip through', async () => {
+ limitCounter = 2;
+
+ const info = await serviceUnderTest().limit(limit, actor);
+
+ expect(info.blocked).toBeTruthy();
+ expect(info.remaining).toBe(0);
+ expect(info.resetMs).toBe(2000);
+ expect(info.resetSec).toBe(2);
+ expect(info.fullResetMs).toBe(2000);
+ expect(info.fullResetSec).toBe(2);
+ });
});
describe('with legacy limit and min interval', () => {
@@ -744,14 +823,9 @@ describe(SkRateLimiterService, () => {
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 };
+ limitCounter = 5;
+ limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -759,7 +833,8 @@ describe(SkRateLimiterService, () => {
});
it('should block when minInterval exceeded', async () => {
- minCounter = { c: 1, t: 0 };
+ minCounter = 1;
+ minTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -767,7 +842,8 @@ describe(SkRateLimiterService, () => {
});
it('should calculate correct info when allowed', async () => {
- counter = { c: 1, t: 0 };
+ limitCounter = 1;
+ limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -779,7 +855,8 @@ describe(SkRateLimiterService, () => {
});
it('should calculate correct info when blocked by limit', async () => {
- counter = { c: 5, t: 0 };
+ limitCounter = 5;
+ limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -791,7 +868,8 @@ describe(SkRateLimiterService, () => {
});
it('should calculate correct info when blocked by minInterval', async () => {
- minCounter = { c: 1, t: 0 };
+ minCounter = 1;
+ minTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -803,7 +881,8 @@ describe(SkRateLimiterService, () => {
});
it('should allow when counter is filled but interval has passed', async () => {
- counter = { c: 5, t: 0 };
+ limitCounter = 5;
+ limitTimestamp = 0;
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
@@ -812,7 +891,8 @@ describe(SkRateLimiterService, () => {
});
it('should allow when minCounter is filled but interval has passed', async () => {
- minCounter = { c: 1, t: 0 };
+ minCounter = 1;
+ minTimestamp = 0;
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
@@ -821,8 +901,10 @@ describe(SkRateLimiterService, () => {
});
it('should scale limit and interval by factor', async () => {
- counter = { c: 5, t: 0 };
- minCounter = { c: 1, t: 0 };
+ limitCounter = 5;
+ limitTimestamp = 0;
+ minCounter = 1;
+ minTimestamp = 0;
mockTimeService.now += 500;
const info = await serviceUnderTest().limit(limit, actor, 0.5);
@@ -830,27 +912,81 @@ describe(SkRateLimiterService, () => {
expect(info.blocked).toBeFalsy();
});
- it('should set key expiration', async () => {
- const mock = jest.fn(mockRedisSet);
- mockRedisSet = mock;
+ it('should set bucket counter expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_c', '1', 'EX', 1]);
+ });
+
+ it('should set bucket timestamp expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
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]);
+ expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_t', '0', 'EX', 1]);
+ });
+
+ it('should set min counter expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['set', 'rl_actor_test_min_c', '1', 'EX', 1]);
+ });
+
+ it('should set min timestamp expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['set', 'rl_actor_test_min_t', '0', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
- counter = { c: 5, t: 0 };
- minCounter = { c: 1, t: 0 };
+ limitCounter = 5;
+ limitTimestamp = 0;
+ minCounter = 1;
+ minTimestamp = 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);
+ expect(limitCounter).toBe(5);
+ expect(limitTimestamp).toBe(0);
+ expect(minCounter).toBe(1);
+ expect(minTimestamp).toBe(0);
+ });
+
+ it('should apply correction if extra calls slip through', async () => {
+ limitCounter = 6;
+ minCounter = 6;
+
+ const info = await serviceUnderTest().limit(limit, actor);
+
+ expect(info.blocked).toBeTruthy();
+ expect(info.remaining).toBe(0);
+ expect(info.resetMs).toBe(2000);
+ expect(info.resetSec).toBe(2);
+ expect(info.fullResetMs).toBe(6000);
+ expect(info.fullResetSec).toBe(6);
});
});
});
--
cgit v1.2.3-freya
From ead781900dcf98b9dace91aa4a5ec7b3cecf07e2 Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Mon, 9 Dec 2024 19:04:59 -0500
Subject: enable rate limits for dev environment
---
packages/backend/src/server/api/SkRateLimiterService.ts | 2 +-
packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/api/SkRateLimiterService.ts
index 05166ed93c..b11d1556ba 100644
--- a/packages/backend/src/server/api/SkRateLimiterService.ts
+++ b/packages/backend/src/server/api/SkRateLimiterService.ts
@@ -24,7 +24,7 @@ export class SkRateLimiterService {
@Inject(EnvService)
envService: EnvService,
) {
- this.disabled = envService.env.NODE_ENV !== 'production';
+ this.disabled = envService.env.NODE_ENV === 'test';
}
public async limit(limit: Keyed, actor: string, factor = 1): Promise {
diff --git a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
index 871c9afa64..90030495ed 100644
--- a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
+++ b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
@@ -120,7 +120,7 @@ describe(SkRateLimiterService, () => {
});
});
- it('should bypass in non-production', async () => {
+ it('should bypass in test environment', async () => {
mockEnvironment.NODE_ENV = 'test';
const info = await serviceUnderTest().limit({ key: 'l', type: undefined, max: 0 }, actor);
--
cgit v1.2.3-freya
From 407b2423af31ecaf44035f66a180a0bbc40e3aaa Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Tue, 10 Dec 2024 19:01:35 -0500
Subject: fix redis transaction implementation
---
packages/backend/src/core/CoreModule.ts | 6 +
packages/backend/src/core/RedisConnectionPool.ts | 103 ++++++
packages/backend/src/core/TimeoutService.ts | 76 +++++
packages/backend/src/misc/rate-limit-utils.ts | 19 +-
.../backend/src/server/api/SkRateLimiterService.ts | 220 ++++++-------
.../unit/server/api/SkRateLimiterServiceTests.ts | 344 ++++++++-------------
6 files changed, 429 insertions(+), 339 deletions(-)
create mode 100644 packages/backend/src/core/RedisConnectionPool.ts
create mode 100644 packages/backend/src/core/TimeoutService.ts
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index b18db7f366..caf135ae4b 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -155,6 +155,8 @@ import { QueueModule } from './QueueModule.js';
import { QueueService } from './QueueService.js';
import { LoggerService } from './LoggerService.js';
import { SponsorsService } from './SponsorsService.js';
+import { RedisConnectionPool } from './RedisConnectionPool.js';
+import { TimeoutService } from './TimeoutService.js';
import type { Provider } from '@nestjs/common';
//#region 文字列ベースでのinjection用(循環参照対応のため)
@@ -383,6 +385,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ChannelFollowingService,
RegistryApiService,
ReversiService,
+ RedisConnectionPool,
+ TimeoutService,
TimeService,
EnvService,
@@ -684,6 +688,8 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ChannelFollowingService,
RegistryApiService,
ReversiService,
+ RedisConnectionPool,
+ TimeoutService,
TimeService,
EnvService,
diff --git a/packages/backend/src/core/RedisConnectionPool.ts b/packages/backend/src/core/RedisConnectionPool.ts
new file mode 100644
index 0000000000..7ebefdfcb3
--- /dev/null
+++ b/packages/backend/src/core/RedisConnectionPool.ts
@@ -0,0 +1,103 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
+import Redis, { RedisOptions } from 'ioredis';
+import { DI } from '@/di-symbols.js';
+import type { Config } from '@/config.js';
+import Logger from '@/logger.js';
+import { Timeout, TimeoutService } from '@/core/TimeoutService.js';
+import { LoggerService } from './LoggerService.js';
+
+/**
+ * Target number of connections to keep open and ready for use.
+ * The pool may grow beyond this during bursty traffic, but it will always shrink back to this number.
+ * The pool may remain below this number is the server never experiences enough traffic to consume this many clients.
+ */
+export const poolSize = 16;
+
+/**
+ * How often to drop an idle connection from the pool.
+ * This will never shrink the pool below poolSize.
+ */
+export const poolShrinkInterval = 5 * 1000; // 5 seconds
+
+@Injectable()
+export class RedisConnectionPool implements OnApplicationShutdown {
+ private readonly poolShrinkTimer: Timeout;
+ private readonly pool: Redis.Redis[] = [];
+ private readonly logger: Logger;
+ private readonly redisOptions: RedisOptions;
+
+ constructor(@Inject(DI.config) config: Config, loggerService: LoggerService, timeoutService: TimeoutService) {
+ this.logger = loggerService.getLogger('redis-pool');
+ this.poolShrinkTimer = timeoutService.setInterval(() => this.shrinkPool(), poolShrinkInterval);
+ this.redisOptions = {
+ ...config.redis,
+
+ // Set lazyConnect so that we can await() the connection manually.
+ // This helps to avoid a "stampede" of new connections (which are processed in the background!) under bursty conditions.
+ lazyConnect: true,
+ enableOfflineQueue: false,
+ };
+ }
+
+ /**
+ * Gets a Redis connection from the pool, or creates a new connection if the pool is empty.
+ * The returned object MUST be returned with a call to free(), even in the case of exceptions!
+ * Use a try...finally block for safe handling.
+ */
+ public async alloc(): Promise {
+ let redis = this.pool.pop();
+
+ // The pool may be empty if we're under heavy load and/or we haven't opened all connections.
+ // Just construct a new instance, which will eventually be added to the pool.
+ // Excess clients will be disposed eventually.
+ if (!redis) {
+ redis = new Redis.Redis(this.redisOptions);
+ await redis.connect();
+ }
+
+ return redis;
+ }
+
+ /**
+ * Returns a Redis connection to the pool.
+ * The instance MUST not be used after returning!
+ * Use a try...finally block for safe handling.
+ */
+ public async free(redis: Redis.Redis): Promise {
+ // https://redis.io/docs/latest/commands/reset/
+ await redis.reset();
+
+ this.pool.push(redis);
+ }
+
+ public async onApplicationShutdown(): Promise {
+ // Cancel timer, otherwise it will cause a memory leak
+ clearInterval(this.poolShrinkTimer);
+
+ // Disconnect all remaining instances
+ while (this.pool.length > 0) {
+ await this.dropClient();
+ }
+ }
+
+ private async shrinkPool(): Promise {
+ this.logger.debug(`Pool size is ${this.pool.length}`);
+ if (this.pool.length > poolSize) {
+ await this.dropClient();
+ }
+ }
+
+ private async dropClient(): Promise {
+ try {
+ const redis = this.pool.pop();
+ await redis?.quit();
+ } catch (err) {
+ this.logger.warn(`Error disconnecting from redis: ${err}`, { err });
+ }
+ }
+}
diff --git a/packages/backend/src/core/TimeoutService.ts b/packages/backend/src/core/TimeoutService.ts
new file mode 100644
index 0000000000..093b9a7b04
--- /dev/null
+++ b/packages/backend/src/core/TimeoutService.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * Provides access to setTimeout, setInterval, and related functions.
+ * Used to support deterministic unit testing.
+ */
+export class TimeoutService {
+ /**
+ * Returns a promise that resolves after the specified timeout in milliseconds.
+ */
+ public delay(timeout: number): Promise {
+ return new Promise(resolve => {
+ this.setTimeout(resolve, timeout);
+ });
+ }
+
+ /**
+ * Passthrough to node's setTimeout
+ */
+ public setTimeout(handler: TimeoutHandler, timeout?: number): Timeout {
+ return setTimeout(() => handler(), timeout);
+ }
+
+ /**
+ * Passthrough to node's setInterval
+ */
+ public setInterval(handler: TimeoutHandler, timeout?: number): Timeout {
+ return setInterval(() => handler(), timeout);
+ }
+
+ /**
+ * Passthrough to node's clearTimeout
+ */
+ public clearTimeout(timeout: Timeout) {
+ clearTimeout(timeout);
+ }
+
+ /**
+ * Passthrough to node's clearInterval
+ */
+ public clearInterval(timeout: Timeout) {
+ clearInterval(timeout);
+ }
+}
+
+/**
+ * Function to be called when a timer or interval elapses.
+ */
+export type TimeoutHandler = () => void;
+
+/**
+ * A fucked TS issue causes the DOM setTimeout to get merged with Node setTimeout, creating a "quantum method" that returns either "number" or "NodeJS.Timeout" depending on how it's called.
+ * This would be fine, except it always matches the *wrong type*!
+ * The result is this "impossible" scenario:
+ *
+ * ```typescript
+ * // Test evaluates to "false", because the method's return type is not equal to itself.
+ * type Test = ReturnType extends ReturnType ? true : false;
+ *
+ * // This is a compiler error, because the type is broken and triggers some internal TS bug.
+ * const timeout = setTimeout(handler);
+ * clearTimeout(timeout); // compiler error here, because even type inference doesn't work.
+ *
+ * // This fails to compile.
+ * function test(handler, timeout): ReturnType {
+ * return setTimeout(handler, timeout);
+ * }
+ * ```
+ *
+ * The bug is marked as "wontfix" by TS devs, so we have to work around it ourselves. -_-
+ * By forcing the return type to *explicitly* include both types, we at least make it possible to work with the resulting token.
+ */
+export type Timeout = NodeJS.Timeout | number;
diff --git a/packages/backend/src/misc/rate-limit-utils.ts b/packages/backend/src/misc/rate-limit-utils.ts
index 9909bb97fa..cc13111390 100644
--- a/packages/backend/src/misc/rate-limit-utils.ts
+++ b/packages/backend/src/misc/rate-limit-utils.ts
@@ -117,12 +117,27 @@ export interface LimitInfo {
fullResetMs: number;
}
+export const disabledLimitInfo: Readonly = Object.freeze({
+ blocked: false,
+ remaining: Number.MAX_SAFE_INTEGER,
+ resetSec: 0,
+ resetMs: 0,
+ fullResetSec: 0,
+ fullResetMs: 0,
+});
+
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 type MaxLegacyLimit = LegacyRateLimit & { duration: number, max: number };
+export function hasMaxLimit(limit: LegacyRateLimit): limit is MaxLegacyLimit {
+ return limit.max != null && limit.duration != null;
+}
+
+export type MinLegacyLimit = LegacyRateLimit & { minInterval: number };
+export function hasMinLimit(limit: LegacyRateLimit): limit is MinLegacyLimit {
+ return limit.minInterval != null;
}
export function sendRateLimitHeaders(reply: FastifyReply, info: LimitInfo): void {
diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/api/SkRateLimiterService.ts
index b11d1556ba..71681aadc9 100644
--- a/packages/backend/src/server/api/SkRateLimiterService.ts
+++ b/packages/backend/src/server/api/SkRateLimiterService.ts
@@ -7,8 +7,9 @@ import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { TimeService } from '@/core/TimeService.js';
import { EnvService } from '@/core/EnvService.js';
-import { DI } from '@/di-symbols.js';
-import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed } from '@/misc/rate-limit-utils.js';
+import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed, hasMaxLimit, disabledLimitInfo, MaxLegacyLimit, MinLegacyLimit } from '@/misc/rate-limit-utils.js';
+import { RedisConnectionPool } from '@/core/RedisConnectionPool.js';
+import { TimeoutService } from '@/core/TimeoutService.js';
@Injectable()
export class SkRateLimiterService {
@@ -18,8 +19,11 @@ export class SkRateLimiterService {
@Inject(TimeService)
private readonly timeService: TimeService,
- @Inject(DI.redis)
- private readonly redisClient: Redis.Redis,
+ @Inject(TimeoutService)
+ private readonly timeoutService: TimeoutService,
+
+ @Inject(RedisConnectionPool)
+ private readonly redisPool: RedisConnectionPool,
@Inject(EnvService)
envService: EnvService,
@@ -29,117 +33,110 @@ export class SkRateLimiterService {
public async limit(limit: Keyed, actor: string, factor = 1): Promise {
if (this.disabled || factor === 0) {
- return {
- blocked: false,
- remaining: Number.MAX_SAFE_INTEGER,
- resetSec: 0,
- resetMs: 0,
- fullResetSec: 0,
- fullResetMs: 0,
- };
+ return disabledLimitInfo;
}
if (factor < 0) {
throw new Error(`Rate limit factor is zero or negative: ${factor}`);
}
- return await this.tryLimit(limit, actor, factor);
+ const redis = await this.redisPool.alloc();
+ try {
+ return await this.tryLimit(redis, limit, actor, factor);
+ } finally {
+ await this.redisPool.free(redis);
+ }
}
- private async tryLimit(limit: Keyed, actor: string, factor: number, retry = 1): Promise {
+ private async tryLimit(redis: Redis.Redis, limit: Keyed, actor: string, factor: number, retry = 0): Promise {
try {
+ if (retry > 0) {
+ // Real-world testing showed the need for backoff to "spread out" bursty traffic.
+ const backoff = Math.round(Math.pow(2, retry + Math.random()));
+ await this.timeoutService.delay(backoff);
+ }
+
if (isLegacyRateLimit(limit)) {
- return await this.limitLegacy(limit, actor, factor);
+ return await this.limitLegacy(redis, limit, actor, factor);
} else {
- return await this.limitBucket(limit, actor, factor);
+ return await this.limitBucket(redis, limit, actor, factor);
}
} catch (err) {
// We may experience collision errors from optimistic locking.
// This is expected, so we should retry a few times before giving up.
// https://redis.io/docs/latest/develop/interact/transactions/#optimistic-locking-using-check-and-set
- if (err instanceof TransactionError && retry < 3) {
- return await this.tryLimit(limit, actor, factor, retry + 1);
+ if (err instanceof ConflictError && retry < 4) {
+ // We can reuse the same connection to reduce pool contention, but we have to reset it first.
+ await redis.reset();
+ return await this.tryLimit(redis, limit, actor, factor, retry + 1);
}
throw err;
}
}
- private async limitLegacy(limit: Keyed, actor: string, factor: number): Promise {
- const promises: Promise[] = [];
-
- // The "min" limit - if present - is handled directly.
- if (hasMinLimit(limit)) {
- promises.push(
- this.limitMin(limit, actor, factor),
- );
+ private async limitLegacy(redis: Redis.Redis, limit: Keyed, actor: string, factor: number): Promise {
+ if (hasMaxLimit(limit)) {
+ return await this.limitMaxLegacy(redis, limit, actor, factor);
+ } else if (hasMinLimit(limit)) {
+ return await this.limitMinLegacy(redis, limit, actor, factor);
+ } else {
+ return disabledLimitInfo;
}
+ }
- // 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),
- );
- }
+ private async limitMaxLegacy(redis: Redis.Redis, limit: Keyed, actor: string, factor: number): Promise {
+ if (limit.duration === 0) return disabledLimitInfo;
+ if (limit.duration < 0) throw new Error(`Invalid rate limit ${limit.key}: duration is negative (${limit.duration})`);
+ if (limit.max < 1) throw new Error(`Invalid rate limit ${limit.key}: max is less than 1 (${limit.max})`);
- 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),
- };
- }
+ // Derive initial dripRate from minInterval OR duration/max.
+ const initialDripRate = Math.max(limit.minInterval ?? Math.round(limit.duration / limit.max), 1);
- private async limitMin(limit: Keyed & { minInterval: number }, actor: string, factor: number): Promise {
- if (limit.minInterval === 0) return null;
- if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`);
+ // Calculate dripSize to reach max at exactly duration
+ const dripSize = Math.max(Math.round(limit.max / (limit.duration / initialDripRate)), 1);
- const minInterval = Math.max(Math.ceil(limit.minInterval * factor), 0);
- const expirationSec = Math.max(Math.ceil(minInterval / 1000), 1);
+ // Calculate final dripRate from dripSize and duration/max
+ const dripRate = Math.max(Math.round(limit.duration / (limit.max / dripSize)), 1);
- // Check for window clear
- const counter = await this.getLimitCounter(limit, actor, 'min');
- if (counter.counter > 0) {
- const isCleared = this.timeService.now - counter.timestamp >= minInterval;
- if (isCleared) {
- counter.counter = 0;
- }
- }
+ const bucketLimit: Keyed = {
+ type: 'bucket',
+ key: limit.key,
+ size: limit.max,
+ dripRate,
+ dripSize,
+ };
+ return await this.limitBucket(redis, bucketLimit, actor, factor);
+ }
- // Increment the limit, then synchronize with redis
- const blocked = counter.counter > 0;
- if (!blocked) {
- counter.counter++;
- counter.timestamp = this.timeService.now;
- await this.updateLimitCounter(limit, actor, 'min', expirationSec, counter);
- }
+ private async limitMinLegacy(redis: Redis.Redis, limit: Keyed, actor: string, factor: number): Promise {
+ if (limit.minInterval === 0) return disabledLimitInfo;
+ if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`);
- // Calculate limit status
- const resetMs = Math.max(minInterval - (this.timeService.now - counter.timestamp), 0);
- const resetSec = Math.ceil(resetMs / 1000);
- return { blocked, remaining: 0, resetSec, resetMs, fullResetSec: resetSec, fullResetMs: resetMs };
+ const dripRate = Math.max(Math.round(limit.minInterval), 1);
+ const bucketLimit: Keyed = {
+ type: 'bucket',
+ key: limit.key,
+ size: 1,
+ dripRate,
+ dripSize: 1,
+ };
+ return await this.limitBucket(redis, bucketLimit, actor, factor);
}
- private async limitBucket(limit: Keyed, actor: string, factor: number): Promise {
+ private async limitBucket(redis: Redis.Redis, limit: Keyed, actor: string, factor: number): Promise {
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 redisKey = createLimitKey(limit, actor);
const bucketSize = Math.max(Math.ceil(limit.size / factor), 1);
const dripRate = Math.ceil(limit.dripRate ?? 1000);
const dripSize = Math.ceil(limit.dripSize ?? 1);
const expirationSec = Math.max(Math.ceil(bucketSize / dripRate), 1);
// Simulate bucket drips
- const counter = await this.getLimitCounter(limit, actor, 'bucket');
+ const counter = await this.getLimitCounter(redis, redisKey);
if (counter.counter > 0) {
const dripsSinceLastTick = Math.floor((this.timeService.now - counter.timestamp) / dripRate) * dripSize;
counter.counter = Math.max(counter.counter - dripsSinceLastTick, 0);
@@ -150,7 +147,7 @@ export class SkRateLimiterService {
if (!blocked) {
counter.counter++;
counter.timestamp = this.timeService.now;
- await this.updateLimitCounter(limit, actor, 'bucket', expirationSec, counter);
+ await this.updateLimitCounter(redis, redisKey, expirationSec, counter);
}
// Calculate how much time is needed to free up a bucket slot
@@ -167,60 +164,49 @@ export class SkRateLimiterService {
return { blocked, remaining, resetSec, resetMs, fullResetSec, fullResetMs };
}
- private async getLimitCounter(limit: Keyed, actor: string, subject: string): Promise {
- const timestampKey = createLimitKey(limit, actor, subject, 't');
- const counterKey = createLimitKey(limit, actor, subject, 'c');
-
- const [timestamp, counter] = await this.executeRedis(
- [
- ['get', timestampKey],
- ['get', counterKey],
- ],
- [
- timestampKey,
- counterKey,
- ],
- );
+ private async getLimitCounter(redis: Redis.Redis, key: string): Promise {
+ const counter: LimitCounter = { counter: 0, timestamp: 0 };
- return {
- timestamp: timestamp ? parseInt(timestamp) : 0,
- counter: counter ? parseInt(counter) : 0,
- };
+ // Watch the key BEFORE reading it!
+ await redis.watch(key);
+ const data = await redis.get(key);
+
+ // Data may be missing or corrupt if the key doesn't exist.
+ // This is an expected edge case.
+ if (data) {
+ const parts = data.split(':');
+ if (parts.length === 2) {
+ counter.counter = parseInt(parts[0]);
+ counter.timestamp = parseInt(parts[1]);
+ }
+ }
+
+ return counter;
}
- private async updateLimitCounter(limit: Keyed, actor: string, subject: string, expirationSec: number, counter: LimitCounter): Promise {
- const timestampKey = createLimitKey(limit, actor, subject, 't');
- const counterKey = createLimitKey(limit, actor, subject, 'c');
-
- await this.executeRedis(
- [
- ['set', timestampKey, counter.timestamp.toString(), 'EX', expirationSec],
- ['set', counterKey, counter.counter.toString(), 'EX', expirationSec],
- ],
- [
- timestampKey,
- counterKey,
- ],
+ private async updateLimitCounter(redis: Redis.Redis, key: string, expirationSec: number, counter: LimitCounter): Promise {
+ const data = `${counter.counter}:${counter.timestamp}`;
+
+ await this.executeRedisMulti(
+ redis,
+ [['set', key, data, 'EX', expirationSec]],
);
}
- private async executeRedis(batch: RedisBatch, watch: string[]): Promise> {
- const results = await this.redisClient
- .multi(batch)
- .watch(watch)
- .exec();
+ private async executeRedisMulti(redis: Redis.Redis, batch: RedisBatch): Promise> {
+ const results = await redis.multi(batch).exec();
- // Transaction error
+ // Transaction conflict (retryable)
if (!results) {
- throw new TransactionError('Redis error: transaction conflict');
+ throw new ConflictError('Redis error: transaction conflict');
}
- // The entire call failed
+ // Transaction failed (fatal)
if (results.length !== batch.length) {
throw new Error('Redis error: failed to execute batch');
}
- // A particular command failed
+ // Command failed (fatal)
const errors = results.map(r => r[0]).filter(e => e != null);
if (errors.length > 0) {
throw new AggregateError(errors, `Redis error: failed to execute command(s): '${errors.join('\', \'')}'`);
@@ -233,11 +219,11 @@ export class SkRateLimiterService {
type RedisBatch = [string, ...unknown[]][] & { length: Num };
type RedisResults = (string | null)[] & { length: Num };
-function createLimitKey(limit: Keyed, actor: string, subject: string, value: string): string {
- return `rl_${actor}_${limit.key}_${subject}_${value}`;
+function createLimitKey(limit: Keyed, actor: string): string {
+ return `rl_${actor}_${limit.key}`;
}
-class TransactionError extends Error {}
+class ConflictError extends Error {}
interface LimitCounter {
timestamp: number;
diff --git a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
index 90030495ed..deb6b9f80e 100644
--- a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
+++ b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
@@ -6,6 +6,8 @@
import type Redis from 'ioredis';
import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
import { BucketRateLimit, Keyed, LegacyRateLimit } from '@/misc/rate-limit-utils.js';
+import { RedisConnectionPool } from '@/core/RedisConnectionPool.js';
+import { Timeout, TimeoutHandler, TimeoutService } from '@/core/TimeoutService.js';
/* eslint-disable @typescript-eslint/no-non-null-assertion */
@@ -24,28 +26,50 @@ describe(SkRateLimiterService, () => {
},
};
+ function callMockRedis(command: [string, ...unknown[]]) {
+ const handlerResults = mockRedis.map(handler => handler(command));
+ const finalResult = handlerResults.findLast(result => result != null);
+ return finalResult ?? [null, null];
+ }
+
+ // I apologize to anyone who tries to read this later 🥲
mockRedis = [];
mockRedisExec = (batch) => {
const results: [Error | null, unknown][] = batch.map(command => {
- const handlerResults = mockRedis.map(handler => handler(command));
- const finalResult = handlerResults.findLast(result => result != null);
- return finalResult ?? [new Error('test error: no handler'), null];
+ return callMockRedis(command);
});
return Promise.resolve(results);
};
const mockRedisClient = {
+ watch(...args: unknown[]) {
+ const result = callMockRedis(['watch', ...args]);
+ return Promise.resolve(result[0] ?? result[1]);
+ },
+ get(...args: unknown[]) {
+ const result = callMockRedis(['get', ...args]);
+ return Promise.resolve(result[0] ?? result[1]);
+ },
+ set(...args: unknown[]) {
+ const result = callMockRedis(['set', ...args]);
+ return Promise.resolve(result[0] ?? result[1]);
+ },
multi(batch: [string, ...unknown[]][]) {
return {
- watch() {
- return {
- exec() {
- return mockRedisExec(batch);
- },
- };
+ exec() {
+ return mockRedisExec(batch);
},
};
},
+ reset() {
+ return Promise.resolve();
+ },
} as unknown as Redis.Redis;
+ const mockRedisPool = {
+ alloc() {
+ return Promise.resolve(mockRedisClient);
+ },
+ free() {},
+ } as unknown as RedisConnectionPool;
mockEnvironment = Object.create(process.env);
mockEnvironment.NODE_ENV = 'production';
@@ -53,9 +77,22 @@ describe(SkRateLimiterService, () => {
env: mockEnvironment,
};
+ const mockTimeoutService = new class extends TimeoutService {
+ setTimeout(handler: TimeoutHandler): Timeout {
+ handler();
+ return 0;
+ }
+ setInterval(handler: TimeoutHandler): Timeout {
+ handler();
+ return 0;
+ }
+ clearTimeout() {}
+ clearInterval() {}
+ };
+
let service: SkRateLimiterService | undefined = undefined;
serviceUnderTest = () => {
- return service ??= new SkRateLimiterService(mockTimeService, mockRedisClient, mockEnvService);
+ return service ??= new SkRateLimiterService(mockTimeService, mockTimeoutService, mockRedisPool, mockEnvService);
};
});
@@ -65,57 +102,23 @@ describe(SkRateLimiterService, () => {
let limitCounter: number | undefined = undefined;
let limitTimestamp: number | undefined = undefined;
- let minCounter: number | undefined = undefined;
- let minTimestamp: number | undefined = undefined;
beforeEach(() => {
limitCounter = undefined;
limitTimestamp = undefined;
- minCounter = undefined;
- minTimestamp = undefined;
mockRedis.push(([command, ...args]) => {
- if (command === 'set' && args[0] === 'rl_actor_test_bucket_t') {
- limitTimestamp = parseInt(args[1] as string);
- return [null, args[1]];
- }
- if (command === 'get' && args[0] === 'rl_actor_test_bucket_t') {
- return [null, limitTimestamp?.toString() ?? null];
- }
- // if (command === 'incr' && args[0] === 'rl_actor_test_bucket_c') {
- // limitCounter = (limitCounter ?? 0) + 1;
- // return [null, null];
- // }
- if (command === 'set' && args[0] === 'rl_actor_test_bucket_c') {
- limitCounter = parseInt(args[1] as string);
+ if (command === 'set' && args[0] === 'rl_actor_test') {
+ const parts = (args[1] as string).split(':');
+ limitCounter = parseInt(parts[0] as string);
+ limitTimestamp = parseInt(parts[1] as string);
return [null, args[1]];
}
- if (command === 'get' && args[0] === 'rl_actor_test_bucket_c') {
- return [null, limitCounter?.toString() ?? null];
+ if (command === 'get' && args[0] === 'rl_actor_test') {
+ const data = `${limitCounter ?? 0}:${limitTimestamp ?? 0}`;
+ return [null, data];
}
- if (command === 'set' && args[0] === 'rl_actor_test_min_t') {
- minTimestamp = parseInt(args[1] as string);
- return [null, args[1]];
- }
- if (command === 'get' && args[0] === 'rl_actor_test_min_t') {
- return [null, minTimestamp?.toString() ?? null];
- }
- // if (command === 'incr' && args[0] === 'rl_actor_test_min_c') {
- // minCounter = (minCounter ?? 0) + 1;
- // return [null, null];
- // }
- if (command === 'set' && args[0] === 'rl_actor_test_min_c') {
- minCounter = parseInt(args[1] as string);
- return [null, args[1]];
- }
- if (command === 'get' && args[0] === 'rl_actor_test_min_c') {
- return [null, minCounter?.toString() ?? null];
- }
- // if (command === 'expire') {
- // return [null, null];
- // }
-
return null;
});
});
@@ -266,19 +269,7 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_c', '1', 'EX', 1]);
- });
-
- it('should set timestamp expiration', async () => {
- const commands: unknown[][] = [];
- mockRedis.push(command => {
- commands.push(command);
- return null;
- });
-
- await serviceUnderTest().limit(limit, actor);
-
- expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_t', '0', 'EX', 1]);
+ expect(commands).toContainEqual(['set', 'rl_actor_test', '1:0', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
@@ -379,12 +370,12 @@ describe(SkRateLimiterService, () => {
it('should retry when redis conflicts', async () => {
let numCalls = 0;
- const realMockRedisExec = mockRedisExec;
+ const originalExec = mockRedisExec;
mockRedisExec = () => {
- if (numCalls > 0) {
- mockRedisExec = realMockRedisExec;
- }
numCalls++;
+ if (numCalls > 1) {
+ mockRedisExec = originalExec;
+ }
return Promise.resolve(null);
};
@@ -393,7 +384,7 @@ describe(SkRateLimiterService, () => {
expect(numCalls).toBe(2);
});
- it('should bail out after 3 tries', async () => {
+ it('should bail out after 5 tries', async () => {
let numCalls = 0;
mockRedisExec = () => {
numCalls++;
@@ -403,7 +394,7 @@ describe(SkRateLimiterService, () => {
const promise = serviceUnderTest().limit(limit, actor);
await expect(promise).rejects.toThrow(/transaction conflict/);
- expect(numCalls).toBe(3);
+ expect(numCalls).toBe(5);
});
it('should apply correction if extra calls slip through', async () => {
@@ -450,8 +441,8 @@ describe(SkRateLimiterService, () => {
it('should increment counter when called', async () => {
await serviceUnderTest().limit(limit, actor);
- expect(minCounter).not.toBeUndefined();
- expect(minCounter).toBe(1);
+ expect(limitCounter).not.toBeUndefined();
+ expect(limitCounter).toBe(1);
});
it('should set timestamp when called', async () => {
@@ -459,30 +450,19 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(minCounter).not.toBeUndefined();
- expect(minTimestamp).toBe(1000);
+ expect(limitCounter).not.toBeUndefined();
+ expect(limitTimestamp).toBe(1000);
});
it('should decrement counter when minInterval has passed', async () => {
- minCounter = 1;
- minTimestamp = 0;
- mockTimeService.now = 1000;
-
- await serviceUnderTest().limit(limit, actor);
-
- expect(minCounter).not.toBeUndefined();
- expect(minCounter).toBe(1); // 1 (starting) - 1 (interval) + 1 (call) = 1
- });
-
- it('should reset counter entirely', async () => {
- minCounter = 2;
- minTimestamp = 0;
+ limitCounter = 1;
+ limitTimestamp = 0;
mockTimeService.now = 1000;
await serviceUnderTest().limit(limit, actor);
- expect(minCounter).not.toBeUndefined();
- expect(minCounter).toBe(1); // 2 (starting) - 2 (interval) + 1 (call) = 1
+ expect(limitCounter).not.toBeUndefined();
+ expect(limitCounter).toBe(1); // 1 (starting) - 1 (interval) + 1 (call) = 1
});
it('should maintain counter between calls over time', async () => {
@@ -495,13 +475,13 @@ describe(SkRateLimiterService, () => {
mockTimeService.now += 1000; // 0 - 1 = 0
await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
- expect(minCounter).toBe(1);
- expect(minTimestamp).toBe(3000);
+ expect(limitCounter).toBe(1);
+ expect(limitTimestamp).toBe(3000);
});
it('should block when interval exceeded', async () => {
- minCounter = 1;
- minTimestamp = 0;
+ limitCounter = 1;
+ limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -509,8 +489,8 @@ describe(SkRateLimiterService, () => {
});
it('should calculate correct info when blocked', async () => {
- minCounter = 1;
- minTimestamp = 0;
+ limitCounter = 1;
+ limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -521,8 +501,8 @@ describe(SkRateLimiterService, () => {
});
it('should allow when bucket is filled but interval has passed', async () => {
- minCounter = 1;
- minTimestamp = 0;
+ limitCounter = 1;
+ limitTimestamp = 0;
mockTimeService.now = 1000;
const info = await serviceUnderTest().limit(limit, actor);
@@ -531,8 +511,8 @@ describe(SkRateLimiterService, () => {
});
it('should scale interval by factor', async () => {
- minCounter = 1;
- minTimestamp = 0;
+ limitCounter = 1;
+ limitTimestamp = 0;
mockTimeService.now += 500;
const info = await serviceUnderTest().limit(limit, actor, 0.5);
@@ -549,30 +529,18 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(commands).toContainEqual(['set', 'rl_actor_test_min_c', '1', 'EX', 1]);
- });
-
- it('should set timestamp expiration', async () => {
- const commands: unknown[][] = [];
- mockRedis.push(command => {
- commands.push(command);
- return null;
- });
-
- await serviceUnderTest().limit(limit, actor);
-
- expect(commands).toContainEqual(['set', 'rl_actor_test_min_t', '0', 'EX', 1]);
+ expect(commands).toContainEqual(['set', 'rl_actor_test', '1:0', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
- minCounter = 1;
- minTimestamp = 0;
+ limitCounter = 1;
+ limitTimestamp = 0;
mockTimeService.now += 100;
await serviceUnderTest().limit(limit, actor);
- expect(minCounter).toBe(1);
- expect(minTimestamp).toBe(0);
+ expect(limitCounter).toBe(1);
+ expect(limitTimestamp).toBe(0);
});
it('should skip if factor is zero', async () => {
@@ -605,17 +573,17 @@ describe(SkRateLimiterService, () => {
await expect(promise).rejects.toThrow(/minInterval is negative/);
});
- it('should not apply correction to extra calls', async () => {
- minCounter = 2;
+ it('should apply correction if extra calls slip through', async () => {
+ limitCounter = 2;
const info = await serviceUnderTest().limit(limit, actor);
expect(info.blocked).toBeTruthy();
expect(info.remaining).toBe(0);
- expect(info.resetMs).toBe(1000);
- expect(info.resetSec).toBe(1);
- expect(info.fullResetMs).toBe(1000);
- expect(info.fullResetSec).toBe(1);
+ expect(info.resetMs).toBe(2000);
+ expect(info.resetSec).toBe(2);
+ expect(info.fullResetMs).toBe(2000);
+ expect(info.fullResetSec).toBe(2);
});
});
@@ -720,19 +688,7 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_c', '1', 'EX', 1]);
- });
-
- it('should set timestamp expiration', async () => {
- const commands: unknown[][] = [];
- mockRedis.push(command => {
- commands.push(command);
- return null;
- });
-
- await serviceUnderTest().limit(limit, actor);
-
- expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_t', '0', 'EX', 1]);
+ expect(commands).toContainEqual(['set', 'rl_actor_test', '1:0', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
@@ -774,12 +730,21 @@ describe(SkRateLimiterService, () => {
await expect(promise).rejects.toThrow(/factor is zero or negative/);
});
+ it('should skip if duration is zero', async () => {
+ limit.duration = 0;
+
+ const info = await serviceUnderTest().limit(limit, actor);
+
+ expect(info.blocked).toBeFalsy();
+ expect(info.remaining).toBe(Number.MAX_SAFE_INTEGER);
+ });
+
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/);
+ await expect(promise).rejects.toThrow(/max is less than 1/);
});
it('should throw if max is negative', async () => {
@@ -787,7 +752,7 @@ describe(SkRateLimiterService, () => {
const promise = serviceUnderTest().limit(limit, actor);
- await expect(promise).rejects.toThrow(/size is less than 1/);
+ await expect(promise).rejects.toThrow(/max is less than 1/);
});
it('should apply correction if extra calls slip through', async () => {
@@ -811,7 +776,7 @@ describe(SkRateLimiterService, () => {
limit = {
type: undefined,
key,
- max: 5,
+ max: 10,
duration: 5000,
minInterval: 1000,
};
@@ -824,7 +789,7 @@ describe(SkRateLimiterService, () => {
});
it('should block when limit exceeded', async () => {
- limitCounter = 5;
+ limitCounter = 10;
limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -832,17 +797,8 @@ describe(SkRateLimiterService, () => {
expect(info.blocked).toBeTruthy();
});
- it('should block when minInterval exceeded', async () => {
- minCounter = 1;
- minTimestamp = 0;
-
- const info = await serviceUnderTest().limit(limit, actor);
-
- expect(info.blocked).toBeTruthy();
- });
-
it('should calculate correct info when allowed', async () => {
- limitCounter = 1;
+ limitCounter = 9;
limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -850,12 +806,12 @@ describe(SkRateLimiterService, () => {
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);
+ expect(info.fullResetSec).toBe(5);
+ expect(info.fullResetMs).toBe(5000);
});
- it('should calculate correct info when blocked by limit', async () => {
- limitCounter = 5;
+ it('should calculate correct info when blocked', async () => {
+ limitCounter = 10;
limitTimestamp = 0;
const info = await serviceUnderTest().limit(limit, actor);
@@ -867,19 +823,6 @@ describe(SkRateLimiterService, () => {
expect(info.fullResetMs).toBe(5000);
});
- it('should calculate correct info when blocked by minInterval', async () => {
- minCounter = 1;
- minTimestamp = 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 () => {
limitCounter = 5;
limitTimestamp = 0;
@@ -890,21 +833,23 @@ describe(SkRateLimiterService, () => {
expect(info.blocked).toBeFalsy();
});
- it('should allow when minCounter is filled but interval has passed', async () => {
- minCounter = 1;
- minTimestamp = 0;
- mockTimeService.now = 1000;
+ it('should drip according to minInterval', async () => {
+ limitCounter = 10;
+ limitTimestamp = 0;
+ mockTimeService.now += 1000;
- const info = await serviceUnderTest().limit(limit, actor);
+ const i1 = await serviceUnderTest().limit(limit, actor);
+ const i2 = await serviceUnderTest().limit(limit, actor);
+ const i3 = await serviceUnderTest().limit(limit, actor);
- expect(info.blocked).toBeFalsy();
+ expect(i1.blocked).toBeFalsy();
+ expect(i2.blocked).toBeFalsy();
+ expect(i3.blocked).toBeTruthy();
});
it('should scale limit and interval by factor', async () => {
limitCounter = 5;
limitTimestamp = 0;
- minCounter = 1;
- minTimestamp = 0;
mockTimeService.now += 500;
const info = await serviceUnderTest().limit(limit, actor, 0.5);
@@ -912,43 +857,7 @@ describe(SkRateLimiterService, () => {
expect(info.blocked).toBeFalsy();
});
- it('should set bucket counter expiration', async () => {
- const commands: unknown[][] = [];
- mockRedis.push(command => {
- commands.push(command);
- return null;
- });
-
- await serviceUnderTest().limit(limit, actor);
-
- expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_c', '1', 'EX', 1]);
- });
-
- it('should set bucket timestamp expiration', async () => {
- const commands: unknown[][] = [];
- mockRedis.push(command => {
- commands.push(command);
- return null;
- });
-
- await serviceUnderTest().limit(limit, actor);
-
- expect(commands).toContainEqual(['set', 'rl_actor_test_bucket_t', '0', 'EX', 1]);
- });
-
- it('should set min counter expiration', async () => {
- const commands: unknown[][] = [];
- mockRedis.push(command => {
- commands.push(command);
- return null;
- });
-
- await serviceUnderTest().limit(limit, actor);
-
- expect(commands).toContainEqual(['set', 'rl_actor_test_min_c', '1', 'EX', 1]);
- });
-
- it('should set min timestamp expiration', async () => {
+ it('should set counter expiration', async () => {
const commands: unknown[][] = [];
mockRedis.push(command => {
commands.push(command);
@@ -957,27 +866,22 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(commands).toContainEqual(['set', 'rl_actor_test_min_t', '0', 'EX', 1]);
+ expect(commands).toContainEqual(['set', 'rl_actor_test', '1:0', 'EX', 1]);
});
it('should not increment when already blocked', async () => {
- limitCounter = 5;
+ limitCounter = 10;
limitTimestamp = 0;
- minCounter = 1;
- minTimestamp = 0;
mockTimeService.now += 100;
await serviceUnderTest().limit(limit, actor);
- expect(limitCounter).toBe(5);
+ expect(limitCounter).toBe(10);
expect(limitTimestamp).toBe(0);
- expect(minCounter).toBe(1);
- expect(minTimestamp).toBe(0);
});
it('should apply correction if extra calls slip through', async () => {
- limitCounter = 6;
- minCounter = 6;
+ limitCounter = 12;
const info = await serviceUnderTest().limit(limit, actor);
--
cgit v1.2.3-freya
From 0ea9d6ec5d4f037b37a98603f8942404530f2802 Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Wed, 11 Dec 2024 09:10:11 -0500
Subject: use atomic variant of Leaky Bucket for safe concurrent rate limits
---
packages/backend/src/core/CoreModule.ts | 6 -
packages/backend/src/core/RedisConnectionPool.ts | 103 -----------
packages/backend/src/core/TimeoutService.ts | 76 --------
.../backend/src/server/SkRateLimiterService.md | 143 ++++++++++++++
.../backend/src/server/api/SkRateLimiterService.ts | 206 ++++++++++++---------
.../unit/server/api/SkRateLimiterServiceTests.ts | 182 +++++++++++-------
6 files changed, 375 insertions(+), 341 deletions(-)
delete mode 100644 packages/backend/src/core/RedisConnectionPool.ts
delete mode 100644 packages/backend/src/core/TimeoutService.ts
create mode 100644 packages/backend/src/server/SkRateLimiterService.md
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index caf135ae4b..b18db7f366 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -155,8 +155,6 @@ import { QueueModule } from './QueueModule.js';
import { QueueService } from './QueueService.js';
import { LoggerService } from './LoggerService.js';
import { SponsorsService } from './SponsorsService.js';
-import { RedisConnectionPool } from './RedisConnectionPool.js';
-import { TimeoutService } from './TimeoutService.js';
import type { Provider } from '@nestjs/common';
//#region 文字列ベースでのinjection用(循環参照対応のため)
@@ -385,8 +383,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ChannelFollowingService,
RegistryApiService,
ReversiService,
- RedisConnectionPool,
- TimeoutService,
TimeService,
EnvService,
@@ -688,8 +684,6 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
ChannelFollowingService,
RegistryApiService,
ReversiService,
- RedisConnectionPool,
- TimeoutService,
TimeService,
EnvService,
diff --git a/packages/backend/src/core/RedisConnectionPool.ts b/packages/backend/src/core/RedisConnectionPool.ts
deleted file mode 100644
index 7ebefdfcb3..0000000000
--- a/packages/backend/src/core/RedisConnectionPool.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-/*
- * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
-import Redis, { RedisOptions } from 'ioredis';
-import { DI } from '@/di-symbols.js';
-import type { Config } from '@/config.js';
-import Logger from '@/logger.js';
-import { Timeout, TimeoutService } from '@/core/TimeoutService.js';
-import { LoggerService } from './LoggerService.js';
-
-/**
- * Target number of connections to keep open and ready for use.
- * The pool may grow beyond this during bursty traffic, but it will always shrink back to this number.
- * The pool may remain below this number is the server never experiences enough traffic to consume this many clients.
- */
-export const poolSize = 16;
-
-/**
- * How often to drop an idle connection from the pool.
- * This will never shrink the pool below poolSize.
- */
-export const poolShrinkInterval = 5 * 1000; // 5 seconds
-
-@Injectable()
-export class RedisConnectionPool implements OnApplicationShutdown {
- private readonly poolShrinkTimer: Timeout;
- private readonly pool: Redis.Redis[] = [];
- private readonly logger: Logger;
- private readonly redisOptions: RedisOptions;
-
- constructor(@Inject(DI.config) config: Config, loggerService: LoggerService, timeoutService: TimeoutService) {
- this.logger = loggerService.getLogger('redis-pool');
- this.poolShrinkTimer = timeoutService.setInterval(() => this.shrinkPool(), poolShrinkInterval);
- this.redisOptions = {
- ...config.redis,
-
- // Set lazyConnect so that we can await() the connection manually.
- // This helps to avoid a "stampede" of new connections (which are processed in the background!) under bursty conditions.
- lazyConnect: true,
- enableOfflineQueue: false,
- };
- }
-
- /**
- * Gets a Redis connection from the pool, or creates a new connection if the pool is empty.
- * The returned object MUST be returned with a call to free(), even in the case of exceptions!
- * Use a try...finally block for safe handling.
- */
- public async alloc(): Promise {
- let redis = this.pool.pop();
-
- // The pool may be empty if we're under heavy load and/or we haven't opened all connections.
- // Just construct a new instance, which will eventually be added to the pool.
- // Excess clients will be disposed eventually.
- if (!redis) {
- redis = new Redis.Redis(this.redisOptions);
- await redis.connect();
- }
-
- return redis;
- }
-
- /**
- * Returns a Redis connection to the pool.
- * The instance MUST not be used after returning!
- * Use a try...finally block for safe handling.
- */
- public async free(redis: Redis.Redis): Promise {
- // https://redis.io/docs/latest/commands/reset/
- await redis.reset();
-
- this.pool.push(redis);
- }
-
- public async onApplicationShutdown(): Promise {
- // Cancel timer, otherwise it will cause a memory leak
- clearInterval(this.poolShrinkTimer);
-
- // Disconnect all remaining instances
- while (this.pool.length > 0) {
- await this.dropClient();
- }
- }
-
- private async shrinkPool(): Promise {
- this.logger.debug(`Pool size is ${this.pool.length}`);
- if (this.pool.length > poolSize) {
- await this.dropClient();
- }
- }
-
- private async dropClient(): Promise {
- try {
- const redis = this.pool.pop();
- await redis?.quit();
- } catch (err) {
- this.logger.warn(`Error disconnecting from redis: ${err}`, { err });
- }
- }
-}
diff --git a/packages/backend/src/core/TimeoutService.ts b/packages/backend/src/core/TimeoutService.ts
deleted file mode 100644
index 093b9a7b04..0000000000
--- a/packages/backend/src/core/TimeoutService.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-/*
- * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-/**
- * Provides access to setTimeout, setInterval, and related functions.
- * Used to support deterministic unit testing.
- */
-export class TimeoutService {
- /**
- * Returns a promise that resolves after the specified timeout in milliseconds.
- */
- public delay(timeout: number): Promise {
- return new Promise(resolve => {
- this.setTimeout(resolve, timeout);
- });
- }
-
- /**
- * Passthrough to node's setTimeout
- */
- public setTimeout(handler: TimeoutHandler, timeout?: number): Timeout {
- return setTimeout(() => handler(), timeout);
- }
-
- /**
- * Passthrough to node's setInterval
- */
- public setInterval(handler: TimeoutHandler, timeout?: number): Timeout {
- return setInterval(() => handler(), timeout);
- }
-
- /**
- * Passthrough to node's clearTimeout
- */
- public clearTimeout(timeout: Timeout) {
- clearTimeout(timeout);
- }
-
- /**
- * Passthrough to node's clearInterval
- */
- public clearInterval(timeout: Timeout) {
- clearInterval(timeout);
- }
-}
-
-/**
- * Function to be called when a timer or interval elapses.
- */
-export type TimeoutHandler = () => void;
-
-/**
- * A fucked TS issue causes the DOM setTimeout to get merged with Node setTimeout, creating a "quantum method" that returns either "number" or "NodeJS.Timeout" depending on how it's called.
- * This would be fine, except it always matches the *wrong type*!
- * The result is this "impossible" scenario:
- *
- * ```typescript
- * // Test evaluates to "false", because the method's return type is not equal to itself.
- * type Test = ReturnType extends ReturnType ? true : false;
- *
- * // This is a compiler error, because the type is broken and triggers some internal TS bug.
- * const timeout = setTimeout(handler);
- * clearTimeout(timeout); // compiler error here, because even type inference doesn't work.
- *
- * // This fails to compile.
- * function test(handler, timeout): ReturnType {
- * return setTimeout(handler, timeout);
- * }
- * ```
- *
- * The bug is marked as "wontfix" by TS devs, so we have to work around it ourselves. -_-
- * By forcing the return type to *explicitly* include both types, we at least make it possible to work with the resulting token.
- */
-export type Timeout = NodeJS.Timeout | number;
diff --git a/packages/backend/src/server/SkRateLimiterService.md b/packages/backend/src/server/SkRateLimiterService.md
new file mode 100644
index 0000000000..c2752f5027
--- /dev/null
+++ b/packages/backend/src/server/SkRateLimiterService.md
@@ -0,0 +1,143 @@
+# SkRateLimiterService - Leaky Bucket Rate Limit Implementation
+
+SkRateLimiterService replaces Misskey's RateLimiterService for all use cases.
+It offers a simplified API, detailed metrics, and support for Rate Limit headers.
+The prime feature is an implementation of Leaky Bucket - a flexible rate limiting scheme that better supports bursty request patterns common with human interaction.
+
+## Compatibility
+
+The API is backwards-compatible with existing limit definitions, but it's preferred to use the new BucketRateLimit interface.
+Legacy limits will be "translated" into a bucket limit in a way that attempts to respect max, duration, and minInterval (if present).
+SkRateLimiterService is quite not plug-and-play compatible with existing call sites, because it no longer throws when a limit is exceeded.
+Instead, the returned LimitInfo object will have "blocked" set to true.
+Callers are responsible for checking this property and taking any desired action, such as rejecting a request or returning limit details.
+
+## Headers
+
+LimitInfo objects (returned by SkRateLimitService.limit()) can be passed to rate-limit-utils.attachHeaders() to send standard rate limit headers with an HTTP response.
+The defined headers are:
+
+| Header | Definition | Example |
+|-------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------|
+| `X-RateLimit-Remaining` | Number of calls that can be made without triggering the rate limit. Will be zero if the limit is already exceeded, or will be exceeded by the next request. | `X-RateLimit-Remaining: 1` |
+| `X-RateLimit-Clear` | Time in seconds required to completely clear the rate limit "bucket". | `X-RateLimit-Clear: 1.5` |
+| `X-RateLimit-Reset` | Contains the number of seconds to wait before retrying the current request. Clients should delay for at least this long before making another call. Only included if the rate limit has already been exceeded. | `X-RateLimit-Reset: 0.755` |
+| `Retry-After` | Like `X-RateLimit-Reset`, but measured in seconds (rounded up). Preserved for backwards compatibility, and only included if the rate limit has already been exceeded. | `Retry-After: 2` |
+
+Note: rate limit headers are not standardized, except for `Retry-After`.
+Header meanings and usage have been devised by adapting common patterns to work with a leaky bucket model instead.
+
+## Performance
+
+SkRateLimiterService makes between 1 and 4 redis transactions per rate limit check.
+One call is read-only, while the others perform at least one write operation.
+Two integer keys are stored per client/subject, and both expire together after the maximum duration of the limit.
+While performance has not been formally tested, it's expected that SkRateLimiterService will perform roughly on par with the legacy RateLimiterService.
+Redis memory usage should be notably lower due to the reduced number of keys and avoidance of set / array constructions.
+
+## Concurrency and Multi-Node Correctness
+
+To provide consistency across multi-node environments, leaky bucket is implemented with only atomic operations (Increment, Decrement, Add, and Subtract).
+This allows the use of Optimistic Locking via modify-check-rollback logic.
+If a data conflict is detected during the "drip" operation, then it's safely reverted by executing its inverse (Increment <-> Decrement, Add <-> Subtract).
+We don't need to check for conflicts when adding the current request, as all checks account for the case where the bucket has been "overfilled".
+Should that happen, the limit delay will be extended until the bucket size is back within limits.
+
+There is one non-atomic `SET` operation used to populate the initial Timestamp value, but we can safely ignore data races there.
+Any possible conflict would have to occur within a few-milliseconds window, which means that the final value can be no more than a few milliseconds off from the expected value.
+This error does not compound, as all further operations are relative (Increment and Add).
+Thus, it's considered an acceptable tradeoff given the limitations imposed by Redis and IORedis library.
+
+## Algorithm Pseudocode
+
+The Atomic Leaky Bucket algorithm is described here, in pseudocode:
+
+```
+# Terms
+# * Now - UNIX timestamp of the current moment
+# * Bucket Size - Maximum number of requests allowed in the bucket
+# * Counter - Number of requests in the bucket
+# * Drip Rate - How often to decrement counter
+# * Drip Size - How much to decrement the counter
+# * Timestamp - UNIX timestamp of last bucket drip
+# * Delta Counter - Difference between current and expected counter value
+# * Delta Timestamp - Difference between current and expected timestamp value
+
+# 0 - Calculations
+dripRate = ceil(limit.dripRate ?? 1000);
+dripSize = ceil(limit.dripSize ?? 1);
+bucketSize = max(ceil(limit.size / factor), 1);
+maxExpiration = max(ceil((dripRate * ceil(bucketSize / dripSize)) / 1000), 1);;
+
+# 1 - Read
+MULTI
+ GET 'counter' INTO counter
+ GET 'timestamp' INTO timestamp
+EXEC
+
+# 2 - Drip
+if (counter > 0) {
+ # Deltas
+ deltaCounter = floor((now - timestamp) / dripRate) * dripSize;
+ deltaCounter = min(deltaCounter, counter);
+ deltaTimestamp = deltaCounter * dripRate;
+ if (deltaCounter > 0) {
+ # Update
+ expectedTimestamp = timestamp
+ MULTI
+ GET 'timestamp' INTO canaryTimestamp
+ INCRBY 'timestamp' deltaTimestamp
+ EXPIRE 'timestamp' maxExpiration
+ GET 'timestamp' INTO timestamp
+ DECRBY 'counter' deltaCounter
+ EXPIRE 'counter' maxExpiration
+ GET 'counter' INTO counter
+ EXEC
+ # Rollback
+ if (canaryTimestamp != expectedTimestamp) {
+ MULTI
+ DECRBY 'timestamp' deltaTimestamp
+ GET 'timestamp' INTO timestmamp
+ INCRBY 'counter' deltaCounter
+ GET 'counter' INTO counter
+ EXEC
+ }
+ }
+}
+
+# 3 - Check
+blocked = counter >= bucketSize
+if (!blocked) {
+ if (timestamp == 0) {
+ # Edge case - set the initial value for timestamp.
+ # Otherwise the first request will immediately drip away.
+ MULTI
+ SET 'timestamp', now
+ EXPIRE 'timestamp' maxExpiration
+ INCR 'counter'
+ EXPIRE 'counter' maxExpiration
+ GET 'counter' INTO counter
+ EXEC
+ } else {
+ MULTI
+ INCR 'counter'
+ EXPIRE 'counter' maxExpiration
+ GET 'counter' INTO counter
+ EXEC
+ }
+}
+
+# 4 - Handle
+if (blocked) {
+ # Application-specific code goes here.
+ # At this point blocked, counter, and timestamp are all accurate and synced to redis.
+ # Caller can apply limits, calculate headers, log audit failure, or anything else.
+}
+```
+
+## Notes, Resources, and Further Reading
+
+* https://en.wikipedia.org/wiki/Leaky_bucket#As_a_meter
+* https://ietf-wg-httpapi.github.io/ratelimit-headers/darrelmiller-policyname/draft-ietf-httpapi-ratelimit-headers.txt
+* https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After
+* https://stackoverflow.com/a/16022625
diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/api/SkRateLimiterService.ts
index 71681aadc9..d349e192e1 100644
--- a/packages/backend/src/server/api/SkRateLimiterService.ts
+++ b/packages/backend/src/server/api/SkRateLimiterService.ts
@@ -8,8 +8,7 @@ import Redis from 'ioredis';
import { TimeService } from '@/core/TimeService.js';
import { EnvService } from '@/core/EnvService.js';
import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed, hasMaxLimit, disabledLimitInfo, MaxLegacyLimit, MinLegacyLimit } from '@/misc/rate-limit-utils.js';
-import { RedisConnectionPool } from '@/core/RedisConnectionPool.js';
-import { TimeoutService } from '@/core/TimeoutService.js';
+import { DI } from '@/di-symbols.js';
@Injectable()
export class SkRateLimiterService {
@@ -19,11 +18,8 @@ export class SkRateLimiterService {
@Inject(TimeService)
private readonly timeService: TimeService,
- @Inject(TimeoutService)
- private readonly timeoutService: TimeoutService,
-
- @Inject(RedisConnectionPool)
- private readonly redisPool: RedisConnectionPool,
+ @Inject(DI.redis)
+ private readonly redisClient: Redis.Redis,
@Inject(EnvService)
envService: EnvService,
@@ -31,6 +27,12 @@ export class SkRateLimiterService {
this.disabled = envService.env.NODE_ENV === 'test';
}
+ /**
+ * Check & increment a rate limit
+ * @param limit The limit definition
+ * @param actor Client who is calling this limit
+ * @param factor Scaling factor - smaller = larger limit (less restrictive)
+ */
public async limit(limit: Keyed, actor: string, factor = 1): Promise {
if (this.disabled || factor === 0) {
return disabledLimitInfo;
@@ -40,52 +42,28 @@ export class SkRateLimiterService {
throw new Error(`Rate limit factor is zero or negative: ${factor}`);
}
- const redis = await this.redisPool.alloc();
- try {
- return await this.tryLimit(redis, limit, actor, factor);
- } finally {
- await this.redisPool.free(redis);
- }
+ return await this.tryLimit(limit, actor, factor);
}
- private async tryLimit(redis: Redis.Redis, limit: Keyed, actor: string, factor: number, retry = 0): Promise {
- try {
- if (retry > 0) {
- // Real-world testing showed the need for backoff to "spread out" bursty traffic.
- const backoff = Math.round(Math.pow(2, retry + Math.random()));
- await this.timeoutService.delay(backoff);
- }
-
- if (isLegacyRateLimit(limit)) {
- return await this.limitLegacy(redis, limit, actor, factor);
- } else {
- return await this.limitBucket(redis, limit, actor, factor);
- }
- } catch (err) {
- // We may experience collision errors from optimistic locking.
- // This is expected, so we should retry a few times before giving up.
- // https://redis.io/docs/latest/develop/interact/transactions/#optimistic-locking-using-check-and-set
- if (err instanceof ConflictError && retry < 4) {
- // We can reuse the same connection to reduce pool contention, but we have to reset it first.
- await redis.reset();
- return await this.tryLimit(redis, limit, actor, factor, retry + 1);
- }
-
- throw err;
+ private async tryLimit(limit: Keyed, actor: string, factor: number): Promise {
+ if (isLegacyRateLimit(limit)) {
+ return await this.limitLegacy(limit, actor, factor);
+ } else {
+ return await this.limitBucket(limit, actor, factor);
}
}
- private async limitLegacy(redis: Redis.Redis, limit: Keyed, actor: string, factor: number): Promise {
+ private async limitLegacy(limit: Keyed, actor: string, factor: number): Promise {
if (hasMaxLimit(limit)) {
- return await this.limitMaxLegacy(redis, limit, actor, factor);
+ return await this.limitMaxLegacy(limit, actor, factor);
} else if (hasMinLimit(limit)) {
- return await this.limitMinLegacy(redis, limit, actor, factor);
+ return await this.limitMinLegacy(limit, actor, factor);
} else {
return disabledLimitInfo;
}
}
- private async limitMaxLegacy(redis: Redis.Redis, limit: Keyed, actor: string, factor: number): Promise {
+ private async limitMaxLegacy(limit: Keyed, actor: string, factor: number): Promise {
if (limit.duration === 0) return disabledLimitInfo;
if (limit.duration < 0) throw new Error(`Invalid rate limit ${limit.key}: duration is negative (${limit.duration})`);
if (limit.max < 1) throw new Error(`Invalid rate limit ${limit.key}: max is less than 1 (${limit.max})`);
@@ -106,10 +84,10 @@ export class SkRateLimiterService {
dripRate,
dripSize,
};
- return await this.limitBucket(redis, bucketLimit, actor, factor);
+ return await this.limitBucket(bucketLimit, actor, factor);
}
- private async limitMinLegacy(redis: Redis.Redis, limit: Keyed, actor: string, factor: number): Promise {
+ private async limitMinLegacy(limit: Keyed, actor: string, factor: number): Promise {
if (limit.minInterval === 0) return disabledLimitInfo;
if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`);
@@ -121,33 +99,83 @@ export class SkRateLimiterService {
dripRate,
dripSize: 1,
};
- return await this.limitBucket(redis, bucketLimit, actor, factor);
+ return await this.limitBucket(bucketLimit, actor, factor);
}
- private async limitBucket(redis: Redis.Redis, limit: Keyed, actor: string, factor: number): Promise {
+ /**
+ * Implementation of Leaky Bucket rate limiting - see SkRateLimiterService.md for details.
+ */
+ private async limitBucket(limit: Keyed, actor: string, factor: number): Promise {
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 redisKey = createLimitKey(limit, actor);
+ // 0 - Calculate
+ const now = this.timeService.now;
const bucketSize = Math.max(Math.ceil(limit.size / factor), 1);
const dripRate = Math.ceil(limit.dripRate ?? 1000);
const dripSize = Math.ceil(limit.dripSize ?? 1);
- const expirationSec = Math.max(Math.ceil(bucketSize / dripRate), 1);
-
- // Simulate bucket drips
- const counter = await this.getLimitCounter(redis, redisKey);
- if (counter.counter > 0) {
- const dripsSinceLastTick = Math.floor((this.timeService.now - counter.timestamp) / dripRate) * dripSize;
- counter.counter = Math.max(counter.counter - dripsSinceLastTick, 0);
+ const expirationSec = Math.max(Math.ceil((dripRate * Math.ceil(bucketSize / dripSize)) / 1000), 1);
+
+ // 1 - Read
+ const counterKey = createLimitKey(limit, actor, 'c');
+ const timestampKey = createLimitKey(limit, actor, 't');
+ const counter = await this.getLimitCounter(counterKey, timestampKey);
+
+ // 2 - Drip
+ const dripsSinceLastTick = Math.floor((now - counter.timestamp) / dripRate) * dripSize;
+ const deltaCounter = Math.min(dripsSinceLastTick, counter.counter);
+ const deltaTimestamp = dripsSinceLastTick * dripRate;
+ if (deltaCounter > 0) {
+ // Execute the next drip(s)
+ const results = await this.executeRedisMulti(
+ ['get', timestampKey],
+ ['incrby', timestampKey, deltaTimestamp],
+ ['expire', timestampKey, expirationSec],
+ ['get', timestampKey],
+ ['decrby', counterKey, deltaCounter],
+ ['expire', counterKey, expirationSec],
+ ['get', counterKey],
+ );
+ const expectedTimestamp = counter.timestamp;
+ const canaryTimestamp = results[0] ? parseInt(results[0]) : 0;
+ counter.timestamp = results[3] ? parseInt(results[3]) : 0;
+ counter.counter = results[6] ? parseInt(results[6]) : 0;
+
+ // Check for a data collision and rollback
+ if (canaryTimestamp !== expectedTimestamp) {
+ const rollbackResults = await this.executeRedisMulti(
+ ['decrby', timestampKey, deltaTimestamp],
+ ['get', timestampKey],
+ ['incrby', counterKey, deltaCounter],
+ ['get', counterKey],
+ );
+ counter.timestamp = rollbackResults[1] ? parseInt(rollbackResults[1]) : 0;
+ counter.counter = rollbackResults[3] ? parseInt(rollbackResults[3]) : 0;
+ }
}
- // Increment the limit, then synchronize with redis
+ // 3 - Check
const blocked = counter.counter >= bucketSize;
if (!blocked) {
- counter.counter++;
- counter.timestamp = this.timeService.now;
- await this.updateLimitCounter(redis, redisKey, expirationSec, counter);
+ if (counter.timestamp === 0) {
+ const results = await this.executeRedisMulti(
+ ['set', timestampKey, now],
+ ['expire', timestampKey, expirationSec],
+ ['incr', counterKey],
+ ['expire', counterKey, expirationSec],
+ ['get', counterKey],
+ );
+ counter.timestamp = now;
+ counter.counter = results[4] ? parseInt(results[4]) : 0;
+ } else {
+ const results = await this.executeRedisMulti(
+ ['incr', counterKey],
+ ['expire', counterKey, expirationSec],
+ ['get', counterKey],
+ );
+ counter.counter = results[2] ? parseInt(results[2]) : 0;
+ }
}
// Calculate how much time is needed to free up a bucket slot
@@ -164,37 +192,20 @@ export class SkRateLimiterService {
return { blocked, remaining, resetSec, resetMs, fullResetSec, fullResetMs };
}
- private async getLimitCounter(redis: Redis.Redis, key: string): Promise {
- const counter: LimitCounter = { counter: 0, timestamp: 0 };
-
- // Watch the key BEFORE reading it!
- await redis.watch(key);
- const data = await redis.get(key);
-
- // Data may be missing or corrupt if the key doesn't exist.
- // This is an expected edge case.
- if (data) {
- const parts = data.split(':');
- if (parts.length === 2) {
- counter.counter = parseInt(parts[0]);
- counter.timestamp = parseInt(parts[1]);
- }
- }
-
- return counter;
- }
-
- private async updateLimitCounter(redis: Redis.Redis, key: string, expirationSec: number, counter: LimitCounter): Promise {
- const data = `${counter.counter}:${counter.timestamp}`;
-
- await this.executeRedisMulti(
- redis,
- [['set', key, data, 'EX', expirationSec]],
+ private async getLimitCounter(counterKey: string, timestampKey: string): Promise {
+ const [counter, timestamp] = await this.executeRedisMulti(
+ ['get', counterKey],
+ ['get', timestampKey],
);
+
+ return {
+ counter: counter ? parseInt(counter) : 0,
+ timestamp: timestamp ? parseInt(timestamp) : 0,
+ };
}
- private async executeRedisMulti(redis: Redis.Redis, batch: RedisBatch): Promise> {
- const results = await redis.multi(batch).exec();
+ private async executeRedisMulti(...batch: RedisCommand[]): Promise {
+ const results = await this.redisClient.multi(batch).exec();
// Transaction conflict (retryable)
if (!results) {
@@ -206,21 +217,32 @@ export class SkRateLimiterService {
throw new Error('Redis error: failed to execute batch');
}
+ // Map responses
+ const errors: Error[] = [];
+ const responses: RedisResult[] = [];
+ for (const [error, response] of results) {
+ if (error) errors.push(error);
+ responses.push(response as RedisResult);
+ }
+
// Command failed (fatal)
- const errors = results.map(r => r[0]).filter(e => e != null);
if (errors.length > 0) {
- throw new AggregateError(errors, `Redis error: failed to execute command(s): '${errors.join('\', \'')}'`);
+ const errorMessages = errors
+ .map((e, i) => `Error in command ${i}: ${e}`)
+ .join('\', \'');
+ throw new AggregateError(errors, `Redis error: failed to execute command(s): '${errorMessages}'`);
}
- return results.map(r => r[1]) as RedisResults;
+ return responses;
}
}
-type RedisBatch = [string, ...unknown[]][] & { length: Num };
-type RedisResults = (string | null)[] & { length: Num };
+// Not correct, but good enough for the basic commands we use.
+type RedisResult = string | null;
+type RedisCommand = [command: string, ...args: unknown[]];
-function createLimitKey(limit: Keyed, actor: string): string {
- return `rl_${actor}_${limit.key}`;
+function createLimitKey(limit: Keyed, actor: string, value: string): string {
+ return `rl_${actor}_${limit.key}_${value}`;
}
class ConflictError extends Error {}
diff --git a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
index deb6b9f80e..bf424852e6 100644
--- a/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
+++ b/packages/backend/test/unit/server/api/SkRateLimiterServiceTests.ts
@@ -6,8 +6,6 @@
import type Redis from 'ioredis';
import { SkRateLimiterService } from '@/server/api/SkRateLimiterService.js';
import { BucketRateLimit, Keyed, LegacyRateLimit } from '@/misc/rate-limit-utils.js';
-import { RedisConnectionPool } from '@/core/RedisConnectionPool.js';
-import { Timeout, TimeoutHandler, TimeoutService } from '@/core/TimeoutService.js';
/* eslint-disable @typescript-eslint/no-non-null-assertion */
@@ -64,12 +62,6 @@ describe(SkRateLimiterService, () => {
return Promise.resolve();
},
} as unknown as Redis.Redis;
- const mockRedisPool = {
- alloc() {
- return Promise.resolve(mockRedisClient);
- },
- free() {},
- } as unknown as RedisConnectionPool;
mockEnvironment = Object.create(process.env);
mockEnvironment.NODE_ENV = 'production';
@@ -77,22 +69,9 @@ describe(SkRateLimiterService, () => {
env: mockEnvironment,
};
- const mockTimeoutService = new class extends TimeoutService {
- setTimeout(handler: TimeoutHandler): Timeout {
- handler();
- return 0;
- }
- setInterval(handler: TimeoutHandler): Timeout {
- handler();
- return 0;
- }
- clearTimeout() {}
- clearInterval() {}
- };
-
let service: SkRateLimiterService | undefined = undefined;
serviceUnderTest = () => {
- return service ??= new SkRateLimiterService(mockTimeService, mockTimeoutService, mockRedisPool, mockEnvService);
+ return service ??= new SkRateLimiterService(mockTimeService, mockRedisClient, mockEnvService);
};
});
@@ -108,15 +87,70 @@ describe(SkRateLimiterService, () => {
limitTimestamp = undefined;
mockRedis.push(([command, ...args]) => {
- if (command === 'set' && args[0] === 'rl_actor_test') {
- const parts = (args[1] as string).split(':');
- limitCounter = parseInt(parts[0] as string);
- limitTimestamp = parseInt(parts[1] as string);
- return [null, args[1]];
+ if (command === 'get') {
+ if (args[0] === 'rl_actor_test_c') {
+ const data = limitCounter?.toString() ?? null;
+ return [null, data];
+ }
+ if (args[0] === 'rl_actor_test_t') {
+ const data = limitTimestamp?.toString() ?? null;
+ return [null, data];
+ }
+ }
+
+ if (command === 'set') {
+ if (args[0] === 'rl_actor_test_c') {
+ limitCounter = parseInt(args[1] as string);
+ return [null, args[1]];
+ }
+ if (args[0] === 'rl_actor_test_t') {
+ limitTimestamp = parseInt(args[1] as string);
+ return [null, args[1]];
+ }
+ }
+
+ if (command === 'incr') {
+ if (args[0] === 'rl_actor_test_c') {
+ limitCounter = (limitCounter ?? 0) + 1;
+ return [null, null];
+ }
+ if (args[0] === 'rl_actor_test_t') {
+ limitTimestamp = (limitTimestamp ?? 0) + 1;
+ return [null, null];
+ }
+ }
+
+ if (command === 'incrby') {
+ if (args[0] === 'rl_actor_test_c') {
+ limitCounter = (limitCounter ?? 0) + parseInt(args[1] as string);
+ return [null, null];
+ }
+ if (args[0] === 'rl_actor_test_t') {
+ limitTimestamp = (limitTimestamp ?? 0) + parseInt(args[1] as string);
+ return [null, null];
+ }
+ }
+
+ if (command === 'decr') {
+ if (args[0] === 'rl_actor_test_c') {
+ limitCounter = (limitCounter ?? 0) - 1;
+ return [null, null];
+ }
+ if (args[0] === 'rl_actor_test_t') {
+ limitTimestamp = (limitTimestamp ?? 0) - 1;
+ return [null, null];
+ }
}
- if (command === 'get' && args[0] === 'rl_actor_test') {
- const data = `${limitCounter ?? 0}:${limitTimestamp ?? 0}`;
- return [null, data];
+
+ if (command === 'decrby') {
+ if (args[0] === 'rl_actor_test_c') {
+ limitCounter = (limitCounter ?? 0) - parseInt(args[1] as string);
+ return [null, null];
+ }
+ if (args[0] === 'rl_actor_test_t') {
+ limitTimestamp = (limitTimestamp ?? 0) - parseInt(args[1] as string);
+ return [null, null];
+ }
}
return null;
@@ -269,7 +303,19 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(commands).toContainEqual(['set', 'rl_actor_test', '1:0', 'EX', 1]);
+ expect(commands).toContainEqual(['expire', 'rl_actor_test_c', 1]);
+ });
+
+ it('should set timestamp expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['expire', 'rl_actor_test_t', 1]);
});
it('should not increment when already blocked', async () => {
@@ -368,35 +414,6 @@ describe(SkRateLimiterService, () => {
await expect(promise).rejects.toThrow(/dripSize is less than 1/);
});
- it('should retry when redis conflicts', async () => {
- let numCalls = 0;
- const originalExec = mockRedisExec;
- mockRedisExec = () => {
- numCalls++;
- if (numCalls > 1) {
- mockRedisExec = originalExec;
- }
- return Promise.resolve(null);
- };
-
- await serviceUnderTest().limit(limit, actor);
-
- expect(numCalls).toBe(2);
- });
-
- it('should bail out after 5 tries', async () => {
- let numCalls = 0;
- mockRedisExec = () => {
- numCalls++;
- return Promise.resolve(null);
- };
-
- const promise = serviceUnderTest().limit(limit, actor);
-
- await expect(promise).rejects.toThrow(/transaction conflict/);
- expect(numCalls).toBe(5);
- });
-
it('should apply correction if extra calls slip through', async () => {
limitCounter = 2;
@@ -473,8 +490,9 @@ describe(SkRateLimiterService, () => {
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
+ const info = await serviceUnderTest().limit(limit, actor); // 0 + 1 = 1
+ expect(info.blocked).toBeFalsy();
expect(limitCounter).toBe(1);
expect(limitTimestamp).toBe(3000);
});
@@ -529,7 +547,19 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(commands).toContainEqual(['set', 'rl_actor_test', '1:0', 'EX', 1]);
+ expect(commands).toContainEqual(['expire', 'rl_actor_test_c', 1]);
+ });
+
+ it('should set timer expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['expire', 'rl_actor_test_t', 1]);
});
it('should not increment when already blocked', async () => {
@@ -688,7 +718,19 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(commands).toContainEqual(['set', 'rl_actor_test', '1:0', 'EX', 1]);
+ expect(commands).toContainEqual(['expire', 'rl_actor_test_c', 1]);
+ });
+
+ it('should set timestamp expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['expire', 'rl_actor_test_t', 1]);
});
it('should not increment when already blocked', async () => {
@@ -866,7 +908,19 @@ describe(SkRateLimiterService, () => {
await serviceUnderTest().limit(limit, actor);
- expect(commands).toContainEqual(['set', 'rl_actor_test', '1:0', 'EX', 1]);
+ expect(commands).toContainEqual(['expire', 'rl_actor_test_c', 1]);
+ });
+
+ it('should set timestamp expiration', async () => {
+ const commands: unknown[][] = [];
+ mockRedis.push(command => {
+ commands.push(command);
+ return null;
+ });
+
+ await serviceUnderTest().limit(limit, actor);
+
+ expect(commands).toContainEqual(['expire', 'rl_actor_test_t', 1]);
});
it('should not increment when already blocked', async () => {
--
cgit v1.2.3-freya
From 0f5c78a69bf9ccf645b981e6ec844878feafd36c Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Wed, 11 Dec 2024 09:10:56 -0500
Subject: increase chart rate limits (fixes 429s in control panel / info pages)
---
packages/backend/src/server/api/endpoints/charts/active-users.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/ap-request.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/drive.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/federation.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/instance.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/notes.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/user/drive.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/user/following.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/user/notes.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/user/pv.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/user/reactions.ts | 7 ++++---
packages/backend/src/server/api/endpoints/charts/users.ts | 7 ++++---
12 files changed, 48 insertions(+), 36 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts
index f6c0c045df..dcdcf46d0b 100644
--- a/packages/backend/src/server/api/endpoints/charts/active-users.ts
+++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/ap-request.ts b/packages/backend/src/server/api/endpoints/charts/ap-request.ts
index 4c5c0d5d20..28c64229e7 100644
--- a/packages/backend/src/server/api/endpoints/charts/ap-request.ts
+++ b/packages/backend/src/server/api/endpoints/charts/ap-request.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts
index 8210ec8fe7..69ff3c5d7a 100644
--- a/packages/backend/src/server/api/endpoints/charts/drive.ts
+++ b/packages/backend/src/server/api/endpoints/charts/drive.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts
index 56a5dbea31..bd870cc3d9 100644
--- a/packages/backend/src/server/api/endpoints/charts/federation.ts
+++ b/packages/backend/src/server/api/endpoints/charts/federation.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts
index 7f79e1356d..765bf024ee 100644
--- a/packages/backend/src/server/api/endpoints/charts/instance.ts
+++ b/packages/backend/src/server/api/endpoints/charts/instance.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts
index b3660b558b..ecac436311 100644
--- a/packages/backend/src/server/api/endpoints/charts/notes.ts
+++ b/packages/backend/src/server/api/endpoints/charts/notes.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts
index 716c41f385..98ec40ade2 100644
--- a/packages/backend/src/server/api/endpoints/charts/user/drive.ts
+++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts
index b67b5ca338..cb3dd36bab 100644
--- a/packages/backend/src/server/api/endpoints/charts/user/following.ts
+++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts
index e5587cab86..0742a21210 100644
--- a/packages/backend/src/server/api/endpoints/charts/user/notes.ts
+++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/user/pv.ts b/packages/backend/src/server/api/endpoints/charts/user/pv.ts
index cbae3a21c1..a220381b00 100644
--- a/packages/backend/src/server/api/endpoints/charts/user/pv.ts
+++ b/packages/backend/src/server/api/endpoints/charts/user/pv.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts
index d734240742..3bb33622c2 100644
--- a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts
+++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts
index 6e1a8ebd4f..b5452517ab 100644
--- a/packages/backend/src/server/api/endpoints/charts/users.ts
+++ b/packages/backend/src/server/api/endpoints/charts/users.ts
@@ -17,10 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
- // 10 calls per 5 seconds
+ // Burst up to 100, then 2/sec average
limit: {
- duration: 1000 * 5,
- max: 10,
+ type: 'bucket',
+ size: 100,
+ dripRate: 500,
},
} as const;
--
cgit v1.2.3-freya
From 755ff8783b9b4c1b4e5949fa417e0c85a8bc0e29 Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Wed, 11 Dec 2024 14:07:25 -0500
Subject: clarify naming of legacy rate limit methods
---
packages/backend/src/server/api/SkRateLimiterService.ts | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/SkRateLimiterService.ts b/packages/backend/src/server/api/SkRateLimiterService.ts
index d349e192e1..38c97b63df 100644
--- a/packages/backend/src/server/api/SkRateLimiterService.ts
+++ b/packages/backend/src/server/api/SkRateLimiterService.ts
@@ -55,15 +55,15 @@ export class SkRateLimiterService {
private async limitLegacy(limit: Keyed, actor: string, factor: number): Promise {
if (hasMaxLimit(limit)) {
- return await this.limitMaxLegacy(limit, actor, factor);
+ return await this.limitLegacyMinMax(limit, actor, factor);
} else if (hasMinLimit(limit)) {
- return await this.limitMinLegacy(limit, actor, factor);
+ return await this.limitLegacyMinOnly(limit, actor, factor);
} else {
return disabledLimitInfo;
}
}
- private async limitMaxLegacy(limit: Keyed, actor: string, factor: number): Promise {
+ private async limitLegacyMinMax(limit: Keyed, actor: string, factor: number): Promise {
if (limit.duration === 0) return disabledLimitInfo;
if (limit.duration < 0) throw new Error(`Invalid rate limit ${limit.key}: duration is negative (${limit.duration})`);
if (limit.max < 1) throw new Error(`Invalid rate limit ${limit.key}: max is less than 1 (${limit.max})`);
@@ -87,7 +87,7 @@ export class SkRateLimiterService {
return await this.limitBucket(bucketLimit, actor, factor);
}
- private async limitMinLegacy(limit: Keyed, actor: string, factor: number): Promise {
+ private async limitLegacyMinOnly(limit: Keyed, actor: string, factor: number): Promise {
if (limit.minInterval === 0) return disabledLimitInfo;
if (limit.minInterval < 0) throw new Error(`Invalid rate limit ${limit.key}: minInterval is negative (${limit.minInterval})`);
--
cgit v1.2.3-freya
From 00c4637b1186fa78a3955e8b5103c05c2e2b4431 Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Thu, 12 Dec 2024 07:34:14 -0500
Subject: federate profile when `hideOnlineStatus` changes
---
packages/backend/src/server/api/endpoints/i/update.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 8e61b8f784..c640caee75 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -587,7 +587,7 @@ export default class extends Endpoint { // eslint-
// these two methods need to be kept in sync with
// `ApRendererService.renderPerson`
private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial): boolean {
- for (const field of ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs'] as (keyof MiUser)[]) {
+ for (const field of ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus'] as (keyof MiUser)[]) {
if ((field in newUser) && oldUser[field] !== newUser[field]) {
return true;
}
--
cgit v1.2.3-freya
From fe37aa2ce851dfba4cb7b47ee569d20272b7f75e Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Mon, 9 Dec 2024 09:11:39 -0500
Subject: Add "enable RSS" user privacy toggle
---
locales/index.d.ts | 8 ++++++++
.../backend/migration/1733748798177-add_user_enableRss.js | 13 +++++++++++++
packages/backend/src/core/SignupService.ts | 1 +
packages/backend/src/core/activitypub/ApRendererService.ts | 1 +
packages/backend/src/core/activitypub/misc/contexts.ts | 1 +
.../backend/src/core/activitypub/models/ApPersonService.ts | 2 ++
packages/backend/src/core/activitypub/type.ts | 1 +
packages/backend/src/core/entities/UserEntityService.ts | 1 +
packages/backend/src/models/User.ts | 11 +++++++++++
packages/backend/src/models/json-schema/user.ts | 4 ++++
packages/backend/src/server/api/endpoints/i/update.ts | 2 ++
packages/backend/src/server/web/ClientServerService.ts | 1 +
packages/frontend/src/pages/settings/privacy.vue | 6 ++++++
packages/misskey-js/src/autogen/types.ts | 2 ++
sharkey-locales/en-US.yml | 2 ++
15 files changed, 56 insertions(+)
create mode 100644 packages/backend/migration/1733748798177-add_user_enableRss.js
(limited to 'packages/backend/src/server/api')
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 7ab1b7f545..3ffa402598 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -10847,6 +10847,14 @@ export interface Locale extends ILocale {
* Stop note search from indexing your public notes.
*/
"makeIndexableDescription": string;
+ /**
+ * Enable RSS feed
+ */
+ "enableRss": string;
+ /**
+ * Generate an RSS feed containing your basic profile details and public notes. Users can subscribe to the feed without a follow request or approval.
+ */
+ "enableRssDescription": string;
/**
* Require approval for new users
*/
diff --git a/packages/backend/migration/1733748798177-add_user_enableRss.js b/packages/backend/migration/1733748798177-add_user_enableRss.js
new file mode 100644
index 0000000000..64662ca7b8
--- /dev/null
+++ b/packages/backend/migration/1733748798177-add_user_enableRss.js
@@ -0,0 +1,13 @@
+export class AddUserEnableRss1733748798177 {
+ name = 'AddUserEnableRss1733748798177'
+
+ async up(queryRunner) {
+ // Disable by default, then specifically enable for all existing local users.
+ await queryRunner.query(`ALTER TABLE "user" ADD "enable_rss" boolean NOT NULL DEFAULT false`);
+ await queryRunner.query(`UPDATE "user" SET "enable_rss" = true WHERE host IS NULL;`)
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "enable_rss"`);
+ }
+}
diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts
index 1b0b1e5bbd..3f0523b610 100644
--- a/packages/backend/src/core/SignupService.ts
+++ b/packages/backend/src/core/SignupService.ts
@@ -135,6 +135,7 @@ export class SignupService {
isRoot: isTheFirstUser,
approved: isTheFirstUser || (opts.approved ?? !this.meta.approvalRequiredForSignup),
signupReason: reason,
+ enableRss: false,
}));
await transactionalEntityManager.save(new MiUserKeypair({
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index ff909778e8..57489c754f 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -531,6 +531,7 @@ export class ApRendererService {
hideOnlineStatus: user.hideOnlineStatus,
noindex: user.noindex,
indexable: !user.noindex,
+ enableRss: user.enableRss,
speakAsCat: user.speakAsCat,
attachment: attachment.length ? attachment : undefined,
};
diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts
index 1c4239502e..9c2640758f 100644
--- a/packages/backend/src/core/activitypub/misc/contexts.ts
+++ b/packages/backend/src/core/activitypub/misc/contexts.ts
@@ -567,6 +567,7 @@ const extension_context_definition = {
hideOnlineStatus: 'sharkey:hideOnlineStatus',
backgroundUrl: 'sharkey:backgroundUrl',
listenbrainz: 'sharkey:listenbrainz',
+ enableRss: 'sharkey:enableRss',
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',
} satisfies Context;
diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts
index 2cb31b1f09..b1bd379861 100644
--- a/packages/backend/src/core/activitypub/models/ApPersonService.ts
+++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts
@@ -385,6 +385,7 @@ export class ApPersonService implements OnModuleInit {
lastFetchedAt: new Date(),
name: truncate(person.name, nameLength),
noindex: (person as any).noindex ?? false,
+ enableRss: person.enableRss === true,
isLocked: person.manuallyApprovesFollowers,
movedToUri: person.movedTo,
movedAt: person.movedTo ? new Date() : null,
@@ -584,6 +585,7 @@ export class ApPersonService implements OnModuleInit {
isCat: (person as any).isCat === true,
speakAsCat: (person as any).speakAsCat != null ? (person as any).speakAsCat === true : (person as any).isCat === true,
noindex: (person as any).noindex ?? false,
+ enableRss: person.enableRss === true,
isLocked: person.manuallyApprovesFollowers,
movedToUri: person.movedTo ?? null,
alsoKnownAs: person.alsoKnownAs ?? null,
diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts
index a0a5ae00dc..6da382e3ec 100644
--- a/packages/backend/src/core/activitypub/type.ts
+++ b/packages/backend/src/core/activitypub/type.ts
@@ -217,6 +217,7 @@ export interface IActor extends IObject {
'vcard:Address'?: string;
hideOnlineStatus?: boolean;
noindex?: boolean;
+ enableRss?: boolean;
listenbrainz?: string;
backgroundUrl?: string;
}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index b1832ca0f5..8a48c8c6b4 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -539,6 +539,7 @@ export class UserEntityService implements OnModuleInit {
isBot: user.isBot,
isCat: user.isCat,
noindex: user.noindex,
+ enableRss: user.enableRss,
isSilenced: user.isSilenced || this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
speakAsCat: user.speakAsCat ?? false,
approved: user.approved,
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 73a44de558..8420b5b129 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -311,6 +311,17 @@ export class MiUser {
})
public signupReason: string | null;
+ /**
+ * True if profile RSS feeds are enabled for this user.
+ * Enabled by default (opt-out) for existing users, to avoid breaking any existing feeds.
+ * Disabled by default (opt-in) for newly created users, for privacy.
+ */
+ @Column('boolean', {
+ name: 'enable_rss',
+ default: true,
+ })
+ public enableRss = true;
+
constructor(data: Partial) {
if (data == null) return;
diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts
index d5e847cc40..12ed1f2009 100644
--- a/packages/backend/src/models/json-schema/user.ts
+++ b/packages/backend/src/models/json-schema/user.ts
@@ -130,6 +130,10 @@ export const packedUserLiteSchema = {
type: 'boolean',
nullable: false, optional: false,
},
+ enableRss: {
+ type: 'boolean',
+ nullable: false, optional: false,
+ },
isBot: {
type: 'boolean',
nullable: false, optional: true,
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index c640caee75..cae2964628 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -187,6 +187,7 @@ export const paramDef = {
noCrawle: { type: 'boolean' },
preventAiLearning: { type: 'boolean' },
noindex: { type: 'boolean' },
+ enableRss: { type: 'boolean' },
isBot: { type: 'boolean' },
isCat: { type: 'boolean' },
speakAsCat: { type: 'boolean' },
@@ -337,6 +338,7 @@ export default class extends Endpoint { // eslint-
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;
if (typeof ps.publicReactions === 'boolean') profileUpdates.publicReactions = ps.publicReactions;
if (typeof ps.noindex === 'boolean') updates.noindex = ps.noindex;
+ if (typeof ps.enableRss === 'boolean') updates.enableRss = ps.enableRss;
if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;
if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed;
diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts
index aca98c4d37..42f9b104ff 100644
--- a/packages/backend/src/server/web/ClientServerService.ts
+++ b/packages/backend/src/server/web/ClientServerService.ts
@@ -510,6 +510,7 @@ export class ClientServerService {
usernameLower: username.toLowerCase(),
host: host ?? IsNull(),
isSuspended: false,
+ enableRss: true,
});
return user && await this.feedService.packFeed(user);
diff --git a/packages/frontend/src/pages/settings/privacy.vue b/packages/frontend/src/pages/settings/privacy.vue
index b155d6e316..dccfb584ae 100644
--- a/packages/frontend/src/pages/settings/privacy.vue
+++ b/packages/frontend/src/pages/settings/privacy.vue
@@ -43,6 +43,10 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.makeExplorable }}
{{ i18n.ts.makeExplorableDescription }}
+
+ {{ i18n.ts.enableRss }}
+ {{ i18n.ts.enableRssDescription }}
+
@@ -89,6 +93,7 @@ const isLocked = ref($i.isLocked);
const autoAcceptFollowed = ref($i.autoAcceptFollowed);
const noCrawle = ref($i.noCrawle);
const noindex = ref($i.noindex);
+const enableRss = ref($i.enableRss);
const isExplorable = ref($i.isExplorable);
const hideOnlineStatus = ref($i.hideOnlineStatus);
const publicReactions = ref($i.publicReactions);
@@ -106,6 +111,7 @@ function save() {
autoAcceptFollowed: !!autoAcceptFollowed.value,
noCrawle: !!noCrawle.value,
noindex: !!noindex.value,
+ enableRss: !!enableRss.value,
isExplorable: !!isExplorable.value,
hideOnlineStatus: !!hideOnlineStatus.value,
publicReactions: !!publicReactions.value,
diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts
index 29c2e814d2..b161698f27 100644
--- a/packages/misskey-js/src/autogen/types.ts
+++ b/packages/misskey-js/src/autogen/types.ts
@@ -3913,6 +3913,7 @@ export type components = {
/** @default false */
isSystem?: boolean;
noindex: boolean;
+ enableRss: boolean;
isBot?: boolean;
isCat?: boolean;
speakAsCat?: boolean;
@@ -21320,6 +21321,7 @@ export type operations = {
noCrawle?: boolean;
preventAiLearning?: boolean;
noindex?: boolean;
+ enableRss?: boolean;
isBot?: boolean;
isCat?: boolean;
speakAsCat?: boolean;
diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml
index f15646a156..4e462edda7 100644
--- a/sharkey-locales/en-US.yml
+++ b/sharkey-locales/en-US.yml
@@ -88,6 +88,8 @@ searchEngineCustomURIDescription: "The custom URI must be input in the format li
searchEngineCusomURI: "Custom URI"
makeIndexable: "Make public notes not indexable"
makeIndexableDescription: "Stop note search from indexing your public notes."
+enableRss: "Enable RSS feed"
+enableRssDescription: "Generate an RSS feed containing your basic profile details and public notes. Users can subscribe to the feed without a follow request or approval."
sendErrorReportsDescription: "When turned on, detailed error information will be shared with Sharkey when a problem occurs, helping to improve the quality of Sharkey.\nThis will include information such the version of your OS, what browser you're using, your activity in Sharkey, etc."
noInquiryUrlWarning: "Contact URL is not set."
misskeyUpdated: "Sharkey has been updated!"
--
cgit v1.2.3-freya
From 02b600c9dae3e4f461fef884e061468d8b647b63 Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Thu, 12 Dec 2024 07:29:02 -0500
Subject: federate profile when changing `enableRss` value
---
packages/backend/src/server/api/endpoints/i/update.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index cae2964628..91f871a952 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -589,7 +589,7 @@ export default class extends Endpoint { // eslint-
// these two methods need to be kept in sync with
// `ApRendererService.renderPerson`
private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial): boolean {
- for (const field of ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus'] as (keyof MiUser)[]) {
+ for (const field of ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss'] as (keyof MiUser)[]) {
if ((field in newUser) && oldUser[field] !== newUser[field]) {
return true;
}
--
cgit v1.2.3-freya
From 1c65f234458c5ce6f67280bc2aa7b4e04f1aa671 Mon Sep 17 00:00:00 2001
From: Hazelnoot
Date: Thu, 12 Dec 2024 07:31:11 -0500
Subject: safer typings for `userNeedsPublishing` and `profileNeedsPublishing`
---
packages/backend/src/server/api/endpoints/i/update.ts | 18 ++++++++++++------
1 file changed, 12 insertions(+), 6 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index 91f871a952..c22cbe5d4b 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -589,12 +589,15 @@ export default class extends Endpoint { // eslint-
// these two methods need to be kept in sync with
// `ApRendererService.renderPerson`
private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial): boolean {
- for (const field of ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss'] as (keyof MiUser)[]) {
+ const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss'];
+ for (const field of basicFields) {
if ((field in newUser) && oldUser[field] !== newUser[field]) {
return true;
}
}
- for (const arrayField of ['emojis', 'tags'] as (keyof MiUser)[]) {
+
+ const arrayFields: (keyof MiUser)[] = ['emojis', 'tags'];
+ for (const arrayField of arrayFields) {
if ((arrayField in newUser) !== (arrayField in oldUser)) {
return true;
}
@@ -604,7 +607,7 @@ export default class extends Endpoint { // eslint-
if (!Array.isArray(oldArray) || !Array.isArray(newArray)) {
return true;
}
- if (oldArray.join("\0") !== newArray.join("\0")) {
+ if (oldArray.join('\0') !== newArray.join('\0')) {
return true;
}
}
@@ -612,12 +615,15 @@ export default class extends Endpoint { // eslint-
}
private profileNeedsPublishing(oldProfile: MiUserProfile, newProfile: Partial): boolean {
- for (const field of ['description', 'followedMessage', 'birthday', 'location', 'listenbrainz'] as (keyof MiUserProfile)[]) {
+ const basicFields: (keyof MiUserProfile)[] = ['description', 'followedMessage', 'birthday', 'location', 'listenbrainz'];
+ for (const field of basicFields) {
if ((field in newProfile) && oldProfile[field] !== newProfile[field]) {
return true;
}
}
- for (const arrayField of ['fields'] as (keyof MiUserProfile)[]) {
+
+ const arrayFields: (keyof MiUserProfile)[] = ['fields'];
+ for (const arrayField of arrayFields) {
if ((arrayField in newProfile) !== (arrayField in oldProfile)) {
return true;
}
@@ -627,7 +633,7 @@ export default class extends Endpoint { // eslint-
if (!Array.isArray(oldArray) || !Array.isArray(newArray)) {
return true;
}
- if (oldArray.join("\0") !== newArray.join("\0")) {
+ if (oldArray.join('\0') !== newArray.join('\0')) {
return true;
}
}
--
cgit v1.2.3-freya
From e9f68ab2b03bda4aa972f917f26e53d10f7058b7 Mon Sep 17 00:00:00 2001
From: dakkar
Date: Thu, 12 Dec 2024 17:40:22 +0000
Subject: actually publish profile updates with changes to new mk fields
I had forgotten to check these
---
packages/backend/src/server/api/endpoints/i/update.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts
index fe28b50338..bb0f5aa11a 100644
--- a/packages/backend/src/server/api/endpoints/i/update.ts
+++ b/packages/backend/src/server/api/endpoints/i/update.ts
@@ -621,7 +621,7 @@ export default class extends Endpoint { // eslint-
// these two methods need to be kept in sync with
// `ApRendererService.renderPerson`
private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial): boolean {
- const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss'];
+ const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore'];
for (const field of basicFields) {
if ((field in newUser) && oldUser[field] !== newUser[field]) {
return true;
--
cgit v1.2.3-freya
From 0c1dd73341bdbdb05dfea0b6215fa4e0c3cd7a8b Mon Sep 17 00:00:00 2001
From: dakkar
Date: Fri, 13 Dec 2024 15:56:07 +0000
Subject: on 429, retry `fetchAccount` instead of failing
when switching between accounts, with many tabs open (10 seem to be
enough), they all hit the endpoint at the same time, and some get rate
limited.
treating a 429 as a fatal error confuses the frontend, which ends up
logging the user out of all their accounts.
this code makes the frontend retry, after waiting the appropriate
amount of time.
seems to work fine in my testing.
---
packages/backend/src/server/api/ApiCallService.ts | 1 +
packages/frontend/src/account.ts | 7 +++++++
2 files changed, 8 insertions(+)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index c6c33f7303..974be7e4e1 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -340,6 +340,7 @@ export class ApiCallService implements OnApplicationShutdown {
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
+ info,
});
}
}
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts
index e3416f2c29..f0a464084f 100644
--- a/packages/frontend/src/account.ts
+++ b/packages/frontend/src/account.ts
@@ -147,6 +147,13 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
text: i18n.ts.tokenRevokedDescription,
});
}
+ } else if (res.error.id === 'd5826d14-3982-4d2e-8011-b9e9f02499ef') {
+ // rate limited
+ const timeToWait = res.error.info?.resetMs ?? 1000;
+ window.setTimeout(timeToWait, () => {
+ fetchAccount(token, id, forceShowDialog).then(done, fail);
+ });
+ return;
} else {
await alert({
type: 'error',
--
cgit v1.2.3-freya
From 9b1fc969086fb0c3ea88c979f254e964f9218dc5 Mon Sep 17 00:00:00 2001
From: dakkar
Date: Fri, 13 Dec 2024 16:17:43 +0000
Subject: fix passing rate limiting info via ApiError
---
packages/backend/src/server/api/ApiCallService.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts
index 974be7e4e1..03f25a51fe 100644
--- a/packages/backend/src/server/api/ApiCallService.ts
+++ b/packages/backend/src/server/api/ApiCallService.ts
@@ -340,8 +340,7 @@ export class ApiCallService implements OnApplicationShutdown {
code: 'RATE_LIMIT_EXCEEDED',
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
httpStatusCode: 429,
- info,
- });
+ }, info);
}
}
}
--
cgit v1.2.3-freya
From e50ff9db6ae91e1ad34bc50935300515841bf719 Mon Sep 17 00:00:00 2001
From: Marie
Date: Sun, 15 Dec 2024 22:41:16 +0100
Subject: upd: make schedule time work cross timezones
---
packages/backend/package.json | 2 +-
.../server/api/endpoints/notes/schedule/create.ts | 10 ++--
packages/frontend/package.json | 1 +
.../frontend/src/components/MkScheduleEditor.vue | 3 +-
pnpm-lock.yaml | 54 ++++++++--------------
5 files changed, 28 insertions(+), 42 deletions(-)
(limited to 'packages/backend/src/server/api')
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 702b788061..dd6c9cc792 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -141,11 +141,11 @@
"juice": "11.0.0",
"megalodon": "workspace:*",
"meilisearch": "0.45.0",
- "juice": "11.0.0",
"microformats-parser": "2.0.2",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
+ "moment": "^2.30.1",
"ms": "3.0.0-canary.1",
"nanoid": "5.0.8",
"nested-property": "4.0.0",
diff --git a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
index 7d20b6b82a..c6032fbdae 100644
--- a/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
+++ b/packages/backend/src/server/api/endpoints/notes/schedule/create.ts
@@ -6,6 +6,7 @@
import ms from 'ms';
import { In } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
+import moment from 'moment';
import { isPureRenote } from '@/misc/is-renote.js';
import type { MiUser } from '@/models/User.js';
import type {
@@ -307,7 +308,7 @@ export default class extends Endpoint { // eslint-
if (ps.poll) {
let scheduleNote_scheduledAt = Date.now();
if (typeof ps.scheduleNote.scheduledAt === 'number') {
- scheduleNote_scheduledAt = ps.scheduleNote.scheduledAt;
+ scheduleNote_scheduledAt = moment.utc(ps.scheduleNote.scheduledAt).local().valueOf();
}
if (typeof ps.poll.expiresAt === 'number') {
if (ps.poll.expiresAt < scheduleNote_scheduledAt) {
@@ -318,7 +319,7 @@ export default class extends Endpoint { // eslint-
}
}
if (typeof ps.scheduleNote.scheduledAt === 'number') {
- if (ps.scheduleNote.scheduledAt < Date.now()) {
+ if (moment.utc(ps.scheduleNote.scheduledAt).local().valueOf() < Date.now()) {
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredSchedule);
}
} else {
@@ -347,14 +348,15 @@ export default class extends Endpoint { // eslint-
if (ps.scheduleNote.scheduledAt) {
me.token = null;
const noteId = this.idService.gen(new Date().getTime());
+ const schedNoteLocalTime = moment.utc(ps.scheduleNote.scheduledAt).local().valueOf();
await this.noteScheduleRepository.insert({
id: noteId,
note: note,
userId: me.id,
- scheduledAt: new Date(ps.scheduleNote.scheduledAt),
+ scheduledAt: new Date(schedNoteLocalTime),
});
- const delay = new Date(ps.scheduleNote.scheduledAt).getTime() - Date.now();
+ const delay = new Date(schedNoteLocalTime).getTime() - Date.now();
await this.queueService.ScheduleNotePostQueue.add(String(delay), {
scheduleNoteId: noteId,
}, {
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index 752b6cb388..ce3fd90a86 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -57,6 +57,7 @@
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
+ "moment": "^2.30.1",
"photoswipe": "5.4.4",
"punycode": "2.3.1",
"rollup": "4.26.0",
diff --git a/packages/frontend/src/components/MkScheduleEditor.vue b/packages/frontend/src/components/MkScheduleEditor.vue
index f40d37c962..695a474998 100644
--- a/packages/frontend/src/components/MkScheduleEditor.vue
+++ b/packages/frontend/src/components/MkScheduleEditor.vue
@@ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only