Merge tag '2024.10.1' into feature/2024.10

This commit is contained in:
dakkar 2024-11-08 15:52:37 +00:00
commit f079edaf3c
454 changed files with 9728 additions and 3363 deletions

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "sharkey",
"version": "2024.10.0-dev",
"version": "2024.10.3-rc",
"codename": "shonk",
"repository": {
"type": "git",

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

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

View 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',
],
};

View file

@ -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"`);
}
}

View file

@ -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"`);
}
}

View 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"`);
}
}

View file

@ -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"`);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -274,13 +274,15 @@ 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.userEntityService.isRemoteUser(oldAccount)) {
this.federatedInstanceService.fetch(oldAccount.host).then(async i => {
this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(oldAccount)) {
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?

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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,17 +563,17 @@ export class NoteCreateService implements OnApplicationShutdown {
}
// Register host
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.updateNotesCountQueue.enqueue(i.id, 1);
}
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(user)) {
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) {
this.instanceChart.updateNote(i.host, note, true);
}
});
}
}
// ハッシュタグ更新

View file

@ -115,25 +115,22 @@ 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.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.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
}
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, false);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(user)) {
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) {
this.instanceChart.updateNote(i.host, note, false);
}
});
}
}
}

View file

@ -94,6 +94,13 @@ export class QueueService {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: true,
});
this.systemQueue.add('checkModeratorsActivity', {
}, {
// 毎時30分に起動
repeat: { pattern: '30 * * * *' },
removeOnComplete: true,
});
}
@bindThis

View file

@ -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
@ -422,49 +424,78 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
public async isExplorable(role: { id: MiRole['id']} | null): Promise<boolean> {
public async isExplorable(role: { id: MiRole['id'] } | null): Promise<boolean> {
if (role == null) return false;
const check = await this.rolesRepository.findOneBy({ id: role.id });
if (check == null) return false;
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)) })
: [];
// Setを経由して重複を除去ユーザIDは重複する可能性があるので
const now = Date.now();
const result = [
// Setを経由して重複を除去ユーザIDは重複する可能性があるので
...new Set(
assigns
.filter(it =>
(excludeExpire)
? (it.expiresAt == null || it.expiresAt.getTime() > now)
: true,
)
.map(a => a.userId),
),
];
const resultSet = new Set(
assigns
.filter(it =>
(excludeExpire)
? (it.expiresAt == null || it.expiresAt.getTime() > now)
: 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({
id: In(ids),
}) : [];
return users;
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),
})
: [];
}
@bindThis

View file

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

View file

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

View file

@ -305,20 +305,22 @@ export class UserFollowingService implements OnModuleInit {
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(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.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, true);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
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.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
@ -437,20 +439,22 @@ export class UserFollowingService implements OnModuleInit {
//#endregion
//#region Update instance stats
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
this.federatedInstanceService.fetch(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.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.updateFollowers(i.host, false);
}
});
if (this.meta.enableStatsForFederatedInstances) {
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
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.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

View file

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

View file

@ -434,13 +434,15 @@ export class ApPersonService implements OnModuleInit {
this.cacheService.uriPersonCache.set(user.uri, user);
// Register host
this.federatedInstanceService.fetch(host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host);
}
});
if (this.meta.enableStatsForFederatedInstances) {
this.federatedInstanceService.fetchOrRegister(host).then(i => {
this.instancesRepository.increment({ id: i.id }, 'usersCount', 1);
if (this.meta.enableChartsForFederatedInstances) {
this.instanceChart.newUser(i.host);
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
});
}
this.usersChart.update(user, true);

View file

@ -53,6 +53,8 @@ export class AbuseUserReportEntityService {
schema: 'UserDetailedNotMe',
}) : null,
forwarded: report.forwarded,
resolvedAs: report.resolvedAs,
moderationNote: report.moderationNote,
});
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [
'abuseReportResolved',
// ユーザが作成された時
'userCreated',
// モデレータが一定期間不在である警告
'inactiveModeratorsWarning',
// モデレータが一定期間不在のためシステムにより招待制へと変更された
'inactiveModeratorsInvitationOnlyChanged',
] as const;
export type SystemWebhookEventType = typeof systemWebhookEventTypes[number];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {
});
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
this.apRequestChart.deliverSucc();
this.federationChart.deliverd(i.host, true);
if (this.meta.enableStatsForFederatedInstances) {
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
}
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',
});

View file

@ -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);
});
// アクティビティを処理

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
},
// 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);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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'
]

View file

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

View file

@ -0,0 +1,6 @@
certificates
volumes
.env
docker.env
*.test.conf
*.test.default.yml

View 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
```

View 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

View 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

View 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:

View 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

View 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

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

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

View 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

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

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

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

View 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 }, {});
});
});

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

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

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

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

View 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