diff --git a/.config/docker_example.env b/.config/docker_example.env index 4fe8e76b78..c61248da2e 100644 --- a/.config/docker_example.env +++ b/.config/docker_example.env @@ -1,5 +1,11 @@ +# misskey settings +# MISSKEY_URL=https://example.tld/ + # db settings POSTGRES_PASSWORD=example-misskey-pass +# DATABASE_PASSWORD=${POSTGRES_PASSWORD} POSTGRES_USER=example-misskey-user +# DATABASE_USER=${POSTGRES_USER} POSTGRES_DB=misskey +# DATABASE_DB=${POSTGRES_DB} DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}" diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 42ac18de1b..bd0ad2872a 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -6,6 +6,7 @@ #───┘ URL └───────────────────────────────────────────────────── # Final accessible URL seen by a user. +# You can set url from an environment variable instead. url: https://example.tld/ # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE @@ -38,9 +39,11 @@ db: port: 5432 # Database name + # You can set db from an environment variable instead. db: misskey # Auth + # You can set user and pass from environment variables instead. user: example-misskey-user pass: example-misskey-pass @@ -161,12 +164,12 @@ id: 'aidx' #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 +# deliverJobConcurrency: 16 +# inboxJobConcurrency: 4 # Job rate limiter # deliverJobPerSec: 128 -# inboxJobPerSec: 32 +# inboxJobPerSec: 64 # Job attempts # deliverJobMaxAttempts: 12 diff --git a/.config/example.yml b/.config/example.yml index b11cbd1373..0d525f61c4 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -230,15 +230,15 @@ id: 'aidx' #clusterLimit: 1 # Job concurrency per worker -#deliverJobConcurrency: 128 -#inboxJobConcurrency: 16 +#deliverJobConcurrency: 16 +#inboxJobConcurrency: 4 #relationshipJobConcurrency: 16 # What's relationshipJob?: # Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. # Job rate limiter -#deliverJobPerSec: 128 -#inboxJobPerSec: 32 +#deliverJobPerSec: 1024 +#inboxJobPerSec: 64 #relationshipJobPerSec: 64 # Job attempts diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index beefcfd0a2..d74d741e02 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -157,12 +157,12 @@ id: 'aidx' #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 +# deliverJobConcurrency: 16 +# inboxJobConcurrency: 4 # Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 32 +# deliverJobPerSec: 1024 +# inboxJobPerSec: 64 # Job attempts # deliverJobMaxAttempts: 12 diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 1b7b68b14f..e7db18316c 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -4,10 +4,11 @@ on: push: paths: - packages/misskey-js/** + - .github/workflows/api-misskey-js.yml pull_request: paths: - packages/misskey-js/** - + - .github/workflows/api-misskey-js.yml jobs: report: @@ -20,7 +21,7 @@ jobs: - run: corepack enable - name: Setup Node.js - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index f254af0d1f..d4e99f966e 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout head uses: actions/checkout@v4.1.1 - name: Setup Node.js - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' diff --git a/.github/workflows/check-misskey-js-autogen.yml b/.github/workflows/check-misskey-js-autogen.yml index 39acad8bc3..3a2a2d5f8d 100644 --- a/.github/workflows/check-misskey-js-autogen.yml +++ b/.github/workflows/check-misskey-js-autogen.yml @@ -28,7 +28,7 @@ jobs: - name: setup node id: setup-node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: pnpm diff --git a/.github/workflows/check-misskey-js-version.yml b/.github/workflows/check-misskey-js-version.yml index 325a893605..99c29ac974 100644 --- a/.github/workflows/check-misskey-js-version.yml +++ b/.github/workflows/check-misskey-js-version.yml @@ -6,12 +6,13 @@ on: paths: - packages/misskey-js/package.json - package.json + - .github/workflows/check-misskey-js-version.yml pull_request: branches: [ develop ] paths: - packages/misskey-js/package.json - package.json - + - .github/workflows/check-misskey-js-version.yml jobs: check-version: # ルートの package.json と packages/misskey-js/package.json のバージョンが一致しているかを確認する diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 9b9c8f11c4..4afafabf2e 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -9,7 +9,7 @@ on: paths: - packages/backend/** - .github/workflows/get-api-diff.yml - + - .github/workflows/get-api-diff.yml jobs: get-from-misskey: runs-on: ubuntu-latest @@ -34,7 +34,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1a1b30168a..c21fc95123 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,6 +11,7 @@ on: - packages/sw/** - packages/misskey-js/** - packages/shared/eslint.config.js + - .github/workflows/lint.yml pull_request: paths: - packages/backend/** @@ -18,7 +19,7 @@ on: - packages/sw/** - packages/misskey-js/** - packages/shared/eslint.config.js - + - .github/workflows/lint.yml jobs: pnpm_install: runs-on: ubuntu-latest @@ -28,7 +29,7 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' @@ -39,6 +40,8 @@ jobs: needs: [pnpm_install] runs-on: ubuntu-latest continue-on-error: true + env: + eslint-cache-version: v1 strategy: matrix: workspace: @@ -52,13 +55,20 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile - - run: pnpm --filter ${{ matrix.workspace }} run eslint + - name: Restore eslint cache + uses: actions/cache@v4.0.2 + with: + path: node_modules/.cache/eslint + key: eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}- + - run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location node_modules/.cache/eslint --cache-strategy content typecheck: needs: [pnpm_install] @@ -75,7 +85,7 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index de2247e772..95251bfe31 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -4,10 +4,11 @@ on: push: paths: - locales/** + - .github/workflows/locale.yml pull_request: paths: - locales/** - + - .github/workflows/locale.yml jobs: locale_verify: runs-on: ubuntu-latest @@ -18,7 +19,7 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index edfdab99e9..22c04ff297 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -26,7 +26,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/release-edit-with-push.yml b/.github/workflows/release-edit-with-push.yml index 57657a4ba7..f86c1948f8 100644 --- a/.github/workflows/release-edit-with-push.yml +++ b/.github/workflows/release-edit-with-push.yml @@ -6,7 +6,7 @@ on: - develop paths: - 'CHANGELOG.md' - + # - .github/workflows/release-edit-with-push.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index daa76509c8..68452aacaf 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -36,7 +36,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js 20.x - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index b1c54bb3e7..bfb79ef090 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -9,12 +9,13 @@ on: - packages/backend/** # for permissions - packages/misskey-js/** + - .github/workflows/test-backend.yml pull_request: paths: - packages/backend/** # for permissions - packages/misskey-js/** - + - .github/workflows/test-backend.yml jobs: unit: runs-on: ubuntu-latest @@ -45,7 +46,7 @@ jobs: - name: Install FFmpeg uses: FedericoCarboni/setup-ffmpeg@v3 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -92,7 +93,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 9d5053b82a..c17a9fd387 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -11,7 +11,7 @@ on: - packages/misskey-js/** # for e2e - packages/backend/** - + - .github/workflows/test-frontend.yml pull_request: paths: - packages/frontend/** @@ -19,7 +19,7 @@ on: - packages/misskey-js/** # for e2e - packages/backend/** - + - .github/workflows/test-frontend.yml jobs: vitest: runs-on: ubuntu-latest @@ -35,7 +35,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -90,7 +90,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 2589d908b8..6ee67e8735 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -8,11 +8,12 @@ on: branches: [ develop ] paths: - packages/misskey-js/** + - .github/workflows/test-misskey-js.yml pull_request: branches: [ develop ] paths: - packages/misskey-js/** - + - .github/workflows/test-misskey-js.yml jobs: test: @@ -30,7 +31,7 @@ jobs: - run: corepack enable - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 7f8db65293..18d02ec030 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -25,7 +25,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index 24340e7d81..90f2929a25 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -7,10 +7,11 @@ on: - develop paths: - packages/backend/** + - .github/workflows/validate-api-json.yml pull_request: paths: - packages/backend/** - + - .github/workflows/validate-api-json.yml jobs: validate-api-json: runs-on: ubuntu-latest @@ -26,7 +27,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/CHANGELOG.md b/CHANGELOG.md index 470e375741..f120aafa13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,28 @@ -## Unreleased +## 2024.7.0 ### Note - デッキUIの新着ノートをサウンドで通知する機能の追加(v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。 ### General - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 +- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に + - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます +- Feat: 連合に使うHTTP SignaturesがEd25519鍵に対応するように #13464 + - Ed25519署名に対応するサーバーが増えると、deliverで要求されるサーバーリソースが削減されます - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 ### Client - Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善 +- Enhance: 非ログイン時に他サーバーに遷移するアクションを追加 - Enhance: 非ログイン時のハイライトTLのデザインを改善 - Enhance: フロントエンドのアクセシビリティ改善 (Based on https://github.com/taiyme/misskey/pull/226) +- Enhance: サーバー情報ページ・お問い合わせページを改善 + (Cherry-picked from https://github.com/taiyme/misskey/pull/238) +- Enhance: AiScriptを0.19.0にアップデート +- Enhance: Allow negative delay for MFM animation elements (`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`) - Enhance: 「デバイスのダークモードと同期する」を有効にしている際のカラーモード切替設定の挙動を視覚的にわかりやすく - Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正 - Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968) @@ -23,6 +32,9 @@ - Fix: テーマプレビューが見れない問題を修正 - Fix: ショートカットキーが連打できる問題を修正 (Cherry-picked from https://github.com/taiyme/misskey/pull/234) +- Fix: MkSignin.vueのcredentialRequestからReactivityを削除(ProxyがPasskey認証処理に渡ることを避けるため) +- Fix: 「アニメーション画像を再生しない」がオンのときでもサーバーのバナー画像・背景画像がアニメーションしてしまう問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/574) ### Server - Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949) @@ -32,7 +44,8 @@ - Enhance: エンドポイント`gallery/posts/update`の必須項目を`postId`のみに - Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに - Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに -- Fix: チャート生成時にinstance.suspentionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正 +- Enhance: `default.yml`内の`url`, `db.db`, `db.user`, `db.pass`を環境変数から読み込めるように +- Fix: チャート生成時にinstance.suspensionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正 - Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006) - Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036) - Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059) @@ -48,6 +61,12 @@ 3. フォローしていないアクティブなユーザ 4. フォローしていない非アクティブなユーザ + また、自分自身のアカウントもサジェストされるようになりました。 +- Fix: 一般ユーザーから見たユーザーのバッジの一覧に公開されていないものが含まれることがある問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/652) +- Fix: ユーザーのリアクション一覧でミュート/ブロックが機能していなかった問題を修正 +- Fix: エラーメッセージの誤字を修正 (#14213) + ### Misskey.js - Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応) - Feat: `/admin/role/create` のロールポリシーの型を修正 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b718f3703f..532a2dc66f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -185,7 +185,7 @@ TODO ## Environment Variable - `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`). -- `MISSKEY_WEBFINGER_USE_HTTP`: If it's set true, WebFinger requests will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. +- `MISSKEY_USE_HTTP`: If it's set true, federation requests (like nodeinfo and webfinger) will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. (was `MISSKEY_WEBFINGER_USE_HTTP`) ## Continuous integration Misskey uses GitHub Actions for executing automated tests. diff --git a/chart/files/default.yml b/chart/files/default.yml index f98b8ebfee..4017588fa0 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -178,12 +178,12 @@ id: "aidx" #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 +# deliverJobConcurrency: 16 +# inboxJobConcurrency: 4 # Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 32 +# deliverJobPerSec: 1024 +# inboxJobPerSec: 64 # Job attempts # deliverJobMaxAttempts: 12 diff --git a/compose_example.yml b/compose_example.yml index 75d0d3a59c..336bd814a7 100644 --- a/compose_example.yml +++ b/compose_example.yml @@ -17,6 +17,8 @@ services: networks: - internal_network - external_network + # env_file: + # - .config/docker.env volumes: - ./files:/misskey/files - ./.config:/misskey/.config:ro diff --git a/locales/index.d.ts b/locales/index.d.ts index 5089f7802e..694ee53a1f 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -736,6 +736,22 @@ export interface Locale extends ILocale { * リモートで表示 */ "showOnRemote": string; + /** + * リモートで続行 + */ + "continueOnRemote": string; + /** + * Misskey Hubからサーバーを選択 + */ + "chooseServerOnMisskeyHub": string; + /** + * サーバーのドメインを直接指定 + */ + "specifyServerHost": string; + /** + * ドメインを入力してください + */ + "inputHostName": string; /** * 全般 */ @@ -1921,9 +1937,13 @@ export interface Locale extends ILocale { */ "onlyOneFileCanBeAttached": string; /** - * 続行する前に、サインアップまたはサインインが必要です + * 続行する前に、登録またはログインが必要です */ "signinRequired": string; + /** + * 続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります + */ + "signinOrContinueOnRemote": string; /** * 招待 */ @@ -4984,6 +5004,10 @@ export interface Locale extends ILocale { * お問い合わせ */ "inquiry": string; + /** + * もう一度お試しください。 + */ + "tryAgain": string; "_delivery": { /** * 配信状態 @@ -6594,6 +6618,10 @@ export interface Locale extends ILocale { * ファイルにNSFWを常に付与 */ "alwaysMarkNsfw": string; + /** + * アイコンとバナーの更新を許可 + */ + "canUpdateBioMedia": string; /** * ノートのピン留めの最大数 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a03d792140..bb3999f0e3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -180,6 +180,10 @@ addAccount: "アカウントを追加" reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗しました" showOnRemote: "リモートで表示" +continueOnRemote: "リモートで続行" +chooseServerOnMisskeyHub: "Misskey Hubからサーバーを選択" +specifyServerHost: "サーバーのドメインを直接指定" +inputHostName: "ドメインを入力してください" general: "全般" wallpaper: "壁紙" setWallpaper: "壁紙を設定" @@ -476,7 +480,8 @@ attachAsFileQuestion: "クリップボードのテキストが長いです。テ noMessagesYet: "まだチャットはありません" newMessageExists: "新しいメッセージがあります" onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" -signinRequired: "続行する前に、サインアップまたはサインインが必要です" +signinRequired: "続行する前に、登録またはログインが必要です" +signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります" invitations: "招待" invitationCode: "招待コード" checking: "確認しています" @@ -1242,6 +1247,7 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ noDescription: "説明文はありません" alwaysConfirmFollow: "フォローの際常に確認する" inquiry: "お問い合わせ" +tryAgain: "もう一度お試しください。" _delivery: status: "配信状態" @@ -1705,6 +1711,7 @@ _role: canManageAvatarDecorations: "アバターデコレーションの管理" driveCapacity: "ドライブ容量" alwaysMarkNsfw: "ファイルにNSFWを常に付与" + canUpdateBioMedia: "アイコンとバナーの更新を許可" pinMax: "ノートのピン留めの最大数" antennaMax: "アンテナの作成可能数" wordMuteMax: "ワードミュートの最大文字数" diff --git a/package.json b/package.json index bf8415d212..0dd7afb9e9 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "2024.5.0", + "version": "2024.7.0-beta.0", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@9.0.6", + "packageManager": "pnpm@9.5.0", "workspaces": [ "packages/frontend", "packages/backend", diff --git a/packages/backend/migration/1708980134301-APMultipleKeys.js b/packages/backend/migration/1708980134301-APMultipleKeys.js new file mode 100644 index 0000000000..ca55526c6e --- /dev/null +++ b/packages/backend/migration/1708980134301-APMultipleKeys.js @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class APMultipleKeys1708980134301 { + name = 'APMultipleKeys1708980134301' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_171e64971c780ebd23fae140bb"`); + await queryRunner.query(`ALTER TABLE "user_keypair" ADD "ed25519PublicKey" character varying(128)`); + await queryRunner.query(`ALTER TABLE "user_keypair" ADD "ed25519PrivateKey" character varying(128)`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_0db6a5fdb992323449edc8ee421" PRIMARY KEY ("userId", "keyId")`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_0db6a5fdb992323449edc8ee421"`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_171e64971c780ebd23fae140bba" PRIMARY KEY ("keyId")`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "UQ_10c146e4b39b443ede016f6736d" UNIQUE ("userId")`); + await queryRunner.query(`CREATE INDEX "IDX_10c146e4b39b443ede016f6736" ON "user_publickey" ("userId") `); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`DROP INDEX "public"."IDX_10c146e4b39b443ede016f6736"`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "UQ_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_171e64971c780ebd23fae140bba"`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_0db6a5fdb992323449edc8ee421" PRIMARY KEY ("userId", "keyId")`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "PK_0db6a5fdb992323449edc8ee421"`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "PK_10c146e4b39b443ede016f6736d" PRIMARY KEY ("userId")`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "followersVisibility" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "followersVisibility" TYPE "public"."user_profile_followersVisibility_enum_old" USING "followersVisibility"::"text"::"public"."user_profile_followersVisibility_enum_old"`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "followersVisibility" SET DEFAULT 'public'`); + await queryRunner.query(`ALTER TABLE "user_keypair" DROP COLUMN "ed25519PrivateKey"`); + await queryRunner.query(`ALTER TABLE "user_keypair" DROP COLUMN "ed25519PublicKey"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_171e64971c780ebd23fae140bb" ON "user_publickey" ("keyId") `); + } +} diff --git a/packages/backend/migration/1709242519122-HttpSignImplLv.js b/packages/backend/migration/1709242519122-HttpSignImplLv.js new file mode 100644 index 0000000000..7748bae006 --- /dev/null +++ b/packages/backend/migration/1709242519122-HttpSignImplLv.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class HttpSignImplLv1709242519122 { + name = 'HttpSignImplLv1709242519122' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" ADD "httpMessageSignaturesImplementationLevel" character varying(16) NOT NULL DEFAULT '00'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "httpMessageSignaturesImplementationLevel"`); + } +} diff --git a/packages/backend/migration/1709269211718-APMultipleKeysFix1.js b/packages/backend/migration/1709269211718-APMultipleKeysFix1.js new file mode 100644 index 0000000000..d2011802f2 --- /dev/null +++ b/packages/backend/migration/1709269211718-APMultipleKeysFix1.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class APMultipleKeys1709269211718 { + name = 'APMultipleKeys1709269211718' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "UQ_10c146e4b39b443ede016f6736d"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "UQ_10c146e4b39b443ede016f6736d" UNIQUE ("userId")`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 22fdc5cf16..893171ebd6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -79,13 +79,13 @@ "@fastify/multipart": "8.3.0", "@fastify/static": "7.0.4", "@fastify/view": "9.1.0", + "@misskey-dev/node-http-message-signatures": "0.0.10", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.1.0", "@napi-rs/canvas": "^0.1.53", "@nestjs/common": "10.3.10", "@nestjs/core": "10.3.10", "@nestjs/testing": "10.3.10", - "@peertube/http-signature": "1.7.0", "@sentry/node": "8.13.0", "@sentry/profiling-node": "8.13.0", "@simplewebauthn/server": "10.0.0", diff --git a/packages/backend/src/@types/http-signature.d.ts b/packages/backend/src/@types/http-signature.d.ts deleted file mode 100644 index 75b62e55f0..0000000000 --- a/packages/backend/src/@types/http-signature.d.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -declare module '@peertube/http-signature' { - import type { IncomingMessage, ClientRequest } from 'node:http'; - - interface ISignature { - keyId: string; - algorithm: string; - headers: string[]; - signature: string; - } - - interface IOptions { - headers?: string[]; - algorithm?: string; - strict?: boolean; - authorizationHeaderName?: string; - } - - interface IParseRequestOptions extends IOptions { - clockSkew?: number; - } - - interface IParsedSignature { - scheme: string; - params: ISignature; - signingString: string; - algorithm: string; - keyId: string; - } - - type RequestSignerConstructorOptions = - IRequestSignerConstructorOptionsFromProperties | - IRequestSignerConstructorOptionsFromFunction; - - interface IRequestSignerConstructorOptionsFromProperties { - keyId: string; - key: string | Buffer; - algorithm?: string; - } - - interface IRequestSignerConstructorOptionsFromFunction { - sign?: (data: string, cb: (err: any, sig: ISignature) => void) => void; - } - - class RequestSigner { - constructor(options: RequestSignerConstructorOptions); - - public writeHeader(header: string, value: string): string; - - public writeDateHeader(): string; - - public writeTarget(method: string, path: string): void; - - public sign(cb: (err: any, authz: string) => void): void; - } - - interface ISignRequestOptions extends IOptions { - keyId: string; - key: string; - httpVersion?: string; - } - - export function parse(request: IncomingMessage, options?: IParseRequestOptions): IParsedSignature; - export function parseRequest(request: IncomingMessage, options?: IParseRequestOptions): IParsedSignature; - - export function sign(request: ClientRequest, options: ISignRequestOptions): boolean; - export function signRequest(request: ClientRequest, options: ISignRequestOptions): boolean; - export function createSigner(): RequestSigner; - export function isSigner(obj: any): obj is RequestSigner; - - export function sshKeyToPEM(key: string): string; - export function sshKeyFingerprint(key: string): string; - export function pemToRsaSSHKey(pem: string, comment: string): string; - - export function verify(parsedSignature: IParsedSignature, pubkey: string | Buffer): boolean; - export function verifySignature(parsedSignature: IParsedSignature, pubkey: string | Buffer): boolean; - export function verifyHMAC(parsedSignature: IParsedSignature, secret: string): boolean; -} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 0ac521d409..3e5a1e81cd 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -23,7 +23,7 @@ type RedisOptionsSource = Partial & { * 設定ファイルの型 */ type Source = { - url: string; + url?: string; port?: number; socket?: string; chmodSocket?: string; @@ -31,9 +31,9 @@ type Source = { db: { host: string; port: number; - db: string; - user: string; - pass: string; + db?: string; + user?: string; + pass?: string; disableCache?: boolean; extra?: { [x: string]: string }; }; @@ -202,13 +202,17 @@ export function loadConfig(): Config { : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; - const url = tryCreateUrl(config.url); + const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? ''); const version = meta.version; const host = url.host; const hostname = url.hostname; const scheme = url.protocol.replace(/:$/, ''); const wsScheme = scheme.replace('http', 'ws'); + const dbDb = config.db.db ?? process.env.DATABASE_DB ?? ''; + const dbUser = config.db.user ?? process.env.DATABASE_USER ?? ''; + const dbPass = config.db.pass ?? process.env.DATABASE_PASSWORD ?? ''; + const externalMediaProxy = config.mediaProxy ? config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy : null; @@ -231,7 +235,7 @@ export function loadConfig(): Config { apiUrl: `${scheme}://${host}/api`, authUrl: `${scheme}://${host}/auth`, driveUrl: `${scheme}://${host}/files`, - db: config.db, + db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass }, dbReplications: config.dbReplications, dbSlaves: config.dbSlaves, meilisearch: config.meilisearch, @@ -259,7 +263,7 @@ export function loadConfig(): Config { deliverJobMaxAttempts: config.deliverJobMaxAttempts, inboxJobMaxAttempts: config.inboxJobMaxAttempts, proxyRemoteFiles: config.proxyRemoteFiles, - signToActivityPubGet: config.signToActivityPubGet, + signToActivityPubGet: config.signToActivityPubGet ?? true, mediaProxy: externalMediaProxy ?? internalMediaProxy, externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy, videoThumbnailGenerator: config.videoThumbnailGenerator ? diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index a238f4973a..c132cc7e7b 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -3,11 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +// dummy export const MAX_NOTE_TEXT_LENGTH = 3000; export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days +export const REMOTE_USER_CACHE_TTL = 1000 * 60 * 60 * 3; // 3hours +export const REMOTE_USER_MOVE_COOLDOWN = 1000 * 60 * 60 * 24 * 14; // 14days + +export const REMOTE_SERVER_CACHE_TTL = 1000 * 60 * 60 * 3; // 3hours + //#region hard limits // If you change DB_* values, you must also change the DB schema. diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index 69a57b4854..ca0864f679 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/_.js'; import type { MiUser } from '@/models/User.js'; @@ -12,30 +13,44 @@ import { RelayService } from '@/core/RelayService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; @Injectable() -export class AccountUpdateService { +export class AccountUpdateService implements OnModuleInit { + private apDeliverManagerService: ApDeliverManagerService; constructor( + private moduleRef: ModuleRef, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, private userEntityService: UserEntityService, private apRendererService: ApRendererService, - private apDeliverManagerService: ApDeliverManagerService, private relayService: RelayService, ) { } + async onModuleInit() { + this.apDeliverManagerService = this.moduleRef.get(ApDeliverManagerService.name); + } + @bindThis - public async publishToFollowers(userId: MiUser['id']) { + /** + * Deliver account update to followers + * @param userId user id + * @param deliverKey optional. Private key to sign the deliver. + */ + public async publishToFollowers(userId: MiUser['id'], deliverKey?: PrivateKeyWithPem) { const user = await this.usersRepository.findOneBy({ id: userId }); if (user == null) throw new Error('user not found'); // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); - this.apDeliverManagerService.deliverToFollowers(user, content); - this.relayService.deliverToRelays(user, content); + await Promise.allSettled([ + this.apDeliverManagerService.deliverToFollowers(user, content, deliverKey), + this.relayService.deliverToRelays(user, content, deliverKey), + ]); } } } diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts index 6c5b0f6a36..60ddc9cde2 100644 --- a/packages/backend/src/core/CreateSystemUserService.ts +++ b/packages/backend/src/core/CreateSystemUserService.ts @@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { IsNull, DataSource } from 'typeorm'; -import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; +import { genRSAAndEd25519KeyPair } from '@/misc/gen-key-pair.js'; import { MiUser } from '@/models/User.js'; import { MiUserProfile } from '@/models/UserProfile.js'; import { IdService } from '@/core/IdService.js'; @@ -38,7 +38,7 @@ export class CreateSystemUserService { // Generate secret const secret = generateNativeUserToken(); - const keyPair = await genRsaKeyPair(); + const keyPair = await genRSAAndEd25519KeyPair(); let account!: MiUser; @@ -64,9 +64,8 @@ export class CreateSystemUserService { }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); await transactionalEntityManager.insert(MiUserKeypair, { - publicKey: keyPair.publicKey, - privateKey: keyPair.privateKey, userId: account.id, + ...keyPair, }); await transactionalEntityManager.insert(MiUserProfile, { diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 6799f2c5bb..7aeeb78178 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -40,6 +40,7 @@ export class FederatedInstanceService implements OnApplicationShutdown { firstRetrievedAt: new Date(parsed.firstRetrievedAt), latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, + notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null, }; }, }); diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index aa16468ecb..dc53c8711d 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -15,6 +15,7 @@ import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { REMOTE_SERVER_CACHE_TTL } from '@/const.js'; import type { DOMWindow } from 'jsdom'; type NodeInfo = { @@ -24,6 +25,7 @@ type NodeInfo = { version?: unknown; }; metadata?: { + httpMessageSignaturesImplementationLevel?: unknown, name?: unknown; nodeName?: unknown; nodeDescription?: unknown; @@ -39,6 +41,7 @@ type NodeInfo = { @Injectable() export class FetchInstanceMetadataService { private logger: Logger; + private httpColon = 'https://'; constructor( private httpRequestService: HttpRequestService, @@ -48,6 +51,7 @@ export class FetchInstanceMetadataService { private redisClient: Redis.Redis, ) { this.logger = this.loggerService.getLogger('metadata', 'cyan'); + this.httpColon = process.env.MISSKEY_USE_HTTP?.toLowerCase() === 'true' ? 'http://' : 'https://'; } @bindThis @@ -59,7 +63,7 @@ export class FetchInstanceMetadataService { return await this.redisClient.set( `fetchInstanceMetadata:mutex:v2:${host}`, '1', 'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395 - 'GET' // 古い値を返す(なかったらnull) + 'GET', // 古い値を返す(なかったらnull) ); } @@ -73,23 +77,24 @@ export class FetchInstanceMetadataService { public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise { const host = instance.host; - // finallyでunlockされてしまうのでtry内でロックチェックをしない - // (returnであってもfinallyは実行される) - if (!force && await this.tryLock(host) === '1') { - // 1が返ってきていたらロックされているという意味なので、何もしない - return; + if (!force) { + // キャッシュ有効チェックはロック取得前に行う + const _instance = await this.federatedInstanceService.fetch(host); + const now = Date.now(); + if (_instance && _instance.infoUpdatedAt != null && (now - _instance.infoUpdatedAt.getTime() < REMOTE_SERVER_CACHE_TTL)) { + this.logger.debug(`Skip because updated recently ${_instance.infoUpdatedAt.toJSON()}`); + return; + } + + // finallyでunlockされてしまうのでtry内でロックチェックをしない + // (returnであってもfinallyは実行される) + if (await this.tryLock(host) === '1') { + // 1が返ってきていたら他にロックされているという意味なので、何もしない + return; + } } try { - if (!force) { - const _instance = await this.federatedInstanceService.fetch(host); - const now = Date.now(); - if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { - // unlock at the finally caluse - return; - } - } - this.logger.info(`Fetching metadata of ${instance.host} ...`); const [info, dom, manifest] = await Promise.all([ @@ -118,6 +123,14 @@ export class FetchInstanceMetadataService { updates.openRegistrations = info.openRegistrations; updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null; updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null; + if (info.metadata && info.metadata.httpMessageSignaturesImplementationLevel && ( + info.metadata.httpMessageSignaturesImplementationLevel === '01' || + info.metadata.httpMessageSignaturesImplementationLevel === '11' + )) { + updates.httpMessageSignaturesImplementationLevel = info.metadata.httpMessageSignaturesImplementationLevel; + } else { + updates.httpMessageSignaturesImplementationLevel = '00'; + } } if (name) updates.name = name; @@ -129,6 +142,12 @@ export class FetchInstanceMetadataService { await this.federatedInstanceService.update(instance.id, updates); this.logger.succ(`Successfuly updated metadata of ${instance.host}`); + this.logger.debug('Updated metadata:', { + info: !!info, + dom: !!dom, + manifest: !!manifest, + updates, + }); } catch (e) { this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`); } finally { @@ -141,7 +160,7 @@ export class FetchInstanceMetadataService { this.logger.info(`Fetching nodeinfo of ${instance.host} ...`); try { - const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo') + const wellknown = await this.httpRequestService.getJson(this.httpColon + instance.host + '/.well-known/nodeinfo') .catch(err => { if (err.statusCode === 404) { throw new Error('No nodeinfo provided'); @@ -184,7 +203,7 @@ export class FetchInstanceMetadataService { private async fetchDom(instance: MiInstance): Promise { this.logger.info(`Fetching HTML of ${instance.host} ...`); - const url = 'https://' + instance.host; + const url = this.httpColon + instance.host; const html = await this.httpRequestService.getHtml(url); @@ -196,7 +215,7 @@ export class FetchInstanceMetadataService { @bindThis private async fetchManifest(instance: MiInstance): Promise | null> { - const url = 'https://' + instance.host; + const url = this.httpColon + instance.host; const manifestUrl = url + '/manifest.json'; @@ -207,7 +226,7 @@ export class FetchInstanceMetadataService { @bindThis private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise { - const url = 'https://' + instance.host; + const url = this.httpColon + instance.host; if (doc) { // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 @@ -234,12 +253,12 @@ export class FetchInstanceMetadataService { @bindThis private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { - const url = 'https://' + instance.host; + const url = this.httpColon + instance.host; return (new URL(manifest.icons[0].src, url)).href; } if (doc) { - const url = 'https://' + instance.host; + const url = this.httpColon + instance.host; // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 const links = Array.from(doc.getElementsByTagName('link')).reverse(); diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index a70743bed2..2a7d8d4bbe 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -245,6 +245,7 @@ export interface InternalEventTypes { unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; + userKeypairUpdated: { userId: MiUser['id']; }; } // name/messages(spec) pairs dictionary diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 7f3cac7c58..4249c158d7 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -70,7 +70,7 @@ export class HttpRequestService { localAddress: config.outgoingAddress, }); - const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); + const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 16); this.httpAgent = config.proxy ? new HttpProxyAgent({ diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 80827a500b..dd3f2182b4 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -13,7 +13,6 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; -import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; import type { DbJobData, DeliverJobData, @@ -33,7 +32,7 @@ import type { UserWebhookDeliverQueue, SystemWebhookDeliverQueue, } from './QueueModule.js'; -import type httpSignature from '@peertube/http-signature'; +import { genRFC3230DigestHeader, type PrivateKeyWithPem, type ParsedSignature } from '@misskey-dev/node-http-message-signatures'; import type * as Bull from 'bullmq'; @Injectable() @@ -90,21 +89,21 @@ export class QueueService { } @bindThis - public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { + public async deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean, privateKey?: PrivateKeyWithPem) { if (content == null) return null; if (to == null) return null; const contentBody = JSON.stringify(content); - const digest = ApRequestCreator.createDigest(contentBody); const data: DeliverJobData = { user: { id: user.id, }, content: contentBody, - digest, + digest: await genRFC3230DigestHeader(contentBody, 'SHA-256'), to, isSharedInbox, + privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem }, }; return this.deliverQueue.add(to, data, { @@ -122,13 +121,13 @@ export class QueueService { * @param user `{ id: string; }` この関数ではThinUserに変換しないので前もって変換してください * @param content IActivity | null * @param inboxes `Map` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox) + * @param forceMainKey boolean | undefined, force to use main (rsa) key * @returns void */ @bindThis - public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map) { + public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map, privateKey?: PrivateKeyWithPem) { if (content == null) return null; const contentBody = JSON.stringify(content); - const digest = ApRequestCreator.createDigest(contentBody); const opts = { attempts: this.config.deliverJobMaxAttempts ?? 12, @@ -144,9 +143,9 @@ export class QueueService { data: { user, content: contentBody, - digest, to: d[0], isSharedInbox: d[1], + privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem }, } as DeliverJobData, opts, }))); @@ -155,7 +154,7 @@ export class QueueService { } @bindThis - public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { + public inbox(activity: IActivity, signature: ParsedSignature | null) { const data = { activity: activity, signature, diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 8dd3d64f5b..ad01f98902 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -16,6 +16,8 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { DI } from '@/di-symbols.js'; import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; +import { UserKeypairService } from './UserKeypairService.js'; +import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; const ACTOR_USERNAME = 'relay.actor' as const; @@ -34,6 +36,7 @@ export class RelayService { private queueService: QueueService, private createSystemUserService: CreateSystemUserService, private apRendererService: ApRendererService, + private userKeypairService: UserKeypairService, ) { this.relaysCache = new MemorySingleCache(1000 * 60 * 10); } @@ -111,7 +114,7 @@ export class RelayService { } @bindThis - public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any): Promise { + public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any, privateKey?: PrivateKeyWithPem): Promise { if (activity == null) return; const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ @@ -121,11 +124,9 @@ export class RelayService { const copy = deepClone(activity); if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; + privateKey = privateKey ?? await this.userKeypairService.getLocalUserPrivateKeyPem(user.id); + const signed = await this.apRendererService.attachLdSignature(copy, privateKey); - const signed = await this.apRendererService.attachLdSignature(copy, user); - - for (const relay of relays) { - this.queueService.deliver(user, signed, relay.inbox, false); - } + this.queueService.deliverMany(user, signed, new Map(relays.map(({ inbox }) => [inbox, false])), privateKey); } } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index e2ebecb99f..94026fd503 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -47,6 +47,7 @@ export type RolePolicies = { canHideAds: boolean; driveCapacityMb: number; alwaysMarkNsfw: boolean; + canUpdateBioMedia: boolean; pinLimit: number; antennaLimit: number; wordMuteLimit: number; @@ -75,6 +76,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canHideAds: false, driveCapacityMb: 100, alwaysMarkNsfw: false, + canUpdateBioMedia: true, pinLimit: 5, antennaLimit: 5, wordMuteLimit: 200, @@ -376,6 +378,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), + canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 5522ecd6cc..54c6170062 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { generateKeyPair } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { DataSource, IsNull } from 'typeorm'; @@ -21,6 +20,7 @@ import { bindThis } from '@/decorators.js'; import UsersChart from '@/core/chart/charts/users.js'; import { UtilityService } from '@/core/UtilityService.js'; import { MetaService } from '@/core/MetaService.js'; +import { genRSAAndEd25519KeyPair } from '@/misc/gen-key-pair.js'; @Injectable() export class SignupService { @@ -93,22 +93,7 @@ export class SignupService { } } - const keyPair = await new Promise((res, rej) => - generateKeyPair('rsa', { - modulusLength: 2048, - publicKeyEncoding: { - type: 'spki', - format: 'pem', - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem', - cipher: undefined, - passphrase: undefined, - }, - }, (err, publicKey, privateKey) => - err ? rej(err) : res([publicKey, privateKey]), - )); + const keyPair = await genRSAAndEd25519KeyPair(); let account!: MiUser; @@ -131,9 +116,8 @@ export class SignupService { })); await transactionalEntityManager.save(new MiUserKeypair({ - publicKey: keyPair[0], - privateKey: keyPair[1], userId: account.id, + ...keyPair, })); await transactionalEntityManager.save(new MiUserProfile({ diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts index 51ac99179a..aa90f1e209 100644 --- a/packages/backend/src/core/UserKeypairService.ts +++ b/packages/backend/src/core/UserKeypairService.ts @@ -5,41 +5,184 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { genEd25519KeyPair, importPrivateKey, PrivateKey, PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; import type { MiUser } from '@/models/User.js'; import type { UserKeypairsRepository } from '@/models/_.js'; -import { RedisKVCache } from '@/misc/cache.js'; +import { RedisKVCache, MemoryKVCache } from '@/misc/cache.js'; import type { MiUserKeypair } from '@/models/UserKeypair.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { GlobalEventService, GlobalEvents } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { webcrypto } from 'node:crypto'; @Injectable() export class UserKeypairService implements OnApplicationShutdown { - private cache: RedisKVCache; + private keypairEntityCache: RedisKVCache; + private privateKeyObjectCache: MemoryKVCache; constructor( @Inject(DI.redis) private redisClient: Redis.Redis, - + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.userKeypairsRepository) private userKeypairsRepository: UserKeypairsRepository, + + private globalEventService: GlobalEventService, + private userEntityService: UserEntityService, ) { - this.cache = new RedisKVCache(this.redisClient, 'userKeypair', { + this.keypairEntityCache = new RedisKVCache(this.redisClient, 'userKeypair', { lifetime: 1000 * 60 * 60 * 24, // 24h memoryCacheLifetime: Infinity, fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), toRedisConverter: (value) => JSON.stringify(value), fromRedisConverter: (value) => JSON.parse(value), }); + this.privateKeyObjectCache = new MemoryKVCache(1000 * 60 * 60 * 1); + + this.redisForSub.on('message', this.onMessage); } @bindThis public async getUserKeypair(userId: MiUser['id']): Promise { - return await this.cache.fetch(userId); + return await this.keypairEntityCache.fetch(userId); + } + + /** + * Get private key [Only PrivateKeyWithPem for queue data etc.] + * @param userIdOrHint user id or MiUserKeypair + * @param preferType + * If ed25519-like(`ed25519`, `01`, `11`) is specified, ed25519 keypair will be returned if exists. + * Otherwise, main keypair will be returned. + * @returns + */ + @bindThis + public async getLocalUserPrivateKeyPem( + userIdOrHint: MiUser['id'] | MiUserKeypair, + preferType?: string, + ): Promise { + const keypair = typeof userIdOrHint === 'string' ? await this.getUserKeypair(userIdOrHint) : userIdOrHint; + if ( + preferType && ['01', '11', 'ed25519'].includes(preferType.toLowerCase()) && + keypair.ed25519PublicKey != null && keypair.ed25519PrivateKey != null + ) { + return { + keyId: `${this.userEntityService.genLocalUserUri(keypair.userId)}#ed25519-key`, + privateKeyPem: keypair.ed25519PrivateKey, + }; + } + return { + keyId: `${this.userEntityService.genLocalUserUri(keypair.userId)}#main-key`, + privateKeyPem: keypair.privateKey, + }; + } + + /** + * Get private key [Only PrivateKey for ap request] + * Using cache due to performance reasons of `crypto.subtle.importKey` + * @param userIdOrHint user id, MiUserKeypair, or PrivateKeyWithPem + * @param preferType + * If ed25519-like(`ed25519`, `01`, `11`) is specified, ed25519 keypair will be returned if exists. + * Otherwise, main keypair will be returned. (ignored if userIdOrHint is PrivateKeyWithPem) + * @returns + */ + @bindThis + public async getLocalUserPrivateKey( + userIdOrHint: MiUser['id'] | MiUserKeypair | PrivateKeyWithPem, + preferType?: string, + ): Promise { + if (typeof userIdOrHint === 'object' && 'privateKeyPem' in userIdOrHint) { + // userIdOrHint is PrivateKeyWithPem + return { + keyId: userIdOrHint.keyId, + privateKey: await this.privateKeyObjectCache.fetch(userIdOrHint.keyId, async () => { + return await importPrivateKey(userIdOrHint.privateKeyPem); + }), + }; + } + + const userId = typeof userIdOrHint === 'string' ? userIdOrHint : userIdOrHint.userId; + const getKeypair = () => typeof userIdOrHint === 'string' ? this.getUserKeypair(userId) : userIdOrHint; + + if (preferType && ['01', '11', 'ed25519'].includes(preferType.toLowerCase())) { + const keyId = `${this.userEntityService.genLocalUserUri(userId)}#ed25519-key`; + const fetched = await this.privateKeyObjectCache.fetchMaybe(keyId, async () => { + const keypair = await getKeypair(); + if (keypair.ed25519PublicKey != null && keypair.ed25519PrivateKey != null) { + return await importPrivateKey(keypair.ed25519PrivateKey); + } + return; + }); + if (fetched) { + return { + keyId, + privateKey: fetched, + }; + } + } + + const keyId = `${this.userEntityService.genLocalUserUri(userId)}#main-key`; + return { + keyId, + privateKey: await this.privateKeyObjectCache.fetch(keyId, async () => { + const keypair = await getKeypair(); + return await importPrivateKey(keypair.privateKey); + }), + }; } + @bindThis + public async refresh(userId: MiUser['id']): Promise { + return await this.keypairEntityCache.refresh(userId); + } + + /** + * If DB has ed25519 keypair, refresh cache and return it. + * If not, create, save and return ed25519 keypair. + * @param userId user id + * @returns MiUserKeypair if keypair is created, void if keypair is already exists + */ + @bindThis + public async refreshAndPrepareEd25519KeyPair(userId: MiUser['id']): Promise { + await this.refresh(userId); + const keypair = await this.keypairEntityCache.fetch(userId); + if (keypair.ed25519PublicKey != null) { + return; + } + + const ed25519 = await genEd25519KeyPair(); + await this.userKeypairsRepository.update({ userId }, { + ed25519PublicKey: ed25519.publicKey, + ed25519PrivateKey: ed25519.privateKey, + }); + this.globalEventService.publishInternalEvent('userKeypairUpdated', { userId }); + const result = { + ...keypair, + ed25519PublicKey: ed25519.publicKey, + ed25519PrivateKey: ed25519.privateKey, + }; + this.keypairEntityCache.set(userId, result); + return result; + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'userKeypairUpdated': { + this.refresh(body.userId); + break; + } + } + } + } @bindThis public dispose(): void { - this.cache.dispose(); + this.keypairEntityCache.dispose(); } @bindThis diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index d594a223f4..fc5a68c72e 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -3,27 +3,23 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import { Not, IsNull } from 'typeorm'; -import type { FollowingsRepository } from '@/models/_.js'; +import { Injectable } from '@nestjs/common'; import type { MiUser } from '@/models/User.js'; -import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserKeypairService } from './UserKeypairService.js'; +import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js'; @Injectable() export class UserSuspendService { constructor( - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - private userEntityService: UserEntityService, - private queueService: QueueService, private globalEventService: GlobalEventService, private apRendererService: ApRendererService, + private userKeypairService: UserKeypairService, + private apDeliverManagerService: ApDeliverManagerService, ) { } @@ -32,28 +28,12 @@ export class UserSuspendService { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true }); if (this.userEntityService.isLocalUser(user)) { - // 知り得る全SharedInboxにDelete配信 const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - - const queue: string[] = []; - - const followings = await this.followingsRepository.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); - - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - this.queueService.deliver(user, content, inbox, true); - } + const manager = this.apDeliverManagerService.createDeliverManager(user, content); + manager.addAllKnowingSharedInboxRecipe(); + // process deliver時にはキーペアが消去されているはずなので、ここで挿入する + const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main'); + manager.execute({ privateKey }); } } @@ -62,28 +42,12 @@ export class UserSuspendService { this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); if (this.userEntityService.isLocalUser(user)) { - // 知り得る全SharedInboxにUndo Delete配信 const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); - - const queue: string[] = []; - - const followings = await this.followingsRepository.find({ - where: [ - { followerSharedInbox: Not(IsNull()) }, - { followeeSharedInbox: Not(IsNull()) }, - ], - select: ['followerSharedInbox', 'followeeSharedInbox'], - }); - - const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox); - - for (const inbox of inboxes) { - if (inbox != null && !queue.includes(inbox)) queue.push(inbox); - } - - for (const inbox of queue) { - this.queueService.deliver(user as any, content, inbox, true); - } + const manager = this.apDeliverManagerService.createDeliverManager(user, content); + manager.addAllKnowingSharedInboxRecipe(); + // process deliver時にはキーペアが消去されているはずなので、ここで挿入する + const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main'); + manager.execute({ privateKey }); } } } diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index 374536a741..aa1144778c 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -46,7 +46,7 @@ export class WebfingerService { const m = query.match(mRegex); if (m) { const hostname = m[2]; - const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; + const useHttp = process.env.MISSKEY_USE_HTTP && process.env.MISSKEY_USE_HTTP.toLowerCase() === 'true'; return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`; } diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index f6b70ead44..973394683f 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; +import type { MiUser, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import { MemoryKVCache } from '@/misc/cache.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js'; @@ -13,9 +13,12 @@ import { CacheService } from '@/core/CacheService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { MiLocalUser, MiRemoteUser } from '@/models/User.js'; +import Logger from '@/logger.js'; import { getApId } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; +import { ApLoggerService } from './ApLoggerService.js'; import type { IObject } from './type.js'; +import { UtilityService } from '../UtilityService.js'; export type UriParseResult = { /** wether the URI was generated by us */ @@ -35,8 +38,8 @@ export type UriParseResult = { @Injectable() export class ApDbResolverService implements OnApplicationShutdown { - private publicKeyCache: MemoryKVCache; - private publicKeyByUserIdCache: MemoryKVCache; + private publicKeyByUserIdCache: MemoryKVCache; + private logger: Logger; constructor( @Inject(DI.config) @@ -53,9 +56,17 @@ export class ApDbResolverService implements OnApplicationShutdown { private cacheService: CacheService, private apPersonService: ApPersonService, + private apLoggerService: ApLoggerService, + private utilityService: UtilityService, ) { - this.publicKeyCache = new MemoryKVCache(Infinity); - this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); + this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); + this.logger = this.apLoggerService.logger.createSubLogger('db-resolver'); + } + + private punyHost(url: string): string { + const urlObj = new URL(url); + const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`; + return host; } @bindThis @@ -116,62 +127,141 @@ export class ApDbResolverService implements OnApplicationShutdown { } } - /** - * AP KeyId => Misskey User and Key - */ @bindThis - public async getAuthUserFromKeyId(keyId: string): Promise<{ - user: MiRemoteUser; - key: MiUserPublickey; - } | null> { - const key = await this.publicKeyCache.fetch(keyId, async () => { - const key = await this.userPublickeysRepository.findOneBy({ - keyId, - }); - - if (key == null) return null; - - return key; - }, key => key != null); - - if (key == null) return null; - - const user = await this.cacheService.findUserById(key.userId).catch(() => null) as MiRemoteUser | null; - if (user == null) return null; - if (user.isDeleted) return null; - - return { - user, - key, - }; + private async refreshAndFindKey(userId: MiUser['id'], keyId: string): Promise { + this.refreshCacheByUserId(userId); + const keys = await this.getPublicKeyByUserId(userId); + if (keys == null || !Array.isArray(keys) || keys.length === 0) { + this.logger.warn(`No key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`); + return null; + } + const exactKey = keys.find(x => x.keyId === keyId); + if (exactKey) return exactKey; + this.logger.warn(`No exact key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`); + return null; } /** * AP Actor id => Misskey User and Key + * @param uri AP Actor id + * @param keyId Key id to find. If not specified, main key will be selected. + * @returns + * 1. `null` if the user and key host do not match + * 2. `{ user: null, key: null }` if the user is not found + * 3. `{ user: MiRemoteUser, key: null }` if key is not found + * 4. `{ user: MiRemoteUser, key: MiUserPublickey }` if both are found */ @bindThis - public async getAuthUserFromApId(uri: string): Promise<{ + public async getAuthUserFromApId(uri: string, keyId?: string): Promise<{ user: MiRemoteUser; key: MiUserPublickey | null; - } | null> { - const user = await this.apPersonService.resolvePerson(uri) as MiRemoteUser; - if (user.isDeleted) return null; + } | { + user: null; + key: null; + } | + null> { + if (keyId) { + if (this.punyHost(uri) !== this.punyHost(keyId)) { + /** + * keyIdはURL形式かつkeyIdのホストはuriのホストと一致するはず + * (ApPersonService.validateActorに由来) + * + * ただ、Mastodonはリプライ関連で他人のトゥートをHTTP Signature署名して送ってくることがある + * そのような署名は有効性に疑問があるので無視することにする + * ここではuriとkeyIdのホストが一致しない場合は無視する + * ハッシュをなくしたkeyIdとuriの同一性を比べてみてもいいが、`uri#*-key`というkeyIdを設定するのが + * 決まりごとというわけでもないため幅を持たせることにする + * + * + * The keyId should be in URL format and its host should match the host of the uri + * (derived from ApPersonService.validateActor) + * + * However, Mastodon sometimes sends toots from other users with HTTP Signature signing for reply-related purposes + * Such signatures are of questionable validity, so we choose to ignore them + * Here, we ignore cases where the hosts of uri and keyId do not match + * We could also compare the equality of keyId without the hash and uri, but since setting a keyId like `uri#*-key` + * is not a strict rule, we decide to allow for some flexibility + */ + this.logger.warn(`actor uri and keyId are not matched uri=${uri} keyId=${keyId}`); + return null; + } + } - const key = await this.publicKeyByUserIdCache.fetch( - user.id, - () => this.userPublickeysRepository.findOneBy({ userId: user.id }), + const user = await this.apPersonService.resolvePerson(uri, undefined, true) as MiRemoteUser; + if (user.isDeleted) return { user: null, key: null }; + + const keys = await this.getPublicKeyByUserId(user.id); + + if (keys == null || !Array.isArray(keys) || keys.length === 0) { + this.logger.warn(`No key found uri=${uri} userId=${user.id} keys=${JSON.stringify(keys)}`); + return { user, key: null }; + } + + if (!keyId) { + // Choose the main-like + const mainKey = keys.find(x => { + try { + const url = new URL(x.keyId); + const path = url.pathname.split('/').pop()?.toLowerCase(); + if (url.hash) { + if (url.hash.toLowerCase().includes('main')) { + return true; + } + } else if (path?.includes('main') || path === 'publickey') { + return true; + } + } catch { /* noop */ } + + return false; + }); + return { user, key: mainKey ?? keys[0] }; + } + + const exactKey = keys.find(x => x.keyId === keyId); + if (exactKey) return { user, key: exactKey }; + + /** + * keyIdで見つからない場合、まずはキャッシュを更新して再取得 + * If not found with keyId, update cache and reacquire + */ + const cacheRaw = this.publicKeyByUserIdCache.cache.get(user.id); + if (cacheRaw && cacheRaw.date > Date.now() - 1000 * 60 * 12) { + const exactKey = await this.refreshAndFindKey(user.id, keyId); + if (exactKey) return { user, key: exactKey }; + } + + /** + * lastFetchedAtでの更新制限を弱めて再取得 + * Reacquisition with weakened update limit at lastFetchedAt + */ + if (user.lastFetchedAt == null || user.lastFetchedAt < new Date(Date.now() - 1000 * 60 * 12)) { + this.logger.info(`Fetching user to find public key uri=${uri} userId=${user.id} keyId=${keyId}`); + const renewed = await this.apPersonService.fetchPersonWithRenewal(uri, 0); + if (renewed == null || renewed.isDeleted) return null; + + return { user, key: await this.refreshAndFindKey(user.id, keyId) }; + } + + this.logger.warn(`No key found uri=${uri} userId=${user.id} keyId=${keyId}`); + return { user, key: null }; + } + + @bindThis + public async getPublicKeyByUserId(userId: MiUser['id']): Promise { + return await this.publicKeyByUserIdCache.fetch( + userId, + () => this.userPublickeysRepository.find({ where: { userId } }), v => v != null, ); + } - return { - user, - key, - }; + @bindThis + public refreshCacheByUserId(userId: MiUser['id']): void { + this.publicKeyByUserIdCache.delete(userId); } @bindThis public dispose(): void { - this.publicKeyCache.dispose(); this.publicKeyByUserIdCache.dispose(); } diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 5d07cd8e8f..db3302e6ff 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -9,10 +9,14 @@ import { DI } from '@/di-symbols.js'; import type { FollowingsRepository } from '@/models/_.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { QueueService } from '@/core/QueueService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import type { IActivity } from '@/core/activitypub/type.js'; import { ThinUser } from '@/queue/types.js'; +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import type Logger from '@/logger.js'; +import { UserKeypairService } from '../UserKeypairService.js'; +import { ApLoggerService } from './ApLoggerService.js'; +import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; interface IRecipe { type: string; @@ -27,12 +31,19 @@ interface IDirectRecipe extends IRecipe { to: MiRemoteUser; } +interface IAllKnowingSharedInboxRecipe extends IRecipe { + type: 'AllKnowingSharedInbox'; +} + const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe => recipe.type === 'Followers'; const isDirect = (recipe: IRecipe): recipe is IDirectRecipe => recipe.type === 'Direct'; +const isAllKnowingSharedInbox = (recipe: IRecipe): recipe is IAllKnowingSharedInboxRecipe => + recipe.type === 'AllKnowingSharedInbox'; + class DeliverManager { private actor: ThinUser; private activity: IActivity | null; @@ -40,16 +51,18 @@ class DeliverManager { /** * Constructor - * @param userEntityService + * @param userKeypairService * @param followingsRepository * @param queueService * @param actor Actor * @param activity Activity to deliver */ constructor( - private userEntityService: UserEntityService, + private userKeypairService: UserKeypairService, private followingsRepository: FollowingsRepository, private queueService: QueueService, + private accountUpdateService: AccountUpdateService, + private logger: Logger, actor: { id: MiUser['id']; host: null; }, activity: IActivity | null, @@ -91,6 +104,18 @@ class DeliverManager { this.addRecipe(recipe); } + /** + * Add recipe for all-knowing shared inbox deliver + */ + @bindThis + public addAllKnowingSharedInboxRecipe(): void { + const deliver: IAllKnowingSharedInboxRecipe = { + type: 'AllKnowingSharedInbox', + }; + + this.addRecipe(deliver); + } + /** * Add recipe * @param recipe Recipe @@ -104,11 +129,44 @@ class DeliverManager { * Execute delivers */ @bindThis - public async execute(): Promise { + public async execute(opts?: { privateKey?: PrivateKeyWithPem }): Promise { + //#region MIGRATION + if (!opts?.privateKey) { + /** + * ed25519の署名がなければ追加する + */ + const created = await this.userKeypairService.refreshAndPrepareEd25519KeyPair(this.actor.id); + if (created) { + // createdが存在するということは新規作成されたということなので、フォロワーに配信する + this.logger.info(`ed25519 key pair created for user ${this.actor.id} and publishing to followers`); + // リモートに配信 + const keyPair = await this.userKeypairService.getLocalUserPrivateKeyPem(created, 'main'); + await this.accountUpdateService.publishToFollowers(this.actor.id, keyPair); + } + } + //#endregion + + //#region collect inboxes by recipes // The value flags whether it is shared or not. // key: inbox URL, value: whether it is sharedInbox const inboxes = new Map(); + if (this.recipes.some(r => isAllKnowingSharedInbox(r))) { + // all-knowing shared inbox + const followings = await this.followingsRepository.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) }, + ], + select: ['followerSharedInbox', 'followeeSharedInbox'], + }); + + for (const following of followings) { + if (following.followeeSharedInbox) inboxes.set(following.followeeSharedInbox, true); + if (following.followerSharedInbox) inboxes.set(following.followerSharedInbox, true); + } + } + // build inbox list // Process follower recipes first to avoid duplication when processing direct recipes later. if (this.recipes.some(r => isFollowers(r))) { @@ -142,39 +200,49 @@ class DeliverManager { inboxes.set(recipe.to.inbox, false); } + //#endregion // deliver - await this.queueService.deliverMany(this.actor, this.activity, inboxes); + await this.queueService.deliverMany(this.actor, this.activity, inboxes, opts?.privateKey); + this.logger.info(`Deliver queues dispatched: inboxes=${inboxes.size} actorId=${this.actor.id} activityId=${this.activity?.id}`); } } @Injectable() export class ApDeliverManagerService { + private logger: Logger; + constructor( @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - private userEntityService: UserEntityService, + private userKeypairService: UserKeypairService, private queueService: QueueService, + private accountUpdateService: AccountUpdateService, + private apLoggerService: ApLoggerService, ) { + this.logger = this.apLoggerService.logger.createSubLogger('deliver-manager'); } /** * Deliver activity to followers * @param actor * @param activity Activity + * @param forceMainKey Force to use main (rsa) key */ @bindThis - public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise { + public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, privateKey?: PrivateKeyWithPem): Promise { const manager = new DeliverManager( - this.userEntityService, + this.userKeypairService, this.followingsRepository, this.queueService, + this.accountUpdateService, + this.logger, actor, activity, ); manager.addFollowersRecipe(); - await manager.execute(); + await manager.execute({ privateKey }); } /** @@ -186,9 +254,11 @@ export class ApDeliverManagerService { @bindThis public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise { const manager = new DeliverManager( - this.userEntityService, + this.userKeypairService, this.followingsRepository, this.queueService, + this.accountUpdateService, + this.logger, actor, activity, ); @@ -199,10 +269,11 @@ export class ApDeliverManagerService { @bindThis public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager { return new DeliverManager( - this.userEntityService, + this.userKeypairService, this.followingsRepository, this.queueService, - + this.accountUpdateService, + this.logger, actor, activity, ); diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e2164fec1d..1bef9fe071 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -114,15 +114,8 @@ export class ApInboxService { result = await this.performOneActivity(actor, activity); } - // ついでにリモートユーザーの情報が古かったら更新しておく - if (actor.uri) { - if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { - setImmediate(() => { - this.apPersonService.updatePerson(actor.uri); - }); - } - } - return result; + // ついでにリモートユーザーの情報が古かったら更新しておく? + // → No, この関数が呼び出される前に署名検証で更新されているはず } @bindThis diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index ab75b9abbd..4036d2794a 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -25,7 +25,7 @@ export class ApMfmService { } @bindThis - public getNoteHtml(note: MiNote, apAppend?: string) { + public getNoteHtml(note: Pick, apAppend?: string) { let noMisskeyContent = false; const srcMfm = (note.text ?? '') + (apAppend ?? ''); diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 98e944f347..5d7419f934 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -22,7 +22,6 @@ import { UserKeypairService } from '@/core/UserKeypairService.js'; import { MfmService } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import type { MiUserKeypair } from '@/models/UserKeypair.js'; import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; @@ -31,6 +30,7 @@ import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; +import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; @Injectable() export class ApRendererService { @@ -251,15 +251,15 @@ export class ApRendererService { } @bindThis - public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey { + public renderKey(user: MiLocalUser, publicKey: string, postfix?: string): IKey { return { - id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, + id: `${this.userEntityService.genLocalUserUri(user.id)}${postfix ?? '/publickey'}`, type: 'Key', owner: this.userEntityService.genLocalUserUri(user.id), - publicKeyPem: createPublicKey(key.publicKey).export({ + publicKeyPem: createPublicKey(publicKey).export({ type: 'spki', format: 'pem', - }), + }) as string, }; } @@ -499,7 +499,10 @@ export class ApRendererService { tag, manuallyApprovesFollowers: user.isLocked, discoverable: user.isExplorable, - publicKey: this.renderKey(user, keypair, '#main-key'), + publicKey: this.renderKey(user, keypair.publicKey, '#main-key'), + additionalPublicKeys: [ + ...(keypair.ed25519PublicKey ? [this.renderKey(user, keypair.ed25519PublicKey, '#ed25519-key')] : []), + ], isCat: user.isCat, attachment: attachment.length ? attachment : undefined, }; @@ -622,12 +625,10 @@ export class ApRendererService { } @bindThis - public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise { - const keypair = await this.userKeypairService.getUserKeypair(user.id); - + public async attachLdSignature(activity: any, key: PrivateKeyWithPem): Promise { const jsonLd = this.jsonLdService.use(); jsonLd.debug = false; - activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); + activity = await jsonLd.signRsaSignature2017(activity, key.privateKeyPem, key.keyId); return activity; } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 93ac8ce9a7..0cae91316b 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as crypto from 'node:crypto'; import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; +import { genRFC3230DigestHeader, signAsDraftToRequest } from '@misskey-dev/node-http-message-signatures'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; @@ -15,122 +15,61 @@ import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import type Logger from '@/logger.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; +import type { PrivateKeyWithPem, PrivateKey } from '@misskey-dev/node-http-message-signatures'; -type Request = { - url: string; - method: string; - headers: Record; -}; +export async function createSignedPost(args: { level: string; key: PrivateKey; url: string; body: string; digest?: string, additionalHeaders: Record }) { + const u = new URL(args.url); + const request = { + url: u.href, + method: 'POST', + headers: { + 'Date': new Date().toUTCString(), + 'Host': u.host, + 'Content-Type': 'application/activity+json', + ...args.additionalHeaders, + } as Record, + }; -type Signed = { - request: Request; - signingString: string; - signature: string; - signatureHeader: string; -}; + // TODO: httpMessageSignaturesImplementationLevelによって新規格で通信をするようにする + const digestHeader = args.digest ?? await genRFC3230DigestHeader(args.body, 'SHA-256'); + request.headers['Digest'] = digestHeader; -type PrivateKey = { - privateKeyPem: string; - keyId: string; -}; + const result = await signAsDraftToRequest( + request, + args.key, + ['(request-target)', 'date', 'host', 'digest'], + ); -export class ApRequestCreator { - static createSignedPost(args: { key: PrivateKey, url: string, body: string, digest?: string, additionalHeaders: Record }): Signed { - const u = new URL(args.url); - const digestHeader = args.digest ?? this.createDigest(args.body); + return { + request, + ...result, + }; +} - const request: Request = { - url: u.href, - method: 'POST', - headers: this.#objectAssignWithLcKey({ - 'Date': new Date().toUTCString(), - 'Host': u.host, - 'Content-Type': 'application/activity+json', - 'Digest': digestHeader, - }, args.additionalHeaders), - }; +export async function createSignedGet(args: { level: string; key: PrivateKey; url: string; additionalHeaders: Record }) { + const u = new URL(args.url); + const request = { + url: u.href, + method: 'GET', + headers: { + 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'Date': new Date().toUTCString(), + 'Host': new URL(args.url).host, + ...args.additionalHeaders, + } as Record, + }; - const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']); + // TODO: httpMessageSignaturesImplementationLevelによって新規格で通信をするようにする + const result = await signAsDraftToRequest( + request, + args.key, + ['(request-target)', 'date', 'host', 'accept'], + ); - return { - request, - signingString: result.signingString, - signature: result.signature, - signatureHeader: result.signatureHeader, - }; - } - - static createDigest(body: string) { - return `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}`; - } - - static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record }): Signed { - const u = new URL(args.url); - - const request: Request = { - url: u.href, - method: 'GET', - headers: this.#objectAssignWithLcKey({ - 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"', - 'Date': new Date().toUTCString(), - 'Host': new URL(args.url).host, - }, args.additionalHeaders), - }; - - const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']); - - return { - request, - signingString: result.signingString, - signature: result.signature, - signatureHeader: result.signatureHeader, - }; - } - - static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed { - const signingString = this.#genSigningString(request, includeHeaders); - const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64'); - const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`; - - request.headers = this.#objectAssignWithLcKey(request.headers, { - Signature: signatureHeader, - }); - // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! - delete request.headers['host']; - - return { - request, - signingString, - signature, - signatureHeader, - }; - } - - static #genSigningString(request: Request, includeHeaders: string[]): string { - request.headers = this.#lcObjectKey(request.headers); - - const results: string[] = []; - - for (const key of includeHeaders.map(x => x.toLowerCase())) { - if (key === '(request-target)') { - results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`); - } else { - results.push(`${key}: ${request.headers[key]}`); - } - } - - return results.join('\n'); - } - - static #lcObjectKey(src: Record): Record { - const dst: Record = {}; - for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key]; - return dst; - } - - static #objectAssignWithLcKey(a: Record, b: Record): Record { - return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b)); - } + return { + request, + ...result, + }; } @Injectable() @@ -150,21 +89,28 @@ export class ApRequestService { } @bindThis - public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise { + public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, level: string, digest?: string, key?: PrivateKeyWithPem): Promise { const body = typeof object === 'string' ? object : JSON.stringify(object); - - const keypair = await this.userKeypairService.getUserKeypair(user.id); - - const req = ApRequestCreator.createSignedPost({ - key: { - privateKeyPem: keypair.privateKey, - keyId: `${this.config.url}/users/${user.id}#main-key`, - }, + const keyFetched = await this.userKeypairService.getLocalUserPrivateKey(key ?? user.id, level); + const req = await createSignedPost({ + level, + key: keyFetched, url, body, - digest, additionalHeaders: { + 'User-Agent': this.config.userAgent, }, + digest, + }); + + // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! + delete req.request.headers['Host']; + + this.logger.debug('create signed post', { + version: 'draft', + level, + url, + keyId: keyFetched.keyId, }); await this.httpRequestService.send(url, { @@ -180,19 +126,27 @@ export class ApRequestService { * @param url URL to fetch */ @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }): Promise { - const keypair = await this.userKeypairService.getUserKeypair(user.id); - - const req = ApRequestCreator.createSignedGet({ - key: { - privateKeyPem: keypair.privateKey, - keyId: `${this.config.url}/users/${user.id}#main-key`, - }, + public async signedGet(url: string, user: { id: MiUser['id'] }, level: string): Promise { + const key = await this.userKeypairService.getLocalUserPrivateKey(user.id, level); + const req = await createSignedGet({ + level, + key, url, additionalHeaders: { + 'User-Agent': this.config.userAgent, }, }); + // node-fetch will generate this for us. if we keep 'Host', it won't change with redirects! + delete req.request.headers['Host']; + + this.logger.debug('create signed get', { + version: 'draft', + level, + url, + keyId: key.keyId, + }); + const res = await this.httpRequestService.send(url, { method: req.request.method, headers: req.request.headers, diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index bb3c40f093..727ff6f956 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -16,6 +16,7 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { isCollectionOrOrderedCollection } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; @@ -41,6 +42,7 @@ export class Resolver { private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, + private federatedInstanceService: FederatedInstanceService, private loggerService: LoggerService, private recursionLimit = 100, ) { @@ -103,8 +105,10 @@ export class Resolver { this.user = await this.instanceActorService.getInstanceActor(); } + const server = await this.federatedInstanceService.fetch(host); + const object = (this.user - ? await this.apRequestService.signedGet(value, this.user) as IObject + ? await this.apRequestService.signedGet(value, this.user, server.httpMessageSignaturesImplementationLevel) as IObject : await this.httpRequestService.getActivityJson(value)) as IObject; if ( @@ -200,6 +204,7 @@ export class ApResolverService { private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, + private federatedInstanceService: FederatedInstanceService, private loggerService: LoggerService, ) { } @@ -220,6 +225,7 @@ export class ApResolverService { this.httpRequestService, this.apRendererService, this.apDbResolverService, + this.federatedInstanceService, this.loggerService, ); } diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index feb8c42c56..fc4e3e3bef 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -134,6 +134,7 @@ const security_v1 = { 'privateKey': { '@id': 'sec:privateKey', '@type': '@id' }, 'privateKeyPem': 'sec:privateKeyPem', 'publicKey': { '@id': 'sec:publicKey', '@type': '@id' }, + 'additionalPublicKeys': { '@id': 'sec:publicKey', '@type': '@id' }, 'publicKeyBase58': 'sec:publicKeyBase58', 'publicKeyPem': 'sec:publicKeyPem', 'publicKeyWif': 'sec:publicKeyWif', diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 398c8695d2..c41fc713d5 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -3,9 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { verify } from 'crypto'; import { Inject, Injectable } from '@nestjs/common'; import promiseLimit from 'promise-limit'; -import { DataSource } from 'typeorm'; +import { DataSource, In, Not } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; @@ -34,10 +35,12 @@ import { StatusError } from '@/misc/status-error.js'; import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { checkHttps } from '@/misc/check-https.js'; +import { REMOTE_USER_CACHE_TTL, REMOTE_USER_MOVE_COOLDOWN } from '@/const.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -47,7 +50,7 @@ import type { ApResolverService, Resolver } from '../ApResolverService.js'; import type { ApLoggerService } from '../ApLoggerService.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { ApImageService } from './ApImageService.js'; -import type { IActor, IObject } from '../type.js'; +import type { IActor, IKey, IObject } from '../type.js'; const nameLength = 128; const summaryLength = 2048; @@ -100,6 +103,8 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + + private roleService: RoleService, ) { } @@ -182,13 +187,38 @@ export class ApPersonService implements OnModuleInit { } if (x.publicKey) { - if (typeof x.publicKey.id !== 'string') { - throw new Error('invalid Actor: publicKey.id is not a string'); + const publicKeys = Array.isArray(x.publicKey) ? x.publicKey : [x.publicKey]; + + for (const publicKey of publicKeys) { + if (typeof publicKey.id !== 'string') { + throw new Error('invalid Actor: publicKey.id is not a string'); + } + + const publicKeyIdHost = this.punyHost(publicKey.id); + if (publicKeyIdHost !== expectHost) { + throw new Error('invalid Actor: publicKey.id has different host'); + } + } + } + + if (x.additionalPublicKeys) { + if (!x.publicKey) { + throw new Error('invalid Actor: additionalPublicKeys is set but publicKey is not'); } - const publicKeyIdHost = this.punyHost(x.publicKey.id); - if (publicKeyIdHost !== expectHost) { - throw new Error('invalid Actor: publicKey.id has different host'); + if (!Array.isArray(x.additionalPublicKeys)) { + throw new Error('invalid Actor: additionalPublicKeys is not an array'); + } + + for (const key of x.additionalPublicKeys) { + if (typeof key.id !== 'string') { + throw new Error('invalid Actor: additionalPublicKeys.id is not a string'); + } + + const keyIdHost = this.punyHost(key.id); + if (keyIdHost !== expectHost) { + throw new Error('invalid Actor: additionalPublicKeys.id has different host'); + } } } @@ -225,6 +255,33 @@ export class ApPersonService implements OnModuleInit { return null; } + /** + * uriからUser(Person)をフェッチします。 + * + * Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。 + * また、TTLが0でない場合、TTLを過ぎていた場合はupdatePersonを実行します。 + */ + @bindThis + async fetchPersonWithRenewal(uri: string, TTL = REMOTE_USER_CACHE_TTL): Promise { + const exist = await this.fetchPerson(uri); + if (exist == null) return null; + + if (this.userEntityService.isRemoteUser(exist)) { + if (TTL === 0 || exist.lastFetchedAt == null || Date.now() - exist.lastFetchedAt.getTime() > TTL) { + this.logger.debug('fetchPersonWithRenewal: renew', { uri, TTL, lastFetchedAt: exist.lastFetchedAt }); + try { + await this.updatePerson(exist.uri); + return await this.fetchPerson(uri); + } catch (err) { + this.logger.error('error occurred while renewing user', { err }); + } + } + this.logger.debug('fetchPersonWithRenewal: use cache', { uri, TTL, lastFetchedAt: exist.lastFetchedAt }); + } + + return exist; + } + private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any): Promise>> { if (user == null) throw new Error('failed to create user: user is null'); @@ -238,6 +295,11 @@ export class ApPersonService implements OnModuleInit { return this.apImageService.resolveImage(user, img).catch(() => null); })); + if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null)) + && !(await this.roleService.getUserPolicies(user.id)).canUpdateBioMedia) { + return {}; + } + /* we don't want to return nulls on errors! if the database fields are already null, nothing changes; if the database has old @@ -355,11 +417,15 @@ export class ApPersonService implements OnModuleInit { })); if (person.publicKey) { - await transactionalEntityManager.save(new MiUserPublickey({ - userId: user.id, - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, - })); + const publicKeys = new Map(); + (person.additionalPublicKeys ?? []).forEach(key => publicKeys.set(key.id, key)); + (Array.isArray(person.publicKey) ? person.publicKey : [person.publicKey]).forEach(key => publicKeys.set(key.id, key)); + + await transactionalEntityManager.save(Array.from(publicKeys.values(), key => new MiUserPublickey({ + keyId: key.id, + userId: user!.id, + keyPem: key.publicKeyPem, + }))); } }); } catch (e) { @@ -505,11 +571,29 @@ export class ApPersonService implements OnModuleInit { // Update user await this.usersRepository.update(exist.id, updates); - if (person.publicKey) { - await this.userPublickeysRepository.update({ userId: exist.id }, { - keyId: person.publicKey.id, - keyPem: person.publicKey.publicKeyPem, + try { + // Deleteアクティビティ受信時にもここが走ってsaveがuserforeign key制約エラーを吐くことがある + // とりあえずtry-catchで囲っておく + const publicKeys = new Map(); + if (person.publicKey) { + (person.additionalPublicKeys ?? []).forEach(key => publicKeys.set(key.id, key)); + (Array.isArray(person.publicKey) ? person.publicKey : [person.publicKey]).forEach(key => publicKeys.set(key.id, key)); + + await this.userPublickeysRepository.save(Array.from(publicKeys.values(), key => ({ + keyId: key.id, + userId: exist.id, + keyPem: key.publicKeyPem, + }))); + } + + this.userPublickeysRepository.delete({ + keyId: Not(In(Array.from(publicKeys.keys()))), + userId: exist.id, + }).catch(err => { + this.logger.error('something happened while deleting remote user public keys:', { userId: exist.id, err }); }); + } catch (err) { + this.logger.error('something happened while updating remote user public keys:', { userId: exist.id, err }); } let _description: string | null = null; @@ -551,7 +635,7 @@ export class ApPersonService implements OnModuleInit { exist.movedAt == null || // 以前のmovingから14日以上経過した場合のみ移行処理を許可 // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) - exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() + exist.movedAt.getTime() + REMOTE_USER_MOVE_COOLDOWN < updated.movedAt.getTime() )) { this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); return this.processRemoteMove(updated, movePreventUris) @@ -574,9 +658,9 @@ export class ApPersonService implements OnModuleInit { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolvePerson(uri: string, resolver?: Resolver): Promise { + public async resolvePerson(uri: string, resolver?: Resolver, withRenewal = false): Promise { //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.fetchPerson(uri); + const exist = withRenewal ? await this.fetchPersonWithRenewal(uri) : await this.fetchPerson(uri); if (exist) return exist; //#endregion diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index 4fae1e897b..73004d10b0 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -74,10 +74,10 @@ export class ApQuestionService { //#region このサーバーに既に登録されているか const note = await this.notesRepository.findOneBy({ uri }); - if (note == null) throw new Error('Question is not registed'); + if (note == null) throw new Error('Question is not registered'); const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); - if (poll == null) throw new Error('Question is not registed'); + if (poll == null) throw new Error('Question is not registered'); //#endregion // resolve new Question object diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 5b6c6c8ca6..1d55971660 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -55,7 +55,7 @@ export function getOneApId(value: ApObject): string { export function getApId(value: string | IObject): string { if (typeof value === 'string') return value; if (typeof value.id === 'string') return value.id; - throw new Error('cannot detemine id'); + throw new Error('cannot determine id'); } /** @@ -169,10 +169,8 @@ export interface IActor extends IObject { discoverable?: boolean; inbox: string; sharedInbox?: string; // 後方互換性のため - publicKey?: { - id: string; - publicKeyPem: string; - }; + publicKey?: IKey | IKey[]; + additionalPublicKeys?: IKey[]; followers?: string | ICollection | IOrderedCollection; following?: string | ICollection | IOrderedCollection; featured?: string | IOrderedCollection; @@ -236,8 +234,9 @@ export const isEmoji = (object: IObject): object is IApEmoji => export interface IKey extends IObject { type: 'Key'; + id: string; owner: string; - publicKeyPem: string | Buffer; + publicKeyPem: string; } export interface IApDocument extends IObject { diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 9117b13914..fd0f55c6ab 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -56,6 +56,7 @@ export class InstanceEntityService { infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, moderationNote: iAmModerator ? instance.moderationNote : null, + httpMessageSignaturesImplementationLevel: instance.httpMessageSignaturesImplementationLevel, }; } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index da96878713..7fd093c191 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -501,11 +501,15 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由でローカルユーザーのみ - badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.sort((a, b) => b.displayOrder - a.displayOrder).map(r => ({ - name: r.name, - iconUrl: r.iconUrl, - displayOrder: r.displayOrder, - }))) : undefined, + badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs + .filter((r) => r.isPublic || iAmModerator) + .sort((a, b) => b.displayOrder - a.displayOrder) + .map((r) => ({ + name: r.name, + iconUrl: r.iconUrl, + displayOrder: r.displayOrder, + })) + ) : undefined, ...(isDetailed ? { url: profile!.url, diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index bba64a06ef..f498c110bf 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -195,6 +195,9 @@ export class MemoryKVCache { private lifetime: number; private gcIntervalHandle: NodeJS.Timeout; + /** + * @param lifetime キャッシュの生存期間 (ms) + */ constructor(lifetime: MemoryKVCache['lifetime']) { this.cache = new Map(); this.lifetime = lifetime; diff --git a/packages/backend/src/misc/gen-key-pair.ts b/packages/backend/src/misc/gen-key-pair.ts index 02a303dc0a..0b033ec33e 100644 --- a/packages/backend/src/misc/gen-key-pair.ts +++ b/packages/backend/src/misc/gen-key-pair.ts @@ -3,39 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as crypto from 'node:crypto'; -import * as util from 'node:util'; +import { genEd25519KeyPair, genRsaKeyPair } from '@misskey-dev/node-http-message-signatures'; -const generateKeyPair = util.promisify(crypto.generateKeyPair); - -export async function genRsaKeyPair(modulusLength = 2048) { - return await generateKeyPair('rsa', { - modulusLength, - publicKeyEncoding: { - type: 'spki', - format: 'pem', - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem', - cipher: undefined, - passphrase: undefined, - }, - }); -} - -export async function genEcKeyPair(namedCurve: 'prime256v1' | 'secp384r1' | 'secp521r1' | 'curve25519' = 'prime256v1') { - return await generateKeyPair('ec', { - namedCurve, - publicKeyEncoding: { - type: 'spki', - format: 'pem', - }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem', - cipher: undefined, - passphrase: undefined, - }, - }); +export async function genRSAAndEd25519KeyPair(rsaModulusLength = 4096) { + const [rsa, ed25519] = await Promise.all([genRsaKeyPair(rsaModulusLength), genEd25519KeyPair()]); + return { + publicKey: rsa.publicKey, + privateKey: rsa.privateKey, + ed25519PublicKey: ed25519.publicKey, + ed25519PrivateKey: ed25519.privateKey, + }; } diff --git a/packages/backend/src/misc/is-user-related.ts b/packages/backend/src/misc/is-user-related.ts index 93c9b2b814..862d6e6a38 100644 --- a/packages/backend/src/misc/is-user-related.ts +++ b/packages/backend/src/misc/is-user-related.ts @@ -4,6 +4,10 @@ */ export function isUserRelated(note: any, userIds: Set, ignoreAuthor = false): boolean { + if (!note) { + return false; + } + if (userIds.has(note.userId) && !ignoreAuthor) { return true; } diff --git a/packages/backend/src/models/Instance.ts b/packages/backend/src/models/Instance.ts index 17cd5c6665..f2f2831cf1 100644 --- a/packages/backend/src/models/Instance.ts +++ b/packages/backend/src/models/Instance.ts @@ -158,4 +158,9 @@ export class MiInstance { length: 16384, default: '', }) public moderationNote: string; + + @Column('varchar', { + length: 16, default: '00', nullable: false, + }) + public httpMessageSignaturesImplementationLevel: string; } diff --git a/packages/backend/src/models/UserKeypair.ts b/packages/backend/src/models/UserKeypair.ts index f5252d126c..afa74ef11a 100644 --- a/packages/backend/src/models/UserKeypair.ts +++ b/packages/backend/src/models/UserKeypair.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { PrimaryColumn, Entity, JoinColumn, Column, OneToOne } from 'typeorm'; +import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -12,22 +12,42 @@ export class MiUserKeypair { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @ManyToOne(type => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() public user: MiUser | null; + /** + * RSA public key + */ @Column('varchar', { length: 4096, }) public publicKey: string; + /** + * RSA private key + */ @Column('varchar', { length: 4096, }) public privateKey: string; + @Column('varchar', { + length: 128, + nullable: true, + default: null, + }) + public ed25519PublicKey: string | null; + + @Column('varchar', { + length: 128, + nullable: true, + default: null, + }) + public ed25519PrivateKey: string | null; + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/UserPublickey.ts b/packages/backend/src/models/UserPublickey.ts index 6bcd785304..0ecff2bcbe 100644 --- a/packages/backend/src/models/UserPublickey.ts +++ b/packages/backend/src/models/UserPublickey.ts @@ -9,7 +9,13 @@ import { MiUser } from './User.js'; @Entity('user_publickey') export class MiUserPublickey { - @PrimaryColumn(id()) + @PrimaryColumn('varchar', { + length: 256, + }) + public keyId: string; + + @Index() + @Column(id()) public userId: MiUser['id']; @OneToOne(type => MiUser, { @@ -18,12 +24,6 @@ export class MiUserPublickey { @JoinColumn() public user: MiUser | null; - @Index({ unique: true }) - @Column('varchar', { - length: 256, - }) - public keyId: string; - @Column('varchar', { length: 4096, }) diff --git a/packages/backend/src/models/json-schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts index ed40d405c6..c02e7f557a 100644 --- a/packages/backend/src/models/json-schema/federation-instance.ts +++ b/packages/backend/src/models/json-schema/federation-instance.ts @@ -116,5 +116,9 @@ export const packedFederationInstanceSchema = { type: 'string', optional: true, nullable: true, }, + httpMessageSignaturesImplementationLevel: { + type: 'string', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index d9987a70c3..7366f05356 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -228,6 +228,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canUpdateBioMedia: { + type: 'boolean', + optional: false, nullable: false, + }, pinLimit: { type: 'integer', optional: false, nullable: false, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 7bd74f3210..169b22c3f5 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -250,9 +250,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, { ...baseQueueOptions(this.config, QUEUE.DELIVER), autorun: false, - concurrency: this.config.deliverJobConcurrency ?? 128, + concurrency: this.config.deliverJobConcurrency ?? 16, limiter: { - max: this.config.deliverJobPerSec ?? 128, + max: this.config.deliverJobPerSec ?? 1024, duration: 1000, }, settings: { @@ -290,9 +290,9 @@ export class QueueProcessorService implements OnApplicationShutdown { }, { ...baseQueueOptions(this.config, QUEUE.INBOX), autorun: false, - concurrency: this.config.inboxJobConcurrency ?? 16, + concurrency: this.config.inboxJobConcurrency ?? 4, limiter: { - max: this.config.inboxJobPerSec ?? 32, + max: this.config.inboxJobPerSec ?? 64, duration: 1000, }, settings: { diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index d665945861..3bd9187e8b 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -73,25 +73,33 @@ export class DeliverProcessorService { } try { - await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest); + const _server = await this.federatedInstanceService.fetch(host); + await this.fetchInstanceMetadataService.fetchInstanceMetadata(_server).then(() => {}); + const server = await this.federatedInstanceService.fetch(host); + + await this.apRequestService.signedPost( + job.data.user, + job.data.to, + job.data.content, + server.httpMessageSignaturesImplementationLevel, + job.data.digest, + job.data.privateKey, + ); // Update stats - this.federatedInstanceService.fetch(host).then(i => { - if (i.isNotResponding) { - this.federatedInstanceService.update(i.id, { - isNotResponding: false, - notRespondingSince: null, - }); - } + if (server.isNotResponding) { + this.federatedInstanceService.update(server.id, { + isNotResponding: false, + notRespondingSince: null, + }); + } - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - this.apRequestChart.deliverSucc(); - this.federationChart.deliverd(i.host, true); + this.apRequestChart.deliverSucc(); + this.federationChart.deliverd(server.host, true); - if (meta.enableChartsForFederatedInstances) { - this.instanceChart.requestSent(i.host, true); - } - }); + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestSent(server.host, true); + } return 'Success'; } catch (res) { diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index fa7009f8f5..935c623df1 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -5,8 +5,8 @@ import { URL } from 'node:url'; import { Injectable } from '@nestjs/common'; -import httpSignature from '@peertube/http-signature'; import * as Bull from 'bullmq'; +import { verifyDraftSignature } from '@misskey-dev/node-http-message-signatures'; import type Logger from '@/logger.js'; import { MetaService } from '@/core/MetaService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -20,6 +20,7 @@ import type { MiRemoteUser } from '@/models/User.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { StatusError } from '@/misc/status-error.js'; +import * as Acct from '@/misc/acct.js'; import { UtilityService } from '@/core/UtilityService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; @@ -52,8 +53,15 @@ export class InboxProcessorService { @bindThis public async process(job: Bull.Job): Promise { - const signature = job.data.signature; // HTTP-signature + const signature = job.data.signature ? + 'version' in job.data.signature ? job.data.signature.value : job.data.signature + : null; + if (Array.isArray(signature)) { + // RFC 9401はsignatureが配列になるが、とりあえずエラーにする + throw new Error('signature is array'); + } let activity = job.data.activity; + let actorUri = getApId(activity.actor); //#region Log const info = Object.assign({}, activity); @@ -61,7 +69,7 @@ export class InboxProcessorService { this.logger.debug(JSON.stringify(info, null, 2)); //#endregion - const host = this.utilityService.toPuny(new URL(signature.keyId).hostname); + const host = this.utilityService.toPuny(new URL(actorUri).hostname); // ブロックしてたら中断 const meta = await this.metaService.fetch(); @@ -69,69 +77,76 @@ export class InboxProcessorService { return `Blocked request: ${host}`; } - const keyIdLower = signature.keyId.toLowerCase(); - if (keyIdLower.startsWith('acct:')) { - return `Old keyId is no longer supported. ${keyIdLower}`; - } - // HTTP-Signature keyIdを元にDBから取得 - let authUser: { - user: MiRemoteUser; - key: MiUserPublickey | null; - } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); + let authUser: Awaited> = null; + let httpSignatureIsValid = null as boolean | null; - // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 - if (authUser == null) { - try { - authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor)); - } catch (err) { - // 対象が4xxならスキップ - if (err instanceof StatusError) { - if (!err.isRetryable) { - throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`); - } - throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`); + try { + authUser = await this.apDbResolverService.getAuthUserFromApId(actorUri, signature?.keyId); + } catch (err) { + // 対象が4xxならスキップ + if (err instanceof StatusError) { + if (!err.isRetryable) { + throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`); } + throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`); } } - // それでもわからなければ終了 - if (authUser == null) { + // authUser.userがnullならスキップ + if (authUser != null && authUser.user == null) { throw new Bull.UnrecoverableError('skip: failed to resolve user'); } - // publicKey がなくても終了 - if (authUser.key == null) { - throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey'); + if (signature != null && authUser != null) { + if (signature.keyId.toLowerCase().startsWith('acct:')) { + this.logger.warn(`Old keyId is no longer supported. lowerKeyId=${signature.keyId.toLowerCase()}`); + } else if (authUser.key != null) { + // keyがなかったらLD Signatureで検証するべき + // HTTP-Signatureの検証 + const errorLogger = (ms: any) => this.logger.error(ms); + httpSignatureIsValid = await verifyDraftSignature(signature, authUser.key.keyPem, errorLogger); + this.logger.debug('Inbox message validation: ', { + userId: authUser.user.id, + userAcct: Acct.toString(authUser.user), + parsedKeyId: signature.keyId, + foundKeyId: authUser.key.keyId, + httpSignatureValid: httpSignatureIsValid, + }); + } } - // HTTP-Signatureの検証 - const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); - - // また、signatureのsignerは、activity.actorと一致する必要がある - if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { + if ( + authUser == null || + httpSignatureIsValid !== true || + authUser.user.uri !== actorUri // 一応チェック + ) { // 一致しなくても、でもLD-Signatureがありそうならそっちも見る const ldSignature = activity.signature; - if (ldSignature) { + + if (ldSignature && ldSignature.creator) { if (ldSignature.type !== 'RsaSignature2017') { throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`); } - // ldSignature.creator: https://example.oom/users/user#main-key - // みたいになっててUserを引っ張れば公開キーも入ることを期待する - if (ldSignature.creator) { - const candicate = ldSignature.creator.replace(/#.*/, ''); - await this.apPersonService.resolvePerson(candicate).catch(() => null); + if (ldSignature.creator.toLowerCase().startsWith('acct:')) { + throw new Bull.UnrecoverableError(`old key not supported ${ldSignature.creator}`); } - // keyIdからLD-Signatureのユーザーを取得 - authUser = await this.apDbResolverService.getAuthUserFromKeyId(ldSignature.creator); + authUser = await this.apDbResolverService.getAuthUserFromApId(actorUri, ldSignature.creator); + if (authUser == null) { - throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした'); + throw new Bull.UnrecoverableError(`skip: LD-Signatureのactorとcreatorが一致しませんでした uri=${actorUri} creator=${ldSignature.creator}`); + } + if (authUser.user == null) { + throw new Bull.UnrecoverableError(`skip: LD-Signatureのユーザーが取得できませんでした uri=${actorUri} creator=${ldSignature.creator}`); + } + // 一応actorチェック + if (authUser.user.uri !== actorUri) { + throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${actorUri})`); } - if (authUser.key == null) { - throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'); + throw new Bull.UnrecoverableError(`skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした uri=${actorUri} creator=${ldSignature.creator}`); } const jsonLd = this.jsonLdService.use(); @@ -142,13 +157,27 @@ export class InboxProcessorService { throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); } + // ブロックしてたら中断 + const ldHost = this.utilityService.extractDbHost(authUser.user.uri); + if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) { + throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); + } + // アクティビティを正規化 + // GHSA-2vxv-pv3m-3wvj delete activity.signature; try { activity = await jsonLd.compact(activity) as IActivity; } catch (e) { throw new Bull.UnrecoverableError(`skip: failed to compact activity: ${e}`); } + + // actorが正規化前後で一致しているか確認 + actorUri = getApId(activity.actor); + if (authUser.user.uri !== actorUri) { + throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity(after normalization).actor(${actorUri})`); + } + // TODO: 元のアクティビティと非互換な形に正規化される場合は転送をスキップする // https://github.com/mastodon/mastodon/blob/664b0ca/app/services/activitypub/process_collection_service.rb#L24-L29 activity.signature = ldSignature; @@ -158,19 +187,8 @@ export class InboxProcessorService { delete compactedInfo['@context']; this.logger.debug(`compacted: ${JSON.stringify(compactedInfo, null, 2)}`); //#endregion - - // もう一度actorチェック - if (authUser.user.uri !== activity.actor) { - throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); - } - - // ブロックしてたら中断 - const ldHost = this.utilityService.extractDbHost(authUser.user.uri); - if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) { - throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); - } } else { - throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`); + throw new Bull.UnrecoverableError(`skip: http-signature verification failed and no LD-Signature. http_signature_keyId=${signature?.keyId}`); } } diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index a4077a0547..f2466f2e3d 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -9,7 +9,24 @@ import type { MiNote } from '@/models/Note.js'; import type { MiUser } from '@/models/User.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { IActivity } from '@/core/activitypub/type.js'; -import type httpSignature from '@peertube/http-signature'; +import type { ParsedSignature, PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures'; + +/** + * @peertube/http-signature 時代の古いデータにも対応しておく + * TODO: 2026年ぐらいには消す + */ +export interface OldParsedSignature { + scheme: 'Signature'; + params: { + keyId: string; + algorithm: string; + headers: string[]; + signature: string; + }; + signingString: string; + algorithm: string; + keyId: string; +} export type DeliverJobData = { /** Actor */ @@ -22,11 +39,13 @@ export type DeliverJobData = { to: string; /** whether it is sharedInbox */ isSharedInbox: boolean; + /** force to use main (rsa) key */ + privateKey?: PrivateKeyWithPem; }; export type InboxJobData = { activity: IActivity; - signature: httpSignature.IParsedSignature; + signature: ParsedSignature | OldParsedSignature | null; }; export type RelationshipJobData = { diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 3255d64621..753eaad047 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -3,11 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import * as crypto from 'node:crypto'; import { IncomingMessage } from 'node:http'; import { Inject, Injectable } from '@nestjs/common'; import fastifyAccepts from '@fastify/accepts'; -import httpSignature from '@peertube/http-signature'; +import { verifyDigestHeader, parseRequestSignature } from '@misskey-dev/node-http-message-signatures'; import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; import accepts from 'accepts'; import vary from 'vary'; @@ -31,12 +30,17 @@ import { IActivity } from '@/core/activitypub/type.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; +import { LoggerService } from '@/core/LoggerService.js'; +import Logger from '@/logger.js'; const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; @Injectable() export class ActivityPubServerService { + private logger: Logger; + private inboxLogger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -71,8 +75,11 @@ export class ActivityPubServerService { private queueService: QueueService, private userKeypairService: UserKeypairService, private queryService: QueryService, + private loggerService: LoggerService, ) { //this.createServer = this.createServer.bind(this); + this.logger = this.loggerService.getLogger('server-ap', 'gray'); + this.inboxLogger = this.logger.createSubLogger('inbox', 'gray'); } @bindThis @@ -100,70 +107,44 @@ export class ActivityPubServerService { } @bindThis - private inbox(request: FastifyRequest, reply: FastifyReply) { - let signature; + private async inbox(request: FastifyRequest, reply: FastifyReply) { + if (request.body == null) { + this.inboxLogger.warn('request body is empty'); + reply.code(400); + return; + } + + let signature: ReturnType; + + const verifyDigest = await verifyDigestHeader(request.raw, request.rawBody || '', true); + if (verifyDigest !== true) { + this.inboxLogger.warn('digest verification failed'); + reply.code(401); + return; + } try { - signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); - } catch (e) { + signature = parseRequestSignature(request.raw, { + requiredInputs: { + draft: ['(request-target)', 'digest', 'host', 'date'], + }, + }); + } catch (err) { + this.inboxLogger.warn('signature header parsing failed', { err }); + + if (typeof request.body === 'object' && 'signature' in request.body) { + // LD SignatureがあればOK + this.queueService.inbox(request.body as IActivity, null); + reply.code(202); + return; + } + + this.inboxLogger.warn('signature header parsing failed and LD signature not found'); reply.code(401); return; } - if (signature.params.headers.indexOf('host') === -1 - || request.headers.host !== this.config.host) { - // Host not specified or not match. - reply.code(401); - return; - } - - if (signature.params.headers.indexOf('digest') === -1) { - // Digest not found. - reply.code(401); - } else { - const digest = request.headers.digest; - - if (typeof digest !== 'string') { - // Huh? - reply.code(401); - return; - } - - const re = /^([a-zA-Z0-9\-]+)=(.+)$/; - const match = digest.match(re); - - if (match == null) { - // Invalid digest - reply.code(401); - return; - } - - const algo = match[1].toUpperCase(); - const digestValue = match[2]; - - if (algo !== 'SHA-256') { - // Unsupported digest algorithm - reply.code(401); - return; - } - - if (request.rawBody == null) { - // Bad request - reply.code(400); - return; - } - - const hash = crypto.createHash('sha256').update(request.rawBody).digest('base64'); - - if (hash !== digestValue) { - // Invalid digest - reply.code(401); - return; - } - } - this.queueService.inbox(request.body as IActivity, signature); - reply.code(202); } @@ -640,7 +621,7 @@ export class ActivityPubServerService { if (this.userEntityService.isLocalUser(user)) { reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); - return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair))); + return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair.publicKey))); } else { reply.code(400); return; diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index cc18997fdc..c0f8084768 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -94,6 +94,13 @@ export class NodeinfoServerService { localComments: 0, }, metadata: { + /** + * '00': Draft, RSA only + * '01': Draft, Ed25519 suported + * '11': RFC 9421, Ed25519 supported + */ + httpMessageSignaturesImplementationLevel: '01', + nodeName: meta.name, nodeDescription: meta.description, nodeAdmins: [{ diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts index 305ae1af1d..bfe230da8d 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -56,7 +56,8 @@ export default class extends Endpoint { // eslint- const res = [] as [string, number][]; for (const job of jobs) { - const host = new URL(job.data.signature.keyId).host; + const signature = job.data.signature ? 'version' in job.data.signature ? job.data.signature.value : job.data.signature : null; + const host = signature ? Array.isArray(signature) ? 'TODO' : new URL(signature.keyId).host : new URL(job.data.activity.actor).host; if (res.find(x => x[0] === host)) { res.find(x => x[0] === host)![1]++; } else { diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index b39b52bc41..a1e2fa5e4c 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -25,7 +25,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; +import { RolePolicies, RoleService } from '@/core/RoleService.js'; import { CacheService } from '@/core/CacheService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; @@ -256,6 +256,7 @@ export default class extends Endpoint { // eslint- const profileUpdates = {} as Partial; const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + let policies: RolePolicies | null = null; if (ps.name !== undefined) { if (ps.name === null) { @@ -296,14 +297,16 @@ export default class extends Endpoint { // eslint- } if (ps.mutedWords !== undefined) { - checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + policies ??= await this.roleService.getUserPolicies(user.id); + checkMuteWordCount(ps.mutedWords, policies.wordMuteLimit); validateMuteWordRegex(ps.mutedWords); profileUpdates.mutedWords = ps.mutedWords; profileUpdates.enableWordMute = ps.mutedWords.length > 0; } if (ps.hardMutedWords !== undefined) { - checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + policies ??= await this.roleService.getUserPolicies(user.id); + checkMuteWordCount(ps.hardMutedWords, policies.wordMuteLimit); validateMuteWordRegex(ps.hardMutedWords); profileUpdates.hardMutedWords = ps.hardMutedWords; } @@ -322,13 +325,17 @@ export default class extends Endpoint { // eslint- if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.alwaysMarkNsfw === 'boolean') { - if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); + policies ??= await this.roleService.getUserPolicies(user.id); + if (policies.alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; } if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; if (ps.avatarId) { + policies ??= await this.roleService.getUserPolicies(user.id); + if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole); + const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId }); if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); @@ -344,6 +351,9 @@ export default class extends Endpoint { // eslint- } if (ps.bannerId) { + policies ??= await this.roleService.getUserPolicies(user.id); + if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole); + const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); @@ -359,14 +369,15 @@ export default class extends Endpoint { // eslint- } if (ps.avatarDecorations) { + policies ??= await this.roleService.getUserPolicies(user.id); const decorations = await this.avatarDecorationService.getAll(true); - const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]); + const myRoles = await this.roleService.getUserRoles(user.id); const allRoles = await this.roleService.getRoles(); const decorationIds = decorations .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) .map(d => d.id); - if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); + if (ps.avatarDecorations.length > policies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ id: d.id, diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index aca883a052..7805ae3288 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { RoleService } from '@/core/RoleService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -74,6 +75,7 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { + const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set(); const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users if (!iAmModerator) { const user = await this.cacheService.findUserById(ps.userId); @@ -85,8 +87,15 @@ export default class extends Endpoint { // eslint- if ((me == null || me.id !== ps.userId) && !profile.publicReactions) { throw new ApiError(meta.errors.reactionsNotPublic); } + + // early return if me is blocked by requesting user + if (userIdsWhoBlockingMe.has(ps.userId)) { + return []; + } } + const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set(); + const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('reaction.userId = :userId', { userId: ps.userId }) @@ -94,9 +103,15 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); - const reactions = await query + const reactions = (await query .limit(ps.limit) - .getMany(); + .getMany()).filter(reaction => { + if (reaction.note?.userId === ps.userId) return true; // we can see reactions to note of requesting user + if (me && isUserRelated(reaction.note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(reaction.note, userIdsWhoMeMuting)) return false; + + return true; + }); return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true }); }); diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index 13c56b88a6..06548fa7da 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -206,7 +206,7 @@ describe('2要素認証', () => { username, }, alice); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); const signinResponse = await api('signin', { ...signinParam(), @@ -248,7 +248,7 @@ describe('2要素認証', () => { keyName, credentialId, creationOptions: registerKeyResponse.body, - }) as any, alice); + } as any) as any, alice); assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); assert.strictEqual(keyDoneResponse.body.name, keyName); @@ -257,22 +257,22 @@ describe('2要素認証', () => { username, }); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.securityKeys, true); + assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, true); const signinResponse = await api('signin', { ...signinParam(), }); assert.strictEqual(signinResponse.status, 200); assert.strictEqual(signinResponse.body.i, undefined); - assert.notEqual(signinResponse.body.challenge, undefined); - assert.notEqual(signinResponse.body.allowCredentials, undefined); - assert.strictEqual(signinResponse.body.allowCredentials[0].id, credentialId.toString('base64url')); + assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined); + assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined); + assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url')); const signinResponse2 = await api('signin', signinWithSecurityKeyParam({ keyName, credentialId, requestOptions: signinResponse.body, - })); + } as any)); assert.strictEqual(signinResponse2.status, 200); assert.notEqual(signinResponse2.body.i, undefined); @@ -307,7 +307,7 @@ describe('2要素認証', () => { keyName, credentialId, creationOptions: registerKeyResponse.body, - }) as any, alice); + } as any) as any, alice); assert.strictEqual(keyDoneResponse.status, 200); const passwordLessResponse = await api('i/2fa/password-less', { @@ -319,7 +319,7 @@ describe('2要素認証', () => { username, }); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.usePasswordLessLogin, true); + assert.strictEqual((usersShowResponse.body as unknown as { usePasswordLessLogin: boolean }).usePasswordLessLogin, true); const signinResponse = await api('signin', { ...signinParam(), @@ -333,7 +333,7 @@ describe('2要素認証', () => { keyName, credentialId, requestOptions: signinResponse.body, - }), + } as any), password: '', }); assert.strictEqual(signinResponse2.status, 200); @@ -370,7 +370,7 @@ describe('2要素認証', () => { keyName, credentialId, creationOptions: registerKeyResponse.body, - }) as any, alice); + } as any) as any, alice); assert.strictEqual(keyDoneResponse.status, 200); const renamedKey = 'other-key'; @@ -383,6 +383,7 @@ describe('2要素認証', () => { const iResponse = await api('i', { }, alice); assert.strictEqual(iResponse.status, 200); + assert.ok(iResponse.body.securityKeysList); const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url')); assert.strictEqual(securityKeys.length, 1); assert.strictEqual(securityKeys[0].name, renamedKey); @@ -419,13 +420,14 @@ describe('2要素認証', () => { keyName, credentialId, creationOptions: registerKeyResponse.body, - }) as any, alice); + } as any) as any, alice); assert.strictEqual(keyDoneResponse.status, 200); // テストの実行順によっては複数残ってるので全部消す const iResponse = await api('i', { }, alice); assert.strictEqual(iResponse.status, 200); + assert.ok(iResponse.body.securityKeysList); for (const key of iResponse.body.securityKeysList) { const removeKeyResponse = await api('i/2fa/remove-key', { token: otpToken(registerResponse.body.secret), @@ -439,7 +441,7 @@ describe('2要素認証', () => { username, }); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.securityKeys, false); + assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, false); const signinResponse = await api('signin', { ...signinParam(), @@ -470,7 +472,7 @@ describe('2要素認証', () => { username, }); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); const unregisterResponse = await api('i/2fa/unregister', { token: otpToken(registerResponse.body.secret), diff --git a/packages/backend/test/e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts index c61b0c2a86..2dd645d97a 100644 --- a/packages/backend/test/e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -410,21 +410,21 @@ describe('API visibility', () => { test('[HTL] public-post が 自分が見れる', async () => { const res = await api('notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === pub.id); + const notes = res.body.filter(n => n.id === pub.id); assert.strictEqual(notes[0].text, 'x'); }); test('[HTL] public-post が 非フォロワーから見れない', async () => { const res = await api('notes/timeline', { limit: 100 }, other); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === pub.id); + const notes = res.body.filter(n => n.id === pub.id); assert.strictEqual(notes.length, 0); }); test('[HTL] followers-post が フォロワーから見れる', async () => { const res = await api('notes/timeline', { limit: 100 }, follower); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === fol.id); + const notes = res.body.filter(n => n.id === fol.id); assert.strictEqual(notes[0].text, 'x'); }); //#endregion @@ -433,21 +433,21 @@ describe('API visibility', () => { test('[replies] followers-reply が フォロワーから見れる', async () => { const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, follower); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); + const notes = res.body.filter(n => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => { const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, other); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); + const notes = res.body.filter(n => n.id === folR.id); assert.strictEqual(notes.length, 0); }); test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, target); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); + const notes = res.body.filter(n => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); //#endregion @@ -456,14 +456,14 @@ describe('API visibility', () => { test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { const res = await api('notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); + const notes = res.body.filter(n => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => { const res = await api('notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folM.id); + const notes = res.body.filter(n => n.id === folM.id); assert.strictEqual(notes[0].text, '@target x'); }); //#endregion diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index e4f798498f..35b0e59383 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -6,7 +6,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { api, post, signup } from '../utils.js'; +import { api, castAsError, post, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Block', () => { @@ -33,7 +33,7 @@ describe('Block', () => { const res = await api('following/create', { userId: alice.id }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); + assert.strictEqual(castAsError(res.body).error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); }); test('ブロックされているユーザーにリアクションできない', async () => { @@ -42,7 +42,8 @@ describe('Block', () => { const res = await api('notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); }); test('ブロックされているユーザーに返信できない', async () => { @@ -51,7 +52,8 @@ describe('Block', () => { const res = await api('notes/create', { replyId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); }); test('ブロックされているユーザーのノートをRenoteできない', async () => { @@ -60,7 +62,7 @@ describe('Block', () => { const res = await api('notes/create', { renoteId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); + assert.strictEqual(castAsError(res.body).error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); }); // TODO: ユーザーリストに入れられないテスト diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index a229ec06f9..a130c3698d 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -79,14 +79,14 @@ describe('クリップ', () => { }; const deleteClip = async (parameters: Misskey.entities.ClipsDeleteRequest, request: Partial> = {}): Promise => { - return await successfulApiCall({ + await successfulApiCall({ endpoint: 'clips/delete', parameters, user: alice, ...request, }, { status: 204, - }) as any as void; + }); }; const show = async (parameters: Misskey.entities.ClipsShowRequest, request: Partial> = {}): Promise => { @@ -454,25 +454,25 @@ describe('クリップ', () => { let aliceClip: Misskey.entities.Clip; const favorite = async (parameters: Misskey.entities.ClipsFavoriteRequest, request: Partial> = {}): Promise => { - return successfulApiCall({ + await successfulApiCall({ endpoint: 'clips/favorite', parameters, user: alice, ...request, }, { status: 204, - }) as any as void; + }); }; const unfavorite = async (parameters: Misskey.entities.ClipsUnfavoriteRequest, request: Partial> = {}): Promise => { - return successfulApiCall({ + await successfulApiCall({ endpoint: 'clips/unfavorite', parameters, user: alice, ...request, }, { status: 204, - }) as any as void; + }); }; const myFavorites = async (request: Partial> = {}): Promise => { diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 2b2699ecd9..5aaec7f6f9 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -10,7 +10,7 @@ import * as assert from 'assert'; // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; import { MiUser } from '@/models/_.js'; -import { api, initTestDb, post, signup, simpleGet, uploadFile } from '../utils.js'; +import { api, castAsError, initTestDb, post, signup, simpleGet, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Endpoints', () => { @@ -164,7 +164,7 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.id, alice.id); + assert.strictEqual((res.body as unknown as { id: string }).id, alice.id); }); test('ユーザーが存在しなかったら怒る', async () => { @@ -285,7 +285,8 @@ describe('Endpoints', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'CANNOT_REACT_TO_RENOTE'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.code, 'CANNOT_REACT_TO_RENOTE'); }); test('引用にリアクションできる', async () => { @@ -1063,7 +1064,7 @@ describe('Endpoints', () => { userId: bob.id, }, alice); assert.strictEqual(res1.status, 204); - assert.strictEqual(res2.body?.memo, memo); + assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo); }); test('自分に関するメモを更新できる', async () => { @@ -1078,7 +1079,7 @@ describe('Endpoints', () => { userId: alice.id, }, alice); assert.strictEqual(res1.status, 204); - assert.strictEqual(res2.body?.memo, memo); + assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo); }); test('メモを削除できる', async () => { @@ -1099,7 +1100,7 @@ describe('Endpoints', () => { }, alice); // memoには常に文字列かnullが入っている(5cac151) - assert.strictEqual(res.body.memo, null); + assert.strictEqual((res.body as unknown as { memo: string | null }).memo, null); }); test('メモは個人ごとに独立して保存される', async () => { @@ -1126,8 +1127,8 @@ describe('Endpoints', () => { }, carol), ]); - assert.strictEqual(resAlice.body.memo, memoAliceToBob); - assert.strictEqual(resCarol.body.memo, memoCarolToBob); + assert.strictEqual((resAlice.body as unknown as { memo: string }).memo, memoAliceToBob); + assert.strictEqual((resCarol.body as unknown as { memo: string }).memo, memoCarolToBob); }); }); }); diff --git a/packages/backend/test/e2e/exports.ts b/packages/backend/test/e2e/exports.ts index 80a5331a6d..4bcecc9716 100644 --- a/packages/backend/test/e2e/exports.ts +++ b/packages/backend/test/e2e/exports.ts @@ -61,14 +61,14 @@ describe('export-clips', () => { }); test('basic export', async () => { - let res = await api('clips/create', { + const res1 = await api('clips/create', { name: 'foo', description: 'bar', }, alice); - assert.strictEqual(res.status, 200); + assert.strictEqual(res1.status, 200); - res = await api('i/export-clips', {}, alice); - assert.strictEqual(res.status, 204); + const res2 = await api('i/export-clips', {}, alice); + assert.strictEqual(res2.status, 204); const exported = await pollFirstDriveFile(); assert.strictEqual(exported[0].name, 'foo'); @@ -77,7 +77,7 @@ describe('export-clips', () => { }); test('export with notes', async () => { - let res = await api('clips/create', { + const res = await api('clips/create', { name: 'foo', description: 'bar', }, alice); @@ -96,15 +96,15 @@ describe('export-clips', () => { }); for (const note of [note1, note2]) { - res = await api('clips/add-note', { + const res2 = await api('clips/add-note', { clipId: clip.id, noteId: note.id, }, alice); - assert.strictEqual(res.status, 204); + assert.strictEqual(res2.status, 204); } - res = await api('i/export-clips', {}, alice); - assert.strictEqual(res.status, 204); + const res3 = await api('i/export-clips', {}, alice); + assert.strictEqual(res3.status, 204); const exported = await pollFirstDriveFile(); assert.strictEqual(exported[0].name, 'foo'); @@ -116,19 +116,19 @@ describe('export-clips', () => { }); test('multiple clips', async () => { - let res = await api('clips/create', { + const res1 = await api('clips/create', { name: 'kawaii', description: 'kawaii', }, alice); - assert.strictEqual(res.status, 200); - const clip1 = res.body; + assert.strictEqual(res1.status, 200); + const clip1 = res1.body; - res = await api('clips/create', { + const res2 = await api('clips/create', { name: 'yuri', description: 'yuri', }, alice); - assert.strictEqual(res.status, 200); - const clip2 = res.body; + assert.strictEqual(res2.status, 200); + const clip2 = res2.body; const note1 = await post(alice, { text: 'baz1', @@ -138,20 +138,26 @@ describe('export-clips', () => { text: 'baz2', }); - res = await api('clips/add-note', { - clipId: clip1.id, - noteId: note1.id, - }, alice); - assert.strictEqual(res.status, 204); + { + const res = await api('clips/add-note', { + clipId: clip1.id, + noteId: note1.id, + }, alice); + assert.strictEqual(res.status, 204); + } - res = await api('clips/add-note', { - clipId: clip2.id, - noteId: note2.id, - }, alice); - assert.strictEqual(res.status, 204); + { + const res = await api('clips/add-note', { + clipId: clip2.id, + noteId: note2.id, + }, alice); + assert.strictEqual(res.status, 204); + } - res = await api('i/export-clips', {}, alice); - assert.strictEqual(res.status, 204); + { + const res = await api('i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + } const exported = await pollFirstDriveFile(); assert.strictEqual(exported[0].name, 'kawaii'); @@ -163,7 +169,7 @@ describe('export-clips', () => { }); test('Clipping other user\'s note', async () => { - let res = await api('clips/create', { + const res = await api('clips/create', { name: 'kawaii', description: 'kawaii', }, alice); @@ -175,14 +181,14 @@ describe('export-clips', () => { visibility: 'followers', }); - res = await api('clips/add-note', { + const res2 = await api('clips/add-note', { clipId: clip.id, noteId: note.id, }, alice); - assert.strictEqual(res.status, 204); + assert.strictEqual(res2.status, 204); - res = await api('i/export-clips', {}, alice); - assert.strictEqual(res.status, 204); + const res3 = await api('i/export-clips', {}, alice); + assert.strictEqual(res3.status, 204); const exported = await pollFirstDriveFile(); assert.strictEqual(exported[0].name, 'kawaii'); diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts index 35240cd3c8..fd798bdb25 100644 --- a/packages/backend/test/e2e/move.ts +++ b/packages/backend/test/e2e/move.ts @@ -13,14 +13,14 @@ import { loadConfig } from '@/config.js'; import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { jobQueue } from '@/boot/common.js'; -import { api, initTestDb, signup, successfulApiCall, uploadFile } from '../utils.js'; +import { api, castAsError, initTestDb, signup, successfulApiCall, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Account Move', () => { let jq: INestApplicationContext; let url: URL; - let root: any; + let root: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse; @@ -93,8 +93,8 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_USER'); + assert.strictEqual(castAsError(res.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); }); test('Unable to add duplicated aliases to alsoKnownAs', async () => { @@ -103,8 +103,8 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'INVALID_PARAM'); - assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); + assert.strictEqual(castAsError(res.body).error.code, 'INVALID_PARAM'); + assert.strictEqual(castAsError(res.body).error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); }); test('Unable to add itself', async () => { @@ -113,8 +113,8 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF'); - assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); + assert.strictEqual(castAsError(res.body).error.code, 'FORBIDDEN_TO_SET_YOURSELF'); + assert.strictEqual(castAsError(res.body).error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); }); test('Unable to add a nonexisting local account to alsoKnownAs', async () => { @@ -123,16 +123,16 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res1.status, 400); - assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + assert.strictEqual(castAsError(res1.body).error.code, 'NO_SUCH_USER'); + assert.strictEqual(castAsError(res1.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); const res2 = await api('i/update', { alsoKnownAs: ['@alice', 'nonexist'], }, bob); assert.strictEqual(res2.status, 400); - assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + assert.strictEqual(castAsError(res2.body).error.code, 'NO_SUCH_USER'); + assert.strictEqual(castAsError(res2.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); }); test('Able to add two existing local account to alsoKnownAs', async () => { @@ -241,8 +241,8 @@ describe('Account Move', () => { }, root); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN'); - assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); + assert.strictEqual(castAsError(res.body).error.code, 'NOT_ROOT_FORBIDDEN'); + assert.strictEqual(castAsError(res.body).error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); }); test('Unable to move to a nonexisting local account', async () => { @@ -251,8 +251,8 @@ describe('Account Move', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_USER'); + assert.strictEqual(castAsError(res.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); }); test('Unable to move if alsoKnownAs is invalid', async () => { @@ -261,8 +261,8 @@ describe('Account Move', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); - assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + assert.strictEqual(castAsError(res.body).error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(castAsError(res.body).error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); }); test('Relationships have been properly migrated', async () => { @@ -279,36 +279,44 @@ describe('Account Move', () => { userId: alice.id, }, alice); assert.strictEqual(aliceFollowings.status, 200); + assert.ok(aliceFollowings); assert.strictEqual(aliceFollowings.body.length, 3); const carolFollowings = await api('users/following', { userId: carol.id, }, carol); assert.strictEqual(carolFollowings.status, 200); + assert.ok(carolFollowings); assert.strictEqual(carolFollowings.body.length, 2); assert.strictEqual(carolFollowings.body[0].followeeId, bob.id); assert.strictEqual(carolFollowings.body[1].followeeId, alice.id); const blockings = await api('blocking/list', {}, dave); assert.strictEqual(blockings.status, 200); + assert.ok(blockings); assert.strictEqual(blockings.body.length, 2); assert.strictEqual(blockings.body[0].blockeeId, bob.id); assert.strictEqual(blockings.body[1].blockeeId, alice.id); const mutings = await api('mute/list', {}, dave); assert.strictEqual(mutings.status, 200); + assert.ok(mutings); assert.strictEqual(mutings.body.length, 2); assert.strictEqual(mutings.body[0].muteeId, bob.id); assert.strictEqual(mutings.body[1].muteeId, alice.id); const rootLists = await api('users/lists/list', {}, root); assert.strictEqual(rootLists.status, 200); + assert.ok(rootLists); + assert.ok(rootLists.body[0].userIds); assert.strictEqual(rootLists.body[0].userIds.length, 2); assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id)); assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id)); const eveLists = await api('users/lists/list', {}, eve); assert.strictEqual(eveLists.status, 200); + assert.ok(eveLists); + assert.ok(eveLists.body[0].userIds); assert.strictEqual(eveLists.body[0].userIds.length, 1); assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id)); }); @@ -347,8 +355,8 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); - assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + assert.strictEqual(castAsError(res.body).error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(castAsError(res.body).error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); }); test('Follow and follower counts are properly adjusted', async () => { @@ -419,8 +427,9 @@ describe('Account Move', () => { ] as const)('Prohibit access after moving: %s', async (endpoint) => { const res = await api(endpoint, {}, alice); assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); }); test('Prohibit access after moving: /antennas/update', async () => { @@ -438,16 +447,19 @@ describe('Account Move', () => { }, alice); assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); }); test('Prohibit access after moving: /drive/files/create', async () => { + // FIXME: 一旦逃げておく const res = await uploadFile(alice); assert.strictEqual(res.status, 403); - assert.strictEqual((res.body! as any as { error: misskey.api.APIError }).error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual((res.body! as any as { error: misskey.api.APIError }).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); }); test('Prohibit updating alsoKnownAs after moving', async () => { @@ -456,8 +468,8 @@ describe('Account Move', () => { }, alice); assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); }); }); }); diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index 0e52c5decc..f37da288b7 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -47,8 +47,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { @@ -92,9 +92,9 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { @@ -108,9 +108,9 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); }); @@ -124,8 +124,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリプライが含まれない', async () => { @@ -138,8 +138,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリプライが含まれない', async () => { @@ -152,8 +152,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { @@ -166,8 +166,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリノートが含まれない', async () => { @@ -180,8 +180,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { @@ -193,8 +193,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, carol); @@ -210,8 +210,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, carol); @@ -228,8 +228,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリプライが含まれない', async () => { const aliceNote = await post(alice, { text: 'hi' }); @@ -241,8 +241,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリプライが含まれない', async () => { @@ -255,8 +255,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { @@ -269,8 +269,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリノートが含まれない', async () => { @@ -283,8 +283,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { @@ -296,8 +296,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, carol); @@ -313,8 +313,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); }); }); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 7ce9f47bc3..5937eb9b49 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -3,16 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { Repository } from "typeorm"; + process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { MiNote } from '@/models/Note.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { api, initTestDb, post, role, signup, uploadFile, uploadUrl } from '../utils.js'; +import { api, castAsError, initTestDb, post, role, signup, uploadFile, uploadUrl } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Note', () => { - let Notes: any; + let Notes: Repository; let root: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse; @@ -61,8 +63,8 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); - assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE'); + assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); }, 1000 * 10); test('存在しないファイルで怒られる', async () => { @@ -72,8 +74,8 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); - assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE'); + assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); }); test('不正なファイルIDで怒られる', async () => { @@ -81,8 +83,8 @@ describe('Note', () => { fileIds: ['kyoppie'], }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); - assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE'); + assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); }); test('返信できる', async () => { @@ -101,6 +103,7 @@ describe('Note', () => { assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId); + assert.ok(res.body.createdNote.reply); assert.strictEqual(res.body.createdNote.reply.text, bobPost.text); }); @@ -118,6 +121,7 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); + assert.ok(res.body.createdNote.renote); assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); }); @@ -137,6 +141,7 @@ describe('Note', () => { assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); + assert.ok(res.body.createdNote.renote); assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); }); @@ -218,7 +223,7 @@ describe('Note', () => { }, bob); assert.strictEqual(bobReply.status, 400); - assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE'); + assert.strictEqual(castAsError(bobReply.body).error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE'); }); test('visibility: specifiedなノートに対してvisibility: specifiedで返信できる', async () => { @@ -256,7 +261,7 @@ describe('Note', () => { }, bob); assert.strictEqual(bobReply.status, 400); - assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY'); + assert.strictEqual(castAsError(bobReply.body).error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY'); }); test('文字数ぎりぎりで怒られない', async () => { @@ -333,6 +338,7 @@ describe('Note', () => { assert.strictEqual(res.body.createdNote.text, post.text); const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); + assert.ok(noteDoc); assert.deepStrictEqual(noteDoc.mentions, [bob.id]); }); @@ -345,6 +351,7 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.ok(res.body.createdNote.files); assert.strictEqual(res.body.createdNote.files.length, 1); assert.strictEqual(res.body.createdNote.files[0].id, file.body!.id); }); @@ -363,8 +370,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id); - assert.notEqual(myNote, null); + const myNote = res.body.find(note => note.id === createdNote.body.createdNote.id); + assert.ok(myNote); + assert.ok(myNote.files); assert.strictEqual(myNote.files.length, 1); assert.strictEqual(myNote.files[0].id, file.body!.id); }); @@ -389,7 +397,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); - assert.notEqual(myNote, null); + assert.ok(myNote); + assert.ok(myNote.renote); + assert.ok(myNote.renote.files); assert.strictEqual(myNote.renote.files.length, 1); assert.strictEqual(myNote.renote.files[0].id, file.body!.id); }); @@ -415,7 +425,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id); - assert.notEqual(myNote, null); + assert.ok(myNote); + assert.ok(myNote.reply); + assert.ok(myNote.reply.files); assert.strictEqual(myNote.reply.files.length, 1); assert.strictEqual(myNote.reply.files[0].id, file.body!.id); }); @@ -446,7 +458,10 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); - assert.notEqual(myNote, null); + assert.ok(myNote); + assert.ok(myNote.renote); + assert.ok(myNote.renote.reply); + assert.ok(myNote.renote.reply.files); assert.strictEqual(myNote.renote.reply.files.length, 1); assert.strictEqual(myNote.renote.reply.files[0].id, file.body!.id); }); @@ -474,7 +489,7 @@ describe('Note', () => { priority: 0, value: true, }, - } as any, + }, }, root); assert.strictEqual(res.status, 200); @@ -498,7 +513,7 @@ describe('Note', () => { }, alice); assert.strictEqual(liftnsfw.status, 400); - assert.strictEqual(liftnsfw.body.error.code, 'RESTRICTED_BY_ROLE'); + assert.strictEqual(castAsError(liftnsfw.body).error.code, 'RESTRICTED_BY_ROLE'); const oldaddnsfw = await api('drive/files/update', { fileId: file.body!.id, @@ -710,7 +725,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note1.status, 400); - assert.strictEqual(note1.body.error.code, 'CONTAINS_PROHIBITED_WORDS'); + assert.strictEqual(castAsError(note1.body).error.code, 'CONTAINS_PROHIBITED_WORDS'); }); test('禁止ワードを含む投稿はエラーになる (正規表現)', async () => { @@ -727,7 +742,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note2.status, 400); - assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS'); + assert.strictEqual(castAsError(note2.body).error.code, 'CONTAINS_PROHIBITED_WORDS'); }); test('禁止ワードを含む投稿はエラーになる (スペースアンド)', async () => { @@ -744,7 +759,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note2.status, 400); - assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS'); + assert.strictEqual(castAsError(note2.body).error.code, 'CONTAINS_PROHIBITED_WORDS'); }); test('禁止ワードを含んでるリモートノートもエラーになる', async () => { @@ -786,7 +801,7 @@ describe('Note', () => { priority: 1, value: 0, }, - } as any, + }, }, root); assert.strictEqual(res.status, 200); @@ -807,7 +822,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note.status, 400); - assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS'); + assert.strictEqual(castAsError(note.body).error.code, 'CONTAINS_TOO_MANY_MENTIONS'); await api('admin/roles/unassign', { userId: alice.id, @@ -840,7 +855,7 @@ describe('Note', () => { priority: 1, value: 0, }, - } as any, + }, }, root); assert.strictEqual(res.status, 200); @@ -863,7 +878,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note.status, 400); - assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS'); + assert.strictEqual(castAsError(note.body).error.code, 'CONTAINS_TOO_MANY_MENTIONS'); await api('admin/roles/unassign', { userId: alice.id, @@ -896,7 +911,7 @@ describe('Note', () => { priority: 1, value: 1, }, - } as any, + }, }, root); assert.strictEqual(res.status, 200); @@ -951,6 +966,7 @@ describe('Note', () => { assert.strictEqual(deleteOneRes.status, 204); let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); + assert.ok(mainNote); assert.strictEqual(mainNote.repliesCount, 1); const deleteTwoRes = await api('notes/delete', { @@ -959,6 +975,7 @@ describe('Note', () => { assert.strictEqual(deleteTwoRes.status, 204); mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); + assert.ok(mainNote); assert.strictEqual(mainNote.repliesCount, 0); }); }); @@ -980,7 +997,7 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); + assert.strictEqual(castAsError(res.body).error.code, 'UNAVAILABLE'); }); afterAll(async () => { @@ -992,7 +1009,7 @@ describe('Note', () => { const res = await api('notes/translate', { noteId: 'foo', targetLang: 'ja' }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_NOTE'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_NOTE'); }); test('不可視なノートは翻訳できない', async () => { @@ -1000,7 +1017,7 @@ describe('Note', () => { const bobTranslateAttempt = await api('notes/translate', { noteId: aliceNote.id, targetLang: 'ja' }, bob); assert.strictEqual(bobTranslateAttempt.status, 400); - assert.strictEqual(bobTranslateAttempt.body.error.code, 'CANNOT_TRANSLATE_INVISIBLE_NOTE'); + assert.strictEqual(castAsError(bobTranslateAttempt.body).error.code, 'CANNOT_TRANSLATE_INVISIBLE_NOTE'); }); test('text: null なノートを翻訳すると空のレスポンスが返ってくる', async () => { @@ -1016,7 +1033,7 @@ describe('Note', () => { // NOTE: デフォルトでは登録されていないので落ちる assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); + assert.strictEqual(castAsError(res.body).error.code, 'UNAVAILABLE'); }); }); }); diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index f6895c43d8..0f636b9ae2 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -42,9 +42,9 @@ describe('Renote Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { @@ -59,9 +59,9 @@ describe('Renote Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); // #12956 @@ -76,8 +76,8 @@ describe('Renote Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true); }); test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 53bb6eb765..1ac99df884 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -33,9 +33,9 @@ describe('Note thread mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolReply.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolReplyWithoutMention.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolReply.id), false); + assert.strictEqual(res.body.some(note => note.id === carolReplyWithoutMention.id), false); }); test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { @@ -93,8 +93,8 @@ describe('Note thread mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReply.id), false); - assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReplyWithoutMention.id), false); + assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReply.id), false); + assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReplyWithoutMention.id), false); // NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい }); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index fccc052d99..fce1eacf00 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -37,8 +37,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); test.concurrent('フォローしているユーザーのノートが含まれる', async () => { @@ -53,8 +53,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { @@ -69,9 +69,9 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { @@ -86,8 +86,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { @@ -103,8 +103,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { @@ -120,8 +120,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { @@ -137,8 +137,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { @@ -156,9 +156,9 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'hi'); }); test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { @@ -175,8 +175,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { @@ -191,8 +191,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { @@ -207,8 +207,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('自分の他人への返信が含まれる', async () => { @@ -221,8 +221,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); }); test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { @@ -237,8 +237,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { @@ -255,8 +255,8 @@ describe('Timelines', () => { withRenotes: false, }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { @@ -273,8 +273,8 @@ describe('Timelines', () => { withRenotes: false, }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { @@ -288,7 +288,7 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { @@ -304,8 +304,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { @@ -322,8 +322,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { @@ -338,7 +338,7 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { @@ -353,7 +353,7 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { @@ -374,11 +374,11 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false); - }, 1000 * 10); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); + }); test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -392,7 +392,7 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('自分の visibility: specified なノートが含まれる', async () => { @@ -404,8 +404,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { @@ -419,8 +419,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => { @@ -432,7 +432,7 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => { @@ -446,7 +446,7 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { @@ -459,8 +459,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'ok'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'ok'); }); /* TODO @@ -474,8 +474,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'ok'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id).text, 'ok'); }); */ @@ -490,7 +490,7 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); }); @@ -505,8 +505,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('他人の他人への返信が含まれない', async () => { @@ -519,8 +519,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); test.concurrent('他人のその人自身への返信が含まれる', async () => { @@ -533,8 +533,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); test.concurrent('チャンネル投稿が含まれない', async () => { @@ -547,7 +547,7 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('リモートユーザーのノートが含まれない', async () => { @@ -559,7 +559,7 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); // 含まれても良いと思うけど実装が面倒なので含まれない @@ -575,8 +575,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('ミュートしているユーザーのノートが含まれない', async () => { @@ -591,8 +591,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { @@ -608,8 +608,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { @@ -626,8 +626,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { @@ -642,8 +642,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { @@ -656,7 +656,7 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { @@ -670,9 +670,9 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }); }); describe('Social TL', () => { @@ -685,7 +685,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => { @@ -697,7 +697,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { @@ -711,7 +711,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { @@ -726,8 +726,8 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('他人の他人への返信が含まれない', async () => { @@ -740,8 +740,8 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); test.concurrent('リモートユーザーのノートが含まれない', async () => { @@ -753,7 +753,7 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { @@ -768,7 +768,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { @@ -783,7 +783,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { @@ -796,7 +796,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { @@ -810,9 +810,9 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }); }); describe('User List TL', () => { @@ -828,7 +828,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { @@ -843,7 +843,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { @@ -858,7 +858,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { @@ -874,7 +874,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { @@ -890,8 +890,8 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); test.concurrent('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { @@ -908,7 +908,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('withReplies: false でリスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { @@ -925,7 +925,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { @@ -942,7 +942,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { @@ -958,7 +958,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { @@ -974,8 +974,8 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => { @@ -990,8 +990,8 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => { @@ -1007,7 +1007,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { @@ -1023,9 +1023,9 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }); test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1039,8 +1039,8 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); test.concurrent('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => { @@ -1056,7 +1056,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); }); @@ -1070,7 +1070,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => { @@ -1082,7 +1082,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { @@ -1096,8 +1096,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); test.concurrent('自身の visibility: followers なノートが含まれる', async () => { @@ -1109,8 +1109,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: alice.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); test.concurrent('チャンネル投稿が含まれない', async () => { @@ -1123,7 +1123,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('[withReplies: false] 他人への返信が含まれない', async () => { @@ -1137,8 +1137,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); }); test.concurrent('[withReplies: true] 他人への返信が含まれる', async () => { @@ -1152,8 +1152,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); test.concurrent('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { @@ -1167,8 +1167,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); }); test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { @@ -1182,9 +1182,9 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - }, 1000 * 10); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + }); test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1196,7 +1196,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => { @@ -1209,7 +1209,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => { @@ -1222,7 +1222,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, bob); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('ミュートしているユーザーに関連する投稿が含まれない', async () => { @@ -1237,7 +1237,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { @@ -1253,9 +1253,9 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote3.id), true); }); test.concurrent('自身の visibility: specified なノートが含まれる', async () => { @@ -1267,7 +1267,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: alice.id, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); }); test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => { @@ -1279,7 +1279,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); /** @see https://github.com/misskey-dev/misskey/issues/14000 */ diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 3458e06384..61fd759932 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -231,7 +231,7 @@ describe('ユーザー', () => { rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); userRoleBadge = await signup({ username: 'userRoleBadge' }); - roleBadge = await role(root, { asBadge: true, name: 'Badge Role' }); + roleBadge = await role(root, { asBadge: true, name: 'Badge Role', isPublic: true }); await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root); userSilenced = await signup({ username: 'userSilenced' }); await post(userSilenced, { text: 'test' }); @@ -655,7 +655,16 @@ describe('ユーザー', () => { iconUrl: roleBadge.iconUrl, displayOrder: roleBadge.displayOrder, }]); - assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない + assert.deepStrictEqual(response.roles, [{ + id: roleBadge.id, + name: roleBadge.name, + color: roleBadge.color, + iconUrl: roleBadge.iconUrl, + description: roleBadge.description, + isModerator: roleBadge.isModerator, + isAdministrator: roleBadge.isAdministrator, + displayOrder: roleBadge.displayOrder, + }]); }); test('をID指定のリスト形式で取得することができる(空)', async () => { const parameters = { userIds: [] }; diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index 3c7e796700..485506ee64 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -14,6 +14,7 @@ import type { InstanceActorService } from '@/core/InstanceActorService.js'; import type { LoggerService } from '@/core/LoggerService.js'; import type { MetaService } from '@/core/MetaService.js'; import type { UtilityService } from '@/core/UtilityService.js'; +import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { bindThis } from '@/decorators.js'; import type { FollowRequestsRepository, @@ -47,6 +48,7 @@ export class MockResolver extends Resolver { {} as HttpRequestService, {} as ApRendererService, {} as ApDbResolverService, + {} as FederatedInstanceService, loggerService, ); } diff --git a/packages/backend/test/unit/ApMfmService.ts b/packages/backend/test/unit/ApMfmService.ts index 79cb81f5c9..e81a321c9b 100644 --- a/packages/backend/test/unit/ApMfmService.ts +++ b/packages/backend/test/unit/ApMfmService.ts @@ -23,10 +23,10 @@ describe('ApMfmService', () => { describe('getNoteHtml', () => { test('Do not provide _misskey_content for simple text', () => { - const note: MiNote = { + const note = { text: 'テキスト #タグ @mention 🍊 :emoji: https://example.com', mentionedRemoteUsers: '[]', - } as any; + }; const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); @@ -35,10 +35,10 @@ describe('ApMfmService', () => { }); test('Provide _misskey_content for MFM', () => { - const note: MiNote = { + const note = { text: '$[tada foo]', mentionedRemoteUsers: '[]', - } as any; + }; const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts index bf8f3ab0e3..2e66b81fcd 100644 --- a/packages/backend/test/unit/FetchInstanceMetadataService.ts +++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts @@ -75,62 +75,61 @@ describe('FetchInstanceMetadataService', () => { test('Lock and update', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any); + federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); + expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); expect(httpRequestService.getJson).toHaveBeenCalled(); }); - test('Lock and don\'t update', async () => { + test('Don\'t lock and update if recently updated', async () => { redisClient.set = mockRedis(); - const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any); + federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date() } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); - expect(tryLockSpy).toHaveBeenCalledTimes(1); - expect(unlockSpy).toHaveBeenCalledTimes(1); expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(tryLockSpy).toHaveBeenCalledTimes(0); + expect(unlockSpy).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); }); test('Do nothing when lock not acquired', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); + federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); + expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(0); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); }); - test('Do when lock not acquired but forced', async () => { + test('Do when forced', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); + federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date(now - 10 * 1000 * 60 * 60 * 24) } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true); + expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); expect(tryLockSpy).toHaveBeenCalledTimes(0); expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalled(); }); }); diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index aa9b34b706..29bd03a201 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -12,7 +12,7 @@ import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import { afterAll, beforeAll, describe, test } from '@jest/globals'; import { GlobalModule } from '@/GlobalModule.js'; -import { FileInfoService } from '@/core/FileInfoService.js'; +import { FileInfo, FileInfoService } from '@/core/FileInfoService.js'; //import { DI } from '@/di-symbols.js'; import { AiService } from '@/core/AiService.js'; import { LoggerService } from '@/core/LoggerService.js'; @@ -28,6 +28,15 @@ const moduleMocker = new ModuleMocker(global); describe('FileInfoService', () => { let app: TestingModule; let fileInfoService: FileInfoService; + const strip = (fileInfo: FileInfo): Omit, 'warnings' | 'blurhash' | 'sensitive' | 'porn'> => { + const fi: Partial = fileInfo; + delete fi.warnings; + delete fi.sensitive; + delete fi.blurhash; + delete fi.porn; + + return fi; + } beforeAll(async () => { app = await Test.createTestingModule({ @@ -63,11 +72,7 @@ describe('FileInfoService', () => { test('Empty file', async () => { const path = `${resources}/emptyfile`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 0, md5: 'd41d8cd98f00b204e9800998ecf8427e', @@ -84,11 +89,7 @@ describe('FileInfoService', () => { describe('IMAGE', () => { test('Generic JPEG', async () => { const path = `${resources}/192.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 5131, md5: '8c9ed0677dd2b8f9f7472c3af247e5e3', @@ -104,11 +105,7 @@ describe('FileInfoService', () => { test('Generic APNG', async () => { const path = `${resources}/anime.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 1868, md5: '08189c607bea3b952704676bb3c979e0', @@ -124,11 +121,7 @@ describe('FileInfoService', () => { test('Generic AGIF', async () => { const path = `${resources}/anime.gif`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 2248, md5: '32c47a11555675d9267aee1a86571e7e', @@ -144,11 +137,7 @@ describe('FileInfoService', () => { test('PNG with alpha', async () => { const path = `${resources}/with-alpha.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 3772, md5: 'f73535c3e1e27508885b69b10cf6e991', @@ -164,11 +153,7 @@ describe('FileInfoService', () => { test('Generic SVG', async () => { const path = `${resources}/image.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 505, md5: 'b6f52b4b021e7b92cdd04509c7267965', @@ -185,11 +170,7 @@ describe('FileInfoService', () => { test('SVG with XML definition', async () => { // https://github.com/misskey-dev/misskey/issues/4413 const path = `${resources}/with-xml-def.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 544, md5: '4b7a346cde9ccbeb267e812567e33397', @@ -205,11 +186,7 @@ describe('FileInfoService', () => { test('Dimension limit', async () => { const path = `${resources}/25000x25000.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 75933, md5: '268c5dde99e17cf8fe09f1ab3f97df56', @@ -225,11 +202,7 @@ describe('FileInfoService', () => { test('Rotate JPEG', async () => { const path = `${resources}/rotate.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 12624, md5: '68d5b2d8d1d1acbbce99203e3ec3857e', @@ -247,11 +220,7 @@ describe('FileInfoService', () => { describe('AUDIO', () => { test('MP3', async () => { const path = `${resources}/kick_gaba7.mp3`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -267,11 +236,7 @@ describe('FileInfoService', () => { test('WAV', async () => { const path = `${resources}/kick_gaba7.wav`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -287,11 +252,7 @@ describe('FileInfoService', () => { test('AAC', async () => { const path = `${resources}/kick_gaba7.aac`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -307,11 +268,7 @@ describe('FileInfoService', () => { test('FLAC', async () => { const path = `${resources}/kick_gaba7.flac`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -327,11 +284,7 @@ describe('FileInfoService', () => { test('MPEG-4 AUDIO (M4A)', async () => { const path = `${resources}/kick_gaba7.m4a`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -347,11 +300,7 @@ describe('FileInfoService', () => { test('WEBM AUDIO', async () => { const path = `${resources}/kick_gaba7.webm`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; diff --git a/packages/backend/test/unit/ap-request.ts b/packages/backend/test/unit/ap-request.ts index d3d39240dc..50894c8b81 100644 --- a/packages/backend/test/unit/ap-request.ts +++ b/packages/backend/test/unit/ap-request.ts @@ -4,10 +4,8 @@ */ import * as assert from 'assert'; -import httpSignature from '@peertube/http-signature'; - -import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; -import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; +import { verifyDraftSignature, parseRequestSignature, genEd25519KeyPair, genRsaKeyPair, importPrivateKey } from '@misskey-dev/node-http-message-signatures'; +import { createSignedGet, createSignedPost } from '@/core/activitypub/ApRequestService.js'; export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { return { @@ -24,38 +22,68 @@ export const buildParsedSignature = (signingString: string, signature: string, a }; }; -describe('ap-request', () => { - test('createSignedPost with verify', async () => { - const keypair = await genRsaKeyPair(); - const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; - const url = 'https://example.com/inbox'; - const activity = { a: 1 }; - const body = JSON.stringify(activity); - const headers = { - 'User-Agent': 'UA', - }; +async function getKeyPair(level: string) { + if (level === '00') { + return await genRsaKeyPair(); + } else if (level === '01') { + return await genEd25519KeyPair(); + } + throw new Error('Invalid level'); +} - const req = ApRequestCreator.createSignedPost({ key, url, body, additionalHeaders: headers }); +describe('ap-request post', () => { + const url = 'https://example.com/inbox'; + const activity = { a: 1 }; + const body = JSON.stringify(activity); + const headers = { + 'User-Agent': 'UA', + }; - const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); + describe.each(['00', '01'])('createSignedPost with verify', (level) => { + test('pem', async () => { + const keypair = await getKeyPair(level); + const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; - const result = httpSignature.verifySignature(parsed, keypair.publicKey); - assert.deepStrictEqual(result, true); - }); + const req = await createSignedPost({ level, key, url, body, additionalHeaders: headers }); - test('createSignedGet with verify', async () => { - const keypair = await genRsaKeyPair(); - const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; - const url = 'https://example.com/outbox'; - const headers = { - 'User-Agent': 'UA', - }; + const parsed = parseRequestSignature(req.request); + expect(parsed.version).toBe('draft'); + expect(Array.isArray(parsed.value)).toBe(false); + const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey); + assert.deepStrictEqual(verify, true); + }); + test('imported', async () => { + const keypair = await getKeyPair(level); + const key = { keyId: 'x', 'privateKey': await importPrivateKey(keypair.privateKey) }; - const req = ApRequestCreator.createSignedGet({ key, url, additionalHeaders: headers }); + const req = await createSignedPost({ level, key, url, body, additionalHeaders: headers }); - const parsed = buildParsedSignature(req.signingString, req.signature, 'rsa-sha256'); - - const result = httpSignature.verifySignature(parsed, keypair.publicKey); - assert.deepStrictEqual(result, true); + const parsed = parseRequestSignature(req.request); + expect(parsed.version).toBe('draft'); + expect(Array.isArray(parsed.value)).toBe(false); + const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey); + assert.deepStrictEqual(verify, true); + }); + }); +}); + +describe('ap-request get', () => { + describe.each(['00', '01'])('createSignedGet with verify', (level) => { + test('pass', async () => { + const keypair = await getKeyPair(level); + const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; + const url = 'https://example.com/outbox'; + const headers = { + 'User-Agent': 'UA', + }; + + const req = await createSignedGet({ level, key, url, additionalHeaders: headers }); + + const parsed = parseRequestSignature(req.request); + expect(parsed.version).toBe('draft'); + expect(Array.isArray(parsed.value)).toBe(false); + const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey); + assert.deepStrictEqual(verify, true); + }); }); }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 06c3f82601..e70befeebe 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -18,6 +18,7 @@ import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; import { type Response } from 'node-fetch'; +import { ApiError } from "@/server/api/error.js"; export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js'; @@ -48,27 +49,28 @@ export const successfulApiCall = async ; }; -export const failedApiCall = async (request: ApiRequest, assertion: { +export const failedApiCall = async (request: ApiRequest, assertion: { status: number, code: string, id: string -}): Promise => { +}): Promise => { const { endpoint, parameters, user } = request; const { status, code, id } = assertion; const res = await api(endpoint, parameters, user); assert.strictEqual(res.status, status, inspect(res.body)); - assert.strictEqual(res.body.error.code, code, inspect(res.body)); - assert.strictEqual(res.body.error.id, id, inspect(res.body)); - return res.body; + assert.ok(res.body); + assert.strictEqual(castAsError(res.body as any).error.code, code, inspect(res.body)); + assert.strictEqual(castAsError(res.body as any).error.id, id, inspect(res.body)); }; -export const api = async (path: E, params: misskey.Endpoints[E]['req'], me?: UserToken): Promise<{ +export const api = async (path: E, params: P, me?: UserToken): Promise<{ status: number, headers: Headers, - body: any + body: misskey.api.SwitchCaseResponseType }> => { const bodyAuth: Record = {}; const headers: Record = { @@ -89,13 +91,14 @@ export const api = async (path: E, params: mi }); const body = res.headers.get('content-type') === 'application/json; charset=utf-8' - ? await res.json() + ? await res.json() as misskey.api.SwitchCaseResponseType : null; return { status: res.status, headers: res.headers, - body, + // FIXME: removing this non-null assertion: requires better typing around empty response. + body: body!, }; }; @@ -141,7 +144,8 @@ export const post = async (user: UserToken, params: misskey.Endpoints['notes/cre const res = await api('notes/create', q, user); - return res.body ? res.body.createdNote : null; + // FIXME: the return type should reflect this fact. + return (res.body ? res.body.createdNote : null)!; }; export const createAppToken = async (user: UserToken, permissions: (typeof misskey.permissions)[number][]) => { @@ -635,3 +639,9 @@ export async function sendEnvResetRequest() { throw new Error('server env update failed.'); } } + +// 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。 +// FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する +export function castAsError(obj: Record): { error: ApiError } { + return obj as { error: ApiError }; +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 743722c231..fbeae08aa0 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -24,12 +24,12 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "5.0.7", "@rollup/pluginutils": "5.1.0", - "@syuilo/aiscript": "0.18.0", + "@syuilo/aiscript": "0.19.0", "@tabler/icons-webfont": "3.3.0", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.0.5", "@vue/compiler-sfc": "3.4.31", - "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.9", + "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11", "astring": "1.8.6", "broadcast-channel": "7.0.0", "buraha": "0.0.1", diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index a3c80e743b..1d4c0b6366 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -30,7 +30,7 @@ import * as os from '@/os.js'; import MkLoading from '@/components/global/MkLoading.vue'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; const props = defineProps<{ code: string; diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 1790e57c24..c940596cde 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -39,7 +39,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { MenuItem } from '@/types/menu.js'; const props = withDefaults(defineProps<{ diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index ea76950c0d..d8ac8024b4 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -42,6 +42,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js'; import { useStream } from '@/stream.js'; import { i18n } from '@/i18n.js'; import { claimAchievement } from '@/scripts/achievements.js'; +import { pleaseLogin } from '@/scripts/please-login.js'; +import { host } from '@/config.js'; import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; @@ -63,7 +65,7 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro const wait = ref(false); const connection = useStream().useChannel('main'); -if (props.user.isFollowing == null) { +if (props.user.isFollowing == null && $i) { misskeyApi('users/show', { userId: props.user.id, }) @@ -78,6 +80,8 @@ function onFollowChange(user: Misskey.entities.UserDetailed) { } async function onClick() { + pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` }); + wait.value = true; try { diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue index 1c6f412dc1..de51a98789 100644 --- a/packages/frontend/src/components/MkInviteCode.vue +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -62,7 +62,7 @@ import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; -import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; diff --git a/packages/frontend/src/components/MkKeyValue.vue b/packages/frontend/src/components/MkKeyValue.vue index 20b1ef2be2..50c9e16e5e 100644 --- a/packages/frontend/src/components/MkKeyValue.vue +++ b/packages/frontend/src/components/MkKeyValue.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 33355bb99e..524c62b4d3 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -6,21 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index 324d1c11de..8dfeb6d2a7 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -8,113 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only -
-
-
- -
- {{ instance.name ?? host }} -
-
-
- - - - - - - -
- - - - -
-
- - - {{ i18n.ts.aboutMisskey }} - - - - {{ i18n.ts.sourceCode }} - - - {{ i18n.ts.sourceCodeIsNotYetProvided }} - -
-
- - -
- - - - - - - - - - - - - {{ i18n.ts.impressum }} - -
- - - -
    -
  1. -
-
- - - {{ i18n.ts.termsOfService }} - - - - {{ i18n.ts.privacyPolicy }} - - - - {{ i18n.ts.feedback }} - -
-
-
- - - - - - - - - - - - - - - - - - - -
- host-meta - host-meta.json - nodeinfo - robots.txt - manifest.json -
-
-
+
@@ -130,26 +24,16 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index eb8a59b34f..3e948abdf1 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -378,6 +378,26 @@ SPDX-License-Identifier: AGPL-3.0-only + + + +
+ + + + + + + + + +
+
+