mirror of
https://activitypub.software/TransFem-org/Sharkey
synced 2024-11-22 05:55:12 +00:00
Merge tag '2024.10.1' into feature/2024.10
This commit is contained in:
commit
f079edaf3c
454 changed files with 9728 additions and 3363 deletions
|
@ -2,6 +2,19 @@
|
|||
# Misskey configuration
|
||||
#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
# ┌────────────────────────┐
|
||||
#───┘ Initial Setup Password └─────────────────────────────────────────────────────
|
||||
|
||||
# Password to initiate setting up admin account.
|
||||
# It will not be used after the initial setup is complete.
|
||||
#
|
||||
# Be sure to change this when you set up Misskey via the Internet.
|
||||
#
|
||||
# The provider of the service who sets up Misskey on behalf of the customer should
|
||||
# set this value to something unique when generating the Misskey config file,
|
||||
# and provide it to the customer.
|
||||
setupPassword: example_password_please_change_this_or_you_will_get_hacked
|
||||
|
||||
# ┌─────┐
|
||||
#───┘ URL └─────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
@ -59,6 +59,20 @@
|
|||
#
|
||||
# publishTarballInsteadOfProvideRepositoryUrl: true
|
||||
|
||||
# ┌────────────────────────┐
|
||||
#───┘ Initial Setup Password └─────────────────────────────────────────────────────
|
||||
|
||||
# Password to initiate setting up admin account.
|
||||
# It will not be used after the initial setup is complete.
|
||||
#
|
||||
# Be sure to change this when you set up Misskey via the Internet.
|
||||
#
|
||||
# The provider of the service who sets up Misskey on behalf of the customer should
|
||||
# set this value to something unique when generating the Misskey config file,
|
||||
# and provide it to the customer.
|
||||
#
|
||||
# setupPassword: example_password_please_change_this_or_you_will_get_hacked
|
||||
|
||||
# ┌─────┐
|
||||
#───┘ URL └─────────────────────────────────────────────────────
|
||||
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -40,7 +40,7 @@ coverage
|
|||
!/.config/docker_example.env
|
||||
!/.config/cypress-devcontainer.yml
|
||||
docker-compose.yml
|
||||
compose.yml
|
||||
./compose.yml
|
||||
.devcontainer/compose.yml
|
||||
!/.devcontainer/compose.yml
|
||||
|
||||
|
|
55
CHANGELOG.md
55
CHANGELOG.md
|
@ -1,3 +1,58 @@
|
|||
## 2024.10.1
|
||||
|
||||
### Note
|
||||
- スパム対策として、モデレータ権限を持つユーザのアクティビティが7日以上確認できない場合は自動的に招待制へと切り替え(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)るようになりました。 ( #13437 )
|
||||
- 切り替わった際はモデレーターへお知らせとして通知されます。登録をオープンな状態で継続したい場合は、コントロールパネルから再度設定を行ってください。
|
||||
|
||||
### General
|
||||
- Feat: ユーザーの名前に禁止ワードを設定できるように
|
||||
|
||||
### Client
|
||||
- Enhance: タイムライン表示時のパフォーマンスを向上
|
||||
- Enhance: アーカイブした個人宛のお知らせを表示・編集できるように
|
||||
- Enhance: l10nの更新
|
||||
- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正
|
||||
|
||||
### Server
|
||||
- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと切り替えるように ( #13437 )
|
||||
- Enhance: 個人宛のお知らせは「わかった」を押すと自動的にアーカイブされるように
|
||||
- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正
|
||||
- Fix: RBT有効時、リノートのリアクションが反映されない問題を修正
|
||||
- Fix: キューのエラーログを簡略化するように
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649)
|
||||
|
||||
## 2024.10.0
|
||||
|
||||
### Note
|
||||
- セキュリティ向上のため、サーバー初期設定時に使用する初期パスワードを設定できるようになりました。今後Misskeyサーバーを新たに設置する際には、初回の起動前にコンフィグファイルの`setupPassword`をコメントアウトし、初期パスワードを設定することをおすすめします。(すでに初期設定を完了しているサーバーについては、この変更に伴い対応する必要はありません)
|
||||
- ホスティングサービスを運営している場合は、コンフィグファイルを構築する際に`setupPassword`をランダムな値に設定し、ユーザーに通知するようにシステムを更新することをおすすめします。
|
||||
- なお、初期パスワードが設定されていない場合でも初期設定を行うことが可能です(UI上で初期パスワードの入力欄を空欄にすると続行できます)。
|
||||
- ユーザーデータを読み込む際の型が一部変更されました。
|
||||
- `twoFactorEnabled`, `usePasswordLessLogin`, `securityKeys`: 自分とモデレーター以外のユーザーからは取得できなくなりました
|
||||
|
||||
### General
|
||||
- Feat: サーバー初期設定時に初期パスワードを設定できるように
|
||||
- Feat: 通報にモデレーションノートを残せるように
|
||||
- Feat: 通報の解決種別を設定できるように
|
||||
- Enhance: 通報の解決と転送を個別に行えるように
|
||||
- Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました
|
||||
- Enhance: 依存関係の更新
|
||||
- Enhance: l10nの更新
|
||||
- Enhance: Playの「人気」タブで10件以上表示可能に #14399
|
||||
- Fix: 連合のホワイトリストが正常に登録されない問題を修正
|
||||
|
||||
### Client
|
||||
- Enhance: デザインの調整
|
||||
- Enhance: ログイン画面の認証フローを改善
|
||||
- Fix: クライアント上での時間ベースの実績獲得動作が実績獲得後も発動していた問題を修正
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/657)
|
||||
|
||||
### Server
|
||||
- Enhance: セキュリティ向上のため、ログイン時にメール通知を行うように
|
||||
- Enhance: 自分とモデレーター以外のユーザーから二要素認証関連のデータが取得できないように
|
||||
- Enhance: 通報および通報解決時に送出されるSystemWebhookにユーザ情報を含めるように ( #14697 )
|
||||
- Fix: `admin/abuse-user-reports`エンドポイントのスキーマが間違っていた問題を修正
|
||||
|
||||
## 2024.9.0
|
||||
|
||||
### General
|
||||
|
|
|
@ -181,31 +181,46 @@ MK_DEV_PREFER=backend pnpm dev
|
|||
- HMR may not work in some environments such as Windows.
|
||||
|
||||
## Testing
|
||||
- Test codes are located in [`/packages/backend/test`](packages/backend/test).
|
||||
|
||||
### Run test
|
||||
Create a config file.
|
||||
You can run non-backend tests by executing following commands:
|
||||
```sh
|
||||
pnpm --filter frontend test
|
||||
pnpm --filter misskey-js test
|
||||
```
|
||||
|
||||
Backend tests require manual preparation of servers. See the next section for more on this.
|
||||
|
||||
### Backend
|
||||
There are three types of test codes for the backend:
|
||||
- Unit tests: [`/packages/backend/test/unit`](/packages/backend/test/unit)
|
||||
- Single-server E2E tests: [`/packages/backend/test/e2e`](/packages/backend/test/e2e)
|
||||
- Multiple-server E2E tests: [`/packages/backend/test-federation`](/packages/backend/test-federation)
|
||||
|
||||
#### Running Unit Tests or Single-server E2E Tests
|
||||
1. Create a config file:
|
||||
```sh
|
||||
cp .github/misskey/test.yml .config/
|
||||
```
|
||||
Prepare DB/Redis for testing.
|
||||
```
|
||||
|
||||
2. Start DB and Redis servers for testing:
|
||||
```sh
|
||||
docker compose -f packages/backend/test/compose.yml up
|
||||
```
|
||||
Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`.
|
||||
Instead, you can prepare an empty (data can be erased) DB and edit `.config/test.yml` appropriately.
|
||||
|
||||
Run all test.
|
||||
3. Run all tests:
|
||||
```sh
|
||||
pnpm --filter backend test # unit tests
|
||||
pnpm --filter backend test:e2e # single-server E2E tests
|
||||
```
|
||||
pnpm test
|
||||
If you want to run a specific test, run as a following command:
|
||||
```sh
|
||||
pnpm --filter backend test -- packages/backend/test/unit/activitypub.ts
|
||||
pnpm --filter backend test:e2e -- packages/backend/test/e2e/nodeinfo.ts
|
||||
```
|
||||
|
||||
#### Run specify test
|
||||
```
|
||||
pnpm jest -- foo.ts
|
||||
```
|
||||
|
||||
### e2e tests
|
||||
TODO
|
||||
#### Running Multiple-server E2E Tests
|
||||
See [`/packages/backend/test-federation/README.md`](/packages/backend/test-federation/README.md).
|
||||
|
||||
## Environment Variable
|
||||
|
||||
|
@ -579,19 +594,19 @@ ESMではディレクトリインポートは廃止されているのと、デ
|
|||
### Lighten CSS vars
|
||||
|
||||
``` css
|
||||
color: hsl(from var(--accent) h s calc(l + 10));
|
||||
color: hsl(from var(--MI_THEME-accent) h s calc(l + 10));
|
||||
```
|
||||
|
||||
### Darken CSS vars
|
||||
|
||||
``` css
|
||||
color: hsl(from var(--accent) h s calc(l - 10));
|
||||
color: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
|
||||
```
|
||||
|
||||
### Add alpha to CSS vars
|
||||
|
||||
``` css
|
||||
color: color(from var(--accent) srgb r g b / 0.5);
|
||||
color: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
|
||||
```
|
||||
|
||||
## Merging from Misskey into Sharkey
|
||||
|
|
|
@ -23,6 +23,7 @@ describe('Before setup instance', () => {
|
|||
|
||||
cy.intercept('POST', '/api/admin/accounts/create').as('signup');
|
||||
|
||||
cy.get('[data-cy-admin-initial-password] input').type('example_password_please_change_this_or_you_will_get_hacked');
|
||||
cy.get('[data-cy-admin-username] input').type('admin');
|
||||
cy.get('[data-cy-admin-password] input').type('admin1234');
|
||||
cy.get('[data-cy-admin-ok]').click();
|
||||
|
@ -119,11 +120,16 @@ 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();
|
||||
cy.get('[data-cy-signin-username] input').type('alice');
|
||||
// Enterキーでサインインできるかの確認も兼ねる
|
||||
|
||||
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
|
||||
// Enterキーで続行できるかの確認も兼ねる
|
||||
cy.get('[data-cy-signin-username] input').type('alice{enter}');
|
||||
|
||||
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
|
||||
// Enterキーで続行できるかの確認も兼ねる
|
||||
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
|
||||
|
||||
cy.wait('@signin');
|
||||
|
@ -138,8 +144,9 @@ describe('After user signup', () => {
|
|||
cy.visitHome();
|
||||
|
||||
cy.get('[data-cy-signin]').click();
|
||||
cy.get('[data-cy-signin-username] input').type('alice');
|
||||
cy.get('[data-cy-signin-password] input').type('alice1234{enter}');
|
||||
|
||||
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
|
||||
cy.get('[data-cy-signin-username] input').type('alice{enter}');
|
||||
|
||||
// TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする
|
||||
cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi);
|
||||
|
|
|
@ -48,16 +48,19 @@ Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => {
|
|||
cy.request('POST', route, {
|
||||
username: username,
|
||||
password: password,
|
||||
...(isAdmin ? { setupPassword: 'example_password_please_change_this_or_you_will_get_hacked' } : {}),
|
||||
}).its('body').as(username);
|
||||
});
|
||||
|
||||
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-username] input').type(username);
|
||||
cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 });
|
||||
cy.get('[data-cy-signin-username] input').type(`${username}{enter}`);
|
||||
cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 });
|
||||
cy.get('[data-cy-signin-password] input').type(`${password}{enter}`);
|
||||
|
||||
cy.wait('@signin').as('signedIn');
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { abuseUserReport } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import { abuseUserReport } from '../packages/frontend/.storybook/fakes.js';
|
||||
import { commonHandlers } from '../packages/frontend/.storybook/mocks.js';
|
||||
import MkAbuseReport from './MkAbuseReport.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
|
@ -34,7 +34,7 @@ defineProps<{
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: not-allowed;
|
||||
--color: color(from var(--error) srgb r g b / 0.25);
|
||||
--color: color(from var(--MI_THEME-error) srgb r g b / 0.25);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 10px, var(--color) 4px, var(--color) 14px);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "sharkey",
|
||||
"version": "2024.10.0-dev",
|
||||
"version": "2024.10.3-rc",
|
||||
"codename": "shonk",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
BIN
packages/backend/assets/tabler-badges/login-2.png
Normal file
BIN
packages/backend/assets/tabler-badges/login-2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
|
@ -12,7 +12,7 @@ export default [
|
|||
languageOptions: {
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json', './test/tsconfig.json'],
|
||||
project: ['./tsconfig.json', './test/tsconfig.json', './test-federation/tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
|
|
13
packages/backend/jest.config.fed.cjs
Normal file
13
packages/backend/jest.config.fed.cjs
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* For a detailed explanation regarding each configuration property and type check, visit:
|
||||
* https://jestjs.io/docs/en/configuration.html
|
||||
*/
|
||||
|
||||
const base = require('./jest.config.cjs');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
testMatch: [
|
||||
'<rootDir>/test-federation/test/**/*.test.ts',
|
||||
],
|
||||
};
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class EnableStatsForFederatedInstances1727318020265 {
|
||||
name = 'EnableStatsForFederatedInstances1727318020265'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableStatsForFederatedInstances" boolean NOT NULL DEFAULT true`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableStatsForFederatedInstances"`);
|
||||
}
|
||||
}
|
|
@ -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"`);
|
||||
}
|
||||
}
|
16
packages/backend/migration/1728550878802-testcaptcha.js
Normal file
16
packages/backend/migration/1728550878802-testcaptcha.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class Testcaptcha1728550878802 {
|
||||
name = 'Testcaptcha1728550878802'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class ProhibitedWordsForNameOfUser1728634286056 {
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWordsForNameOfUser" character varying(1024) array NOT NULL DEFAULT '{}'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWordsForNameOfUser"`);
|
||||
}
|
||||
}
|
|
@ -19,16 +19,18 @@
|
|||
"watch": "node ./scripts/watch.mjs",
|
||||
"restart": "pnpm build && pnpm start",
|
||||
"dev": "node ./scripts/dev.mjs",
|
||||
"typecheck": "pnpm --filter megalodon build && tsc --noEmit && tsc -p test --noEmit",
|
||||
"eslint": "eslint --quiet \"{src,test,js,@types}/**/*.{js,jsx,ts,tsx,vue}\" --cache",
|
||||
"typecheck": "pnpm --filter megalodon build && tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
|
||||
"eslint": "eslint --quiet \"{src,test-federation,test,js,@types}/**/*.{js,jsx,ts,tsx,vue}\" --cache",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
|
||||
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
|
||||
"jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs",
|
||||
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
|
||||
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
|
||||
"test": "pnpm jest",
|
||||
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
|
||||
"test:fed": "pnpm jest:fed",
|
||||
"test-and-coverage": "pnpm jest-and-coverage",
|
||||
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
|
||||
"generate-api-json": "node ./scripts/generate_api_json.js"
|
||||
|
@ -69,20 +71,20 @@
|
|||
"@bull-board/fastify": "6.0.0",
|
||||
"@bull-board/ui": "6.0.0",
|
||||
"@discordapp/twemoji": "15.1.0",
|
||||
"@fastify/accepts": "5.0.0",
|
||||
"@fastify/cookie": "10.0.0",
|
||||
"@fastify/cors": "10.0.0",
|
||||
"@fastify/express": "4.0.0",
|
||||
"@fastify/accepts": "5.0.1",
|
||||
"@fastify/cookie": "10.0.1",
|
||||
"@fastify/cors": "10.0.1",
|
||||
"@fastify/express": "4.0.1",
|
||||
"@fastify/http-proxy": "10.0.0",
|
||||
"@fastify/multipart": "9.0.0",
|
||||
"@fastify/static": "8.0.0",
|
||||
"@fastify/view": "10.0.0",
|
||||
"@fastify/multipart": "9.0.1",
|
||||
"@fastify/static": "8.0.1",
|
||||
"@fastify/view": "10.0.1",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.1.0",
|
||||
"@napi-rs/canvas": "0.1.56",
|
||||
"@nestjs/common": "10.4.3",
|
||||
"@nestjs/core": "10.4.3",
|
||||
"@nestjs/testing": "10.4.3",
|
||||
"@nestjs/common": "10.4.4",
|
||||
"@nestjs/core": "10.4.4",
|
||||
"@nestjs/testing": "10.4.4",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sentry/node": "8.20.0",
|
||||
"@sentry/profiling-node": "8.20.0",
|
||||
|
@ -101,7 +103,7 @@
|
|||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.3",
|
||||
"bullmq": "5.13.2",
|
||||
"bullmq": "5.15.0",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"cbor": "9.0.2",
|
||||
"chalk": "5.3.0",
|
||||
|
@ -151,7 +153,7 @@
|
|||
"oauth2orize": "1.12.0",
|
||||
"oauth2orize-pkce": "0.1.2",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.3.2",
|
||||
"otpauth": "9.3.4",
|
||||
"parse5": "7.1.2",
|
||||
"pg": "8.13.0",
|
||||
"pkce-challenge": "4.1.0",
|
||||
|
@ -169,7 +171,7 @@
|
|||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.1",
|
||||
"sanitize-html": "2.13.0",
|
||||
"sanitize-html": "2.13.1",
|
||||
"secure-json-parse": "2.7.0",
|
||||
"sharp": "0.33.5",
|
||||
"slacc": "0.0.10",
|
||||
|
@ -191,14 +193,14 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@nestjs/platform-express": "10.4.3",
|
||||
"@nestjs/platform-express": "10.4.4",
|
||||
"@simplewebauthn/types": "10.0.0",
|
||||
"@swc/jest": "0.2.36",
|
||||
"@types/accepts": "1.3.7",
|
||||
"@types/archiver": "6.0.2",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"@types/body-parser": "1.19.5",
|
||||
"@types/color-convert": "2.0.3",
|
||||
"@types/color-convert": "2.0.4",
|
||||
"@types/content-disposition": "0.5.8",
|
||||
"@types/fluent-ffmpeg": "2.1.26",
|
||||
"@types/htmlescape": "1.1.3",
|
||||
|
|
|
@ -65,6 +65,8 @@ type Source = {
|
|||
|
||||
publishTarballInsteadOfProvideRepositoryUrl?: boolean;
|
||||
|
||||
setupPassword?: string;
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
@ -179,6 +181,7 @@ export type Config = {
|
|||
|
||||
version: string;
|
||||
publishTarballInsteadOfProvideRepositoryUrl: boolean;
|
||||
setupPassword: string | undefined;
|
||||
host: string;
|
||||
hostname: string;
|
||||
scheme: string;
|
||||
|
@ -280,6 +283,7 @@ export function loadConfig(): Config {
|
|||
return {
|
||||
version,
|
||||
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
|
||||
setupPassword: config.setupPassword,
|
||||
url: url.origin,
|
||||
port: config.port ?? parseInt(process.env.PORT ?? '3000', 10),
|
||||
socket: config.socket,
|
||||
|
|
|
@ -22,6 +22,7 @@ import { RoleService } from '@/core/RoleService.js';
|
|||
import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { IdService } from './IdService.js';
|
||||
|
||||
@Injectable()
|
||||
|
@ -42,6 +43,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
private emailService: EmailService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
this.redisForSub.on('message', this.onMessage);
|
||||
}
|
||||
|
@ -59,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
return;
|
||||
}
|
||||
|
||||
const moderatorIds = await this.roleService.getModeratorIds(true, true);
|
||||
const moderatorIds = await this.roleService.getModeratorIds({
|
||||
includeAdmins: true,
|
||||
excludeExpire: true,
|
||||
});
|
||||
|
||||
for (const moderatorId of moderatorIds) {
|
||||
for (const abuseReport of abuseReports) {
|
||||
|
@ -135,6 +140,26 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
return;
|
||||
}
|
||||
|
||||
const usersMap = await this.userEntityService.packMany(
|
||||
[
|
||||
...new Set([
|
||||
...abuseReports.map(it => it.reporter ?? it.reporterId),
|
||||
...abuseReports.map(it => it.targetUser ?? it.targetUserId),
|
||||
...abuseReports.map(it => it.assignee ?? it.assigneeId),
|
||||
].filter(x => x != null)),
|
||||
],
|
||||
null,
|
||||
{ schema: 'UserLite' },
|
||||
).then(it => new Map(it.map(it => [it.id, it])));
|
||||
const convertedReports = abuseReports.map(it => {
|
||||
return {
|
||||
...it,
|
||||
reporter: usersMap.get(it.reporterId),
|
||||
targetUser: usersMap.get(it.targetUserId),
|
||||
assignee: it.assigneeId ? usersMap.get(it.assigneeId) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const recipientWebhookIds = await this.fetchWebhookRecipients()
|
||||
.then(it => it
|
||||
.filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook')
|
||||
|
@ -142,7 +167,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
.filter(x => x != null));
|
||||
for (const webhookId of recipientWebhookIds) {
|
||||
await Promise.all(
|
||||
abuseReports.map(it => {
|
||||
convertedReports.map(it => {
|
||||
return this.systemWebhookService.enqueueSystemWebhook(
|
||||
webhookId,
|
||||
type,
|
||||
|
@ -263,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
.log(updater, 'createAbuseReportNotificationRecipient', {
|
||||
recipientId: id,
|
||||
recipient: created,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
@ -302,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
recipientId: params.id,
|
||||
before: beforeEntity,
|
||||
after: afterEntity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return afterEntity;
|
||||
}
|
||||
|
@ -324,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
.log(updater, 'deleteAbuseReportNotificationRecipient', {
|
||||
recipientId: id,
|
||||
recipient: entity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -348,7 +370,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
// モデレータ権限の有無で通知先設定を振り分ける
|
||||
const authorizedUserIds = await this.roleService.getModeratorIds(true, true);
|
||||
const authorizedUserIds = await this.roleService.getModeratorIds({
|
||||
includeAdmins: true,
|
||||
excludeExpire: true,
|
||||
});
|
||||
const authorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
|
||||
const unauthorizedUserRecipients = Array.of<MiAbuseReportNotificationRecipient>();
|
||||
for (const recipient of userRecipients) {
|
||||
|
|
|
@ -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,62 +79,98 @@ 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({
|
||||
id: In(params.map(it => it.reportId)),
|
||||
});
|
||||
|
||||
const targetUserMap = new Map();
|
||||
for (const report of reports) {
|
||||
const shouldForward = paramsMap.get(report.id)!.forward;
|
||||
|
||||
if (shouldForward && report.targetUserHost != null) {
|
||||
targetUserMap.set(report.id, await this.usersRepository.findOneByOrFail({ id: report.targetUserId }));
|
||||
} else {
|
||||
targetUserMap.set(report.id, null);
|
||||
}
|
||||
}
|
||||
|
||||
for (const report of reports) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const ps = paramsMap.get(report.id)!;
|
||||
|
||||
await this.abuseUserReportsRepository.update(report.id, {
|
||||
resolved: true,
|
||||
assigneeId: operator.id,
|
||||
forwarded: ps.forward && report.targetUserHost !== null,
|
||||
assigneeId: moderator.id,
|
||||
resolvedAs: ps.resolvedAs,
|
||||
});
|
||||
|
||||
const targetUser = targetUserMap.get(report.id)!;
|
||||
if (targetUser != null) {
|
||||
const actor = await this.instanceActorService.getInstanceActor();
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
if (report.forwarded) {
|
||||
throw new Error('The report has already been forwarded.');
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -274,14 +274,16 @@ export class AccountMoveService {
|
|||
}
|
||||
|
||||
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(oldAccount)) {
|
||||
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
|
||||
this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// FIXME: expensive?
|
||||
for (const followerId of localFollowerIds) {
|
||||
|
|
|
@ -209,6 +209,13 @@ export class AnnouncementService {
|
|||
return;
|
||||
}
|
||||
|
||||
const announcement = await this.announcementsRepository.findOneBy({ id: announcementId });
|
||||
if (announcement != null && announcement.userId === user.id) {
|
||||
await this.announcementsRepository.update(announcementId, {
|
||||
isActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
if ((await this.getUnreadAnnouncements(user)).length === 0) {
|
||||
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
|
||||
}
|
||||
|
|
|
@ -149,5 +149,18 @@ export class CaptchaService {
|
|||
throw new Error(`turnstile-failed: ${errorCodes}`);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async verifyTestcaptcha(response: string | null | undefined): Promise<void> {
|
||||
if (response == null) {
|
||||
throw new Error('testcaptcha-failed: no response provided');
|
||||
}
|
||||
|
||||
const success = response === 'testcaptcha-passed';
|
||||
|
||||
if (!success) {
|
||||
throw new Error('testcaptcha-failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 { AnnouncementService } from './AnnouncementService.js';
|
||||
|
@ -220,6 +221,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 };
|
||||
|
@ -373,6 +375,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FlashService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
|
@ -522,6 +525,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
$WebhookTestService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$FlashService,
|
||||
$SearchService,
|
||||
$ClipService,
|
||||
$FeaturedService,
|
||||
|
@ -672,6 +676,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
|
|||
WebhookTestService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FlashService,
|
||||
SearchService,
|
||||
ClipService,
|
||||
FeaturedService,
|
||||
|
|
|
@ -112,19 +112,33 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async update(id: MiEmoji['id'], data: {
|
||||
public async update(data: (
|
||||
{ id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], }
|
||||
) & {
|
||||
driveFile?: MiDriveFile;
|
||||
name?: string;
|
||||
category?: string | null;
|
||||
aliases?: string[];
|
||||
license?: string | null;
|
||||
isSensitive?: boolean;
|
||||
localOnly?: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][];
|
||||
}, moderator?: MiUser): Promise<void> {
|
||||
const emoji = await this.emojisRepository.findOneByOrFail({ id: id });
|
||||
const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() });
|
||||
if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists');
|
||||
}, moderator?: MiUser): Promise<
|
||||
null
|
||||
| 'NO_SUCH_EMOJI'
|
||||
| 'SAME_NAME_EMOJI_EXISTS'
|
||||
> {
|
||||
const emoji = data.id
|
||||
? await this.getEmojiById(data.id)
|
||||
: await this.getEmojiByName(data.name!);
|
||||
if (emoji === null) return 'NO_SUCH_EMOJI';
|
||||
const id = emoji.id;
|
||||
|
||||
// IDと絵文字名が両方指定されている場合は絵文字名の変更を行うため重複チェックが必要
|
||||
const doNameUpdate = data.id && data.name && (data.name !== emoji.name);
|
||||
if (doNameUpdate) {
|
||||
const isDuplicate = await this.checkDuplicate(data.name!);
|
||||
if (isDuplicate) return 'SAME_NAME_EMOJI_EXISTS';
|
||||
}
|
||||
|
||||
await this.emojisRepository.update(emoji.id, {
|
||||
updatedAt: new Date(),
|
||||
|
@ -151,7 +165,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
|
||||
const packed = await this.emojiEntityService.packDetailed(emoji.id);
|
||||
|
||||
if (emoji.name === data.name) {
|
||||
if (!doNameUpdate) {
|
||||
this.globalEventService.publishBroadcastStream('emojiUpdated', {
|
||||
emojis: [packed],
|
||||
});
|
||||
|
@ -173,6 +187,7 @@ export class CustomEmojiService implements OnApplicationShutdown {
|
|||
after: updated,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -49,7 +49,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance> {
|
||||
public async fetchOrRegister(host: string): Promise<MiInstance> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
|
@ -85,6 +85,24 @@ export class FederatedInstanceService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetch(host: string): Promise<MiInstance | null> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = await this.federatedInstanceCache.get(host);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
||||
if (index == null) {
|
||||
this.federatedInstanceCache.set(host, null);
|
||||
return null;
|
||||
} else {
|
||||
this.federatedInstanceCache.set(host, index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(id: MiInstance['id'], data: Partial<MiInstance>): Promise<void> {
|
||||
const result = await this.instancesRepository.createQueryBuilder().update()
|
||||
|
|
|
@ -82,7 +82,7 @@ export class FetchInstanceMetadataService {
|
|||
|
||||
try {
|
||||
if (!force) {
|
||||
const _instance = await this.federatedInstanceService.fetch(host);
|
||||
const _instance = await this.federatedInstanceService.fetchOrRegister(host);
|
||||
const now = Date.now();
|
||||
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
|
||||
// unlock at the finally caluse
|
||||
|
|
40
packages/backend/src/core/FlashService.ts
Normal file
40
packages/backend/src/core/FlashService.ts
Normal file
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -222,7 +222,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
private cacheService: CacheService,
|
||||
private latestNoteService: LatestNoteService,
|
||||
) {
|
||||
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -563,11 +563,10 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
// Register host
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
if (note.renote && note.text) {
|
||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
||||
} else if (!note.renote) {
|
||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||
if (note.renote && note.text || !note.renote) {
|
||||
this.updateNotesCountQueue.enqueue(i.id, 1);
|
||||
}
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
|
@ -575,6 +574,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ハッシュタグ更新
|
||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
||||
|
|
|
@ -115,19 +115,15 @@ export class NoteDeleteService {
|
|||
this.perUserNotesChart.update(user, note, false);
|
||||
}
|
||||
|
||||
if (note.renoteId && note.text) {
|
||||
// Decrement notes count (user)
|
||||
this.decNotesCountOfUser(user);
|
||||
} else if (!note.renoteId) {
|
||||
if (note.renoteId && note.text || !note.renoteId) {
|
||||
// Decrement notes count (user)
|
||||
this.decNotesCountOfUser(user);
|
||||
}
|
||||
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.fetch(user.host).then(async i => {
|
||||
if (note.renoteId && note.text) {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
} else if (!note.renoteId) {
|
||||
this.federatedInstanceService.fetchOrRegister(user.host).then(async i => {
|
||||
if (note.renoteId && note.text || !note.renoteId) {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
}
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
|
@ -136,6 +132,7 @@ export class NoteDeleteService {
|
|||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const cascadingNote of cascadingNotes) {
|
||||
this.searchService.unindexNote(cascadingNote);
|
||||
|
|
|
@ -94,6 +94,13 @@ export class QueueService {
|
|||
repeat: { pattern: '0 0 * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
|
||||
this.systemQueue.add('checkModeratorsActivity', {
|
||||
}, {
|
||||
// 毎時30分に起動
|
||||
repeat: { pattern: '30 * * * *' },
|
||||
removeOnComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -105,6 +105,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
|
||||
@Injectable()
|
||||
export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
||||
private rootUserIdCache: MemorySingleCache<MiUser['id']>;
|
||||
private rolesCache: MemorySingleCache<MiRole[]>;
|
||||
private roleAssignmentByUserIdCache: MemoryKVCache<MiRoleAssignment[]>;
|
||||
private notificationService: NotificationService;
|
||||
|
@ -140,6 +141,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
private moderationLogService: ModerationLogService,
|
||||
private fanoutTimelineService: FanoutTimelineService,
|
||||
) {
|
||||
this.rootUserIdCache = new MemorySingleCache<MiUser['id']>(1000 * 60 * 60 * 24 * 7); // 1week. rootユーザのIDは不変なので長めに
|
||||
this.rolesCache = new MemorySingleCache<MiRole[]>(1000 * 60 * 60); // 1h
|
||||
this.roleAssignmentByUserIdCache = new MemoryKVCache<MiRoleAssignment[]>(1000 * 60 * 5); // 5m
|
||||
|
||||
|
@ -429,22 +431,35 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
return check.isExplorable;
|
||||
}
|
||||
|
||||
/**
|
||||
* モデレーター権限のロールが割り当てられているユーザID一覧を取得する.
|
||||
*
|
||||
* @param opts.includeAdmins 管理者権限も含めるか(デフォルト: true)
|
||||
* @param opts.includeRoot rootユーザも含めるか(デフォルト: false)
|
||||
* @param opts.excludeExpire 期限切れのロールを除外するか(デフォルト: false)
|
||||
*/
|
||||
@bindThis
|
||||
public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise<MiUser['id'][]> {
|
||||
public async getModeratorIds(opts?: {
|
||||
includeAdmins?: boolean,
|
||||
includeRoot?: boolean,
|
||||
excludeExpire?: boolean,
|
||||
}): Promise<MiUser['id'][]> {
|
||||
const includeAdmins = opts?.includeAdmins ?? true;
|
||||
const includeRoot = opts?.includeRoot ?? false;
|
||||
const excludeExpire = opts?.excludeExpire ?? false;
|
||||
|
||||
const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({}));
|
||||
const moderatorRoles = includeAdmins
|
||||
? roles.filter(r => r.isModerator || r.isAdministrator)
|
||||
: roles.filter(r => r.isModerator);
|
||||
|
||||
// TODO: isRootなアカウントも含める
|
||||
const assigns = moderatorRoles.length > 0
|
||||
? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) })
|
||||
: [];
|
||||
|
||||
const now = Date.now();
|
||||
const result = [
|
||||
// Setを経由して重複を除去(ユーザIDは重複する可能性があるので)
|
||||
...new Set(
|
||||
const now = Date.now();
|
||||
const resultSet = new Set(
|
||||
assigns
|
||||
.filter(it =>
|
||||
(excludeExpire)
|
||||
|
@ -452,19 +467,35 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
: true,
|
||||
)
|
||||
.map(a => a.userId),
|
||||
),
|
||||
];
|
||||
);
|
||||
|
||||
return result.sort((x, y) => x.localeCompare(y));
|
||||
if (includeRoot) {
|
||||
const rootUserId = await this.rootUserIdCache.fetch(async () => {
|
||||
const it = await this.usersRepository.createQueryBuilder('users')
|
||||
.select('id')
|
||||
.where({ isRoot: true })
|
||||
.getRawOne<{ id: string }>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return it!.id;
|
||||
});
|
||||
resultSet.add(rootUserId);
|
||||
}
|
||||
|
||||
return [...resultSet].sort((x, y) => x.localeCompare(y));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getModerators(includeAdmins = true): Promise<MiUser[]> {
|
||||
const ids = await this.getModeratorIds(includeAdmins);
|
||||
const users = ids.length > 0 ? await this.usersRepository.findBy({
|
||||
public async getModerators(opts?: {
|
||||
includeAdmins?: boolean,
|
||||
includeRoot?: boolean,
|
||||
excludeExpire?: boolean,
|
||||
}): Promise<MiUser[]> {
|
||||
const ids = await this.getModeratorIds(opts);
|
||||
return ids.length > 0
|
||||
? await this.usersRepository.findBy({
|
||||
id: In(ids),
|
||||
}) : [];
|
||||
return users;
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -155,8 +155,8 @@ export class SignupService {
|
|||
}));
|
||||
});
|
||||
|
||||
this.usersChart.update(account, true).then();
|
||||
this.userService.notifySystemWebhook(account, 'userCreated').then();
|
||||
this.usersChart.update(account, true);
|
||||
this.userService.notifySystemWebhook(account, 'userCreated');
|
||||
|
||||
return { account, secret };
|
||||
}
|
||||
|
|
|
@ -101,8 +101,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
.log(updater, 'createSystemWebhook', {
|
||||
systemWebhookId: webhook.id,
|
||||
webhook: webhook,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return webhook;
|
||||
}
|
||||
|
@ -139,8 +138,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
systemWebhookId: beforeEntity.id,
|
||||
before: beforeEntity,
|
||||
after: afterEntity,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
|
||||
return afterEntity;
|
||||
}
|
||||
|
@ -158,8 +156,7 @@ export class SystemWebhookService implements OnApplicationShutdown {
|
|||
.log(updater, 'deleteSystemWebhook', {
|
||||
systemWebhookId: webhook.id,
|
||||
webhook,
|
||||
})
|
||||
.then();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -305,21 +305,23 @@ export class UserFollowingService implements OnModuleInit {
|
|||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
this.perUserFollowingChart.update(follower, followee, true);
|
||||
|
@ -437,21 +439,23 @@ export class UserFollowingService implements OnModuleInit {
|
|||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.fetch(follower.host).then(async i => {
|
||||
this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
}
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.fetch(followee.host).then(async i => {
|
||||
this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
this.perUserFollowingChart.update(follower, followee, false);
|
||||
|
|
|
@ -12,11 +12,18 @@ import { Packed } from '@/misc/json-schema.js';
|
|||
import { type WebhookEventTypes } from '@/models/Webhook.js';
|
||||
import { UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
|
||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||
|
||||
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUserReport {
|
||||
return {
|
||||
type AbuseUserReportDto = Omit<MiAbuseUserReport, 'targetUser' | 'reporter' | 'assignee'> & {
|
||||
targetUser: Packed<'UserLite'> | null,
|
||||
reporter: Packed<'UserLite'> | null,
|
||||
assignee: Packed<'UserLite'> | null,
|
||||
};
|
||||
|
||||
function generateAbuseReport(override?: Partial<MiAbuseUserReport>): AbuseUserReportDto {
|
||||
const result: MiAbuseUserReport = {
|
||||
id: 'dummy-abuse-report1',
|
||||
targetUserId: 'dummy-target-user',
|
||||
targetUser: null,
|
||||
|
@ -29,8 +36,17 @@ function generateAbuseReport(override?: Partial<MiAbuseUserReport>): MiAbuseUser
|
|||
comment: 'This is a dummy report for testing purposes.',
|
||||
targetUserHost: null,
|
||||
reporterHost: null,
|
||||
resolvedAs: null,
|
||||
moderationNote: 'foo',
|
||||
...override,
|
||||
};
|
||||
|
||||
return {
|
||||
...result,
|
||||
targetUser: result.targetUser ? toPackedUserLite(result.targetUser) : null,
|
||||
reporter: result.reporter ? toPackedUserLite(result.reporter) : null,
|
||||
assignee: result.assignee ? toPackedUserLite(result.assignee) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function generateDummyUser(override?: Partial<MiUser>): MiUser {
|
||||
|
@ -287,7 +303,8 @@ const dummyUser3 = generateDummyUser({
|
|||
|
||||
@Injectable()
|
||||
export class WebhookTestService {
|
||||
public static NoSuchWebhookError = class extends Error {};
|
||||
public static NoSuchWebhookError = class extends Error {
|
||||
};
|
||||
|
||||
constructor(
|
||||
private userWebhookService: UserWebhookService,
|
||||
|
@ -449,6 +466,22 @@ export class WebhookTestService {
|
|||
send(toPackedUserLite(dummyUser1));
|
||||
break;
|
||||
}
|
||||
case 'inactiveModeratorsWarning': {
|
||||
const dummyTime: ModeratorInactivityRemainingTime = {
|
||||
time: 100000,
|
||||
asDays: 1,
|
||||
asHours: 24,
|
||||
};
|
||||
|
||||
send({
|
||||
remainingTime: dummyTime,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'inactiveModeratorsInvitationOnlyChanged': {
|
||||
send({});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -434,13 +434,15 @@ export class ApPersonService implements OnModuleInit {
|
|||
this.cacheService.uriPersonCache.set(user.uri, user);
|
||||
|
||||
// Register host
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.newUser(i.host);
|
||||
}
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
});
|
||||
}
|
||||
|
||||
this.usersChart.update(user, true);
|
||||
|
||||
|
|
|
@ -53,6 +53,8 @@ export class AbuseUserReportEntityService {
|
|||
schema: 'UserDetailedNotMe',
|
||||
}) : null,
|
||||
forwarded: report.forwarded,
|
||||
resolvedAs: report.resolvedAs,
|
||||
moderationNote: report.moderationNote,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Packed<'Flash'>> {
|
||||
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 = undefined;
|
||||
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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -100,6 +100,7 @@ export class MetaEntityService {
|
|||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
enableFC: instance.enableFC,
|
||||
fcSiteKey: instance.fcSiteKey,
|
||||
enableTestcaptcha: instance.enableTestcaptcha,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png',
|
||||
|
|
|
@ -23,6 +23,30 @@ import type { UserEntityService } from './UserEntityService.js';
|
|||
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
||||
// is-renote.tsとよしなにリンク
|
||||
function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } {
|
||||
return (
|
||||
note.renote != null &&
|
||||
note.reply == null &&
|
||||
note.text == null &&
|
||||
note.cw == null &&
|
||||
(note.fileIds == null || note.fileIds.length === 0) &&
|
||||
!note.hasPoll
|
||||
);
|
||||
}
|
||||
|
||||
function getAppearNoteIds(notes: MiNote[]): Set<string> {
|
||||
const appearNoteIds = new Set<string>();
|
||||
for (const note of notes) {
|
||||
if (isPureRenote(note)) {
|
||||
appearNoteIds.add(note.renoteId);
|
||||
} else {
|
||||
appearNoteIds.add(note.id);
|
||||
}
|
||||
}
|
||||
return appearNoteIds;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NoteEntityService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
|
@ -94,7 +118,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
hide = false;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
const specified = packedNote.visibleUserIds!.some((id: any) => meId === id);
|
||||
const specified = packedNote.visibleUserIds!.some(id => meId === id);
|
||||
|
||||
if (specified) {
|
||||
hide = false;
|
||||
|
@ -244,7 +268,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
return true;
|
||||
} else {
|
||||
// 指定されているかどうか
|
||||
return note.visibleUserIds.some((id: any) => meId === id);
|
||||
return note.visibleUserIds.some(id => meId === id);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -437,7 +461,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
) {
|
||||
if (notes.length === 0) return [];
|
||||
|
||||
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null;
|
||||
const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null;
|
||||
|
||||
const meId = me ? me.id : null;
|
||||
const myReactionsMap = new Map<MiNote['id'], string | null>();
|
||||
|
@ -448,7 +472,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const oldId = this.idService.gen(Date.now() - 2000);
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote
|
||||
if (isPureRenote(note)) {
|
||||
const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0);
|
||||
if (reactionsCount === 0) {
|
||||
myReactionsMap.set(note.renote.id, null);
|
||||
|
|
|
@ -597,11 +597,6 @@ export class UserEntityService implements OnModuleInit {
|
|||
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
|
||||
followersVisibility: profile!.followersVisibility,
|
||||
followingVisibility: profile!.followingVisibility,
|
||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||
usePasswordLessLogin: profile!.usePasswordLessLogin,
|
||||
securityKeys: profile!.twoFactorEnabled
|
||||
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
|
||||
: false,
|
||||
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
|
@ -616,6 +611,14 @@ export class UserEntityService implements OnModuleInit {
|
|||
moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined,
|
||||
} : {}),
|
||||
|
||||
...(isDetailed && (isMe || iAmModerator) ? {
|
||||
twoFactorEnabled: profile!.twoFactorEnabled,
|
||||
usePasswordLessLogin: profile!.usePasswordLessLogin,
|
||||
securityKeys: profile!.twoFactorEnabled
|
||||
? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1)
|
||||
: false,
|
||||
} : {}),
|
||||
|
||||
...(isDetailed && isMe ? {
|
||||
avatarId: user.avatarId,
|
||||
bannerId: user.bannerId,
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { Packed } from '@/misc/json-schema.js';
|
||||
|
||||
// NoteEntityService.isPureRenote とよしなにリンク
|
||||
|
||||
type Renote =
|
||||
MiNote & {
|
||||
renoteId: NonNullable<MiNote['renoteId']>
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -81,6 +81,11 @@ export class MiMeta {
|
|||
})
|
||||
public prohibitedWords: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{}',
|
||||
})
|
||||
public prohibitedWordsForNameOfUser: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, array: true, default: '{}',
|
||||
})
|
||||
|
@ -286,6 +291,11 @@ export class MiMeta {
|
|||
})
|
||||
public fcSecretKey: string | null;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public enableTestcaptcha: boolean;
|
||||
|
||||
// chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること
|
||||
|
||||
@Column('enum', {
|
||||
|
@ -569,6 +579,11 @@ export class MiMeta {
|
|||
})
|
||||
public enableChartsForFederatedInstances: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: true,
|
||||
})
|
||||
public enableStatsForFederatedInstances: boolean;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
|
|
|
@ -3,12 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { userExportableEntities } from '@/types.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiNote } from './Note.js';
|
||||
import { MiAccessToken } from './AccessToken.js';
|
||||
import { MiRole } from './Role.js';
|
||||
import { MiDriveFile } from './DriveFile.js';
|
||||
import { userExportableEntities } from '@/types.js';
|
||||
|
||||
export type MiNotification = {
|
||||
type: 'note';
|
||||
|
@ -86,6 +86,10 @@ export type MiNotification = {
|
|||
createdAt: string;
|
||||
exportedEntity: typeof userExportableEntities[number];
|
||||
fileId: MiDriveFile['id'];
|
||||
} | {
|
||||
type: 'login';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
} | {
|
||||
type: 'app';
|
||||
id: string;
|
||||
|
|
|
@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
|
|||
'abuseReportResolved',
|
||||
// ユーザが作成された時
|
||||
'userCreated',
|
||||
// モデレータが一定期間不在である警告
|
||||
'inactiveModeratorsWarning',
|
||||
// モデレータが一定期間不在のためシステムにより招待制へと変更された
|
||||
'inactiveModeratorsInvitationOnlyChanged',
|
||||
] as const;
|
||||
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];
|
||||
|
||||
|
|
|
@ -139,6 +139,10 @@ export const packedMetaLiteSchema = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableTestcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
swPublickey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
|
|
@ -322,6 +322,16 @@ export const packedNotificationSchema = {
|
|||
format: 'id',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['login'],
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
|
@ -392,21 +392,6 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
nullable: false, optional: false,
|
||||
enum: ['public', 'followers', 'private'],
|
||||
},
|
||||
twoFactorEnabled: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
usePasswordLessLogin: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
securityKeys: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
roles: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
|
@ -428,6 +413,18 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
type: 'string',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
twoFactorEnabled: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
usePasswordLessLogin: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
securityKeys: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
//#region relations
|
||||
isFollowing: {
|
||||
type: 'boolean',
|
||||
|
@ -689,6 +686,21 @@ export const packedMeDetailedOnlySchema = {
|
|||
nullable: false, optional: false,
|
||||
ref: 'RolePolicies',
|
||||
},
|
||||
twoFactorEnabled: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
usePasswordLessLogin: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
securityKeys: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
default: false,
|
||||
},
|
||||
//#region secrets
|
||||
email: {
|
||||
type: 'string',
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
import { QueueLoggerService } from './QueueLoggerService.js';
|
||||
import { QueueProcessorService } from './QueueProcessorService.js';
|
||||
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
|
||||
|
@ -84,6 +85,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
|
|||
DeliverProcessorService,
|
||||
InboxProcessorService,
|
||||
AggregateRetentionProcessorService,
|
||||
CheckExpiredMutingsProcessorService,
|
||||
CheckModeratorsActivityProcessorService,
|
||||
QueueProcessorService,
|
||||
],
|
||||
exports: [
|
||||
|
|
|
@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
|
||||
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
|
||||
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
|
||||
|
@ -68,7 +69,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string {
|
|||
|
||||
// onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする
|
||||
const currentAttempts = job.attemptsMade + (increment ? 1 : 0);
|
||||
const maxAttempts = job.opts ? job.opts.attempts : 0;
|
||||
const maxAttempts = job.opts.attempts ?? 0;
|
||||
|
||||
return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`;
|
||||
}
|
||||
|
@ -124,6 +125,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
private aggregateRetentionProcessorService: AggregateRetentionProcessorService,
|
||||
private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService,
|
||||
private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService,
|
||||
private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService,
|
||||
private cleanProcessorService: CleanProcessorService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger;
|
||||
|
@ -164,6 +166,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
case 'aggregateRetention': return this.aggregateRetentionProcessorService.process();
|
||||
case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process();
|
||||
case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process();
|
||||
case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process();
|
||||
case 'clean': return this.cleanProcessorService.process();
|
||||
default: throw new Error(`unrecognized job type ${job.name} for system`);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,292 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import type Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { EmailService } from '@/core/EmailService.js';
|
||||
import { MiUser, type UserProfilesRepository } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
|
||||
// モデレーターが不在と判断する日付の閾値
|
||||
const MODERATOR_INACTIVITY_LIMIT_DAYS = 7;
|
||||
// 警告通知やログ出力を行う残日数の閾値
|
||||
const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2;
|
||||
// 期限から6時間ごとに通知を行う
|
||||
const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6;
|
||||
const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60;
|
||||
const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24;
|
||||
|
||||
export type ModeratorInactivityEvaluationResult = {
|
||||
isModeratorsInactive: boolean;
|
||||
inactiveModerators: MiUser[];
|
||||
remainingTime: ModeratorInactivityRemainingTime;
|
||||
}
|
||||
|
||||
export type ModeratorInactivityRemainingTime = {
|
||||
time: number;
|
||||
asHours: number;
|
||||
asDays: number;
|
||||
};
|
||||
|
||||
function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) {
|
||||
const subject = 'Moderator Inactivity Warning / モデレーター不在の通知';
|
||||
|
||||
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
|
||||
const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`;
|
||||
const message = [
|
||||
'To Moderators,',
|
||||
'',
|
||||
`A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`,
|
||||
'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.',
|
||||
'',
|
||||
'---------------',
|
||||
'',
|
||||
'To モデレーター各位',
|
||||
'',
|
||||
`モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`,
|
||||
'招待制に切り替わることを望まない場合は、Misskeyにログインして最終アクティブ日時を更新してください。',
|
||||
'',
|
||||
];
|
||||
|
||||
const html = message.join('<br>');
|
||||
const text = message.join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function generateInvitationOnlyChangedMail() {
|
||||
const subject = 'Change to Invitation-Only / 招待制に変更されました';
|
||||
|
||||
const message = [
|
||||
'To Moderators,',
|
||||
'',
|
||||
`Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`,
|
||||
'To cancel the invitation only, you need to access the control panel.',
|
||||
'',
|
||||
'---------------',
|
||||
'',
|
||||
'To モデレーター各位',
|
||||
'',
|
||||
`モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`,
|
||||
'招待制を解除するには、コントロールパネルにアクセスする必要があります。',
|
||||
'',
|
||||
];
|
||||
|
||||
const html = message.join('<br>');
|
||||
const text = message.join('\n');
|
||||
|
||||
return {
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CheckModeratorsActivityProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
private metaService: MetaService,
|
||||
private roleService: RoleService,
|
||||
private emailService: EmailService,
|
||||
private announcementService: AnnouncementService,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(): Promise<void> {
|
||||
this.logger.info('start.');
|
||||
|
||||
const meta = await this.metaService.fetch(false);
|
||||
if (!meta.disableRegistration) {
|
||||
await this.processImpl();
|
||||
} else {
|
||||
this.logger.info('is already invitation only.');
|
||||
}
|
||||
|
||||
this.logger.succ('finish.');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async processImpl() {
|
||||
const evaluateResult = await this.evaluateModeratorsInactiveDays();
|
||||
if (evaluateResult.isModeratorsInactive) {
|
||||
this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`);
|
||||
|
||||
await this.changeToInvitationOnly();
|
||||
await this.notifyChangeToInvitationOnly();
|
||||
} else {
|
||||
const remainingTime = evaluateResult.remainingTime;
|
||||
if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) {
|
||||
const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`;
|
||||
this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`);
|
||||
|
||||
if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) {
|
||||
// ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する
|
||||
// つまり、のこり2日を切ったら6時間ごとに通知が送られる
|
||||
await this.notifyInactiveModeratorsWarning(remainingTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* モデレーターが不在であるかどうかを確認する。trueの場合はモデレーターが不在である。
|
||||
* isModerator, isAdministrator, isRootのいずれかがtrueのユーザを対象に、
|
||||
* {@link MiUser.lastActiveDate}の値が実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前よりも古いユーザがいるかどうかを確認する。
|
||||
* {@link MiUser.lastActiveDate}がnullの場合は、そのユーザは確認の対象外とする。
|
||||
*
|
||||
* -----
|
||||
*
|
||||
* ### サンプルパターン
|
||||
* - 実行日時: 2022-01-30 12:00:00
|
||||
* - 判定基準: 2022-01-23 12:00:00(実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前)
|
||||
*
|
||||
* #### パターン①
|
||||
* - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト
|
||||
* - モデレータB: lastActiveDate = 2022-01-23 12:00:00 ※セーフ(判定基準と同値なのでギリギリ残り0日)
|
||||
* - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日)
|
||||
* - モデレータD: lastActiveDate = null
|
||||
*
|
||||
* この場合、モデレータBのアクティビティのみ判定基準日よりも古くないため、モデレーターが在席と判断される。
|
||||
*
|
||||
* #### パターン②
|
||||
* - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト
|
||||
* - モデレータB: lastActiveDate = 2022-01-22 12:00:00 ※アウト(残り-1日)
|
||||
* - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日)
|
||||
* - モデレータD: lastActiveDate = null
|
||||
*
|
||||
* この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。
|
||||
*/
|
||||
@bindThis
|
||||
public async evaluateModeratorsInactiveDays(): Promise<ModeratorInactivityEvaluationResult> {
|
||||
const today = new Date();
|
||||
const inactivePeriod = new Date(today);
|
||||
inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS);
|
||||
|
||||
const moderators = await this.fetchModerators()
|
||||
.then(it => it.filter(it => it.lastActiveDate != null));
|
||||
const inactiveModerators = moderators
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.filter(it => it.lastActiveDate!.getTime() < inactivePeriod.getTime());
|
||||
|
||||
// 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime())));
|
||||
const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime();
|
||||
const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC);
|
||||
const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC));
|
||||
|
||||
return {
|
||||
isModeratorsInactive: inactiveModerators.length === moderators.length,
|
||||
inactiveModerators,
|
||||
remainingTime: {
|
||||
time: remainingTime,
|
||||
asHours: remainingTimeAsHours,
|
||||
asDays: remainingTimeAsDays,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async changeToInvitationOnly() {
|
||||
await this.metaService.update({ disableRegistration: true });
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) {
|
||||
// -- モデレータへのメール送信
|
||||
|
||||
const moderators = await this.fetchModerators();
|
||||
const moderatorProfiles = await this.userProfilesRepository
|
||||
.findBy({ userId: In(moderators.map(it => it.id)) })
|
||||
.then(it => new Map(it.map(it => [it.userId, it])));
|
||||
|
||||
const mail = generateModeratorInactivityMail(remainingTime);
|
||||
for (const moderator of moderators) {
|
||||
const profile = moderatorProfiles.get(moderator.id);
|
||||
if (profile && profile.email && profile.emailVerified) {
|
||||
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
|
||||
}
|
||||
}
|
||||
|
||||
// -- SystemWebhook
|
||||
|
||||
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
|
||||
.then(it => it.filter(it => it.on.includes('inactiveModeratorsWarning')));
|
||||
for (const systemWebhook of systemWebhooks) {
|
||||
this.systemWebhookService.enqueueSystemWebhook(
|
||||
systemWebhook,
|
||||
'inactiveModeratorsWarning',
|
||||
{ remainingTime: remainingTime },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async notifyChangeToInvitationOnly() {
|
||||
// -- モデレータへのメールとお知らせ(個人向け)送信
|
||||
|
||||
const moderators = await this.fetchModerators();
|
||||
const moderatorProfiles = await this.userProfilesRepository
|
||||
.findBy({ userId: In(moderators.map(it => it.id)) })
|
||||
.then(it => new Map(it.map(it => [it.userId, it])));
|
||||
|
||||
const mail = generateInvitationOnlyChangedMail();
|
||||
for (const moderator of moderators) {
|
||||
this.announcementService.create({
|
||||
title: mail.subject,
|
||||
text: mail.text,
|
||||
forExistingUsers: true,
|
||||
needConfirmationToRead: true,
|
||||
userId: moderator.id,
|
||||
});
|
||||
|
||||
const profile = moderatorProfiles.get(moderator.id);
|
||||
if (profile && profile.email && profile.emailVerified) {
|
||||
this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text);
|
||||
}
|
||||
}
|
||||
|
||||
// -- SystemWebhook
|
||||
|
||||
const systemWebhooks = await this.systemWebhookService.fetchActiveSystemWebhooks()
|
||||
.then(it => it.filter(it => it.on.includes('inactiveModeratorsInvitationOnlyChanged')));
|
||||
for (const systemWebhook of systemWebhooks) {
|
||||
this.systemWebhookService.enqueueSystemWebhook(
|
||||
systemWebhook,
|
||||
'inactiveModeratorsInvitationOnlyChanged',
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async fetchModerators() {
|
||||
// TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する
|
||||
return this.roleService.getModerators({
|
||||
includeAdmins: true,
|
||||
includeRoot: true,
|
||||
excludeExpire: true,
|
||||
});
|
||||
}
|
||||
}
|
|
@ -74,8 +74,17 @@ export class DeliverProcessorService {
|
|||
try {
|
||||
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
|
||||
|
||||
// Update stats
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.apRequestChart.deliverSucc();
|
||||
this.federationChart.deliverd(host, true);
|
||||
|
||||
// Update instance stats
|
||||
process.nextTick(async () => {
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(host)
|
||||
: this.federatedInstanceService.fetch(host));
|
||||
|
||||
if (i == null) return;
|
||||
|
||||
if (i.isNotResponding) {
|
||||
this.federatedInstanceService.update(i.id, {
|
||||
isNotResponding: false,
|
||||
|
@ -83,9 +92,9 @@ export class DeliverProcessorService {
|
|||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableStatsForFederatedInstances) {
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
this.apRequestChart.deliverSucc();
|
||||
this.federationChart.deliverd(i.host, true);
|
||||
}
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestSent(i.host, true);
|
||||
|
@ -94,8 +103,11 @@ export class DeliverProcessorService {
|
|||
|
||||
return 'Success';
|
||||
} catch (res) {
|
||||
// Update stats
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.apRequestChart.deliverFail();
|
||||
this.federationChart.deliverd(host, false);
|
||||
|
||||
// Update instance stats
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
if (!i.isNotResponding) {
|
||||
this.federatedInstanceService.update(i.id, {
|
||||
isNotResponding: true,
|
||||
|
@ -116,9 +128,6 @@ export class DeliverProcessorService {
|
|||
});
|
||||
}
|
||||
|
||||
this.apRequestChart.deliverFail();
|
||||
this.federationChart.deliverd(i.host, false);
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestSent(i.host, false);
|
||||
}
|
||||
|
@ -129,7 +138,7 @@ export class DeliverProcessorService {
|
|||
if (!res.isRetryable) {
|
||||
// 相手が閉鎖していることを明示しているため、配送停止する
|
||||
if (job.data.isSharedInbox && res.statusCode === 410) {
|
||||
this.federatedInstanceService.fetch(host).then(i => {
|
||||
this.federatedInstanceService.fetchOrRegister(host).then(i => {
|
||||
this.federatedInstanceService.update(i.id, {
|
||||
suspensionState: 'goneSuspended',
|
||||
});
|
||||
|
|
|
@ -59,7 +59,7 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('inbox');
|
||||
this.updateInstanceQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseUpdateInstanceJobs, this.performUpdateInstance);
|
||||
this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -194,21 +194,27 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
// Update stats
|
||||
this.federatedInstanceService.fetch(authUser.user.host).then(i => {
|
||||
this.apRequestChart.inbox();
|
||||
this.federationChart.inbox(authUser.user.host);
|
||||
|
||||
// Update instance stats
|
||||
process.nextTick(async () => {
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(authUser.user.host)
|
||||
: this.federatedInstanceService.fetch(authUser.user.host));
|
||||
|
||||
if (i == null) return;
|
||||
|
||||
this.updateInstanceQueue.enqueue(i.id, {
|
||||
latestRequestReceivedAt: new Date(),
|
||||
shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding',
|
||||
});
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
|
||||
this.apRequestChart.inbox();
|
||||
this.federationChart.inbox(i.host);
|
||||
|
||||
if (this.meta.enableChartsForFederatedInstances) {
|
||||
this.instanceChart.requestReceived(i.host);
|
||||
}
|
||||
|
||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||
});
|
||||
|
||||
// アクティビティを処理
|
||||
|
|
|
@ -119,25 +119,30 @@ export class ApiServerService {
|
|||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'frc-captcha-solution'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
'testcaptcha-response'?: string;
|
||||
}
|
||||
}>('/signup', (request, reply) => this.signupApiService.signup(request, reply));
|
||||
|
||||
fastify.post<{
|
||||
Body: {
|
||||
username: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
signature?: string;
|
||||
authenticatorData?: string;
|
||||
clientDataJSON?: string;
|
||||
credentialId?: string;
|
||||
challengeId?: string;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
'hcaptcha-response'?: string;
|
||||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'frc-captcha-solution'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
'testcaptcha-response'?: string;
|
||||
};
|
||||
}>('/signin', (request, reply) => this.signinApiService.signin(request, reply));
|
||||
}>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply));
|
||||
|
||||
fastify.post<{
|
||||
Body: {
|
||||
credential?: AuthenticationResponseJSON;
|
||||
context?: string;
|
||||
};
|
||||
}>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply));
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
@ -470,6 +472,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 };
|
||||
|
@ -876,6 +880,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,
|
||||
|
@ -1276,6 +1282,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,
|
||||
|
|
|
@ -6,12 +6,14 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as argon2 from 'argon2';
|
||||
import * as OTPAuth from 'otpauth';
|
||||
import { IsNull } from 'typeorm';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type {
|
||||
MiMeta,
|
||||
SigninsRepository,
|
||||
UserProfilesRepository,
|
||||
UserSecurityKeysRepository,
|
||||
UsersRepository,
|
||||
} from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
@ -21,6 +23,8 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { WebAuthnService } from '@/core/WebAuthnService.js';
|
||||
import { UserAuthService } from '@/core/UserAuthService.js';
|
||||
import { CaptchaService } from '@/core/CaptchaService.js';
|
||||
import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
|
||||
import { RateLimiterService } from './RateLimiterService.js';
|
||||
import { SigninService } from './SigninService.js';
|
||||
import type { AuthenticationResponseJSON } from '@simplewebauthn/types';
|
||||
|
@ -43,6 +47,9 @@ export class SigninApiService {
|
|||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.userSecurityKeysRepository)
|
||||
private userSecurityKeysRepository: UserSecurityKeysRepository,
|
||||
|
||||
@Inject(DI.signinsRepository)
|
||||
private signinsRepository: SigninsRepository,
|
||||
|
||||
|
@ -51,6 +58,7 @@ export class SigninApiService {
|
|||
private signinService: SigninService,
|
||||
private userAuthService: UserAuthService,
|
||||
private webAuthnService: WebAuthnService,
|
||||
private captchaService: CaptchaService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -59,9 +67,15 @@ export class SigninApiService {
|
|||
request: FastifyRequest<{
|
||||
Body: {
|
||||
username: string;
|
||||
password: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
credential?: AuthenticationResponseJSON;
|
||||
'hcaptcha-response'?: string;
|
||||
'g-recaptcha-response'?: string;
|
||||
'turnstile-response'?: string;
|
||||
'frc-captcha-solution'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
'testcaptcha-response'?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
|
@ -98,11 +112,6 @@ export class SigninApiService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (typeof password !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token != null && typeof token !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
|
@ -133,6 +142,27 @@ export class SigninApiService {
|
|||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1);
|
||||
|
||||
if (password == null) {
|
||||
reply.code(200);
|
||||
if (profile.twoFactorEnabled) {
|
||||
return {
|
||||
finished: false,
|
||||
next: 'password',
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
} else {
|
||||
return {
|
||||
finished: false,
|
||||
next: 'captcha',
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof password !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.approved && this.meta.approvalRequiredForSignup) {
|
||||
reply.code(403);
|
||||
|
@ -148,7 +178,7 @@ export class SigninApiService {
|
|||
// Compare password
|
||||
const same = await argon2.verify(profile.password!, password) || bcrypt.compareSync(password, profile.password!);
|
||||
|
||||
const fail = async (status?: number, failure?: { id: string }) => {
|
||||
const fail = async (status?: number, failure?: { id: string; }) => {
|
||||
// Append signin history
|
||||
await this.signinsRepository.insert({
|
||||
id: this.idService.gen(),
|
||||
|
@ -162,6 +192,44 @@ export class SigninApiService {
|
|||
};
|
||||
|
||||
if (!profile.twoFactorEnabled) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) {
|
||||
await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) {
|
||||
await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableFC && this.meta.fcSecretKey) {
|
||||
await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) {
|
||||
await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) {
|
||||
await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableTestcaptcha) {
|
||||
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (same) {
|
||||
if (profile.password!.startsWith('$2')) {
|
||||
const newHash = await argon2.hash(password);
|
||||
|
@ -220,7 +288,7 @@ export class SigninApiService {
|
|||
id: '93b86c4b-72f9-40eb-9815-798928603d1e',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
} else if (securityKeysAvailable) {
|
||||
if (!same && !profile.usePasswordLessLogin) {
|
||||
return await fail(403, {
|
||||
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
|
||||
|
@ -230,7 +298,23 @@ export class SigninApiService {
|
|||
const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
|
||||
|
||||
reply.code(200);
|
||||
return authRequest;
|
||||
return {
|
||||
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(200);
|
||||
return {
|
||||
finished: false,
|
||||
next: 'totp',
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
}
|
||||
}
|
||||
// never get here
|
||||
}
|
||||
|
|
|
@ -4,13 +4,16 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { SigninsRepository } from '@/models/_.js';
|
||||
import type { SigninsRepository, UserProfilesRepository } from '@/models/_.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { SigninEntityService } from '@/core/entities/SigninEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { EmailService } from '@/core/EmailService.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
@Injectable()
|
||||
|
@ -19,7 +22,12 @@ export class SigninService {
|
|||
@Inject(DI.signinsRepository)
|
||||
private signinsRepository: SigninsRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private signinEntityService: SigninEntityService,
|
||||
private emailService: EmailService,
|
||||
private notificationService: NotificationService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
) {
|
||||
|
@ -28,7 +36,8 @@ export class SigninService {
|
|||
@bindThis
|
||||
public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) {
|
||||
setImmediate(async () => {
|
||||
// Append signin history
|
||||
this.notificationService.createNotification(user.id, 'login', {});
|
||||
|
||||
const record = await this.signinsRepository.insertOne({
|
||||
id: this.idService.gen(),
|
||||
userId: user.id,
|
||||
|
@ -37,15 +46,22 @@ export class SigninService {
|
|||
success: true,
|
||||
});
|
||||
|
||||
// Publish signin event
|
||||
this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record));
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||
if (profile.email && profile.emailVerified) {
|
||||
this.emailService.sendEmail(profile.email, 'New login / ログインがありました',
|
||||
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。',
|
||||
'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。');
|
||||
}
|
||||
});
|
||||
|
||||
reply.code(200);
|
||||
return {
|
||||
finished: true,
|
||||
id: user.id,
|
||||
i: user.token,
|
||||
};
|
||||
i: user.token!,
|
||||
} satisfies Misskey.entities.SigninFlowResponse;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -73,6 +73,7 @@ export class SignupApiService {
|
|||
'turnstile-response'?: string;
|
||||
'm-captcha-response'?: string;
|
||||
'frc-captcha-solution'?: string;
|
||||
'testcaptcha-response'?: string;
|
||||
}
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
|
@ -111,6 +112,12 @@ export class SignupApiService {
|
|||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.meta.enableTestcaptcha) {
|
||||
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
|
||||
throw new FastifyReplyError(400, err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const username = body['username'];
|
||||
|
|
|
@ -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';
|
||||
|
@ -474,6 +476,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],
|
||||
|
|
|
@ -71,9 +71,22 @@ export const meta = {
|
|||
},
|
||||
assignee: {
|
||||
type: 'object',
|
||||
nullable: true, optional: true,
|
||||
nullable: true, optional: false,
|
||||
ref: 'UserDetailedNotMe',
|
||||
},
|
||||
forwarded: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
resolvedAs: {
|
||||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
enum: ['accept', 'reject', null],
|
||||
},
|
||||
moderationNote: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -88,7 +101,6 @@ export const paramDef = {
|
|||
state: { type: 'string', nullable: true, default: null },
|
||||
reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' },
|
||||
targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' },
|
||||
forwarded: { type: 'boolean', default: false },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
|
|
@ -10,6 +10,9 @@ import { SignupService } from '@/core/SignupService.js';
|
|||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import { localUsernameSchema, passwordSchema } from '@/models/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
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';
|
||||
|
@ -17,19 +20,19 @@ import { ApiError } from '@/server/api/error.js';
|
|||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'MeDetailed',
|
||||
properties: {
|
||||
token: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
accessDenied: {
|
||||
message: 'Access denied.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
|
||||
},
|
||||
|
||||
wrongInitialPassword: {
|
||||
message: 'Initial password is incorrect.',
|
||||
code: 'INCORRECT_INITIAL_PASSWORD',
|
||||
id: '97147c55-1ae1-4f6f-91d6-e1c3e0e76d62',
|
||||
},
|
||||
|
||||
errors: {
|
||||
// From ApiCallService.ts
|
||||
noCredential: {
|
||||
message: 'Credential required.',
|
||||
|
@ -51,6 +54,18 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'MeDetailed',
|
||||
properties: {
|
||||
token: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Required token permissions, but we need to check them manually.
|
||||
// ApiCallService checks access in a way that would prevent creating the first account.
|
||||
softPermissions: [
|
||||
|
@ -64,6 +79,7 @@ export const paramDef = {
|
|||
properties: {
|
||||
username: localUsernameSchema,
|
||||
password: passwordSchema,
|
||||
setupPassword: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['username', 'password'],
|
||||
} as const;
|
||||
|
@ -71,13 +87,49 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private roleService: RoleService,
|
||||
private userEntityService: UserEntityService,
|
||||
private signupService: SignupService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, _me, token) => {
|
||||
await this.ensurePermissions(_me, token);
|
||||
const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null;
|
||||
const realUsers = await this.instanceActorService.realLocalUsersPresent();
|
||||
|
||||
if (!realUsers && me == null && token == null) {
|
||||
// 初回セットアップの場合
|
||||
if (this.config.setupPassword != null) {
|
||||
// 初期パスワードが設定されている場合
|
||||
if (ps.setupPassword !== this.config.setupPassword) {
|
||||
// 初期パスワードが違う場合
|
||||
throw new ApiError(meta.errors.wrongInitialPassword);
|
||||
}
|
||||
} else if (ps.setupPassword != null && ps.setupPassword.trim() !== '') {
|
||||
// 初期パスワードが設定されていないのに初期パスワードが入力された場合
|
||||
throw new ApiError(meta.errors.wrongInitialPassword);
|
||||
}
|
||||
} else {
|
||||
if (token && !meta.softPermissions.every(p => token.permission.includes(p))) {
|
||||
// Tokens have scoped permissions which may be *less* than the user's official role, so we need to check.
|
||||
throw new ApiError(meta.errors.noPermission);
|
||||
}
|
||||
|
||||
if (me && !await this.roleService.isAdministrator(me)) {
|
||||
// Only administrators (including root) can create users.
|
||||
throw new ApiError(meta.errors.noAdmin);
|
||||
}
|
||||
|
||||
// Anonymous access is only allowed for initial instance setup (this check may be redundant)
|
||||
if (!me && realUsers) {
|
||||
throw new ApiError(meta.errors.noCredential);
|
||||
}
|
||||
}
|
||||
|
||||
const { account, secret } = await this.signupService.signup({
|
||||
username: ps.username,
|
||||
|
@ -96,21 +148,4 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return res;
|
||||
});
|
||||
}
|
||||
|
||||
private async ensurePermissions(me: MiUser | null, token: MiAccessToken | null): Promise<void> {
|
||||
// Tokens have scoped permissions which may be *less* than the user's official role, so we need to check.
|
||||
if (token && !meta.softPermissions.every(p => token.permission.includes(p))) {
|
||||
throw new ApiError(meta.errors.noPermission);
|
||||
}
|
||||
|
||||
// Only administrators (including root) can create users.
|
||||
if (me && !await this.roleService.isAdministrator(me)) {
|
||||
throw new ApiError(meta.errors.noAdmin);
|
||||
}
|
||||
|
||||
// Anonymous access is only allowed for initial instance setup.
|
||||
if (!me && await this.instanceActorService.realLocalUsersPresent()) {
|
||||
throw new ApiError(meta.errors.noCredential);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import type { DriveFilesRepository } from '@/models/_.js';
|
||||
import type { DriveFilesRepository, MiEmoji } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
|
@ -79,25 +79,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
|
||||
let emojiId;
|
||||
if (ps.id) {
|
||||
emojiId = ps.id;
|
||||
const emoji = await this.customEmojiService.getEmojiById(ps.id);
|
||||
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
|
||||
if (nameNfc && (nameNfc !== emoji.name)) {
|
||||
const isDuplicate = await this.customEmojiService.checkDuplicate(nameNfc);
|
||||
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
|
||||
}
|
||||
} else {
|
||||
if (!nameNfc) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.');
|
||||
const emoji = await this.customEmojiService.getEmojiByName(nameNfc);
|
||||
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
|
||||
emojiId = emoji.id;
|
||||
}
|
||||
// JSON schemeのanyOfの型変換がうまくいっていないらしい
|
||||
const required = { id: ps.id, name: nameNfc } as
|
||||
| { id: MiEmoji['id']; name?: string }
|
||||
| { id?: MiEmoji['id']; name: string };
|
||||
|
||||
await this.customEmojiService.update(emojiId, {
|
||||
const error = await this.customEmojiService.update({
|
||||
...required,
|
||||
driveFile,
|
||||
name: nameNfc,
|
||||
category: ps.category?.normalize('NFC'),
|
||||
aliases: ps.aliases?.map(a => a.normalize('NFC')),
|
||||
license: ps.license,
|
||||
|
@ -105,6 +94,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
localOnly: ps.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction,
|
||||
}, me);
|
||||
|
||||
switch (error) {
|
||||
case null: return;
|
||||
case 'NO_SUCH_EMOJI': throw new ApiError(meta.errors.noSuchEmoji);
|
||||
case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists);
|
||||
}
|
||||
// 網羅性チェック
|
||||
const mustBeNever: never = error;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -81,6 +81,10 @@ export const meta = {
|
|||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
enableTestcaptcha: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
swPublickey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
@ -189,6 +193,13 @@ export const meta = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
prohibitedWordsForNameOfUser: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
bannedEmailDomains: {
|
||||
type: 'array',
|
||||
optional: true, nullable: false,
|
||||
|
@ -368,6 +379,10 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableStatsForFederatedInstances: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
enableServerMachineStats: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
@ -614,6 +629,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
turnstileSiteKey: instance.turnstileSiteKey,
|
||||
enableFC: instance.enableFC,
|
||||
fcSiteKey: instance.fcSiteKey,
|
||||
enableTestcaptcha: instance.enableTestcaptcha,
|
||||
swPublickey: instance.swPublicKey,
|
||||
themeColor: instance.themeColor,
|
||||
mascotImageUrl: instance.mascotImageUrl,
|
||||
|
@ -642,6 +658,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
mediaSilencedHosts: instance.mediaSilencedHosts,
|
||||
sensitiveWords: instance.sensitiveWords,
|
||||
prohibitedWords: instance.prohibitedWords,
|
||||
prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser,
|
||||
preservedUsernames: instance.preservedUsernames,
|
||||
bubbleInstances: instance.bubbleInstances,
|
||||
hcaptchaSecretKey: instance.hcaptchaSecretKey,
|
||||
|
@ -688,6 +705,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
truemailAuthKey: instance.truemailAuthKey,
|
||||
enableChartsForRemoteUser: instance.enableChartsForRemoteUser,
|
||||
enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances,
|
||||
enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances,
|
||||
enableServerMachineStats: instance.enableServerMachineStats,
|
||||
enableAchievements: instance.enableAchievements,
|
||||
enableIdenticonGeneration: instance.enableIdenticonGeneration,
|
||||
|
|
|
@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -72,13 +72,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
break;
|
||||
}
|
||||
case 'moderator': {
|
||||
const moderatorIds = await this.roleService.getModeratorIds(false);
|
||||
const moderatorIds = await this.roleService.getModeratorIds({ includeAdmins: false });
|
||||
if (moderatorIds.length === 0) return [];
|
||||
query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds });
|
||||
break;
|
||||
}
|
||||
case 'adminOrModerator': {
|
||||
const adminOrModeratorIds = await this.roleService.getModeratorIds();
|
||||
const adminOrModeratorIds = await this.roleService.getModeratorIds({ includeAdmins: true });
|
||||
if (adminOrModeratorIds.length === 0) return [];
|
||||
query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds });
|
||||
break;
|
||||
|
|
|
@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -46,6 +46,11 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
prohibitedWordsForNameOfUser: {
|
||||
type: 'array', nullable: true, items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' },
|
||||
mascotImageUrl: { type: 'string', nullable: true },
|
||||
bannerUrl: { type: 'string', nullable: true },
|
||||
|
@ -84,6 +89,7 @@ export const paramDef = {
|
|||
enableFC: { type: 'boolean' },
|
||||
fcSiteKey: { type: 'string', nullable: true },
|
||||
fcSecretKey: { type: 'string', nullable: true },
|
||||
enableTestcaptcha: { type: 'boolean' },
|
||||
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
|
||||
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
|
||||
setSensitiveFlagAutomatically: { type: 'boolean' },
|
||||
|
@ -140,6 +146,7 @@ export const paramDef = {
|
|||
truemailAuthKey: { type: 'string', nullable: true },
|
||||
enableChartsForRemoteUser: { type: 'boolean' },
|
||||
enableChartsForFederatedInstances: { type: 'boolean' },
|
||||
enableStatsForFederatedInstances: { type: 'boolean' },
|
||||
enableServerMachineStats: { type: 'boolean' },
|
||||
enableAchievements: { type: 'boolean' },
|
||||
enableIdenticonGeneration: { type: 'boolean' },
|
||||
|
@ -230,6 +237,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (Array.isArray(ps.prohibitedWords)) {
|
||||
set.prohibitedWords = ps.prohibitedWords.filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(ps.prohibitedWordsForNameOfUser)) {
|
||||
set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(ps.silencedHosts)) {
|
||||
let lastValue = '';
|
||||
set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
|
||||
|
@ -390,6 +400,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.enableFC = ps.enableFC;
|
||||
}
|
||||
|
||||
if (ps.enableTestcaptcha !== undefined) {
|
||||
set.enableTestcaptcha = ps.enableTestcaptcha;
|
||||
}
|
||||
|
||||
if (ps.fcSiteKey !== undefined) {
|
||||
set.fcSiteKey = ps.fcSiteKey;
|
||||
}
|
||||
|
@ -610,6 +624,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
|
||||
}
|
||||
|
||||
if (ps.enableStatsForFederatedInstances !== undefined) {
|
||||
set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances;
|
||||
}
|
||||
|
||||
if (ps.enableServerMachineStats !== undefined) {
|
||||
set.enableServerMachineStats = ps.enableServerMachineStats;
|
||||
}
|
||||
|
@ -709,7 +727,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
if (Array.isArray(ps.federationHosts)) {
|
||||
set.blockedHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
|
||||
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
|
||||
}
|
||||
|
||||
const before = await this.metaService.fetch(true);
|
||||
|
|
|
@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ import { JSDOM } from 'jsdom';
|
|||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js';
|
||||
import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { birthdaySchema, listenbrainzSchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js';
|
||||
import type { MiUserProfile } from '@/models/UserProfile.js';
|
||||
|
@ -22,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { AccountUpdateService } from '@/core/AccountUpdateService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { HashtagService } from '@/core/HashtagService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RolePolicies, RoleService } from '@/core/RoleService.js';
|
||||
|
@ -126,6 +127,13 @@ export const meta = {
|
|||
code: 'RESTRICTED_BY_ROLE',
|
||||
id: '8feff0ba-5ab5-585b-31f4-4df816663fad',
|
||||
},
|
||||
|
||||
nameContainsProhibitedWords: {
|
||||
message: 'Your new name contains prohibited words.',
|
||||
code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS',
|
||||
id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191',
|
||||
httpStatusCode: 422,
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
|
@ -241,6 +249,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private instanceMeta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
@ -265,6 +276,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private cacheService: CacheService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private avatarDecorationService: AvatarDecorationService,
|
||||
private utilityService: UtilityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, _user, token) => {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
|
||||
|
@ -485,6 +497,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields;
|
||||
|
||||
if (newName != null) {
|
||||
let hasProhibitedWords = false;
|
||||
if (!await this.roleService.isModerator(user)) {
|
||||
hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser);
|
||||
}
|
||||
if (hasProhibitedWords) {
|
||||
throw new ApiError(meta.errors.nameContainsProhibitedWords);
|
||||
}
|
||||
|
||||
const tokens = mfm.parseSimple(newName);
|
||||
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens));
|
||||
}
|
||||
|
|
|
@ -108,7 +108,7 @@
|
|||
}
|
||||
for (const [k, v] of Object.entries(themeProps)) {
|
||||
if (k.startsWith('font')) continue;
|
||||
document.documentElement.style.setProperty(`--${k}`, v.toString());
|
||||
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
|
||||
|
||||
// HTMLの theme-color 適用
|
||||
if (k === 'htmlThemeColor') {
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
*/
|
||||
|
||||
html {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
color: var(--MI_THEME-fg);
|
||||
}
|
||||
|
||||
#splash {
|
||||
|
@ -17,7 +17,7 @@ html {
|
|||
width: 100vw;
|
||||
height: 100vh;
|
||||
cursor: wait;
|
||||
background-color: var(--bg);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ html {
|
|||
width: 28px;
|
||||
height: 28px;
|
||||
transform: translateY(80px);
|
||||
color: var(--accent);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
#splashSpinner > .spinner {
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
*/
|
||||
|
||||
html {
|
||||
background-color: var(--bg);
|
||||
color: var(--fg);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
color: var(--MI_THEME-fg);
|
||||
}
|
||||
|
||||
html.embed {
|
||||
|
@ -24,7 +24,7 @@ html.embed {
|
|||
width: 100vw;
|
||||
height: 100vh;
|
||||
cursor: wait;
|
||||
background-color: var(--bg);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ html.embed #splash {
|
|||
box-sizing: border-box;
|
||||
min-height: 300px;
|
||||
border-radius: var(--radius, 12px);
|
||||
border: 1px solid var(--divider, #e8e8e8);
|
||||
border: 1px solid var(--MI_THEME-divider, #e8e8e8);
|
||||
}
|
||||
|
||||
html.embed.norounded #splash {
|
||||
|
@ -67,7 +67,7 @@ html.embed.noborder #splash {
|
|||
width: 28px;
|
||||
height: 28px;
|
||||
transform: translateY(70px);
|
||||
color: var(--accent);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
#splashSpinner > .spinner {
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
* roleAssigned - ロールが付与された
|
||||
* achievementEarned - 実績を獲得
|
||||
* exportCompleted - エクスポートが完了
|
||||
* login - ログイン
|
||||
* app - アプリ通知
|
||||
* test - テスト通知(サーバー側)
|
||||
*/
|
||||
|
@ -35,6 +36,7 @@ export const notificationTypes = [
|
|||
'roleAssigned',
|
||||
'achievementEarned',
|
||||
'exportCompleted',
|
||||
'login',
|
||||
'app',
|
||||
'test',
|
||||
] as const;
|
||||
|
@ -104,6 +106,8 @@ export const moderationLogTypes = [
|
|||
'markSensitiveDriveFile',
|
||||
'unmarkSensitiveDriveFile',
|
||||
'resolveAbuseReport',
|
||||
'forwardAbuseReport',
|
||||
'updateAbuseReportNote',
|
||||
'createInvitation',
|
||||
'createAd',
|
||||
'updateAd',
|
||||
|
@ -298,7 +302,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[];
|
||||
|
|
70
packages/backend/test-federation/.config/example.conf
Normal file
70
packages/backend/test-federation/.config/example.conf
Normal file
|
@ -0,0 +1,70 @@
|
|||
# based on https://github.com/misskey-dev/misskey-hub/blob/7071f63a1c80ee35c71f0fd8a6d8722c118c7574/src/docs/admin/nginx.md
|
||||
|
||||
# For WebSocket
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${HOST};
|
||||
|
||||
# For SSL domain validation
|
||||
root /var/www/html;
|
||||
location /.well-known/acme-challenge/ { allow all; }
|
||||
location /.well-known/pki-validation/ { allow all; }
|
||||
location / { return 301 https://$server_name$request_uri; }
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name ${HOST};
|
||||
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:ssl_session_cache:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
ssl_trusted_certificate /etc/nginx/certificates/rootCA.crt;
|
||||
ssl_certificate /etc/nginx/certificates/$server_name.crt;
|
||||
ssl_certificate_key /etc/nginx/certificates/$server_name.key;
|
||||
|
||||
# SSL protocol settings
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
|
||||
# Change to your upload limit
|
||||
client_max_body_size 80m;
|
||||
|
||||
# Proxy to Node
|
||||
location / {
|
||||
proxy_pass http://misskey.${HOST}:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_redirect off;
|
||||
|
||||
# If it's behind another reverse proxy or CDN, remove the following.
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
|
||||
# For WebSocket
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Cache settings
|
||||
proxy_cache cache1;
|
||||
proxy_cache_lock on;
|
||||
proxy_cache_use_stale updating;
|
||||
proxy_force_ranges on;
|
||||
add_header X-Cache $upstream_cache_status;
|
||||
}
|
||||
}
|
25
packages/backend/test-federation/.config/example.default.yml
Normal file
25
packages/backend/test-federation/.config/example.default.yml
Normal file
|
@ -0,0 +1,25 @@
|
|||
url: https://${HOST}/
|
||||
port: 3000
|
||||
db:
|
||||
host: db.${HOST}
|
||||
port: 5432
|
||||
db: misskey
|
||||
user: postgres
|
||||
pass: postgres
|
||||
dbReplications: false
|
||||
redis:
|
||||
host: redis.test
|
||||
port: 6379
|
||||
id: 'aidx'
|
||||
proxyBypassHosts:
|
||||
- api.deepl.com
|
||||
- api-free.deepl.com
|
||||
- www.recaptcha.net
|
||||
- hcaptcha.com
|
||||
- challenges.cloudflare.com
|
||||
proxyRemoteFiles: true
|
||||
signToActivityPubGet: true
|
||||
allowedPrivateNetworks: [
|
||||
'127.0.0.1/32',
|
||||
'172.20.0.0/16'
|
||||
]
|
|
@ -0,0 +1,5 @@
|
|||
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
|
||||
POSTGRES_DB=misskey
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
MK_VERBOSE=true
|
6
packages/backend/test-federation/.gitignore
vendored
Normal file
6
packages/backend/test-federation/.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
certificates
|
||||
volumes
|
||||
.env
|
||||
docker.env
|
||||
*.test.conf
|
||||
*.test.default.yml
|
24
packages/backend/test-federation/README.md
Normal file
24
packages/backend/test-federation/README.md
Normal file
|
@ -0,0 +1,24 @@
|
|||
## test-federation
|
||||
Test federation between two Misskey servers: `a.test` and `b.test`.
|
||||
|
||||
Before testing, you need to build the entire project, and change working directory to here:
|
||||
```sh
|
||||
pnpm build
|
||||
cd packages/backend/test-federation
|
||||
```
|
||||
|
||||
First, you need to start servers by executing following commands:
|
||||
```sh
|
||||
bash ./setup.sh
|
||||
docker compose up --scale tester=0
|
||||
```
|
||||
|
||||
Then you can run all tests by a following command:
|
||||
```sh
|
||||
docker compose run --no-deps --rm tester
|
||||
```
|
||||
|
||||
For testing a specific file, run a following command:
|
||||
```sh
|
||||
docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts
|
||||
```
|
64
packages/backend/test-federation/compose.a.yml
Normal file
64
packages/backend/test-federation/compose.a.yml
Normal file
|
@ -0,0 +1,64 @@
|
|||
services:
|
||||
a.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: nginx
|
||||
depends_on:
|
||||
misskey.a.test:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal_network_a
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/a.test.conf
|
||||
target: /etc/nginx/conf.d/a.test.conf
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/a.test.crt
|
||||
target: /etc/nginx/certificates/a.test.crt
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/a.test.key
|
||||
target: /etc/nginx/certificates/a.test.key
|
||||
read_only: true
|
||||
|
||||
misskey.a.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: misskey
|
||||
depends_on:
|
||||
db.a.test:
|
||||
condition: service_healthy
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
setup:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- internal_network_a
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/a.test.default.yml
|
||||
target: /misskey/.config/default.yml
|
||||
read_only: true
|
||||
|
||||
db.a.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: db
|
||||
networks:
|
||||
- internal_network_a
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./volumes/db.a
|
||||
target: /var/lib/postgresql/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
networks:
|
||||
internal_network_a:
|
||||
internal: true
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.21.0.0/16
|
||||
ip_range: 172.21.0.0/24
|
64
packages/backend/test-federation/compose.b.yml
Normal file
64
packages/backend/test-federation/compose.b.yml
Normal file
|
@ -0,0 +1,64 @@
|
|||
services:
|
||||
b.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: nginx
|
||||
depends_on:
|
||||
misskey.b.test:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/b.test.conf
|
||||
target: /etc/nginx/conf.d/b.test.conf
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/b.test.crt
|
||||
target: /etc/nginx/certificates/b.test.crt
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/b.test.key
|
||||
target: /etc/nginx/certificates/b.test.key
|
||||
read_only: true
|
||||
|
||||
misskey.b.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: misskey
|
||||
depends_on:
|
||||
db.b.test:
|
||||
condition: service_healthy
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
setup:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./.config/b.test.default.yml
|
||||
target: /misskey/.config/default.yml
|
||||
read_only: true
|
||||
|
||||
db.b.test:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: db
|
||||
networks:
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./volumes/db.b
|
||||
target: /var/lib/postgresql/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
networks:
|
||||
internal_network_b:
|
||||
internal: true
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.22.0.0/16
|
||||
ip_range: 172.22.0.0/24
|
117
packages/backend/test-federation/compose.override.yaml
Normal file
117
packages/backend/test-federation/compose.override.yaml
Normal file
|
@ -0,0 +1,117 @@
|
|||
services:
|
||||
setup:
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-reversi
|
||||
target: /misskey/packages/misskey-reversi/node_modules
|
||||
|
||||
tester:
|
||||
networks:
|
||||
external_network:
|
||||
internal_network:
|
||||
ipv4_address: 172.20.1.1
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules_dev
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend_dev
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js_dev
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
|
||||
daemon:
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network_a
|
||||
- internal_network_b
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules_dev
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend_dev
|
||||
target: /misskey/packages/backend/node_modules
|
||||
|
||||
redis.test:
|
||||
networks:
|
||||
- internal_network_a
|
||||
- internal_network_b
|
||||
|
||||
a.test:
|
||||
networks:
|
||||
- internal_network
|
||||
|
||||
misskey.a.test:
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-reversi
|
||||
target: /misskey/packages/misskey-reversi/node_modules
|
||||
|
||||
b.test:
|
||||
networks:
|
||||
- internal_network
|
||||
|
||||
misskey.b.test:
|
||||
networks:
|
||||
- external_network
|
||||
- internal_network
|
||||
volumes:
|
||||
- type: volume
|
||||
source: node_modules
|
||||
target: /misskey/node_modules
|
||||
- type: volume
|
||||
source: node_modules_backend
|
||||
target: /misskey/packages/backend/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-js
|
||||
target: /misskey/packages/misskey-js/node_modules
|
||||
- type: volume
|
||||
source: node_modules_misskey-reversi
|
||||
target: /misskey/packages/misskey-reversi/node_modules
|
||||
|
||||
networks:
|
||||
external_network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.23.0.0/16
|
||||
ip_range: 172.23.0.0/24
|
||||
internal_network:
|
||||
internal: true
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.20.0.0/16
|
||||
ip_range: 172.20.0.0/24
|
||||
|
||||
volumes:
|
||||
node_modules:
|
||||
node_modules_dev:
|
||||
node_modules_backend:
|
||||
node_modules_backend_dev:
|
||||
node_modules_misskey-js:
|
||||
node_modules_misskey-js_dev:
|
||||
node_modules_misskey-reversi:
|
101
packages/backend/test-federation/compose.tpl.yml
Normal file
101
packages/backend/test-federation/compose.tpl.yml
Normal file
|
@ -0,0 +1,101 @@
|
|||
services:
|
||||
nginx:
|
||||
image: nginx:1.27
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /etc/nginx/certificates/rootCA.crt
|
||||
read_only: true
|
||||
healthcheck:
|
||||
test: service nginx status
|
||||
interval: 5s
|
||||
retries: 20
|
||||
|
||||
misskey:
|
||||
image: node:20
|
||||
env_file:
|
||||
- ./.config/docker.env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ../../../built
|
||||
target: /misskey/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../assets
|
||||
target: /misskey/packages/backend/assets
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../built
|
||||
target: /misskey/packages/backend/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../migration
|
||||
target: /misskey/packages/backend/migration
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../ormconfig.js
|
||||
target: /misskey/packages/backend/ormconfig.js
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../package.json
|
||||
target: /misskey/packages/backend/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/built
|
||||
target: /misskey/packages/misskey-js/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/package.json
|
||||
target: /misskey/packages/misskey-js/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-reversi/built
|
||||
target: /misskey/packages/misskey-reversi/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-reversi/package.json
|
||||
target: /misskey/packages/misskey-reversi/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../healthcheck.sh
|
||||
target: /misskey/healthcheck.sh
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../package.json
|
||||
target: /misskey/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-lock.yaml
|
||||
target: /misskey/pnpm-lock.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /usr/local/share/ca-certificates/rootCA.crt
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
command: >
|
||||
bash -c "
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F backend migrate
|
||||
pnpm -F backend start
|
||||
"
|
||||
healthcheck:
|
||||
test: bash /misskey/healthcheck.sh
|
||||
interval: 5s
|
||||
retries: 20
|
||||
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
env_file:
|
||||
- ./.config/docker.env
|
||||
volumes:
|
||||
healthcheck:
|
||||
test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
|
||||
interval: 5s
|
||||
retries: 20
|
133
packages/backend/test-federation/compose.yml
Normal file
133
packages/backend/test-federation/compose.yml
Normal file
|
@ -0,0 +1,133 @@
|
|||
include:
|
||||
- ./compose.a.yml
|
||||
- ./compose.b.yml
|
||||
|
||||
services:
|
||||
setup:
|
||||
extends:
|
||||
file: ./compose.tpl.yml
|
||||
service: misskey
|
||||
command: >
|
||||
bash -c "
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F backend i
|
||||
pnpm -F misskey-js i
|
||||
pnpm -F misskey-reversi i
|
||||
"
|
||||
|
||||
tester:
|
||||
image: node:20
|
||||
depends_on:
|
||||
a.test:
|
||||
condition: service_healthy
|
||||
b.test:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ../package.json
|
||||
target: /misskey/packages/backend/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../test/resources
|
||||
target: /misskey/packages/backend/test/resources
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./test
|
||||
target: /misskey/packages/backend/test-federation/test
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../jest.config.cjs
|
||||
target: /misskey/packages/backend/jest.config.cjs
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../jest.config.fed.cjs
|
||||
target: /misskey/packages/backend/jest.config.fed.cjs
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/built
|
||||
target: /misskey/packages/misskey-js/built
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/package.json
|
||||
target: /misskey/packages/misskey-js/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../package.json
|
||||
target: /misskey/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-lock.yaml
|
||||
target: /misskey/pnpm-lock.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./certificates/rootCA.crt
|
||||
target: /usr/local/share/ca-certificates/rootCA.crt
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
entrypoint: >
|
||||
bash -c '
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F misskey-js i --frozen-lockfile
|
||||
pnpm -F backend i --frozen-lockfile
|
||||
exec "$0" "$@"
|
||||
'
|
||||
command: pnpm -F backend test:fed
|
||||
|
||||
daemon:
|
||||
image: node:20
|
||||
depends_on:
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ../package.json
|
||||
target: /misskey/packages/backend/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./daemon.ts
|
||||
target: /misskey/packages/backend/test-federation/daemon.ts
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ./tsconfig.json
|
||||
target: /misskey/packages/backend/test-federation/tsconfig.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../package.json
|
||||
target: /misskey/package.json
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-lock.yaml
|
||||
target: /misskey/pnpm-lock.yaml
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../../pnpm-workspace.yaml
|
||||
target: /misskey/pnpm-workspace.yaml
|
||||
read_only: true
|
||||
working_dir: /misskey
|
||||
command: >
|
||||
bash -c "
|
||||
corepack enable && corepack prepare
|
||||
pnpm -F backend i --frozen-lockfile
|
||||
pnpm exec tsc -p ./packages/backend/test-federation
|
||||
node ./packages/backend/test-federation/built/daemon.js
|
||||
"
|
||||
|
||||
redis.test:
|
||||
image: redis:7-alpine
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./volumes/redis
|
||||
target: /data
|
||||
bind:
|
||||
create_host_path: true
|
||||
healthcheck:
|
||||
test: redis-cli ping
|
||||
interval: 5s
|
||||
retries: 20
|
38
packages/backend/test-federation/daemon.ts
Normal file
38
packages/backend/test-federation/daemon.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import IPCIDR from 'ip-cidr';
|
||||
import { Redis } from 'ioredis';
|
||||
|
||||
const TESTER_IP_ADDRESS = '172.20.1.1';
|
||||
|
||||
/**
|
||||
* This should be same as {@link file://./../src/misc/get-ip-hash.ts}.
|
||||
*/
|
||||
function getIpHash(ip: string) {
|
||||
const prefix = IPCIDR.createAddress(ip).mask(64);
|
||||
return `ip-${BigInt('0b' + prefix).toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* This prevents hitting rate limit when login.
|
||||
*/
|
||||
export async function purgeLimit(host: string, client: Redis) {
|
||||
const ipHash = getIpHash(TESTER_IP_ADDRESS);
|
||||
const key = `${host}:limit:${ipHash}:signin`;
|
||||
const res = await client.zrange(key, 0, -1);
|
||||
if (res.length !== 0) {
|
||||
console.log(`${key} - ${JSON.stringify(res)}`);
|
||||
await client.del(key);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Daemon started running');
|
||||
|
||||
{
|
||||
const redisClient = new Redis({
|
||||
host: 'redis.test',
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
purgeLimit('a.test', redisClient);
|
||||
purgeLimit('b.test', redisClient);
|
||||
}, 200);
|
||||
}
|
21
packages/backend/test-federation/eslint.config.js
Normal file
21
packages/backend/test-federation/eslint.config.js
Normal file
|
@ -0,0 +1,21 @@
|
|||
import globals from 'globals';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../../shared/eslint.config.js';
|
||||
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tsParser,
|
||||
project: ['./tsconfig.json'],
|
||||
sourceType: 'module',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
35
packages/backend/test-federation/setup.sh
Normal file
35
packages/backend/test-federation/setup.sh
Normal file
|
@ -0,0 +1,35 @@
|
|||
#!/bin/bash
|
||||
mkdir certificates
|
||||
|
||||
# rootCA
|
||||
openssl genrsa -des3 \
|
||||
-passout pass:rootCA \
|
||||
-out certificates/rootCA.key 4096
|
||||
openssl req -x509 -new -nodes -batch \
|
||||
-key certificates/rootCA.key \
|
||||
-sha256 \
|
||||
-days 1024 \
|
||||
-passin pass:rootCA \
|
||||
-out certificates/rootCA.crt
|
||||
|
||||
# domain
|
||||
function generate {
|
||||
openssl req -new -newkey rsa:2048 -sha256 -nodes \
|
||||
-keyout certificates/$1.key \
|
||||
-subj "/CN=$1/emailAddress=admin@$1/C=JP/ST=/L=/O=Misskey Tester/OU=Some Unit" \
|
||||
-out certificates/$1.csr
|
||||
openssl x509 -req -sha256 \
|
||||
-in certificates/$1.csr \
|
||||
-CA certificates/rootCA.crt \
|
||||
-CAkey certificates/rootCA.key \
|
||||
-CAcreateserial \
|
||||
-passin pass:rootCA \
|
||||
-out certificates/$1.crt \
|
||||
-days 500
|
||||
if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi
|
||||
if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi
|
||||
if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.default.yml > .config/$1.default.yml; fi
|
||||
}
|
||||
|
||||
generate a.test
|
||||
generate b.test
|
52
packages/backend/test-federation/test/abuse-report.test.ts
Normal file
52
packages/backend/test-federation/test/abuse-report.test.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, createModerator, resolveRemoteUser, sleep, type LoginUser } from './utils.js';
|
||||
|
||||
describe('Abuse report', () => {
|
||||
describe('Forwarding report', () => {
|
||||
let alice: LoginUser, bob: LoginUser, aModerator: LoginUser, bModerator: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[aModerator, bModerator] = await Promise.all([
|
||||
createModerator('a.test'),
|
||||
createModerator('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Alice reports Bob, moderator in A forwards it, and B moderator receives it', async () => {
|
||||
const comment = crypto.randomUUID();
|
||||
await alice.client.request('users/report-abuse', { userId: bobInA.id, comment });
|
||||
const reports = await aModerator.client.request('admin/abuse-user-reports', {});
|
||||
const report = reports.filter(report => report.comment === comment)[0];
|
||||
await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id });
|
||||
await sleep();
|
||||
|
||||
const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {});
|
||||
const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0];
|
||||
// NOTE: reporter is not Alice, and is not moderator in A
|
||||
strictEqual(reportInB.reporter.url, 'https://a.test/@instance.actor');
|
||||
strictEqual(reportInB.targetUserId, bob.id);
|
||||
|
||||
// NOTE: cannot forward multiple times
|
||||
await rejects(
|
||||
async () => await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
strictEqual(err.info.e.message, 'The report has already been forwarded.');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
224
packages/backend/test-federation/test/block.test.ts
Normal file
224
packages/backend/test-federation/test/block.test.ts
Normal file
|
@ -0,0 +1,224 @@
|
|||
import { deepStrictEqual, rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
describe('Block', () => {
|
||||
describe('Check follow', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Cannot follow if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 0);
|
||||
});
|
||||
|
||||
// FIXME: this is invalid case
|
||||
test('Cannot follow even if unblocked', async () => {
|
||||
// unblock here
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
// TODO: why still being blocked?
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('Can follow if unblocked', async () => {
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1);
|
||||
});
|
||||
|
||||
test.skip('Remove follower when block them', async () => {
|
||||
test('before block', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1);
|
||||
});
|
||||
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
test('after block', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0);
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check reply', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Cannot reply if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Can reply if unblocked', async () => {
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reply = (await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id })).createdNote;
|
||||
|
||||
await resolveRemoteNote('b.test', reply.id, alice);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check reaction', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Cannot reaction if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// FIXME: this is invalid case
|
||||
test('Cannot reaction even if unblocked', async () => {
|
||||
// unblock here
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
|
||||
// TODO: why still being blocked?
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('Can reaction if unblocked', async () => {
|
||||
await alice.client.request('blocking/delete', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' });
|
||||
|
||||
const _note = await alice.client.request('notes/show', { noteId: note.id });
|
||||
deepStrictEqual(_note.reactions, { '😅': 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check mention', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
/** NOTE: You should mute the target to stop receiving notifications */
|
||||
test('Can mention and notified even if blocked', async () => {
|
||||
await alice.client.request('blocking/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const text = `@${alice.username}@a.test plz unblock me!`;
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text }),
|
||||
notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
175
packages/backend/test-federation/test/drive.test.ts
Normal file
175
packages/backend/test-federation/test/drive.test.ts
Normal file
|
@ -0,0 +1,175 @@
|
|||
import assert, { strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
||||
|
||||
const bAdmin = await fetchAdmin('b.test');
|
||||
|
||||
describe('Drive', () => {
|
||||
describe('Upload image in a.test and resolve from b.test', () => {
|
||||
let uploader: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
uploader = await createAccount('a.test');
|
||||
});
|
||||
|
||||
let image: Misskey.entities.DriveFile, imageInB: Misskey.entities.DriveFile;
|
||||
|
||||
describe('Upload', () => {
|
||||
beforeAll(async () => {
|
||||
image = await uploadFile('a.test', uploader);
|
||||
const noteWithImage = (await uploader.client.request('notes/create', { fileIds: [image.id] })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', noteWithImage.id, bAdmin);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
imageInB = noteInB.files[0];
|
||||
});
|
||||
|
||||
test('Check consistency of DriveFile', () => {
|
||||
// console.log(`a.test: ${JSON.stringify(image, null, '\t')}`);
|
||||
// console.log(`b.test: ${JSON.stringify(imageInB, null, '\t')}`);
|
||||
|
||||
deepStrictEqualWithExcludedFields(image, imageInB, [
|
||||
'id',
|
||||
'createdAt',
|
||||
'size',
|
||||
'url',
|
||||
'thumbnailUrl',
|
||||
'userId',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
let updatedImage: Misskey.entities.DriveFile, updatedImageInB: Misskey.entities.DriveFile;
|
||||
|
||||
describe('Update', () => {
|
||||
beforeAll(async () => {
|
||||
updatedImage = await uploader.client.request('drive/files/update', {
|
||||
fileId: image.id,
|
||||
name: 'updated_192.jpg',
|
||||
isSensitive: true,
|
||||
});
|
||||
|
||||
updatedImageInB = await bAdmin.client.request('drive/files/show', {
|
||||
fileId: imageInB.id,
|
||||
});
|
||||
});
|
||||
|
||||
test('Check consistency', () => {
|
||||
// console.log(`a.test: ${JSON.stringify(updatedImage, null, '\t')}`);
|
||||
// console.log(`b.test: ${JSON.stringify(updatedImageInB, null, '\t')}`);
|
||||
|
||||
// FIXME: not updated with `drive/files/update`
|
||||
strictEqual(updatedImage.isSensitive, true);
|
||||
strictEqual(updatedImage.name, 'updated_192.jpg');
|
||||
strictEqual(updatedImageInB.isSensitive, false);
|
||||
strictEqual(updatedImageInB.name, '192.jpg');
|
||||
});
|
||||
});
|
||||
|
||||
let reupdatedImageInB: Misskey.entities.DriveFile;
|
||||
|
||||
describe('Re-update with attaching to Note', () => {
|
||||
beforeAll(async () => {
|
||||
const noteWithUpdatedImage = (await uploader.client.request('notes/create', { fileIds: [updatedImage.id] })).createdNote;
|
||||
const noteWithUpdatedImageInB = await resolveRemoteNote('a.test', noteWithUpdatedImage.id, bAdmin);
|
||||
assert(noteWithUpdatedImageInB.files != null);
|
||||
strictEqual(noteWithUpdatedImageInB.files.length, 1);
|
||||
reupdatedImageInB = noteWithUpdatedImageInB.files[0];
|
||||
});
|
||||
|
||||
test('Check consistency', () => {
|
||||
// console.log(`b.test: ${JSON.stringify(reupdatedImageInB, null, '\t')}`);
|
||||
|
||||
// `isSensitive` is updated
|
||||
strictEqual(reupdatedImageInB.isSensitive, true);
|
||||
// FIXME: but `name` is not updated
|
||||
strictEqual(reupdatedImageInB.name, '192.jpg');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sensitive flag', () => {
|
||||
describe('isSensitive is federated in delivering to followers', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
strictEqual(notes.length, 1);
|
||||
const noteInB = notes[0];
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSensitive is federated in resolving', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] })).createdNote;
|
||||
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
});
|
||||
});
|
||||
|
||||
/** @see https://github.com/misskey-dev/misskey/issues/12208 */
|
||||
describe('isSensitive is federated in replying', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => {
|
||||
const bobNote = (await bob.client.request('notes/create', { text: 'I\'m Bob' })).createdNote;
|
||||
|
||||
const file = await uploadFile('a.test', alice);
|
||||
await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true });
|
||||
const bobNoteInA = await resolveRemoteNote('b.test', bobNote.id, alice);
|
||||
const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id], replyId: bobNoteInA.id })).createdNote;
|
||||
await sleep();
|
||||
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
assert(noteInB.files != null);
|
||||
strictEqual(noteInB.files.length, 1);
|
||||
strictEqual(noteInB.files[0].isSensitive, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
97
packages/backend/test-federation/test/emoji.test.ts
Normal file
97
packages/backend/test-federation/test/emoji.test.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import assert, { deepStrictEqual, strictEqual } from 'assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
describe('Emoji', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Note delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
const noteInB = notes[0];
|
||||
|
||||
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
|
||||
assert(noteInB.emojis != null);
|
||||
assert(emoji.name in noteInB.emojis);
|
||||
strictEqual(noteInB.emojis[emoji.name], emoji.url);
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Reaction delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
|
||||
deepStrictEqual(noteInB.reactions[`:${emoji.name}@a.test:`], 1);
|
||||
deepStrictEqual(noteInB.reactionEmojis[`${emoji.name}@a.test`], emoji.url);
|
||||
});
|
||||
|
||||
test('Custom emoji are delivered with Profile delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test');
|
||||
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
assert(emoji.name in renewedaliceInB.emojis);
|
||||
strictEqual(renewedaliceInB.emojis[emoji.name], emoji.url);
|
||||
});
|
||||
|
||||
test('Local-only custom emoji aren\'t delivered with Note delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
await alice.client.request('notes/create', { text: `I love :${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const notes = await bob.client.request('notes/timeline', {});
|
||||
const noteInB = notes[0];
|
||||
|
||||
strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`);
|
||||
// deepStrictEqual(noteInB.emojis, {}); // TODO: this fails (why?)
|
||||
deepStrictEqual({ ...noteInB.emojis }, {});
|
||||
});
|
||||
|
||||
test('Local-only custom emoji aren\'t delivered with Reaction delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const noteInB = (await bob.client.request('notes/timeline', {}))[0];
|
||||
deepStrictEqual({ ...noteInB.reactions }, { '❤': 1 });
|
||||
deepStrictEqual({ ...noteInB.reactionEmojis }, {});
|
||||
});
|
||||
|
||||
test('Local-only custom emoji aren\'t delivered with Profile delivery', async () => {
|
||||
const emoji = await addCustomEmoji('a.test', { localOnly: true });
|
||||
const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(renewedaliceInB.name, renewedAlice.name);
|
||||
deepStrictEqual({ ...renewedaliceInB.emojis }, {});
|
||||
});
|
||||
});
|
52
packages/backend/test-federation/test/move.test.ts
Normal file
52
packages/backend/test-federation/test/move.test.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import assert, { strictEqual } from 'node:assert';
|
||||
import { createAccount, type LoginUser, sleep } from './utils.js';
|
||||
|
||||
describe('Move', () => {
|
||||
test('Minimum move', async () => {
|
||||
const [alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
|
||||
await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
|
||||
});
|
||||
|
||||
/** @see https://github.com/misskey-dev/misskey/issues/11320 */
|
||||
describe('Following relation is transferred after move', () => {
|
||||
let alice: LoginUser, bob: LoginUser, carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
carol = await createAccount('a.test');
|
||||
|
||||
// Follow @carol@a.test ==> @alice@a.test
|
||||
await carol.client.request('following/create', { userId: alice.id });
|
||||
|
||||
// Move @alice@a.test ==> @bob@b.test
|
||||
await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] });
|
||||
await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check from follower', async () => {
|
||||
const following = await carol.client.request('users/following', { userId: carol.id });
|
||||
strictEqual(following.length, 2);
|
||||
const followees = following.map(({ followee }) => followee);
|
||||
assert(followees.every(followee => followee != null));
|
||||
assert(followees.some(({ id, url }) => id === alice.id && url === null));
|
||||
assert(followees.some(({ url }) => url === `https://b.test/@${bob.username}`));
|
||||
});
|
||||
|
||||
test('Check from followee', async () => {
|
||||
const followers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(followers.length, 1);
|
||||
const follower = followers[0].follower;
|
||||
assert(follower != null);
|
||||
strictEqual(follower.url, `https://a.test/@${carol.username}`);
|
||||
});
|
||||
});
|
||||
});
|
317
packages/backend/test-federation/test/note.test.ts
Normal file
317
packages/backend/test-federation/test/note.test.ts
Normal file
|
@ -0,0 +1,317 @@
|
|||
import assert, { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js';
|
||||
|
||||
describe('Note', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Note content', () => {
|
||||
test('Consistency of Public Note', async () => {
|
||||
const image = await uploadFile('a.test', alice);
|
||||
const note = (await alice.client.request('notes/create', {
|
||||
text: 'I am Alice!',
|
||||
fileIds: [image.id],
|
||||
poll: {
|
||||
choices: ['neko', 'inu'],
|
||||
multiple: false,
|
||||
expiredAfter: 60 * 60 * 1000,
|
||||
},
|
||||
})).createdNote;
|
||||
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
deepStrictEqualWithExcludedFields(note, resolvedNote, [
|
||||
'id',
|
||||
'emojis',
|
||||
/** Consistency of files is checked at {@link file://./drive.test.ts}, so let's skip. */
|
||||
'fileIds',
|
||||
'files',
|
||||
/** @see https://github.com/misskey-dev/misskey/issues/12409 */
|
||||
'reactionAcceptance',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
});
|
||||
|
||||
test('Consistency of reply', async () => {
|
||||
const _replyedNote = (await alice.client.request('notes/create', {
|
||||
text: 'a',
|
||||
})).createdNote;
|
||||
const note = (await alice.client.request('notes/create', {
|
||||
text: 'b',
|
||||
replyId: _replyedNote.id,
|
||||
})).createdNote;
|
||||
// NOTE: the repliedCount is incremented, so fetch again
|
||||
const replyedNote = await alice.client.request('notes/show', { noteId: _replyedNote.id });
|
||||
strictEqual(replyedNote.repliesCount, 1);
|
||||
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
deepStrictEqualWithExcludedFields(note, resolvedNote, [
|
||||
'id',
|
||||
'emojis',
|
||||
'reactionAcceptance',
|
||||
'replyId',
|
||||
'reply',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
assert(resolvedNote.replyId != null);
|
||||
assert(resolvedNote.reply != null);
|
||||
deepStrictEqualWithExcludedFields(replyedNote, resolvedNote.reply, [
|
||||
'id',
|
||||
// TODO: why clippedCount loses consistency?
|
||||
'clippedCount',
|
||||
'emojis',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
// flaky because this is parallelly incremented, so let's check it below
|
||||
'repliesCount',
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
|
||||
await sleep();
|
||||
|
||||
const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId });
|
||||
strictEqual(resolvedReplyedNote.repliesCount, 1);
|
||||
});
|
||||
|
||||
test('Consistency of Renote', async () => {
|
||||
// NOTE: the renoteCount is not incremented, so no need to fetch again
|
||||
const renotedNote = (await alice.client.request('notes/create', {
|
||||
text: 'a',
|
||||
})).createdNote;
|
||||
const note = (await alice.client.request('notes/create', {
|
||||
text: 'b',
|
||||
renoteId: renotedNote.id,
|
||||
})).createdNote;
|
||||
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
deepStrictEqualWithExcludedFields(note, resolvedNote, [
|
||||
'id',
|
||||
'emojis',
|
||||
'reactionAcceptance',
|
||||
'renoteId',
|
||||
'renote',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
assert(resolvedNote.renoteId != null);
|
||||
assert(resolvedNote.renote != null);
|
||||
deepStrictEqualWithExcludedFields(renotedNote, resolvedNote.renote, [
|
||||
'id',
|
||||
'emojis',
|
||||
'userId',
|
||||
'user',
|
||||
'uri',
|
||||
]);
|
||||
strictEqual(aliceInB.id, resolvedNote.userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other props', () => {
|
||||
test('localOnly', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
|
||||
rejects(
|
||||
async () => await bob.client.request('ap/show', { uri: `https://a.test/notes/${note.id}` }),
|
||||
(err: any) => {
|
||||
/**
|
||||
* FIXME: this error is not handled
|
||||
* @see https://github.com/misskey-dev/misskey/issues/12736
|
||||
*/
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion', () => {
|
||||
describe('Check Delete consistency', () => {
|
||||
let carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
carol = await createAccount('a.test');
|
||||
|
||||
await carol.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Delete is derivered to followers', async () => {
|
||||
const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await bob.client.request('notes/delete', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
await rejects(
|
||||
async () => await carol.client.request('notes/show', { noteId: noteInA.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion of remote user\'s note for moderation', () => {
|
||||
let note: Misskey.entities.Note;
|
||||
|
||||
test('Alice post is deleted in B', async () => {
|
||||
note = (await alice.client.request('notes/create', { text: 'Hello' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const bMod = await createModerator('b.test');
|
||||
await bMod.client.request('notes/delete', { noteId: noteInB.id });
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: implement soft deletion as well as user?
|
||||
* @see https://github.com/misskey-dev/misskey/issues/11437
|
||||
*/
|
||||
test.failing('Not found even if resolve again', async () => {
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await rejects(
|
||||
async () => await bob.client.request('notes/show', { noteId: noteInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_NOTE');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reaction', () => {
|
||||
describe('Consistency', () => {
|
||||
test('Unicode reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reaction = '😅';
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, reaction);
|
||||
strictEqual(reactions[0].user.id, bobInA.id);
|
||||
});
|
||||
|
||||
test('Custom emoji reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const resolvedNote = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test');
|
||||
await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
|
||||
strictEqual(reactions[0].user.id, bobInA.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Acceptance', () => {
|
||||
test('Even if likeOnly, remote users can react with custom emoji, but it is converted to like', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'likeOnly' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test');
|
||||
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, '❤');
|
||||
});
|
||||
|
||||
/**
|
||||
* TODO: this may be unexpected behavior?
|
||||
* @see https://github.com/misskey-dev/misskey/issues/12409
|
||||
*/
|
||||
test('Even if nonSensitiveOnly, remote users can react with sensitive emoji, and it is not converted', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'nonSensitiveOnly' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const emoji = await addCustomEmoji('b.test', { isSensitive: true });
|
||||
await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` });
|
||||
await sleep();
|
||||
|
||||
const reactions = await alice.client.request('notes/reactions', { noteId: note.id });
|
||||
strictEqual(reactions.length, 1);
|
||||
strictEqual(reactions[0].type, `:${emoji.name}@b.test:`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Poll', () => {
|
||||
describe('Any remote user\'s vote is delivered to the author', () => {
|
||||
let carol: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
carol = await createAccount('a.test');
|
||||
});
|
||||
|
||||
test('Bob creates poll and receives a vote from Carol', async () => {
|
||||
const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, carol);
|
||||
await carol.client.request('notes/polls/vote', { noteId: noteInA.id, choice: 0 });
|
||||
await sleep();
|
||||
|
||||
const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Local user\'s vote is delivered to the author\'s remote followers', () => {
|
||||
let bobRemoteFollower: LoginUser, localVoter: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
[
|
||||
bobRemoteFollower,
|
||||
localVoter,
|
||||
] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
await bobRemoteFollower.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('A vote in Bob\'s server is delivered to Bob\'s remote followers', async () => {
|
||||
const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote;
|
||||
// NOTE: resolve before voting
|
||||
const noteInA = await resolveRemoteNote('b.test', note.id, bobRemoteFollower);
|
||||
await localVoter.client.request('notes/polls/vote', { noteId: note.id, choice: 0 });
|
||||
await sleep();
|
||||
|
||||
const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id });
|
||||
assert(noteAfterVote.poll != null);
|
||||
strictEqual(noteAfterVote.poll.choices[0].votes, 1);
|
||||
strictEqual(noteAfterVote.poll.choices[1].votes, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
107
packages/backend/test-federation/test/notification.test.ts
Normal file
107
packages/backend/test-federation/test/notification.test.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
describe('Notification', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Follow', () => {
|
||||
test('Get notification when follow', async () => {
|
||||
await assertNotificationReceived(
|
||||
'b.test', bob,
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
notification => notification.type === 'followRequestAccepted' && notification.userId === aliceInB.id,
|
||||
true,
|
||||
);
|
||||
|
||||
await bob.client.request('following/delete', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Get notification when get followed', async () => {
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
notification => notification.type === 'follow' && notification.userId === bobInA.id,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => await bob.client.request('following/delete', { userId: aliceInB.id }));
|
||||
});
|
||||
|
||||
describe('Note', () => {
|
||||
test('Get notification when get a reaction', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const reaction = '😅';
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction }),
|
||||
notification =>
|
||||
notification.type === 'reaction' && notification.note.id === note.id && notification.userId === bobInA.id && notification.reaction === reaction,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when replied', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const text = crypto.randomUUID();
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text, replyId: noteInB.id }),
|
||||
notification =>
|
||||
notification.type === 'reply' && notification.note.reply!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when renoted', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { renoteId: noteInB.id }),
|
||||
notification =>
|
||||
notification.type === 'renote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when quoted', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
const noteInB = await resolveRemoteNote('a.test', note.id, bob);
|
||||
const text = crypto.randomUUID();
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text, renoteId: noteInB.id }),
|
||||
notification =>
|
||||
notification.type === 'quote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Get notification when mentioned', async () => {
|
||||
const text = `@${alice.username}@a.test`;
|
||||
await assertNotificationReceived(
|
||||
'a.test', alice,
|
||||
async () => await bob.client.request('notes/create', { text }),
|
||||
notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
328
packages/backend/test-federation/test/timeline.test.ts
Normal file
328
packages/backend/test-federation/test/timeline.test.ts
Normal file
|
@ -0,0 +1,328 @@
|
|||
import { strictEqual } from 'assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole } from './utils.js';
|
||||
|
||||
const bAdmin = await fetchAdmin('b.test');
|
||||
|
||||
describe('Timeline', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag');
|
||||
type TimelineEndpoint = keyof Misskey.Endpoints & (`${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag');
|
||||
const timelineMap = new Map<TimelineChannel, TimelineEndpoint>([
|
||||
['antenna', 'antennas/notes'],
|
||||
['globalTimeline', 'notes/global-timeline'],
|
||||
['homeTimeline', 'notes/timeline'],
|
||||
['hybridTimeline', 'notes/hybrid-timeline'],
|
||||
['localTimeline', 'notes/local-timeline'],
|
||||
['roleTimeline', 'roles/notes'],
|
||||
['hashtag', 'notes/search-by-tag'],
|
||||
['userList', 'notes/user-list-timeline'],
|
||||
]);
|
||||
|
||||
async function postAndCheckReception<C extends TimelineChannel>(
|
||||
timelineChannel: C,
|
||||
expect: boolean,
|
||||
noteParams: Misskey.entities.NotesCreateRequest = {},
|
||||
channelParams: Misskey.Channels[C]['params'] = {},
|
||||
) {
|
||||
let note: Misskey.entities.Note | undefined;
|
||||
const text = noteParams.text ?? crypto.randomUUID();
|
||||
const streamingFired = await isFired(
|
||||
'b.test', bob, timelineChannel,
|
||||
async () => {
|
||||
note = (await alice.client.request('notes/create', { text, ...noteParams })).createdNote;
|
||||
},
|
||||
'note', msg => msg.text === text,
|
||||
channelParams,
|
||||
);
|
||||
strictEqual(streamingFired, expect);
|
||||
|
||||
const endpoint = timelineMap.get(timelineChannel)!;
|
||||
const params: Misskey.Endpoints[typeof endpoint]['req'] =
|
||||
endpoint === 'antennas/notes' ? { antennaId: (channelParams as Misskey.Channels['antenna']['params']).antennaId } :
|
||||
endpoint === 'notes/user-list-timeline' ? { listId: (channelParams as Misskey.Channels['userList']['params']).listId } :
|
||||
endpoint === 'notes/search-by-tag' ? { query: (channelParams as Misskey.Channels['hashtag']['params']).q } :
|
||||
endpoint === 'roles/notes' ? { roleId: (channelParams as Misskey.Channels['roleTimeline']['params']).roleId } :
|
||||
{};
|
||||
|
||||
await sleep();
|
||||
const notes = await (bob.client.request as Request)(endpoint, params);
|
||||
const noteInB = notes.filter(({ uri }) => uri === `https://a.test/notes/${note!.id}`).pop();
|
||||
const endpointFired = noteInB != null;
|
||||
strictEqual(endpointFired, expect);
|
||||
|
||||
// Let's check Delete reception
|
||||
if (expect) {
|
||||
const streamingFired = await isNoteUpdatedEventFired(
|
||||
'b.test', bob, noteInB!.id,
|
||||
async () => await alice.client.request('notes/delete', { noteId: note!.id }),
|
||||
msg => msg.type === 'deleted' && msg.id === noteInB!.id,
|
||||
);
|
||||
strictEqual(streamingFired, true);
|
||||
|
||||
await sleep();
|
||||
const notes = await (bob.client.request as Request)(endpoint, params);
|
||||
const endpointFired = notes.every(({ uri }) => uri !== `https://a.test/notes/${note!.id}`);
|
||||
strictEqual(endpointFired, true);
|
||||
}
|
||||
}
|
||||
|
||||
describe('homeTimeline', () => {
|
||||
// NOTE: narrowing scope intentionally to prevent mistakes by copy-and-paste
|
||||
const homeTimeline = 'homeTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true);
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true, { visibility: 'home' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true, { visibility: 'followers' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s localOnly Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, false, { localOnly: true });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s invisible specified-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, false, { visibility: 'specified' });
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: can receive this
|
||||
* @see https://github.com/misskey-dev/misskey/issues/14083
|
||||
*/
|
||||
test.failing('Don\'t receive remote followee\'s invisible and mentioned specified-only Note', async () => {
|
||||
await postAndCheckReception(homeTimeline, false, { text: `@${bob.username}@b.test Hello`, visibility: 'specified' });
|
||||
});
|
||||
|
||||
/**
|
||||
* FIXME: cannot receive this
|
||||
* @see https://github.com/misskey-dev/misskey/issues/14084
|
||||
*/
|
||||
test.failing('Receive remote followee\'s visible specified-only reply to invisible specified-only Note', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'specified' })).createdNote;
|
||||
await postAndCheckReception(homeTimeline, true, { replyId: note.id, visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('localTimeline', () => {
|
||||
const localTimeline = 'localTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Don\'t receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(localTimeline, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hybridTimeline', () => {
|
||||
const hybridTimeline = 'hybridTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true);
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true, { visibility: 'home' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true, { visibility: 'followers' });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(hybridTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('globalTimeline', () => {
|
||||
const globalTimeline = 'globalTimeline';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, true);
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, false, { visibility: 'home' });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, false, { visibility: 'followers' });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(globalTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('userList', () => {
|
||||
const userList = 'userList';
|
||||
|
||||
let list: Misskey.entities.UserList;
|
||||
|
||||
beforeAll(async () => {
|
||||
list = await bob.client.request('users/lists/create', { name: 'Bob\'s List' });
|
||||
await bob.client.request('users/lists/push', { listId: list.id, userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(userList, true, {}, { listId: list.id });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(userList, true, { visibility: 'home' }, { listId: list.id });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(userList, true, { visibility: 'followers' }, { listId: list.id });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(userList, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { listId: list.id });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashtag', () => {
|
||||
const hashtag = 'hashtag';
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}` }, { q: [[tag]] });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s home-only Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'home' }, { q: [[tag]] });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s followers-only Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'followers' }, { q: [[tag]] });
|
||||
});
|
||||
|
||||
test('Receive remote followee\'s visible specified-only Note', async () => {
|
||||
const tag = crypto.randomUUID();
|
||||
await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'specified', visibleUserIds: [bobInA.id] }, { q: [[tag]] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('roleTimeline', () => {
|
||||
const roleTimeline = 'roleTimeline';
|
||||
|
||||
let role: Misskey.entities.Role;
|
||||
|
||||
beforeAll(async () => {
|
||||
role = await createRole('b.test', {
|
||||
name: 'Remote Users',
|
||||
description: 'Remote users are assigned to this role.',
|
||||
condFormula: {
|
||||
/** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */
|
||||
type: 'isRemote' as never,
|
||||
},
|
||||
});
|
||||
await sleep();
|
||||
});
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, true, {}, { roleId: role.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, false, { visibility: 'home' }, { roleId: role.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, false, { visibility: 'followers' }, { roleId: role.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(roleTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { roleId: role.id });
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await bAdmin.client.request('admin/roles/delete', { roleId: role.id });
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: Cannot test
|
||||
describe.skip('antenna', () => {
|
||||
const antenna = 'antenna';
|
||||
|
||||
let bobAntenna: Misskey.entities.Antenna;
|
||||
|
||||
beforeAll(async () => {
|
||||
bobAntenna = await bob.client.request('antennas/create', {
|
||||
name: 'Bob\'s Egosurfing Antenna',
|
||||
src: 'all',
|
||||
keywords: [['Bob']],
|
||||
excludeKeywords: [],
|
||||
users: [],
|
||||
caseSensitive: false,
|
||||
localOnly: false,
|
||||
withReplies: true,
|
||||
withFile: true,
|
||||
});
|
||||
await sleep();
|
||||
});
|
||||
|
||||
describe('Check reception of remote followee\'s Note', () => {
|
||||
test('Receive remote followee\'s Note', async () => {
|
||||
await postAndCheckReception(antenna, true, { text: 'I love Bob (1)' }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s home-only Note', async () => {
|
||||
await postAndCheckReception(antenna, false, { text: 'I love Bob (2)', visibility: 'home' }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s followers-only Note', async () => {
|
||||
await postAndCheckReception(antenna, false, { text: 'I love Bob (3)', visibility: 'followers' }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
|
||||
test('Don\'t receive remote followee\'s visible specified-only Note', async () => {
|
||||
await postAndCheckReception(antenna, false, { text: 'I love Bob (4)', visibility: 'specified', visibleUserIds: [bobInA.id] }, { antennaId: bobAntenna.id });
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await bob.client.request('antennas/delete', { antennaId: bobAntenna.id });
|
||||
});
|
||||
});
|
||||
});
|
560
packages/backend/test-federation/test/user.test.ts
Normal file
560
packages/backend/test-federation/test/user.test.ts
Normal file
|
@ -0,0 +1,560 @@
|
|||
import assert, { rejects, strictEqual } from 'node:assert';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js';
|
||||
|
||||
const [aAdmin, bAdmin] = await Promise.all([
|
||||
fetchAdmin('a.test'),
|
||||
fetchAdmin('b.test'),
|
||||
]);
|
||||
|
||||
describe('User', () => {
|
||||
describe('Profile', () => {
|
||||
describe('Consistency of profile', () => {
|
||||
let alice: LoginUser;
|
||||
let aliceWatcher: LoginUser;
|
||||
let aliceWatcherInB: LoginUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
alice = await createAccount('a.test');
|
||||
[
|
||||
aliceWatcher,
|
||||
aliceWatcherInB,
|
||||
] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Check consistency', async () => {
|
||||
const aliceInA = await aliceWatcher.client.request('users/show', { userId: alice.id });
|
||||
const resolved = await resolveRemoteUser('a.test', aliceInA.id, aliceWatcherInB);
|
||||
const aliceInB = await aliceWatcherInB.client.request('users/show', { userId: resolved.id });
|
||||
|
||||
// console.log(`a.test: ${JSON.stringify(aliceInA, null, '\t')}`);
|
||||
// console.log(`b.test: ${JSON.stringify(aliceInB, null, '\t')}`);
|
||||
|
||||
deepStrictEqualWithExcludedFields(aliceInA, aliceInB, [
|
||||
'id',
|
||||
'host',
|
||||
'avatarUrl',
|
||||
'instance',
|
||||
'badgeRoles',
|
||||
'url',
|
||||
'uri',
|
||||
'createdAt',
|
||||
'lastFetchedAt',
|
||||
'publicReactions',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ffVisibility is federated', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
// NOTE: follow each other
|
||||
await Promise.all([
|
||||
alice.client.request('following/create', { userId: bobInA.id }),
|
||||
bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
]);
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Visibility set public by default', async () => {
|
||||
for (const user of await Promise.all([
|
||||
alice.client.request('users/show', { userId: bobInA.id }),
|
||||
bob.client.request('users/show', { userId: aliceInB.id }),
|
||||
])) {
|
||||
strictEqual(user.followersVisibility, 'public');
|
||||
strictEqual(user.followingVisibility, 'public');
|
||||
}
|
||||
});
|
||||
|
||||
/** FIXME: not working */
|
||||
test.skip('Setting private for followersVisibility is federated', async () => {
|
||||
await Promise.all([
|
||||
alice.client.request('i/update', { followersVisibility: 'private' }),
|
||||
bob.client.request('i/update', { followersVisibility: 'private' }),
|
||||
]);
|
||||
await sleep();
|
||||
|
||||
for (const user of await Promise.all([
|
||||
alice.client.request('users/show', { userId: bobInA.id }),
|
||||
bob.client.request('users/show', { userId: aliceInB.id }),
|
||||
])) {
|
||||
strictEqual(user.followersVisibility, 'private');
|
||||
strictEqual(user.followingVisibility, 'public');
|
||||
}
|
||||
});
|
||||
|
||||
test.skip('Setting private for followingVisibility is federated', async () => {
|
||||
await Promise.all([
|
||||
alice.client.request('i/update', { followingVisibility: 'private' }),
|
||||
bob.client.request('i/update', { followingVisibility: 'private' }),
|
||||
]);
|
||||
await sleep();
|
||||
|
||||
for (const user of await Promise.all([
|
||||
alice.client.request('users/show', { userId: bobInA.id }),
|
||||
bob.client.request('users/show', { userId: aliceInB.id }),
|
||||
])) {
|
||||
strictEqual(user.followersVisibility, 'private');
|
||||
strictEqual(user.followingVisibility, 'private');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCat is federated', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Not isCat for default', () => {
|
||||
strictEqual(aliceInB.isCat, false);
|
||||
});
|
||||
|
||||
test('Becoming a cat is sent to their followers', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('i/update', { isCat: true });
|
||||
await sleep();
|
||||
|
||||
const res = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(res.isCat, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pinning Notes', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
aliceInB = await resolveRemoteUser('a.test', alice.id, bob);
|
||||
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
});
|
||||
|
||||
test('Pinning localOnly Note is not delivered', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
});
|
||||
|
||||
test('Pinning followers-only Note is not delivered', async () => {
|
||||
const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'followers' })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: note.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
});
|
||||
|
||||
let pinnedNote: Misskey.entities.Note;
|
||||
|
||||
test('Pinning normal Note is delivered', async () => {
|
||||
pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote;
|
||||
await alice.client.request('i/pin', { noteId: pinnedNote.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 1);
|
||||
const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob);
|
||||
strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id);
|
||||
});
|
||||
|
||||
test('Unpinning normal Note is delivered', async () => {
|
||||
await alice.client.request('i/unpin', { noteId: pinnedNote.id });
|
||||
await sleep();
|
||||
|
||||
const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
strictEqual(_aliceInB.pinnedNoteIds.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Follow / Unfollow', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('Follow a.test ==> b.test', () => {
|
||||
beforeAll(async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
|
||||
await Promise.all([
|
||||
strictEqual(
|
||||
(await alice.client.request('users/following', { userId: alice.id }))
|
||||
.some(v => v.followeeId === bobInA.id),
|
||||
true,
|
||||
),
|
||||
strictEqual(
|
||||
(await bob.client.request('users/followers', { userId: bob.id }))
|
||||
.some(v => v.followerId === aliceInB.id),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unfollow a.test ==> b.test', () => {
|
||||
beforeAll(async () => {
|
||||
await alice.client.request('following/delete', { userId: bobInA.id });
|
||||
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Check consistency with `users/following` and `users/followers` endpoints', async () => {
|
||||
await Promise.all([
|
||||
strictEqual(
|
||||
(await alice.client.request('users/following', { userId: alice.id }))
|
||||
.some(v => v.followeeId === bobInA.id),
|
||||
false,
|
||||
),
|
||||
strictEqual(
|
||||
(await bob.client.request('users/followers', { userId: bob.id }))
|
||||
.some(v => v.followerId === aliceInB.id),
|
||||
false,
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Follow requests', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
|
||||
await alice.client.request('i/update', { isLocked: true });
|
||||
});
|
||||
|
||||
describe('Send follow request from Bob to Alice and cancel', () => {
|
||||
describe('Bob sends follow request to Alice', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Alice should have a request', async () => {
|
||||
const requests = await alice.client.request('following/requests/list', {});
|
||||
strictEqual(requests.length, 1);
|
||||
strictEqual(requests[0].followee.id, alice.id);
|
||||
strictEqual(requests[0].follower.id, bobInA.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alice cancels it', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/requests/cancel', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Alice should have no requests', async () => {
|
||||
const requests = await alice.client.request('following/requests/list', {});
|
||||
strictEqual(requests.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Send follow request from Bob to Alice and reject', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('following/requests/reject', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Bob should have no requests', async () => {
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/requests/cancel', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'FOLLOW_REQUEST_NOT_FOUND');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Bob doesn\'t follow Alice', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Send follow request from Bob to Alice and accept', () => {
|
||||
beforeAll(async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
await alice.client.request('following/requests/accept', { userId: bobInA.id });
|
||||
await sleep();
|
||||
});
|
||||
|
||||
test('Bob follows Alice', async () => {
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
strictEqual(following[0].followeeId, aliceInB.id);
|
||||
strictEqual(following[0].followerId, bob.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion', () => {
|
||||
describe('Check Delete consistency', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Bob follows Alice, and Alice deleted themself', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
|
||||
await alice.client.request('i/delete-account', { password: alice.password });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // no following relation
|
||||
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_USER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Deletion of remote user for moderation', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Bob follows Alice, then Alice gets deleted in B server', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
|
||||
await bAdmin.client.request('admin/delete-account', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
/**
|
||||
* FIXME: remote account is not deleted!
|
||||
* @see https://github.com/misskey-dev/misskey/issues/14728
|
||||
*/
|
||||
const deletedAlice = await bob.client.request('users/show', { userId: aliceInB.id });
|
||||
assert(deletedAlice.id, aliceInB.id);
|
||||
|
||||
// TODO: why still following relation?
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 1);
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'ALREADY_FOLLOWING');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Alice tries to follow Bob, but it is not processed', async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const following = await alice.client.request('users/following', { userId: alice.id });
|
||||
strictEqual(following.length, 0); // Not following Bob because B server doesn't return Accept
|
||||
|
||||
const followers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(followers.length, 0); // Alice's Follow is not processed
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Suspension', () => {
|
||||
describe('Check suspend/unsuspend consistency', () => {
|
||||
let alice: LoginUser, bob: LoginUser;
|
||||
let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe;
|
||||
|
||||
beforeAll(async () => {
|
||||
[alice, bob] = await Promise.all([
|
||||
createAccount('a.test'),
|
||||
createAccount('b.test'),
|
||||
]);
|
||||
|
||||
[bobInA, aliceInB] = await Promise.all([
|
||||
resolveRemoteUser('b.test', bob.id, alice),
|
||||
resolveRemoteUser('a.test', alice.id, bob),
|
||||
]);
|
||||
});
|
||||
|
||||
test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => {
|
||||
await bob.client.request('following/create', { userId: aliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // followed by Bob
|
||||
|
||||
await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
|
||||
await sleep();
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // no following relation
|
||||
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_USER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('Alice gets unsuspended, Bob succeeds in following Alice', async () => {
|
||||
await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id });
|
||||
await sleep();
|
||||
|
||||
const followers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(followers.length, 1); // FIXME: followers are not deleted??
|
||||
|
||||
/**
|
||||
* FIXME: still rejected!
|
||||
* seems to can't process Undo Delete activity because it is not implemented
|
||||
* related @see https://github.com/misskey-dev/misskey/issues/13273
|
||||
*/
|
||||
await rejects(
|
||||
async () => await bob.client.request('following/create', { userId: aliceInB.id }),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'NO_SUCH_USER');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// FIXME: resolving also fails
|
||||
await rejects(
|
||||
async () => await resolveRemoteUser('a.test', alice.id, bob),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* instead of simple unsuspension, let's tell existence by following from Alice
|
||||
*/
|
||||
test('Alice can follow Bob', async () => {
|
||||
await alice.client.request('following/create', { userId: bobInA.id });
|
||||
await sleep();
|
||||
|
||||
const bobFollowers = await bob.client.request('users/followers', { userId: bob.id });
|
||||
strictEqual(bobFollowers.length, 1); // followed by Alice
|
||||
assert(bobFollowers[0].follower != null);
|
||||
const renewedaliceInB = bobFollowers[0].follower;
|
||||
assert(aliceInB.username === renewedaliceInB.username);
|
||||
assert(aliceInB.host === renewedaliceInB.host);
|
||||
assert(aliceInB.id !== renewedaliceInB.id); // TODO: Same username and host, but their ids are different! Is it OK?
|
||||
|
||||
const following = await bob.client.request('users/following', { userId: bob.id });
|
||||
strictEqual(following.length, 0); // following are deleted
|
||||
|
||||
// Bob tries to follow Alice
|
||||
await bob.client.request('following/create', { userId: renewedaliceInB.id });
|
||||
await sleep();
|
||||
|
||||
const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id });
|
||||
strictEqual(aliceFollowers.length, 1);
|
||||
|
||||
// FIXME: but resolving still fails ...
|
||||
await rejects(
|
||||
async () => await resolveRemoteUser('a.test', alice.id, bob),
|
||||
(err: any) => {
|
||||
strictEqual(err.code, 'INTERNAL_ERROR');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue