Merge branch 'develop' into enh-13161

This commit is contained in:
かっこかり 2024-07-18 01:40:21 +09:00 committed by GitHub
commit 76f109e374
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
154 changed files with 2994 additions and 1914 deletions

View File

@ -1,5 +1,11 @@
# misskey settings
# MISSKEY_URL=https://example.tld/
# db settings # db settings
POSTGRES_PASSWORD=example-misskey-pass POSTGRES_PASSWORD=example-misskey-pass
# DATABASE_PASSWORD=${POSTGRES_PASSWORD}
POSTGRES_USER=example-misskey-user POSTGRES_USER=example-misskey-user
# DATABASE_USER=${POSTGRES_USER}
POSTGRES_DB=misskey POSTGRES_DB=misskey
# DATABASE_DB=${POSTGRES_DB}
DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}" DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}"

View File

@ -6,6 +6,7 @@
#───┘ URL └───────────────────────────────────────────────────── #───┘ URL └─────────────────────────────────────────────────────
# Final accessible URL seen by a user. # Final accessible URL seen by a user.
# You can set url from an environment variable instead.
url: https://example.tld/ url: https://example.tld/
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
@ -38,9 +39,11 @@ db:
port: 5432 port: 5432
# Database name # Database name
# You can set db from an environment variable instead.
db: misskey db: misskey
# Auth # Auth
# You can set user and pass from environment variables instead.
user: example-misskey-user user: example-misskey-user
pass: example-misskey-pass pass: example-misskey-pass
@ -161,12 +164,12 @@ id: 'aidx'
#clusterLimit: 1 #clusterLimit: 1
# Job concurrency per worker # Job concurrency per worker
# deliverJobConcurrency: 128 # deliverJobConcurrency: 16
# inboxJobConcurrency: 16 # inboxJobConcurrency: 4
# Job rate limiter # Job rate limiter
# deliverJobPerSec: 128 # deliverJobPerSec: 128
# inboxJobPerSec: 32 # inboxJobPerSec: 64
# Job attempts # Job attempts
# deliverJobMaxAttempts: 12 # deliverJobMaxAttempts: 12

View File

@ -230,15 +230,15 @@ id: 'aidx'
#clusterLimit: 1 #clusterLimit: 1
# Job concurrency per worker # Job concurrency per worker
#deliverJobConcurrency: 128 #deliverJobConcurrency: 16
#inboxJobConcurrency: 16 #inboxJobConcurrency: 4
#relationshipJobConcurrency: 16 #relationshipJobConcurrency: 16
# What's relationshipJob?: # What's relationshipJob?:
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. # Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
# Job rate limiter # Job rate limiter
#deliverJobPerSec: 128 #deliverJobPerSec: 1024
#inboxJobPerSec: 32 #inboxJobPerSec: 64
#relationshipJobPerSec: 64 #relationshipJobPerSec: 64
# Job attempts # Job attempts

View File

@ -157,12 +157,12 @@ id: 'aidx'
#clusterLimit: 1 #clusterLimit: 1
# Job concurrency per worker # Job concurrency per worker
# deliverJobConcurrency: 128 # deliverJobConcurrency: 16
# inboxJobConcurrency: 16 # inboxJobConcurrency: 4
# Job rate limiter # Job rate limiter
# deliverJobPerSec: 128 # deliverJobPerSec: 1024
# inboxJobPerSec: 32 # inboxJobPerSec: 64
# Job attempts # Job attempts
# deliverJobMaxAttempts: 12 # deliverJobMaxAttempts: 12

View File

@ -4,10 +4,11 @@ on:
push: push:
paths: paths:
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/api-misskey-js.yml
pull_request: pull_request:
paths: paths:
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/api-misskey-js.yml
jobs: jobs:
report: report:
@ -20,7 +21,7 @@ jobs:
- run: corepack enable - run: corepack enable
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View File

@ -14,7 +14,7 @@ jobs:
- name: Checkout head - name: Checkout head
uses: actions/checkout@v4.1.1 uses: actions/checkout@v4.1.1
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'

View File

@ -28,7 +28,7 @@ jobs:
- name: setup node - name: setup node
id: setup-node id: setup-node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: pnpm cache: pnpm

View File

@ -6,12 +6,13 @@ on:
paths: paths:
- packages/misskey-js/package.json - packages/misskey-js/package.json
- package.json - package.json
- .github/workflows/check-misskey-js-version.yml
pull_request: pull_request:
branches: [ develop ] branches: [ develop ]
paths: paths:
- packages/misskey-js/package.json - packages/misskey-js/package.json
- package.json - package.json
- .github/workflows/check-misskey-js-version.yml
jobs: jobs:
check-version: check-version:
# ルートの package.json と packages/misskey-js/package.json のバージョンが一致しているかを確認する # ルートの package.json と packages/misskey-js/package.json のバージョンが一致しているかを確認する

View File

@ -9,7 +9,7 @@ on:
paths: paths:
- packages/backend/** - packages/backend/**
- .github/workflows/get-api-diff.yml - .github/workflows/get-api-diff.yml
- .github/workflows/get-api-diff.yml
jobs: jobs:
get-from-misskey: get-from-misskey:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -34,7 +34,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -11,6 +11,7 @@ on:
- packages/sw/** - packages/sw/**
- packages/misskey-js/** - packages/misskey-js/**
- packages/shared/eslint.config.js - packages/shared/eslint.config.js
- .github/workflows/lint.yml
pull_request: pull_request:
paths: paths:
- packages/backend/** - packages/backend/**
@ -18,7 +19,7 @@ on:
- packages/sw/** - packages/sw/**
- packages/misskey-js/** - packages/misskey-js/**
- packages/shared/eslint.config.js - packages/shared/eslint.config.js
- .github/workflows/lint.yml
jobs: jobs:
pnpm_install: pnpm_install:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -28,7 +29,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.2 - uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
@ -39,6 +40,8 @@ jobs:
needs: [pnpm_install] needs: [pnpm_install]
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: true continue-on-error: true
env:
eslint-cache-version: v1
strategy: strategy:
matrix: matrix:
workspace: workspace:
@ -52,13 +55,20 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.2 - uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'
- run: corepack enable - run: corepack enable
- run: pnpm i --frozen-lockfile - 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: typecheck:
needs: [pnpm_install] needs: [pnpm_install]
@ -75,7 +85,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.2 - uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View File

@ -4,10 +4,11 @@ on:
push: push:
paths: paths:
- locales/** - locales/**
- .github/workflows/locale.yml
pull_request: pull_request:
paths: paths:
- locales/** - locales/**
- .github/workflows/locale.yml
jobs: jobs:
locale_verify: locale_verify:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -18,7 +19,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
submodules: true submodules: true
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4.0.2 - uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View File

@ -26,7 +26,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -6,7 +6,7 @@ on:
- develop - develop
paths: paths:
- 'CHANGELOG.md' - 'CHANGELOG.md'
# - .github/workflows/release-edit-with-push.yml
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -36,7 +36,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js 20.x - name: Use Node.js 20.x
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: 'pnpm' cache: 'pnpm'

View File

@ -9,12 +9,13 @@ on:
- packages/backend/** - packages/backend/**
# for permissions # for permissions
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/test-backend.yml
pull_request: pull_request:
paths: paths:
- packages/backend/** - packages/backend/**
# for permissions # for permissions
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/test-backend.yml
jobs: jobs:
unit: unit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -45,7 +46,7 @@ jobs:
- name: Install FFmpeg - name: Install FFmpeg
uses: FedericoCarboni/setup-ffmpeg@v3 uses: FedericoCarboni/setup-ffmpeg@v3
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
@ -92,7 +93,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -11,7 +11,7 @@ on:
- packages/misskey-js/** - packages/misskey-js/**
# for e2e # for e2e
- packages/backend/** - packages/backend/**
- .github/workflows/test-frontend.yml
pull_request: pull_request:
paths: paths:
- packages/frontend/** - packages/frontend/**
@ -19,7 +19,7 @@ on:
- packages/misskey-js/** - packages/misskey-js/**
# for e2e # for e2e
- packages/backend/** - packages/backend/**
- .github/workflows/test-frontend.yml
jobs: jobs:
vitest: vitest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -35,7 +35,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'
@ -90,7 +90,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -8,11 +8,12 @@ on:
branches: [ develop ] branches: [ develop ]
paths: paths:
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/test-misskey-js.yml
pull_request: pull_request:
branches: [ develop ] branches: [ develop ]
paths: paths:
- packages/misskey-js/** - packages/misskey-js/**
- .github/workflows/test-misskey-js.yml
jobs: jobs:
test: test:
@ -30,7 +31,7 @@ jobs:
- run: corepack enable - run: corepack enable
- name: Setup Node.js ${{ matrix.node-version }} - name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -25,7 +25,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -7,10 +7,11 @@ on:
- develop - develop
paths: paths:
- packages/backend/** - packages/backend/**
- .github/workflows/validate-api-json.yml
pull_request: pull_request:
paths: paths:
- packages/backend/** - packages/backend/**
- .github/workflows/validate-api-json.yml
jobs: jobs:
validate-api-json: validate-api-json:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -26,7 +27,7 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.3
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: 'pnpm' cache: 'pnpm'

View File

@ -1,19 +1,28 @@
## Unreleased ## 2024.7.0
### Note ### Note
- デッキUIの新着ートをサウンドで通知する機能の追加v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。 - デッキUIの新着ートをサウンドで通知する機能の追加v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。
### General ### General
- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に
- 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます
- Feat: 連合に使うHTTP SignaturesがEd25519鍵に対応するように #13464
- Ed25519署名に対応するサーバーが増えると、deliverで要求されるサーバーリソースが削減されます
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
- Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題
- Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 - Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正
### Client ### Client
- Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善 - Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善
- Enhance: 非ログイン時に他サーバーに遷移するアクションを追加
- Enhance: 非ログイン時のハイライトTLのデザインを改善 - Enhance: 非ログイン時のハイライトTLのデザインを改善
- Enhance: フロントエンドのアクセシビリティ改善 - Enhance: フロントエンドのアクセシビリティ改善
(Based on https://github.com/taiyme/misskey/pull/226) (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: 「デバイスのダークモードと同期する」を有効にしている際のカラーモード切替設定の挙動を視覚的にわかりやすく - Enhance: 「デバイスのダークモードと同期する」を有効にしている際のカラーモード切替設定の挙動を視覚的にわかりやすく
- Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正 - Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正
- Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968) - Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968)
@ -23,6 +32,9 @@
- Fix: テーマプレビューが見れない問題を修正 - Fix: テーマプレビューが見れない問題を修正
- Fix: ショートカットキーが連打できる問題を修正 - Fix: ショートカットキーが連打できる問題を修正
(Cherry-picked from https://github.com/taiyme/misskey/pull/234) (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 ### Server
- Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949) - Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949)
@ -32,7 +44,8 @@
- Enhance: エンドポイント`gallery/posts/update`の必須項目を`postId`のみに - Enhance: エンドポイント`gallery/posts/update`の必須項目を`postId`のみに
- Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに - Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに
- Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに - 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: ユーザーのフィードページのMFMをHTMLに展開するように (#14006)
- Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036) - Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036)
- Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059) - Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059)
@ -48,6 +61,12 @@
3. フォローしていないアクティブなユーザ 3. フォローしていないアクティブなユーザ
4. フォローしていない非アクティブなユーザ 4. フォローしていない非アクティブなユーザ
また、自分自身のアカウントもサジェストされるようになりました。
- Fix: 一般ユーザーから見たユーザーのバッジの一覧に公開されていないものが含まれることがある問題を修正
(Cherry-picked from https://github.com/MisskeyIO/misskey/pull/652)
- Fix: ユーザーのリアクション一覧でミュート/ブロックが機能していなかった問題を修正
- Fix: エラーメッセージの誤字を修正 (#14213)
### Misskey.js ### Misskey.js
- Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応) - Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応)
- Feat: `/admin/role/create` のロールポリシーの型を修正 - Feat: `/admin/role/create` のロールポリシーの型を修正

View File

@ -185,7 +185,7 @@ TODO
## Environment Variable ## Environment Variable
- `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`). - `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 ## Continuous integration
Misskey uses GitHub Actions for executing automated tests. Misskey uses GitHub Actions for executing automated tests.

View File

@ -178,12 +178,12 @@ id: "aidx"
#clusterLimit: 1 #clusterLimit: 1
# Job concurrency per worker # Job concurrency per worker
# deliverJobConcurrency: 128 # deliverJobConcurrency: 16
# inboxJobConcurrency: 16 # inboxJobConcurrency: 4
# Job rate limiter # Job rate limiter
# deliverJobPerSec: 128 # deliverJobPerSec: 1024
# inboxJobPerSec: 32 # inboxJobPerSec: 64
# Job attempts # Job attempts
# deliverJobMaxAttempts: 12 # deliverJobMaxAttempts: 12

View File

@ -17,6 +17,8 @@ services:
networks: networks:
- internal_network - internal_network
- external_network - external_network
# env_file:
# - .config/docker.env
volumes: volumes:
- ./files:/misskey/files - ./files:/misskey/files
- ./.config:/misskey/.config:ro - ./.config:/misskey/.config:ro

30
locales/index.d.ts vendored
View File

@ -736,6 +736,22 @@ export interface Locale extends ILocale {
* *
*/ */
"showOnRemote": string; "showOnRemote": string;
/**
*
*/
"continueOnRemote": string;
/**
* Misskey Hubからサーバーを選択
*/
"chooseServerOnMisskeyHub": string;
/**
*
*/
"specifyServerHost": string;
/**
*
*/
"inputHostName": string;
/** /**
* *
*/ */
@ -1921,9 +1937,13 @@ export interface Locale extends ILocale {
*/ */
"onlyOneFileCanBeAttached": string; "onlyOneFileCanBeAttached": string;
/** /**
* *
*/ */
"signinRequired": string; "signinRequired": string;
/**
* 使
*/
"signinOrContinueOnRemote": string;
/** /**
* *
*/ */
@ -4984,6 +5004,10 @@ export interface Locale extends ILocale {
* *
*/ */
"inquiry": string; "inquiry": string;
/**
*
*/
"tryAgain": string;
"_delivery": { "_delivery": {
/** /**
* *
@ -6594,6 +6618,10 @@ export interface Locale extends ILocale {
* NSFWを常に付与 * NSFWを常に付与
*/ */
"alwaysMarkNsfw": string; "alwaysMarkNsfw": string;
/**
*
*/
"canUpdateBioMedia": string;
/** /**
* *
*/ */

View File

@ -180,6 +180,10 @@ addAccount: "アカウントを追加"
reloadAccountsList: "アカウントリストの情報を更新" reloadAccountsList: "アカウントリストの情報を更新"
loginFailed: "ログインに失敗しました" loginFailed: "ログインに失敗しました"
showOnRemote: "リモートで表示" showOnRemote: "リモートで表示"
continueOnRemote: "リモートで続行"
chooseServerOnMisskeyHub: "Misskey Hubからサーバーを選択"
specifyServerHost: "サーバーのドメインを直接指定"
inputHostName: "ドメインを入力してください"
general: "全般" general: "全般"
wallpaper: "壁紙" wallpaper: "壁紙"
setWallpaper: "壁紙を設定" setWallpaper: "壁紙を設定"
@ -476,7 +480,8 @@ attachAsFileQuestion: "クリップボードのテキストが長いです。テ
noMessagesYet: "まだチャットはありません" noMessagesYet: "まだチャットはありません"
newMessageExists: "新しいメッセージがあります" newMessageExists: "新しいメッセージがあります"
onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです"
signinRequired: "続行する前に、サインアップまたはサインインが必要です" signinRequired: "続行する前に、登録またはログインが必要です"
signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります"
invitations: "招待" invitations: "招待"
invitationCode: "招待コード" invitationCode: "招待コード"
checking: "確認しています" checking: "確認しています"
@ -1242,6 +1247,7 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ
noDescription: "説明文はありません" noDescription: "説明文はありません"
alwaysConfirmFollow: "フォローの際常に確認する" alwaysConfirmFollow: "フォローの際常に確認する"
inquiry: "お問い合わせ" inquiry: "お問い合わせ"
tryAgain: "もう一度お試しください。"
_delivery: _delivery:
status: "配信状態" status: "配信状態"
@ -1705,6 +1711,7 @@ _role:
canManageAvatarDecorations: "アバターデコレーションの管理" canManageAvatarDecorations: "アバターデコレーションの管理"
driveCapacity: "ドライブ容量" driveCapacity: "ドライブ容量"
alwaysMarkNsfw: "ファイルにNSFWを常に付与" alwaysMarkNsfw: "ファイルにNSFWを常に付与"
canUpdateBioMedia: "アイコンとバナーの更新を許可"
pinMax: "ノートのピン留めの最大数" pinMax: "ノートのピン留めの最大数"
antennaMax: "アンテナの作成可能数" antennaMax: "アンテナの作成可能数"
wordMuteMax: "ワードミュートの最大文字数" wordMuteMax: "ワードミュートの最大文字数"

View File

@ -1,12 +1,12 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2024.5.0", "version": "2024.7.0-beta.0",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/misskey-dev/misskey.git" "url": "https://github.com/misskey-dev/misskey.git"
}, },
"packageManager": "pnpm@9.0.6", "packageManager": "pnpm@9.5.0",
"workspaces": [ "workspaces": [
"packages/frontend", "packages/frontend",
"packages/backend", "packages/backend",

View File

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

View File

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

View File

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

View File

@ -79,13 +79,13 @@
"@fastify/multipart": "8.3.0", "@fastify/multipart": "8.3.0",
"@fastify/static": "7.0.4", "@fastify/static": "7.0.4",
"@fastify/view": "9.1.0", "@fastify/view": "9.1.0",
"@misskey-dev/node-http-message-signatures": "0.0.10",
"@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.1.0", "@misskey-dev/summaly": "5.1.0",
"@napi-rs/canvas": "^0.1.53", "@napi-rs/canvas": "^0.1.53",
"@nestjs/common": "10.3.10", "@nestjs/common": "10.3.10",
"@nestjs/core": "10.3.10", "@nestjs/core": "10.3.10",
"@nestjs/testing": "10.3.10", "@nestjs/testing": "10.3.10",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "8.13.0", "@sentry/node": "8.13.0",
"@sentry/profiling-node": "8.13.0", "@sentry/profiling-node": "8.13.0",
"@simplewebauthn/server": "10.0.0", "@simplewebauthn/server": "10.0.0",

View File

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

View File

@ -23,7 +23,7 @@ type RedisOptionsSource = Partial<RedisOptions> & {
* *
*/ */
type Source = { type Source = {
url: string; url?: string;
port?: number; port?: number;
socket?: string; socket?: string;
chmodSocket?: string; chmodSocket?: string;
@ -31,9 +31,9 @@ type Source = {
db: { db: {
host: string; host: string;
port: number; port: number;
db: string; db?: string;
user: string; user?: string;
pass: string; pass?: string;
disableCache?: boolean; disableCache?: boolean;
extra?: { [x: string]: string }; extra?: { [x: string]: string };
}; };
@ -202,13 +202,17 @@ export function loadConfig(): Config {
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; 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 version = meta.version;
const host = url.host; const host = url.host;
const hostname = url.hostname; const hostname = url.hostname;
const scheme = url.protocol.replace(/:$/, ''); const scheme = url.protocol.replace(/:$/, '');
const wsScheme = scheme.replace('http', 'ws'); 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 ? const externalMediaProxy = config.mediaProxy ?
config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy
: null; : null;
@ -231,7 +235,7 @@ export function loadConfig(): Config {
apiUrl: `${scheme}://${host}/api`, apiUrl: `${scheme}://${host}/api`,
authUrl: `${scheme}://${host}/auth`, authUrl: `${scheme}://${host}/auth`,
driveUrl: `${scheme}://${host}/files`, driveUrl: `${scheme}://${host}/files`,
db: config.db, db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass },
dbReplications: config.dbReplications, dbReplications: config.dbReplications,
dbSlaves: config.dbSlaves, dbSlaves: config.dbSlaves,
meilisearch: config.meilisearch, meilisearch: config.meilisearch,
@ -259,7 +263,7 @@ export function loadConfig(): Config {
deliverJobMaxAttempts: config.deliverJobMaxAttempts, deliverJobMaxAttempts: config.deliverJobMaxAttempts,
inboxJobMaxAttempts: config.inboxJobMaxAttempts, inboxJobMaxAttempts: config.inboxJobMaxAttempts,
proxyRemoteFiles: config.proxyRemoteFiles, proxyRemoteFiles: config.proxyRemoteFiles,
signToActivityPubGet: config.signToActivityPubGet, signToActivityPubGet: config.signToActivityPubGet ?? true,
mediaProxy: externalMediaProxy ?? internalMediaProxy, mediaProxy: externalMediaProxy ?? internalMediaProxy,
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy, externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,
videoThumbnailGenerator: config.videoThumbnailGenerator ? videoThumbnailGenerator: config.videoThumbnailGenerator ?

View File

@ -3,11 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
// dummy
export const MAX_NOTE_TEXT_LENGTH = 3000; export const MAX_NOTE_TEXT_LENGTH = 3000;
export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min
export const USER_ACTIVE_THRESHOLD = 1000 * 60 * 60 * 24 * 3; // 3days 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 //#region hard limits
// If you change DB_* values, you must also change the DB schema. // If you change DB_* values, you must also change the DB schema.

View File

@ -3,7 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * 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 { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/_.js'; import type { UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.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 { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { PrivateKeyWithPem } from '@misskey-dev/node-http-message-signatures';
@Injectable() @Injectable()
export class AccountUpdateService { export class AccountUpdateService implements OnModuleInit {
private apDeliverManagerService: ApDeliverManagerService;
constructor( constructor(
private moduleRef: ModuleRef,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService,
private relayService: RelayService, private relayService: RelayService,
) { ) {
} }
async onModuleInit() {
this.apDeliverManagerService = this.moduleRef.get(ApDeliverManagerService.name);
}
@bindThis @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 }); const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null) throw new Error('user not found'); if (user == null) throw new Error('user not found');
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));
this.apDeliverManagerService.deliverToFollowers(user, content); await Promise.allSettled([
this.relayService.deliverToRelays(user, content); this.apDeliverManagerService.deliverToFollowers(user, content, deliverKey),
this.relayService.deliverToRelays(user, content, deliverKey),
]);
} }
} }
} }

View File

@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { IsNull, DataSource } from 'typeorm'; 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 { MiUser } from '@/models/User.js';
import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserProfile } from '@/models/UserProfile.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
@ -38,7 +38,7 @@ export class CreateSystemUserService {
// Generate secret // Generate secret
const secret = generateNativeUserToken(); const secret = generateNativeUserToken();
const keyPair = await genRsaKeyPair(); const keyPair = await genRSAAndEd25519KeyPair();
let account!: MiUser; let account!: MiUser;
@ -64,9 +64,8 @@ export class CreateSystemUserService {
}).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
await transactionalEntityManager.insert(MiUserKeypair, { await transactionalEntityManager.insert(MiUserKeypair, {
publicKey: keyPair.publicKey,
privateKey: keyPair.privateKey,
userId: account.id, userId: account.id,
...keyPair,
}); });
await transactionalEntityManager.insert(MiUserProfile, { await transactionalEntityManager.insert(MiUserProfile, {

View File

@ -40,6 +40,7 @@ export class FederatedInstanceService implements OnApplicationShutdown {
firstRetrievedAt: new Date(parsed.firstRetrievedAt), firstRetrievedAt: new Date(parsed.firstRetrievedAt),
latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null,
infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null,
notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null,
}; };
}, },
}); });

View File

@ -15,6 +15,7 @@ import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { REMOTE_SERVER_CACHE_TTL } from '@/const.js';
import type { DOMWindow } from 'jsdom'; import type { DOMWindow } from 'jsdom';
type NodeInfo = { type NodeInfo = {
@ -24,6 +25,7 @@ type NodeInfo = {
version?: unknown; version?: unknown;
}; };
metadata?: { metadata?: {
httpMessageSignaturesImplementationLevel?: unknown,
name?: unknown; name?: unknown;
nodeName?: unknown; nodeName?: unknown;
nodeDescription?: unknown; nodeDescription?: unknown;
@ -39,6 +41,7 @@ type NodeInfo = {
@Injectable() @Injectable()
export class FetchInstanceMetadataService { export class FetchInstanceMetadataService {
private logger: Logger; private logger: Logger;
private httpColon = 'https://';
constructor( constructor(
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
@ -48,6 +51,7 @@ export class FetchInstanceMetadataService {
private redisClient: Redis.Redis, private redisClient: Redis.Redis,
) { ) {
this.logger = this.loggerService.getLogger('metadata', 'cyan'); this.logger = this.loggerService.getLogger('metadata', 'cyan');
this.httpColon = process.env.MISSKEY_USE_HTTP?.toLowerCase() === 'true' ? 'http://' : 'https://';
} }
@bindThis @bindThis
@ -59,7 +63,7 @@ export class FetchInstanceMetadataService {
return await this.redisClient.set( return await this.redisClient.set(
`fetchInstanceMetadata:mutex:v2:${host}`, '1', `fetchInstanceMetadata:mutex:v2:${host}`, '1',
'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395 '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<void> { public async fetchInstanceMetadata(instance: MiInstance, force = false): Promise<void> {
const host = instance.host; const host = instance.host;
// finallyでunlockされてしまうのでtry内でロックチェックをしない if (!force) {
// returnであってもfinallyは実行される // キャッシュ有効チェックはロック取得前に行う
if (!force && await this.tryLock(host) === '1') { const _instance = await this.federatedInstanceService.fetch(host);
// 1が返ってきていたらロックされているという意味なので、何もしない const now = Date.now();
return; 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 { 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} ...`); this.logger.info(`Fetching metadata of ${instance.host} ...`);
const [info, dom, manifest] = await Promise.all([ const [info, dom, manifest] = await Promise.all([
@ -118,6 +123,14 @@ export class FetchInstanceMetadataService {
updates.openRegistrations = info.openRegistrations; updates.openRegistrations = info.openRegistrations;
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null; 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; 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; if (name) updates.name = name;
@ -129,6 +142,12 @@ export class FetchInstanceMetadataService {
await this.federatedInstanceService.update(instance.id, updates); await this.federatedInstanceService.update(instance.id, updates);
this.logger.succ(`Successfuly updated metadata of ${instance.host}`); this.logger.succ(`Successfuly updated metadata of ${instance.host}`);
this.logger.debug('Updated metadata:', {
info: !!info,
dom: !!dom,
manifest: !!manifest,
updates,
});
} catch (e) { } catch (e) {
this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`); this.logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
} finally { } finally {
@ -141,7 +160,7 @@ export class FetchInstanceMetadataService {
this.logger.info(`Fetching nodeinfo of ${instance.host} ...`); this.logger.info(`Fetching nodeinfo of ${instance.host} ...`);
try { 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 => { .catch(err => {
if (err.statusCode === 404) { if (err.statusCode === 404) {
throw new Error('No nodeinfo provided'); throw new Error('No nodeinfo provided');
@ -184,7 +203,7 @@ export class FetchInstanceMetadataService {
private async fetchDom(instance: MiInstance): Promise<DOMWindow['document']> { private async fetchDom(instance: MiInstance): Promise<DOMWindow['document']> {
this.logger.info(`Fetching HTML of ${instance.host} ...`); 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); const html = await this.httpRequestService.getHtml(url);
@ -196,7 +215,7 @@ export class FetchInstanceMetadataService {
@bindThis @bindThis
private async fetchManifest(instance: MiInstance): Promise<Record<string, unknown> | null> { private async fetchManifest(instance: MiInstance): Promise<Record<string, unknown> | null> {
const url = 'https://' + instance.host; const url = this.httpColon + instance.host;
const manifestUrl = url + '/manifest.json'; const manifestUrl = url + '/manifest.json';
@ -207,7 +226,7 @@ export class FetchInstanceMetadataService {
@bindThis @bindThis
private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise<string | null> { private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise<string | null> {
const url = 'https://' + instance.host; const url = this.httpColon + instance.host;
if (doc) { if (doc) {
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
@ -234,12 +253,12 @@ export class FetchInstanceMetadataService {
@bindThis @bindThis
private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> { private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { 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; return (new URL(manifest.icons[0].src, url)).href;
} }
if (doc) { if (doc) {
const url = 'https://' + instance.host; const url = this.httpColon + instance.host;
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const links = Array.from(doc.getElementsByTagName('link')).reverse(); const links = Array.from(doc.getElementsByTagName('link')).reverse();

View File

@ -245,6 +245,7 @@ export interface InternalEventTypes {
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
userKeypairUpdated: { userId: MiUser['id']; };
} }
// name/messages(spec) pairs dictionary // name/messages(spec) pairs dictionary

View File

@ -70,7 +70,7 @@ export class HttpRequestService {
localAddress: config.outgoingAddress, localAddress: config.outgoingAddress,
}); });
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 16);
this.httpAgent = config.proxy this.httpAgent = config.proxy
? new HttpProxyAgent({ ? new HttpProxyAgent({

View File

@ -13,7 +13,6 @@ import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import type { import type {
DbJobData, DbJobData,
DeliverJobData, DeliverJobData,
@ -33,7 +32,7 @@ import type {
UserWebhookDeliverQueue, UserWebhookDeliverQueue,
SystemWebhookDeliverQueue, SystemWebhookDeliverQueue,
} from './QueueModule.js'; } 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'; import type * as Bull from 'bullmq';
@Injectable() @Injectable()
@ -90,21 +89,21 @@ export class QueueService {
} }
@bindThis @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 (content == null) return null;
if (to == null) return null; if (to == null) return null;
const contentBody = JSON.stringify(content); const contentBody = JSON.stringify(content);
const digest = ApRequestCreator.createDigest(contentBody);
const data: DeliverJobData = { const data: DeliverJobData = {
user: { user: {
id: user.id, id: user.id,
}, },
content: contentBody, content: contentBody,
digest, digest: await genRFC3230DigestHeader(contentBody, 'SHA-256'),
to, to,
isSharedInbox, isSharedInbox,
privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem },
}; };
return this.deliverQueue.add(to, data, { return this.deliverQueue.add(to, data, {
@ -122,13 +121,13 @@ export class QueueService {
* @param user `{ id: string; }` ThinUserに変換しないので前もって変換してください * @param user `{ id: string; }` ThinUserに変換しないので前もって変換してください
* @param content IActivity | null * @param content IActivity | null
* @param inboxes `Map<string, boolean>` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox) * @param inboxes `Map<string, boolean>` / key: to (inbox url), value: isSharedInbox (whether it is sharedInbox)
* @param forceMainKey boolean | undefined, force to use main (rsa) key
* @returns void * @returns void
*/ */
@bindThis @bindThis
public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>) { public async deliverMany(user: ThinUser, content: IActivity | null, inboxes: Map<string, boolean>, privateKey?: PrivateKeyWithPem) {
if (content == null) return null; if (content == null) return null;
const contentBody = JSON.stringify(content); const contentBody = JSON.stringify(content);
const digest = ApRequestCreator.createDigest(contentBody);
const opts = { const opts = {
attempts: this.config.deliverJobMaxAttempts ?? 12, attempts: this.config.deliverJobMaxAttempts ?? 12,
@ -144,9 +143,9 @@ export class QueueService {
data: { data: {
user, user,
content: contentBody, content: contentBody,
digest,
to: d[0], to: d[0],
isSharedInbox: d[1], isSharedInbox: d[1],
privateKey: privateKey && { keyId: privateKey.keyId, privateKeyPem: privateKey.privateKeyPem },
} as DeliverJobData, } as DeliverJobData,
opts, opts,
}))); })));
@ -155,7 +154,7 @@ export class QueueService {
} }
@bindThis @bindThis
public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) { public inbox(activity: IActivity, signature: ParsedSignature | null) {
const data = { const data = {
activity: activity, activity: activity,
signature, signature,

View File

@ -16,6 +16,8 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { deepClone } from '@/misc/clone.js'; import { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.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; const ACTOR_USERNAME = 'relay.actor' as const;
@ -34,6 +36,7 @@ export class RelayService {
private queueService: QueueService, private queueService: QueueService,
private createSystemUserService: CreateSystemUserService, private createSystemUserService: CreateSystemUserService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private userKeypairService: UserKeypairService,
) { ) {
this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10); this.relaysCache = new MemorySingleCache<MiRelay[]>(1000 * 60 * 10);
} }
@ -111,7 +114,7 @@ export class RelayService {
} }
@bindThis @bindThis
public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any): Promise<void> { public async deliverToRelays(user: { id: MiUser['id']; host: null; }, activity: any, privateKey?: PrivateKeyWithPem): Promise<void> {
if (activity == null) return; if (activity == null) return;
const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({
@ -121,11 +124,9 @@ export class RelayService {
const copy = deepClone(activity); const copy = deepClone(activity);
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public']; 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); this.queueService.deliverMany(user, signed, new Map(relays.map(({ inbox }) => [inbox, false])), privateKey);
for (const relay of relays) {
this.queueService.deliver(user, signed, relay.inbox, false);
}
} }
} }

View File

@ -47,6 +47,7 @@ export type RolePolicies = {
canHideAds: boolean; canHideAds: boolean;
driveCapacityMb: number; driveCapacityMb: number;
alwaysMarkNsfw: boolean; alwaysMarkNsfw: boolean;
canUpdateBioMedia: boolean;
pinLimit: number; pinLimit: number;
antennaLimit: number; antennaLimit: number;
wordMuteLimit: number; wordMuteLimit: number;
@ -75,6 +76,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canHideAds: false, canHideAds: false,
driveCapacityMb: 100, driveCapacityMb: 100,
alwaysMarkNsfw: false, alwaysMarkNsfw: false,
canUpdateBioMedia: true,
pinLimit: 5, pinLimit: 5,
antennaLimit: 5, antennaLimit: 5,
wordMuteLimit: 200, wordMuteLimit: 200,
@ -376,6 +378,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), 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)), pinLimit: calc('pinLimit', vs => Math.max(...vs)),
antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)),
wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)),

View File

@ -3,7 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { generateKeyPair } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { DataSource, IsNull } from 'typeorm'; import { DataSource, IsNull } from 'typeorm';
@ -21,6 +20,7 @@ import { bindThis } from '@/decorators.js';
import UsersChart from '@/core/chart/charts/users.js'; import UsersChart from '@/core/chart/charts/users.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { genRSAAndEd25519KeyPair } from '@/misc/gen-key-pair.js';
@Injectable() @Injectable()
export class SignupService { export class SignupService {
@ -93,22 +93,7 @@ export class SignupService {
} }
} }
const keyPair = await new Promise<string[]>((res, rej) => const keyPair = await genRSAAndEd25519KeyPair();
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]),
));
let account!: MiUser; let account!: MiUser;
@ -131,9 +116,8 @@ export class SignupService {
})); }));
await transactionalEntityManager.save(new MiUserKeypair({ await transactionalEntityManager.save(new MiUserKeypair({
publicKey: keyPair[0],
privateKey: keyPair[1],
userId: account.id, userId: account.id,
...keyPair,
})); }));
await transactionalEntityManager.save(new MiUserProfile({ await transactionalEntityManager.save(new MiUserProfile({

View File

@ -5,41 +5,184 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis'; 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 { MiUser } from '@/models/User.js';
import type { UserKeypairsRepository } from '@/models/_.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 type { MiUserKeypair } from '@/models/UserKeypair.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.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() @Injectable()
export class UserKeypairService implements OnApplicationShutdown { export class UserKeypairService implements OnApplicationShutdown {
private cache: RedisKVCache<MiUserKeypair>; private keypairEntityCache: RedisKVCache<MiUserKeypair>;
private privateKeyObjectCache: MemoryKVCache<webcrypto.CryptoKey>;
constructor( constructor(
@Inject(DI.redis) @Inject(DI.redis)
private redisClient: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.userKeypairsRepository) @Inject(DI.userKeypairsRepository)
private userKeypairsRepository: UserKeypairsRepository, private userKeypairsRepository: UserKeypairsRepository,
private globalEventService: GlobalEventService,
private userEntityService: UserEntityService,
) { ) {
this.cache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', { this.keypairEntityCache = new RedisKVCache<MiUserKeypair>(this.redisClient, 'userKeypair', {
lifetime: 1000 * 60 * 60 * 24, // 24h lifetime: 1000 * 60 * 60 * 24, // 24h
memoryCacheLifetime: Infinity, memoryCacheLifetime: Infinity,
fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }),
toRedisConverter: (value) => JSON.stringify(value), toRedisConverter: (value) => JSON.stringify(value),
fromRedisConverter: (value) => JSON.parse(value), fromRedisConverter: (value) => JSON.parse(value),
}); });
this.privateKeyObjectCache = new MemoryKVCache<webcrypto.CryptoKey>(1000 * 60 * 60 * 1);
this.redisForSub.on('message', this.onMessage);
} }
@bindThis @bindThis
public async getUserKeypair(userId: MiUser['id']): Promise<MiUserKeypair> { public async getUserKeypair(userId: MiUser['id']): Promise<MiUserKeypair> {
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<PrivateKeyWithPem> {
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<PrivateKey> {
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<void> {
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<MiUserKeypair | void> {
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<void> {
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 @bindThis
public dispose(): void { public dispose(): void {
this.cache.dispose(); this.keypairEntityCache.dispose();
} }
@bindThis @bindThis

View File

@ -3,27 +3,23 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UserKeypairService } from './UserKeypairService.js';
import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
@Injectable() @Injectable()
export class UserSuspendService { export class UserSuspendService {
constructor( constructor(
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private queueService: QueueService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private apRendererService: ApRendererService, 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 }); this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const manager = this.apDeliverManagerService.createDeliverManager(user, content);
const queue: string[] = []; manager.addAllKnowingSharedInboxRecipe();
// process deliver時にはキーペアが消去されているはずなので、ここで挿入する
const followings = await this.followingsRepository.find({ const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main');
where: [ manager.execute({ privateKey });
{ 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);
}
} }
} }
@ -62,28 +42,12 @@ export class UserSuspendService {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false }); this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) { 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 content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
const manager = this.apDeliverManagerService.createDeliverManager(user, content);
const queue: string[] = []; manager.addAllKnowingSharedInboxRecipe();
// process deliver時にはキーペアが消去されているはずなので、ここで挿入する
const followings = await this.followingsRepository.find({ const privateKey = await this.userKeypairService.getLocalUserPrivateKeyPem(user.id, 'main');
where: [ manager.execute({ privateKey });
{ 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);
}
} }
} }
} }

View File

@ -46,7 +46,7 @@ export class WebfingerService {
const m = query.match(mRegex); const m = query.match(mRegex);
if (m) { if (m) {
const hostname = m[2]; 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}` })}`; return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`;
} }

View File

@ -5,7 +5,7 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; 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 type { Config } from '@/config.js';
import { MemoryKVCache } from '@/misc/cache.js'; import { MemoryKVCache } from '@/misc/cache.js';
import type { MiUserPublickey } from '@/models/UserPublickey.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 type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import Logger from '@/logger.js';
import { getApId } from './type.js'; import { getApId } from './type.js';
import { ApPersonService } from './models/ApPersonService.js'; import { ApPersonService } from './models/ApPersonService.js';
import { ApLoggerService } from './ApLoggerService.js';
import type { IObject } from './type.js'; import type { IObject } from './type.js';
import { UtilityService } from '../UtilityService.js';
export type UriParseResult = { export type UriParseResult = {
/** wether the URI was generated by us */ /** wether the URI was generated by us */
@ -35,8 +38,8 @@ export type UriParseResult = {
@Injectable() @Injectable()
export class ApDbResolverService implements OnApplicationShutdown { export class ApDbResolverService implements OnApplicationShutdown {
private publicKeyCache: MemoryKVCache<MiUserPublickey | null>; private publicKeyByUserIdCache: MemoryKVCache<MiUserPublickey[] | null>;
private publicKeyByUserIdCache: MemoryKVCache<MiUserPublickey | null>; private logger: Logger;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
@ -53,9 +56,17 @@ export class ApDbResolverService implements OnApplicationShutdown {
private cacheService: CacheService, private cacheService: CacheService,
private apPersonService: ApPersonService, private apPersonService: ApPersonService,
private apLoggerService: ApLoggerService,
private utilityService: UtilityService,
) { ) {
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(Infinity); this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey[] | null>(Infinity);
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(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 @bindThis
@ -116,62 +127,141 @@ export class ApDbResolverService implements OnApplicationShutdown {
} }
} }
/**
* AP KeyId => Misskey User and Key
*/
@bindThis @bindThis
public async getAuthUserFromKeyId(keyId: string): Promise<{ private async refreshAndFindKey(userId: MiUser['id'], keyId: string): Promise<MiUserPublickey | null> {
user: MiRemoteUser; this.refreshCacheByUserId(userId);
key: MiUserPublickey; const keys = await this.getPublicKeyByUserId(userId);
} | null> { if (keys == null || !Array.isArray(keys) || keys.length === 0) {
const key = await this.publicKeyCache.fetch(keyId, async () => { this.logger.warn(`No key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`);
const key = await this.userPublickeysRepository.findOneBy({ return null;
keyId, }
}); const exactKey = keys.find(x => x.keyId === keyId);
if (exactKey) return exactKey;
if (key == null) return null; this.logger.warn(`No exact key found (refreshAndFindKey) userId=${userId} keyId=${keyId} keys=${JSON.stringify(keys)}`);
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,
};
} }
/** /**
* AP Actor id => Misskey User and Key * 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 @bindThis
public async getAuthUserFromApId(uri: string): Promise<{ public async getAuthUserFromApId(uri: string, keyId?: string): Promise<{
user: MiRemoteUser; user: MiRemoteUser;
key: MiUserPublickey | null; key: MiUserPublickey | null;
} | null> { } | {
const user = await this.apPersonService.resolvePerson(uri) as MiRemoteUser; user: null;
if (user.isDeleted) return 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( const user = await this.apPersonService.resolvePerson(uri, undefined, true) as MiRemoteUser;
user.id, if (user.isDeleted) return { user: null, key: null };
() => this.userPublickeysRepository.findOneBy({ userId: user.id }),
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<MiUserPublickey[] | null> {
return await this.publicKeyByUserIdCache.fetch(
userId,
() => this.userPublickeysRepository.find({ where: { userId } }),
v => v != null, v => v != null,
); );
}
return { @bindThis
user, public refreshCacheByUserId(userId: MiUser['id']): void {
key, this.publicKeyByUserIdCache.delete(userId);
};
} }
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.publicKeyCache.dispose();
this.publicKeyByUserIdCache.dispose(); this.publicKeyByUserIdCache.dispose();
} }

View File

@ -9,10 +9,14 @@ import { DI } from '@/di-symbols.js';
import type { FollowingsRepository } from '@/models/_.js'; import type { FollowingsRepository } from '@/models/_.js';
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { IActivity } from '@/core/activitypub/type.js'; import type { IActivity } from '@/core/activitypub/type.js';
import { ThinUser } from '@/queue/types.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 { interface IRecipe {
type: string; type: string;
@ -27,12 +31,19 @@ interface IDirectRecipe extends IRecipe {
to: MiRemoteUser; to: MiRemoteUser;
} }
interface IAllKnowingSharedInboxRecipe extends IRecipe {
type: 'AllKnowingSharedInbox';
}
const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe => const isFollowers = (recipe: IRecipe): recipe is IFollowersRecipe =>
recipe.type === 'Followers'; recipe.type === 'Followers';
const isDirect = (recipe: IRecipe): recipe is IDirectRecipe => const isDirect = (recipe: IRecipe): recipe is IDirectRecipe =>
recipe.type === 'Direct'; recipe.type === 'Direct';
const isAllKnowingSharedInbox = (recipe: IRecipe): recipe is IAllKnowingSharedInboxRecipe =>
recipe.type === 'AllKnowingSharedInbox';
class DeliverManager { class DeliverManager {
private actor: ThinUser; private actor: ThinUser;
private activity: IActivity | null; private activity: IActivity | null;
@ -40,16 +51,18 @@ class DeliverManager {
/** /**
* Constructor * Constructor
* @param userEntityService * @param userKeypairService
* @param followingsRepository * @param followingsRepository
* @param queueService * @param queueService
* @param actor Actor * @param actor Actor
* @param activity Activity to deliver * @param activity Activity to deliver
*/ */
constructor( constructor(
private userEntityService: UserEntityService, private userKeypairService: UserKeypairService,
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private queueService: QueueService, private queueService: QueueService,
private accountUpdateService: AccountUpdateService,
private logger: Logger,
actor: { id: MiUser['id']; host: null; }, actor: { id: MiUser['id']; host: null; },
activity: IActivity | null, activity: IActivity | null,
@ -91,6 +104,18 @@ class DeliverManager {
this.addRecipe(recipe); 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 * Add recipe
* @param recipe Recipe * @param recipe Recipe
@ -104,11 +129,44 @@ class DeliverManager {
* Execute delivers * Execute delivers
*/ */
@bindThis @bindThis
public async execute(): Promise<void> { public async execute(opts?: { privateKey?: PrivateKeyWithPem }): Promise<void> {
//#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. // The value flags whether it is shared or not.
// key: inbox URL, value: whether it is sharedInbox // key: inbox URL, value: whether it is sharedInbox
const inboxes = new Map<string, boolean>(); const inboxes = new Map<string, boolean>();
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 // build inbox list
// Process follower recipes first to avoid duplication when processing direct recipes later. // Process follower recipes first to avoid duplication when processing direct recipes later.
if (this.recipes.some(r => isFollowers(r))) { if (this.recipes.some(r => isFollowers(r))) {
@ -142,39 +200,49 @@ class DeliverManager {
inboxes.set(recipe.to.inbox, false); inboxes.set(recipe.to.inbox, false);
} }
//#endregion
// deliver // 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() @Injectable()
export class ApDeliverManagerService { export class ApDeliverManagerService {
private logger: Logger;
constructor( constructor(
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService, private userKeypairService: UserKeypairService,
private queueService: QueueService, private queueService: QueueService,
private accountUpdateService: AccountUpdateService,
private apLoggerService: ApLoggerService,
) { ) {
this.logger = this.apLoggerService.logger.createSubLogger('deliver-manager');
} }
/** /**
* Deliver activity to followers * Deliver activity to followers
* @param actor * @param actor
* @param activity Activity * @param activity Activity
* @param forceMainKey Force to use main (rsa) key
*/ */
@bindThis @bindThis
public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> { public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, privateKey?: PrivateKeyWithPem): Promise<void> {
const manager = new DeliverManager( const manager = new DeliverManager(
this.userEntityService, this.userKeypairService,
this.followingsRepository, this.followingsRepository,
this.queueService, this.queueService,
this.accountUpdateService,
this.logger,
actor, actor,
activity, activity,
); );
manager.addFollowersRecipe(); manager.addFollowersRecipe();
await manager.execute(); await manager.execute({ privateKey });
} }
/** /**
@ -186,9 +254,11 @@ export class ApDeliverManagerService {
@bindThis @bindThis
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> { public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
const manager = new DeliverManager( const manager = new DeliverManager(
this.userEntityService, this.userKeypairService,
this.followingsRepository, this.followingsRepository,
this.queueService, this.queueService,
this.accountUpdateService,
this.logger,
actor, actor,
activity, activity,
); );
@ -199,10 +269,11 @@ export class ApDeliverManagerService {
@bindThis @bindThis
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager { public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
return new DeliverManager( return new DeliverManager(
this.userEntityService, this.userKeypairService,
this.followingsRepository, this.followingsRepository,
this.queueService, this.queueService,
this.accountUpdateService,
this.logger,
actor, actor,
activity, activity,
); );

View File

@ -114,15 +114,8 @@ export class ApInboxService {
result = await this.performOneActivity(actor, activity); result = await this.performOneActivity(actor, activity);
} }
// ついでにリモートユーザーの情報が古かったら更新しておく // ついでにリモートユーザーの情報が古かったら更新しておく?
if (actor.uri) { // → No, この関数が呼び出される前に署名検証で更新されているはず
if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
setImmediate(() => {
this.apPersonService.updatePerson(actor.uri);
});
}
}
return result;
} }
@bindThis @bindThis

View File

@ -25,7 +25,7 @@ export class ApMfmService {
} }
@bindThis @bindThis
public getNoteHtml(note: MiNote, apAppend?: string) { public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, apAppend?: string) {
let noMisskeyContent = false; let noMisskeyContent = false;
const srcMfm = (note.text ?? '') + (apAppend ?? ''); const srcMfm = (note.text ?? '') + (apAppend ?? '');

View File

@ -22,7 +22,6 @@ import { UserKeypairService } from '@/core/UserKeypairService.js';
import { MfmService } from '@/core/MfmService.js'; import { MfmService } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.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 type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js';
@ -31,6 +30,7 @@ import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js'; import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.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 { 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() @Injectable()
export class ApRendererService { export class ApRendererService {
@ -251,15 +251,15 @@ export class ApRendererService {
} }
@bindThis @bindThis
public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey { public renderKey(user: MiLocalUser, publicKey: string, postfix?: string): IKey {
return { return {
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, id: `${this.userEntityService.genLocalUserUri(user.id)}${postfix ?? '/publickey'}`,
type: 'Key', type: 'Key',
owner: this.userEntityService.genLocalUserUri(user.id), owner: this.userEntityService.genLocalUserUri(user.id),
publicKeyPem: createPublicKey(key.publicKey).export({ publicKeyPem: createPublicKey(publicKey).export({
type: 'spki', type: 'spki',
format: 'pem', format: 'pem',
}), }) as string,
}; };
} }
@ -499,7 +499,10 @@ export class ApRendererService {
tag, tag,
manuallyApprovesFollowers: user.isLocked, manuallyApprovesFollowers: user.isLocked,
discoverable: user.isExplorable, 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, isCat: user.isCat,
attachment: attachment.length ? attachment : undefined, attachment: attachment.length ? attachment : undefined,
}; };
@ -622,12 +625,10 @@ export class ApRendererService {
} }
@bindThis @bindThis
public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise<IActivity> { public async attachLdSignature(activity: any, key: PrivateKeyWithPem): Promise<IActivity> {
const keypair = await this.userKeypairService.getUserKeypair(user.id);
const jsonLd = this.jsonLdService.use(); const jsonLd = this.jsonLdService.use();
jsonLd.debug = false; 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; return activity;
} }

View File

@ -3,9 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as crypto from 'node:crypto';
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { genRFC3230DigestHeader, signAsDraftToRequest } from '@misskey-dev/node-http-message-signatures';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
@ -15,122 +15,61 @@ import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import type { PrivateKeyWithPem, PrivateKey } from '@misskey-dev/node-http-message-signatures';
type Request = { export async function createSignedPost(args: { level: string; key: PrivateKey; url: string; body: string; digest?: string, additionalHeaders: Record<string, string> }) {
url: string; const u = new URL(args.url);
method: string; const request = {
headers: Record<string, string>; url: u.href,
}; method: 'POST',
headers: {
'Date': new Date().toUTCString(),
'Host': u.host,
'Content-Type': 'application/activity+json',
...args.additionalHeaders,
} as Record<string, string>,
};
type Signed = { // TODO: httpMessageSignaturesImplementationLevelによって新規格で通信をするようにする
request: Request; const digestHeader = args.digest ?? await genRFC3230DigestHeader(args.body, 'SHA-256');
signingString: string; request.headers['Digest'] = digestHeader;
signature: string;
signatureHeader: string;
};
type PrivateKey = { const result = await signAsDraftToRequest(
privateKeyPem: string; request,
keyId: string; args.key,
}; ['(request-target)', 'date', 'host', 'digest'],
);
export class ApRequestCreator { return {
static createSignedPost(args: { key: PrivateKey, url: string, body: string, digest?: string, additionalHeaders: Record<string, string> }): Signed { request,
const u = new URL(args.url); ...result,
const digestHeader = args.digest ?? this.createDigest(args.body); };
}
const request: Request = { export async function createSignedGet(args: { level: string; key: PrivateKey; url: string; additionalHeaders: Record<string, string> }) {
url: u.href, const u = new URL(args.url);
method: 'POST', const request = {
headers: this.#objectAssignWithLcKey({ url: u.href,
'Date': new Date().toUTCString(), method: 'GET',
'Host': u.host, headers: {
'Content-Type': 'application/activity+json', 'Accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
'Digest': digestHeader, 'Date': new Date().toUTCString(),
}, args.additionalHeaders), 'Host': new URL(args.url).host,
}; ...args.additionalHeaders,
} as Record<string, string>,
};
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 { return {
request, request,
signingString: result.signingString, ...result,
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<string, string> }): 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<string, string>): Record<string, string> {
const dst: Record<string, string> = {};
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<string, string>, b: Record<string, string>): Record<string, string> {
return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b));
}
} }
@Injectable() @Injectable()
@ -150,21 +89,28 @@ export class ApRequestService {
} }
@bindThis @bindThis
public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, digest?: string): Promise<void> { public async signedPost(user: { id: MiUser['id'] }, url: string, object: unknown, level: string, digest?: string, key?: PrivateKeyWithPem): Promise<void> {
const body = typeof object === 'string' ? object : JSON.stringify(object); const body = typeof object === 'string' ? object : JSON.stringify(object);
const keyFetched = await this.userKeypairService.getLocalUserPrivateKey(key ?? user.id, level);
const keypair = await this.userKeypairService.getUserKeypair(user.id); const req = await createSignedPost({
level,
const req = ApRequestCreator.createSignedPost({ key: keyFetched,
key: {
privateKeyPem: keypair.privateKey,
keyId: `${this.config.url}/users/${user.id}#main-key`,
},
url, url,
body, body,
digest,
additionalHeaders: { 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, { await this.httpRequestService.send(url, {
@ -180,19 +126,27 @@ export class ApRequestService {
* @param url URL to fetch * @param url URL to fetch
*/ */
@bindThis @bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }): Promise<unknown> { public async signedGet(url: string, user: { id: MiUser['id'] }, level: string): Promise<unknown> {
const keypair = await this.userKeypairService.getUserKeypair(user.id); const key = await this.userKeypairService.getLocalUserPrivateKey(user.id, level);
const req = await createSignedGet({
const req = ApRequestCreator.createSignedGet({ level,
key: { key,
privateKeyPem: keypair.privateKey,
keyId: `${this.config.url}/users/${user.id}#main-key`,
},
url, url,
additionalHeaders: { 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, { const res = await this.httpRequestService.send(url, {
method: req.request.method, method: req.request.method,
headers: req.request.headers, headers: req.request.headers,

View File

@ -16,6 +16,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { isCollectionOrOrderedCollection } from './type.js'; import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js'; import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js'; import { ApRendererService } from './ApRendererService.js';
@ -41,6 +42,7 @@ export class Resolver {
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private federatedInstanceService: FederatedInstanceService,
private loggerService: LoggerService, private loggerService: LoggerService,
private recursionLimit = 100, private recursionLimit = 100,
) { ) {
@ -103,8 +105,10 @@ export class Resolver {
this.user = await this.instanceActorService.getInstanceActor(); this.user = await this.instanceActorService.getInstanceActor();
} }
const server = await this.federatedInstanceService.fetch(host);
const object = (this.user 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; : await this.httpRequestService.getActivityJson(value)) as IObject;
if ( if (
@ -200,6 +204,7 @@ export class ApResolverService {
private httpRequestService: HttpRequestService, private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService, private apDbResolverService: ApDbResolverService,
private federatedInstanceService: FederatedInstanceService,
private loggerService: LoggerService, private loggerService: LoggerService,
) { ) {
} }
@ -220,6 +225,7 @@ export class ApResolverService {
this.httpRequestService, this.httpRequestService,
this.apRendererService, this.apRendererService,
this.apDbResolverService, this.apDbResolverService,
this.federatedInstanceService,
this.loggerService, this.loggerService,
); );
} }

View File

@ -134,6 +134,7 @@ const security_v1 = {
'privateKey': { '@id': 'sec:privateKey', '@type': '@id' }, 'privateKey': { '@id': 'sec:privateKey', '@type': '@id' },
'privateKeyPem': 'sec:privateKeyPem', 'privateKeyPem': 'sec:privateKeyPem',
'publicKey': { '@id': 'sec:publicKey', '@type': '@id' }, 'publicKey': { '@id': 'sec:publicKey', '@type': '@id' },
'additionalPublicKeys': { '@id': 'sec:publicKey', '@type': '@id' },
'publicKeyBase58': 'sec:publicKeyBase58', 'publicKeyBase58': 'sec:publicKeyBase58',
'publicKeyPem': 'sec:publicKeyPem', 'publicKeyPem': 'sec:publicKeyPem',
'publicKeyWif': 'sec:publicKeyWif', 'publicKeyWif': 'sec:publicKeyWif',

View File

@ -3,9 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { verify } from 'crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import promiseLimit from 'promise-limit'; import promiseLimit from 'promise-limit';
import { DataSource } from 'typeorm'; import { DataSource, In, Not } from 'typeorm';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.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 { UtilityService } from '@/core/UtilityService.js';
import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { AccountMoveService } from '@/core/AccountMoveService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js';
import { checkHttps } from '@/misc/check-https.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 { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
import { extractApHashtags } from './tag.js'; import { extractApHashtags } from './tag.js';
import type { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
@ -47,7 +50,7 @@ import type { ApResolverService, Resolver } from '../ApResolverService.js';
import type { ApLoggerService } from '../ApLoggerService.js'; import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js'; 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 nameLength = 128;
const summaryLength = 2048; const summaryLength = 2048;
@ -100,6 +103,8 @@ export class ApPersonService implements OnModuleInit {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
private roleService: RoleService,
) { ) {
} }
@ -182,13 +187,38 @@ export class ApPersonService implements OnModuleInit {
} }
if (x.publicKey) { if (x.publicKey) {
if (typeof x.publicKey.id !== 'string') { const publicKeys = Array.isArray(x.publicKey) ? x.publicKey : [x.publicKey];
throw new Error('invalid Actor: publicKey.id is not a string');
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 (!Array.isArray(x.additionalPublicKeys)) {
if (publicKeyIdHost !== expectHost) { throw new Error('invalid Actor: additionalPublicKeys is not an array');
throw new Error('invalid Actor: publicKey.id has different host'); }
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; return null;
} }
/**
* uriからUser(Person)
*
* Misskeyに対象のPersonが登録されていればそれを返しnullを返します
* TTLが0でない場合TTLを過ぎていた場合はupdatePersonを実行します
*/
@bindThis
async fetchPersonWithRenewal(uri: string, TTL = REMOTE_USER_CACHE_TTL): Promise<MiLocalUser | MiRemoteUser | null> {
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<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'avatarUrl' | 'bannerUrl' | 'avatarBlurhash' | 'bannerBlurhash'>>> { private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'avatarUrl' | 'bannerUrl' | 'avatarBlurhash' | 'bannerBlurhash'>>> {
if (user == null) throw new Error('failed to create user: user is null'); 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); 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 we don't want to return nulls on errors! if the database fields
are already null, nothing changes; if the database has old are already null, nothing changes; if the database has old
@ -355,11 +417,15 @@ export class ApPersonService implements OnModuleInit {
})); }));
if (person.publicKey) { if (person.publicKey) {
await transactionalEntityManager.save(new MiUserPublickey({ const publicKeys = new Map<string, IKey>();
userId: user.id, (person.additionalPublicKeys ?? []).forEach(key => publicKeys.set(key.id, key));
keyId: person.publicKey.id, (Array.isArray(person.publicKey) ? person.publicKey : [person.publicKey]).forEach(key => publicKeys.set(key.id, key));
keyPem: person.publicKey.publicKeyPem,
})); await transactionalEntityManager.save(Array.from(publicKeys.values(), key => new MiUserPublickey({
keyId: key.id,
userId: user!.id,
keyPem: key.publicKeyPem,
})));
} }
}); });
} catch (e) { } catch (e) {
@ -505,11 +571,29 @@ export class ApPersonService implements OnModuleInit {
// Update user // Update user
await this.usersRepository.update(exist.id, updates); await this.usersRepository.update(exist.id, updates);
if (person.publicKey) { try {
await this.userPublickeysRepository.update({ userId: exist.id }, { // Deleteアクティビティ受信時にもここが走ってsaveがuserforeign key制約エラーを吐くことがある
keyId: person.publicKey.id, // とりあえずtry-catchで囲っておく
keyPem: person.publicKey.publicKeyPem, const publicKeys = new Map<string, IKey>();
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; let _description: string | null = null;
@ -551,7 +635,7 @@ export class ApPersonService implements OnModuleInit {
exist.movedAt == null || exist.movedAt == null ||
// 以前のmovingから14日以上経過した場合のみ移行処理を許可 // 以前のmovingから14日以上経過した場合のみ移行処理を許可
// Mastodonのクールダウン期間は30日だが若干緩めに設定しておく // 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})`); this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`);
return this.processRemoteMove(updated, movePreventUris) return this.processRemoteMove(updated, movePreventUris)
@ -574,9 +658,9 @@ export class ApPersonService implements OnModuleInit {
* Misskeyに登録しそれを返します * Misskeyに登録しそれを返します
*/ */
@bindThis @bindThis
public async resolvePerson(uri: string, resolver?: Resolver): Promise<MiLocalUser | MiRemoteUser> { public async resolvePerson(uri: string, resolver?: Resolver, withRenewal = false): Promise<MiLocalUser | MiRemoteUser> {
//#region このサーバーに既に登録されていたらそれを返す //#region このサーバーに既に登録されていたらそれを返す
const exist = await this.fetchPerson(uri); const exist = withRenewal ? await this.fetchPersonWithRenewal(uri) : await this.fetchPerson(uri);
if (exist) return exist; if (exist) return exist;
//#endregion //#endregion

View File

@ -74,10 +74,10 @@ export class ApQuestionService {
//#region このサーバーに既に登録されているか //#region このサーバーに既に登録されているか
const note = await this.notesRepository.findOneBy({ uri }); 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 }); 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 //#endregion
// resolve new Question object // resolve new Question object

View File

@ -55,7 +55,7 @@ export function getOneApId(value: ApObject): string {
export function getApId(value: string | IObject): string { export function getApId(value: string | IObject): string {
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id; 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; discoverable?: boolean;
inbox: string; inbox: string;
sharedInbox?: string; // 後方互換性のため sharedInbox?: string; // 後方互換性のため
publicKey?: { publicKey?: IKey | IKey[];
id: string; additionalPublicKeys?: IKey[];
publicKeyPem: string;
};
followers?: string | ICollection | IOrderedCollection; followers?: string | ICollection | IOrderedCollection;
following?: string | ICollection | IOrderedCollection; following?: string | ICollection | IOrderedCollection;
featured?: string | IOrderedCollection; featured?: string | IOrderedCollection;
@ -236,8 +234,9 @@ export const isEmoji = (object: IObject): object is IApEmoji =>
export interface IKey extends IObject { export interface IKey extends IObject {
type: 'Key'; type: 'Key';
id: string;
owner: string; owner: string;
publicKeyPem: string | Buffer; publicKeyPem: string;
} }
export interface IApDocument extends IObject { export interface IApDocument extends IObject {

View File

@ -56,6 +56,7 @@ export class InstanceEntityService {
infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null,
latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null, latestRequestReceivedAt: instance.latestRequestReceivedAt ? instance.latestRequestReceivedAt.toISOString() : null,
moderationNote: iAmModerator ? instance.moderationNote : null, moderationNote: iAmModerator ? instance.moderationNote : null,
httpMessageSignaturesImplementationLevel: instance.httpMessageSignaturesImplementationLevel,
}; };
} }

View File

@ -501,11 +501,15 @@ export class UserEntityService implements OnModuleInit {
emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), emojis: this.customEmojiService.populateEmojis(user.emojis, user.host),
onlineStatus: this.getOnlineStatus(user), 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 => ({ badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs
name: r.name, .filter((r) => r.isPublic || iAmModerator)
iconUrl: r.iconUrl, .sort((a, b) => b.displayOrder - a.displayOrder)
displayOrder: r.displayOrder, .map((r) => ({
}))) : undefined, name: r.name,
iconUrl: r.iconUrl,
displayOrder: r.displayOrder,
}))
) : undefined,
...(isDetailed ? { ...(isDetailed ? {
url: profile!.url, url: profile!.url,

View File

@ -195,6 +195,9 @@ export class MemoryKVCache<T> {
private lifetime: number; private lifetime: number;
private gcIntervalHandle: NodeJS.Timeout; private gcIntervalHandle: NodeJS.Timeout;
/**
* @param lifetime (ms)
*/
constructor(lifetime: MemoryKVCache<never>['lifetime']) { constructor(lifetime: MemoryKVCache<never>['lifetime']) {
this.cache = new Map(); this.cache = new Map();
this.lifetime = lifetime; this.lifetime = lifetime;

View File

@ -3,39 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as crypto from 'node:crypto'; import { genEd25519KeyPair, genRsaKeyPair } from '@misskey-dev/node-http-message-signatures';
import * as util from 'node:util';
const generateKeyPair = util.promisify(crypto.generateKeyPair); export async function genRSAAndEd25519KeyPair(rsaModulusLength = 4096) {
const [rsa, ed25519] = await Promise.all([genRsaKeyPair(rsaModulusLength), genEd25519KeyPair()]);
export async function genRsaKeyPair(modulusLength = 2048) { return {
return await generateKeyPair('rsa', { publicKey: rsa.publicKey,
modulusLength, privateKey: rsa.privateKey,
publicKeyEncoding: { ed25519PublicKey: ed25519.publicKey,
type: 'spki', ed25519PrivateKey: ed25519.privateKey,
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,
},
});
} }

View File

@ -4,6 +4,10 @@
*/ */
export function isUserRelated(note: any, userIds: Set<string>, ignoreAuthor = false): boolean { export function isUserRelated(note: any, userIds: Set<string>, ignoreAuthor = false): boolean {
if (!note) {
return false;
}
if (userIds.has(note.userId) && !ignoreAuthor) { if (userIds.has(note.userId) && !ignoreAuthor) {
return true; return true;
} }

View File

@ -158,4 +158,9 @@ export class MiInstance {
length: 16384, default: '', length: 16384, default: '',
}) })
public moderationNote: string; public moderationNote: string;
@Column('varchar', {
length: 16, default: '00', nullable: false,
})
public httpMessageSignaturesImplementationLevel: string;
} }

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * 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 { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
@ -12,22 +12,42 @@ export class MiUserKeypair {
@PrimaryColumn(id()) @PrimaryColumn(id())
public userId: MiUser['id']; public userId: MiUser['id'];
@OneToOne(type => MiUser, { @ManyToOne(type => MiUser, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@JoinColumn() @JoinColumn()
public user: MiUser | null; public user: MiUser | null;
/**
* RSA public key
*/
@Column('varchar', { @Column('varchar', {
length: 4096, length: 4096,
}) })
public publicKey: string; public publicKey: string;
/**
* RSA private key
*/
@Column('varchar', { @Column('varchar', {
length: 4096, length: 4096,
}) })
public privateKey: string; 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<MiUserKeypair>) { constructor(data: Partial<MiUserKeypair>) {
if (data == null) return; if (data == null) return;

View File

@ -9,7 +9,13 @@ import { MiUser } from './User.js';
@Entity('user_publickey') @Entity('user_publickey')
export class MiUserPublickey { export class MiUserPublickey {
@PrimaryColumn(id()) @PrimaryColumn('varchar', {
length: 256,
})
public keyId: string;
@Index()
@Column(id())
public userId: MiUser['id']; public userId: MiUser['id'];
@OneToOne(type => MiUser, { @OneToOne(type => MiUser, {
@ -18,12 +24,6 @@ export class MiUserPublickey {
@JoinColumn() @JoinColumn()
public user: MiUser | null; public user: MiUser | null;
@Index({ unique: true })
@Column('varchar', {
length: 256,
})
public keyId: string;
@Column('varchar', { @Column('varchar', {
length: 4096, length: 4096,
}) })

View File

@ -116,5 +116,9 @@ export const packedFederationInstanceSchema = {
type: 'string', type: 'string',
optional: true, nullable: true, optional: true, nullable: true,
}, },
httpMessageSignaturesImplementationLevel: {
type: 'string',
optional: false, nullable: false,
},
}, },
} as const; } as const;

View File

@ -228,6 +228,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
canUpdateBioMedia: {
type: 'boolean',
optional: false, nullable: false,
},
pinLimit: { pinLimit: {
type: 'integer', type: 'integer',
optional: false, nullable: false, optional: false, nullable: false,

View File

@ -250,9 +250,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
}, { }, {
...baseQueueOptions(this.config, QUEUE.DELIVER), ...baseQueueOptions(this.config, QUEUE.DELIVER),
autorun: false, autorun: false,
concurrency: this.config.deliverJobConcurrency ?? 128, concurrency: this.config.deliverJobConcurrency ?? 16,
limiter: { limiter: {
max: this.config.deliverJobPerSec ?? 128, max: this.config.deliverJobPerSec ?? 1024,
duration: 1000, duration: 1000,
}, },
settings: { settings: {
@ -290,9 +290,9 @@ export class QueueProcessorService implements OnApplicationShutdown {
}, { }, {
...baseQueueOptions(this.config, QUEUE.INBOX), ...baseQueueOptions(this.config, QUEUE.INBOX),
autorun: false, autorun: false,
concurrency: this.config.inboxJobConcurrency ?? 16, concurrency: this.config.inboxJobConcurrency ?? 4,
limiter: { limiter: {
max: this.config.inboxJobPerSec ?? 32, max: this.config.inboxJobPerSec ?? 64,
duration: 1000, duration: 1000,
}, },
settings: { settings: {

View File

@ -73,25 +73,33 @@ export class DeliverProcessorService {
} }
try { 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 // Update stats
this.federatedInstanceService.fetch(host).then(i => { if (server.isNotResponding) {
if (i.isNotResponding) { this.federatedInstanceService.update(server.id, {
this.federatedInstanceService.update(i.id, { isNotResponding: false,
isNotResponding: false, notRespondingSince: null,
notRespondingSince: null, });
}); }
}
this.fetchInstanceMetadataService.fetchInstanceMetadata(i); this.apRequestChart.deliverSucc();
this.apRequestChart.deliverSucc(); this.federationChart.deliverd(server.host, true);
this.federationChart.deliverd(i.host, true);
if (meta.enableChartsForFederatedInstances) { if (meta.enableChartsForFederatedInstances) {
this.instanceChart.requestSent(i.host, true); this.instanceChart.requestSent(server.host, true);
} }
});
return 'Success'; return 'Success';
} catch (res) { } catch (res) {

View File

@ -5,8 +5,8 @@
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import httpSignature from '@peertube/http-signature';
import * as Bull from 'bullmq'; import * as Bull from 'bullmq';
import { verifyDraftSignature } from '@misskey-dev/node-http-message-signatures';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.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 type { MiUserPublickey } from '@/models/UserPublickey.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import { StatusError } from '@/misc/status-error.js'; import { StatusError } from '@/misc/status-error.js';
import * as Acct from '@/misc/acct.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { JsonLdService } from '@/core/activitypub/JsonLdService.js';
@ -52,8 +53,15 @@ export class InboxProcessorService {
@bindThis @bindThis
public async process(job: Bull.Job<InboxJobData>): Promise<string> { public async process(job: Bull.Job<InboxJobData>): Promise<string> {
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 activity = job.data.activity;
let actorUri = getApId(activity.actor);
//#region Log //#region Log
const info = Object.assign({}, activity); const info = Object.assign({}, activity);
@ -61,7 +69,7 @@ export class InboxProcessorService {
this.logger.debug(JSON.stringify(info, null, 2)); this.logger.debug(JSON.stringify(info, null, 2));
//#endregion //#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(); const meta = await this.metaService.fetch();
@ -69,69 +77,76 @@ export class InboxProcessorService {
return `Blocked request: ${host}`; 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から取得 // HTTP-Signature keyIdを元にDBから取得
let authUser: { let authUser: Awaited<ReturnType<typeof this.apDbResolverService.getAuthUserFromApId>> = null;
user: MiRemoteUser; let httpSignatureIsValid = null as boolean | null;
key: MiUserPublickey | null;
} | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId);
// keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 try {
if (authUser == null) { authUser = await this.apDbResolverService.getAuthUserFromApId(actorUri, signature?.keyId);
try { } catch (err) {
authUser = await this.apDbResolverService.getAuthUserFromApId(getApId(activity.actor)); // 対象が4xxならスキップ
} catch (err) { if (err instanceof StatusError) {
// 対象が4xxならスキップ if (!err.isRetryable) {
if (err instanceof StatusError) { throw new Bull.UnrecoverableError(`skip: Ignored deleted actors on both ends ${activity.actor} - ${err.statusCode}`);
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}`);
} }
throw new Error(`Error in actor ${activity.actor} - ${err.statusCode}`);
} }
} }
// それでもわからなければ終了 // authUser.userがnullならスキップ
if (authUser == null) { if (authUser != null && authUser.user == null) {
throw new Bull.UnrecoverableError('skip: failed to resolve user'); throw new Bull.UnrecoverableError('skip: failed to resolve user');
} }
// publicKey がなくても終了 if (signature != null && authUser != null) {
if (authUser.key == null) { if (signature.keyId.toLowerCase().startsWith('acct:')) {
throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey'); 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の検証 if (
const httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem); authUser == null ||
httpSignatureIsValid !== true ||
// また、signatureのsignerは、activity.actorと一致する必要がある authUser.user.uri !== actorUri // 一応チェック
if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { ) {
// 一致しなくても、でもLD-Signatureがありそうならそっちも見る // 一致しなくても、でもLD-Signatureがありそうならそっちも見る
const ldSignature = activity.signature; const ldSignature = activity.signature;
if (ldSignature) {
if (ldSignature && ldSignature.creator) {
if (ldSignature.type !== 'RsaSignature2017') { if (ldSignature.type !== 'RsaSignature2017') {
throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`); throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`);
} }
// ldSignature.creator: https://example.oom/users/user#main-key if (ldSignature.creator.toLowerCase().startsWith('acct:')) {
// みたいになっててUserを引っ張れば公開キーも入ることを期待する throw new Bull.UnrecoverableError(`old key not supported ${ldSignature.creator}`);
if (ldSignature.creator) {
const candicate = ldSignature.creator.replace(/#.*/, '');
await this.apPersonService.resolvePerson(candicate).catch(() => null);
} }
// keyIdからLD-Signatureのユーザーを取得 authUser = await this.apDbResolverService.getAuthUserFromApId(actorUri, ldSignature.creator);
authUser = await this.apDbResolverService.getAuthUserFromKeyId(ldSignature.creator);
if (authUser == null) { 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) { 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(); const jsonLd = this.jsonLdService.use();
@ -142,13 +157,27 @@ export class InboxProcessorService {
throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); 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; delete activity.signature;
try { try {
activity = await jsonLd.compact(activity) as IActivity; activity = await jsonLd.compact(activity) as IActivity;
} catch (e) { } catch (e) {
throw new Bull.UnrecoverableError(`skip: failed to compact activity: ${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: 元のアクティビティと非互換な形に正規化される場合は転送をスキップする // TODO: 元のアクティビティと非互換な形に正規化される場合は転送をスキップする
// https://github.com/mastodon/mastodon/blob/664b0ca/app/services/activitypub/process_collection_service.rb#L24-L29 // https://github.com/mastodon/mastodon/blob/664b0ca/app/services/activitypub/process_collection_service.rb#L24-L29
activity.signature = ldSignature; activity.signature = ldSignature;
@ -158,19 +187,8 @@ export class InboxProcessorService {
delete compactedInfo['@context']; delete compactedInfo['@context'];
this.logger.debug(`compacted: ${JSON.stringify(compactedInfo, null, 2)}`); this.logger.debug(`compacted: ${JSON.stringify(compactedInfo, null, 2)}`);
//#endregion //#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 { } 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}`);
} }
} }

View File

@ -9,7 +9,24 @@ import type { MiNote } from '@/models/Note.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiWebhook } from '@/models/Webhook.js'; import type { MiWebhook } from '@/models/Webhook.js';
import type { IActivity } from '@/core/activitypub/type.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 = { export type DeliverJobData = {
/** Actor */ /** Actor */
@ -22,11 +39,13 @@ export type DeliverJobData = {
to: string; to: string;
/** whether it is sharedInbox */ /** whether it is sharedInbox */
isSharedInbox: boolean; isSharedInbox: boolean;
/** force to use main (rsa) key */
privateKey?: PrivateKeyWithPem;
}; };
export type InboxJobData = { export type InboxJobData = {
activity: IActivity; activity: IActivity;
signature: httpSignature.IParsedSignature; signature: ParsedSignature | OldParsedSignature | null;
}; };
export type RelationshipJobData = { export type RelationshipJobData = {

View File

@ -3,11 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import * as crypto from 'node:crypto';
import { IncomingMessage } from 'node:http'; import { IncomingMessage } from 'node:http';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import fastifyAccepts from '@fastify/accepts'; 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 { Brackets, In, IsNull, LessThan, Not } from 'typeorm';
import accepts from 'accepts'; import accepts from 'accepts';
import vary from 'vary'; import vary from 'vary';
@ -31,12 +30,17 @@ import { IActivity } from '@/core/activitypub/type.js';
import { isQuote, isRenote } from '@/misc/is-renote.js'; import { isQuote, isRenote } from '@/misc/is-renote.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
import type { FindOptionsWhere } from 'typeorm'; 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 ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
@Injectable() @Injectable()
export class ActivityPubServerService { export class ActivityPubServerService {
private logger: Logger;
private inboxLogger: Logger;
constructor( constructor(
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@ -71,8 +75,11 @@ export class ActivityPubServerService {
private queueService: QueueService, private queueService: QueueService,
private userKeypairService: UserKeypairService, private userKeypairService: UserKeypairService,
private queryService: QueryService, private queryService: QueryService,
private loggerService: LoggerService,
) { ) {
//this.createServer = this.createServer.bind(this); //this.createServer = this.createServer.bind(this);
this.logger = this.loggerService.getLogger('server-ap', 'gray');
this.inboxLogger = this.logger.createSubLogger('inbox', 'gray');
} }
@bindThis @bindThis
@ -100,70 +107,44 @@ export class ActivityPubServerService {
} }
@bindThis @bindThis
private inbox(request: FastifyRequest, reply: FastifyReply) { private async inbox(request: FastifyRequest, reply: FastifyReply) {
let signature; if (request.body == null) {
this.inboxLogger.warn('request body is empty');
reply.code(400);
return;
}
let signature: ReturnType<typeof parseRequestSignature>;
const verifyDigest = await verifyDigestHeader(request.raw, request.rawBody || '', true);
if (verifyDigest !== true) {
this.inboxLogger.warn('digest verification failed');
reply.code(401);
return;
}
try { try {
signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); signature = parseRequestSignature(request.raw, {
} catch (e) { 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); reply.code(401);
return; 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); this.queueService.inbox(request.body as IActivity, signature);
reply.code(202); reply.code(202);
} }
@ -640,7 +621,7 @@ export class ActivityPubServerService {
if (this.userEntityService.isLocalUser(user)) { if (this.userEntityService.isLocalUser(user)) {
reply.header('Cache-Control', 'public, max-age=180'); reply.header('Cache-Control', 'public, max-age=180');
this.setResponseType(request, reply); this.setResponseType(request, reply);
return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair))); return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair.publicKey)));
} else { } else {
reply.code(400); reply.code(400);
return; return;

View File

@ -94,6 +94,13 @@ export class NodeinfoServerService {
localComments: 0, localComments: 0,
}, },
metadata: { metadata: {
/**
* '00': Draft, RSA only
* '01': Draft, Ed25519 suported
* '11': RFC 9421, Ed25519 supported
*/
httpMessageSignaturesImplementationLevel: '01',
nodeName: meta.name, nodeName: meta.name,
nodeDescription: meta.description, nodeDescription: meta.description,
nodeAdmins: [{ nodeAdmins: [{

View File

@ -56,7 +56,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const res = [] as [string, number][]; const res = [] as [string, number][];
for (const job of jobs) { 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)) { if (res.find(x => x[0] === host)) {
res.find(x => x[0] === host)![1]++; res.find(x => x[0] === host)![1]++;
} else { } else {

View File

@ -25,7 +25,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js';
import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js';
import { HashtagService } from '@/core/HashtagService.js'; import { HashtagService } from '@/core/HashtagService.js';
import { DI } from '@/di-symbols.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 { CacheService } from '@/core/CacheService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
@ -256,6 +256,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profileUpdates = {} as Partial<MiUserProfile>; const profileUpdates = {} as Partial<MiUserProfile>;
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
let policies: RolePolicies | null = null;
if (ps.name !== undefined) { if (ps.name !== undefined) {
if (ps.name === null) { if (ps.name === null) {
@ -296,14 +297,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (ps.mutedWords !== undefined) { 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); validateMuteWordRegex(ps.mutedWords);
profileUpdates.mutedWords = ps.mutedWords; profileUpdates.mutedWords = ps.mutedWords;
profileUpdates.enableWordMute = ps.mutedWords.length > 0; profileUpdates.enableWordMute = ps.mutedWords.length > 0;
} }
if (ps.hardMutedWords !== undefined) { 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); validateMuteWordRegex(ps.hardMutedWords);
profileUpdates.hardMutedWords = ps.hardMutedWords; profileUpdates.hardMutedWords = ps.hardMutedWords;
} }
@ -322,13 +325,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
if (typeof ps.alwaysMarkNsfw === 'boolean') { 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; profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
} }
if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive;
if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
if (ps.avatarId) { 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 }); const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId });
if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar);
@ -344,6 +351,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (ps.bannerId) { 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 }); const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId });
if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner);
@ -359,14 +369,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (ps.avatarDecorations) { if (ps.avatarDecorations) {
policies ??= await this.roleService.getUserPolicies(user.id);
const decorations = await this.avatarDecorationService.getAll(true); 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 allRoles = await this.roleService.getRoles();
const decorationIds = decorations 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))) .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); .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 => ({ updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
id: d.id, id: d.id,

View File

@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { ApiError } from '../../error.js'; import { ApiError } from '../../error.js';
export const meta = { export const meta = {
@ -74,6 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private roleService: RoleService, private roleService: RoleService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set<string>();
const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users
if (!iAmModerator) { if (!iAmModerator) {
const user = await this.cacheService.findUserById(ps.userId); const user = await this.cacheService.findUserById(ps.userId);
@ -85,8 +87,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if ((me == null || me.id !== ps.userId) && !profile.publicReactions) { if ((me == null || me.id !== ps.userId) && !profile.publicReactions) {
throw new ApiError(meta.errors.reactionsNotPublic); 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<string>();
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('reaction.userId = :userId', { userId: ps.userId }) .andWhere('reaction.userId = :userId', { userId: ps.userId })
@ -94,9 +103,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me); this.queryService.generateVisibilityQuery(query, me);
const reactions = await query const reactions = (await query
.limit(ps.limit) .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 }); return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true });
}); });

View File

@ -206,7 +206,7 @@ describe('2要素認証', () => {
username, username,
}, alice); }, alice);
assert.strictEqual(usersShowResponse.status, 200); 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', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
@ -248,7 +248,7 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
creationOptions: registerKeyResponse.body, creationOptions: registerKeyResponse.body,
}) as any, alice); } as any) as any, alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url'));
assert.strictEqual(keyDoneResponse.body.name, keyName); assert.strictEqual(keyDoneResponse.body.name, keyName);
@ -257,22 +257,22 @@ describe('2要素認証', () => {
username, username,
}); });
assert.strictEqual(usersShowResponse.status, 200); 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', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
}); });
assert.strictEqual(signinResponse.status, 200); assert.strictEqual(signinResponse.status, 200);
assert.strictEqual(signinResponse.body.i, undefined); assert.strictEqual(signinResponse.body.i, undefined);
assert.notEqual(signinResponse.body.challenge, undefined); assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined);
assert.notEqual(signinResponse.body.allowCredentials, undefined); assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined);
assert.strictEqual(signinResponse.body.allowCredentials[0].id, credentialId.toString('base64url')); assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url'));
const signinResponse2 = await api('signin', signinWithSecurityKeyParam({ const signinResponse2 = await api('signin', signinWithSecurityKeyParam({
keyName, keyName,
credentialId, credentialId,
requestOptions: signinResponse.body, requestOptions: signinResponse.body,
})); } as any));
assert.strictEqual(signinResponse2.status, 200); assert.strictEqual(signinResponse2.status, 200);
assert.notEqual(signinResponse2.body.i, undefined); assert.notEqual(signinResponse2.body.i, undefined);
@ -307,7 +307,7 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
creationOptions: registerKeyResponse.body, creationOptions: registerKeyResponse.body,
}) as any, alice); } as any) as any, alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
const passwordLessResponse = await api('i/2fa/password-less', { const passwordLessResponse = await api('i/2fa/password-less', {
@ -319,7 +319,7 @@ describe('2要素認証', () => {
username, username,
}); });
assert.strictEqual(usersShowResponse.status, 200); 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', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
@ -333,7 +333,7 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
requestOptions: signinResponse.body, requestOptions: signinResponse.body,
}), } as any),
password: '', password: '',
}); });
assert.strictEqual(signinResponse2.status, 200); assert.strictEqual(signinResponse2.status, 200);
@ -370,7 +370,7 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
creationOptions: registerKeyResponse.body, creationOptions: registerKeyResponse.body,
}) as any, alice); } as any) as any, alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
const renamedKey = 'other-key'; const renamedKey = 'other-key';
@ -383,6 +383,7 @@ describe('2要素認証', () => {
const iResponse = await api('i', { const iResponse = await api('i', {
}, alice); }, alice);
assert.strictEqual(iResponse.status, 200); assert.strictEqual(iResponse.status, 200);
assert.ok(iResponse.body.securityKeysList);
const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url')); const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url'));
assert.strictEqual(securityKeys.length, 1); assert.strictEqual(securityKeys.length, 1);
assert.strictEqual(securityKeys[0].name, renamedKey); assert.strictEqual(securityKeys[0].name, renamedKey);
@ -419,13 +420,14 @@ describe('2要素認証', () => {
keyName, keyName,
credentialId, credentialId,
creationOptions: registerKeyResponse.body, creationOptions: registerKeyResponse.body,
}) as any, alice); } as any) as any, alice);
assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.status, 200);
// テストの実行順によっては複数残ってるので全部消す // テストの実行順によっては複数残ってるので全部消す
const iResponse = await api('i', { const iResponse = await api('i', {
}, alice); }, alice);
assert.strictEqual(iResponse.status, 200); assert.strictEqual(iResponse.status, 200);
assert.ok(iResponse.body.securityKeysList);
for (const key of iResponse.body.securityKeysList) { for (const key of iResponse.body.securityKeysList) {
const removeKeyResponse = await api('i/2fa/remove-key', { const removeKeyResponse = await api('i/2fa/remove-key', {
token: otpToken(registerResponse.body.secret), token: otpToken(registerResponse.body.secret),
@ -439,7 +441,7 @@ describe('2要素認証', () => {
username, username,
}); });
assert.strictEqual(usersShowResponse.status, 200); 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', { const signinResponse = await api('signin', {
...signinParam(), ...signinParam(),
@ -470,7 +472,7 @@ describe('2要素認証', () => {
username, username,
}); });
assert.strictEqual(usersShowResponse.status, 200); 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', { const unregisterResponse = await api('i/2fa/unregister', {
token: otpToken(registerResponse.body.secret), token: otpToken(registerResponse.body.secret),

View File

@ -410,21 +410,21 @@ describe('API visibility', () => {
test('[HTL] public-post が 自分が見れる', async () => { test('[HTL] public-post が 自分が見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, alice); const res = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res.status, 200); 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'); assert.strictEqual(notes[0].text, 'x');
}); });
test('[HTL] public-post が 非フォロワーから見れない', async () => { test('[HTL] public-post が 非フォロワーから見れない', async () => {
const res = await api('notes/timeline', { limit: 100 }, other); const res = await api('notes/timeline', { limit: 100 }, other);
assert.strictEqual(res.status, 200); 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); assert.strictEqual(notes.length, 0);
}); });
test('[HTL] followers-post が フォロワーから見れる', async () => { test('[HTL] followers-post が フォロワーから見れる', async () => {
const res = await api('notes/timeline', { limit: 100 }, follower); const res = await api('notes/timeline', { limit: 100 }, follower);
assert.strictEqual(res.status, 200); 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'); assert.strictEqual(notes[0].text, 'x');
}); });
//#endregion //#endregion
@ -433,21 +433,21 @@ describe('API visibility', () => {
test('[replies] followers-reply が フォロワーから見れる', async () => { test('[replies] followers-reply が フォロワーから見れる', async () => {
const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, follower); const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, follower);
assert.strictEqual(res.status, 200); 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'); assert.strictEqual(notes[0].text, 'x');
}); });
test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => { test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => {
const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, other); const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, other);
assert.strictEqual(res.status, 200); 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); assert.strictEqual(notes.length, 0);
}); });
test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, target); const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, target);
assert.strictEqual(res.status, 200); 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'); assert.strictEqual(notes[0].text, 'x');
}); });
//#endregion //#endregion
@ -456,14 +456,14 @@ describe('API visibility', () => {
test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => {
const res = await api('notes/mentions', { limit: 100 }, target); const res = await api('notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200); 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'); assert.strictEqual(notes[0].text, 'x');
}); });
test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => { test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => {
const res = await api('notes/mentions', { limit: 100 }, target); const res = await api('notes/mentions', { limit: 100 }, target);
assert.strictEqual(res.status, 200); 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'); assert.strictEqual(notes[0].text, '@target x');
}); });
//#endregion //#endregion

View File

@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; 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'; import type * as misskey from 'misskey-js';
describe('Block', () => { describe('Block', () => {
@ -33,7 +33,7 @@ describe('Block', () => {
const res = await api('following/create', { userId: alice.id }, bob); const res = await api('following/create', { userId: alice.id }, bob);
assert.strictEqual(res.status, 400); 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 () => { test('ブロックされているユーザーにリアクションできない', async () => {
@ -42,7 +42,8 @@ describe('Block', () => {
const res = await api('notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); const res = await api('notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob);
assert.strictEqual(res.status, 400); 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 () => { test('ブロックされているユーザーに返信できない', async () => {
@ -51,7 +52,8 @@ describe('Block', () => {
const res = await api('notes/create', { replyId: note.id, text: 'yo' }, bob); const res = await api('notes/create', { replyId: note.id, text: 'yo' }, bob);
assert.strictEqual(res.status, 400); 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 () => { test('ブロックされているユーザーのートをRenoteできない', async () => {
@ -60,7 +62,7 @@ describe('Block', () => {
const res = await api('notes/create', { renoteId: note.id, text: 'yo' }, bob); const res = await api('notes/create', { renoteId: note.id, text: 'yo' }, bob);
assert.strictEqual(res.status, 400); 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: ユーザーリストに入れられないテスト // TODO: ユーザーリストに入れられないテスト

View File

@ -79,14 +79,14 @@ describe('クリップ', () => {
}; };
const deleteClip = async (parameters: Misskey.entities.ClipsDeleteRequest, request: Partial<ApiRequest<'clips/delete'>> = {}): Promise<void> => { const deleteClip = async (parameters: Misskey.entities.ClipsDeleteRequest, request: Partial<ApiRequest<'clips/delete'>> = {}): Promise<void> => {
return await successfulApiCall({ await successfulApiCall({
endpoint: 'clips/delete', endpoint: 'clips/delete',
parameters, parameters,
user: alice, user: alice,
...request, ...request,
}, { }, {
status: 204, status: 204,
}) as any as void; });
}; };
const show = async (parameters: Misskey.entities.ClipsShowRequest, request: Partial<ApiRequest<'clips/show'>> = {}): Promise<Misskey.entities.Clip> => { const show = async (parameters: Misskey.entities.ClipsShowRequest, request: Partial<ApiRequest<'clips/show'>> = {}): Promise<Misskey.entities.Clip> => {
@ -454,25 +454,25 @@ describe('クリップ', () => {
let aliceClip: Misskey.entities.Clip; let aliceClip: Misskey.entities.Clip;
const favorite = async (parameters: Misskey.entities.ClipsFavoriteRequest, request: Partial<ApiRequest<'clips/favorite'>> = {}): Promise<void> => { const favorite = async (parameters: Misskey.entities.ClipsFavoriteRequest, request: Partial<ApiRequest<'clips/favorite'>> = {}): Promise<void> => {
return successfulApiCall({ await successfulApiCall({
endpoint: 'clips/favorite', endpoint: 'clips/favorite',
parameters, parameters,
user: alice, user: alice,
...request, ...request,
}, { }, {
status: 204, status: 204,
}) as any as void; });
}; };
const unfavorite = async (parameters: Misskey.entities.ClipsUnfavoriteRequest, request: Partial<ApiRequest<'clips/unfavorite'>> = {}): Promise<void> => { const unfavorite = async (parameters: Misskey.entities.ClipsUnfavoriteRequest, request: Partial<ApiRequest<'clips/unfavorite'>> = {}): Promise<void> => {
return successfulApiCall({ await successfulApiCall({
endpoint: 'clips/unfavorite', endpoint: 'clips/unfavorite',
parameters, parameters,
user: alice, user: alice,
...request, ...request,
}, { }, {
status: 204, status: 204,
}) as any as void; });
}; };
const myFavorites = async (request: Partial<ApiRequest<'clips/my-favorites'>> = {}): Promise<Misskey.entities.Clip[]> => { const myFavorites = async (request: Partial<ApiRequest<'clips/my-favorites'>> = {}): Promise<Misskey.entities.Clip[]> => {

View File

@ -10,7 +10,7 @@ import * as assert from 'assert';
// https://github.com/node-fetch/node-fetch/pull/1664 // https://github.com/node-fetch/node-fetch/pull/1664
import { Blob } from 'node-fetch'; import { Blob } from 'node-fetch';
import { MiUser } from '@/models/_.js'; 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'; import type * as misskey from 'misskey-js';
describe('Endpoints', () => { describe('Endpoints', () => {
@ -164,7 +164,7 @@ describe('Endpoints', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); 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 () => { test('ユーザーが存在しなかったら怒る', async () => {
@ -285,7 +285,8 @@ describe('Endpoints', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); 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 () => { test('引用にリアクションできる', async () => {
@ -1063,7 +1064,7 @@ describe('Endpoints', () => {
userId: bob.id, userId: bob.id,
}, alice); }, alice);
assert.strictEqual(res1.status, 204); assert.strictEqual(res1.status, 204);
assert.strictEqual(res2.body?.memo, memo); assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo);
}); });
test('自分に関するメモを更新できる', async () => { test('自分に関するメモを更新できる', async () => {
@ -1078,7 +1079,7 @@ describe('Endpoints', () => {
userId: alice.id, userId: alice.id,
}, alice); }, alice);
assert.strictEqual(res1.status, 204); assert.strictEqual(res1.status, 204);
assert.strictEqual(res2.body?.memo, memo); assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo);
}); });
test('メモを削除できる', async () => { test('メモを削除できる', async () => {
@ -1099,7 +1100,7 @@ describe('Endpoints', () => {
}, alice); }, alice);
// memoには常に文字列かnullが入っている(5cac151) // memoには常に文字列かnullが入っている(5cac151)
assert.strictEqual(res.body.memo, null); assert.strictEqual((res.body as unknown as { memo: string | null }).memo, null);
}); });
test('メモは個人ごとに独立して保存される', async () => { test('メモは個人ごとに独立して保存される', async () => {
@ -1126,8 +1127,8 @@ describe('Endpoints', () => {
}, carol), }, carol),
]); ]);
assert.strictEqual(resAlice.body.memo, memoAliceToBob); assert.strictEqual((resAlice.body as unknown as { memo: string }).memo, memoAliceToBob);
assert.strictEqual(resCarol.body.memo, memoCarolToBob); assert.strictEqual((resCarol.body as unknown as { memo: string }).memo, memoCarolToBob);
}); });
}); });
}); });

View File

@ -61,14 +61,14 @@ describe('export-clips', () => {
}); });
test('basic export', async () => { test('basic export', async () => {
let res = await api('clips/create', { const res1 = await api('clips/create', {
name: 'foo', name: 'foo',
description: 'bar', description: 'bar',
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res1.status, 200);
res = await api('i/export-clips', {}, alice); const res2 = await api('i/export-clips', {}, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res2.status, 204);
const exported = await pollFirstDriveFile(); const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].name, 'foo'); assert.strictEqual(exported[0].name, 'foo');
@ -77,7 +77,7 @@ describe('export-clips', () => {
}); });
test('export with notes', async () => { test('export with notes', async () => {
let res = await api('clips/create', { const res = await api('clips/create', {
name: 'foo', name: 'foo',
description: 'bar', description: 'bar',
}, alice); }, alice);
@ -96,15 +96,15 @@ describe('export-clips', () => {
}); });
for (const note of [note1, note2]) { for (const note of [note1, note2]) {
res = await api('clips/add-note', { const res2 = await api('clips/add-note', {
clipId: clip.id, clipId: clip.id,
noteId: note.id, noteId: note.id,
}, alice); }, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res2.status, 204);
} }
res = await api('i/export-clips', {}, alice); const res3 = await api('i/export-clips', {}, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res3.status, 204);
const exported = await pollFirstDriveFile(); const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].name, 'foo'); assert.strictEqual(exported[0].name, 'foo');
@ -116,19 +116,19 @@ describe('export-clips', () => {
}); });
test('multiple clips', async () => { test('multiple clips', async () => {
let res = await api('clips/create', { const res1 = await api('clips/create', {
name: 'kawaii', name: 'kawaii',
description: 'kawaii', description: 'kawaii',
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res1.status, 200);
const clip1 = res.body; const clip1 = res1.body;
res = await api('clips/create', { const res2 = await api('clips/create', {
name: 'yuri', name: 'yuri',
description: 'yuri', description: 'yuri',
}, alice); }, alice);
assert.strictEqual(res.status, 200); assert.strictEqual(res2.status, 200);
const clip2 = res.body; const clip2 = res2.body;
const note1 = await post(alice, { const note1 = await post(alice, {
text: 'baz1', text: 'baz1',
@ -138,20 +138,26 @@ describe('export-clips', () => {
text: 'baz2', text: 'baz2',
}); });
res = await api('clips/add-note', { {
clipId: clip1.id, const res = await api('clips/add-note', {
noteId: note1.id, clipId: clip1.id,
}, alice); noteId: note1.id,
assert.strictEqual(res.status, 204); }, alice);
assert.strictEqual(res.status, 204);
}
res = await api('clips/add-note', { {
clipId: clip2.id, const res = await api('clips/add-note', {
noteId: note2.id, clipId: clip2.id,
}, alice); noteId: note2.id,
assert.strictEqual(res.status, 204); }, 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(); const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].name, 'kawaii'); assert.strictEqual(exported[0].name, 'kawaii');
@ -163,7 +169,7 @@ describe('export-clips', () => {
}); });
test('Clipping other user\'s note', async () => { test('Clipping other user\'s note', async () => {
let res = await api('clips/create', { const res = await api('clips/create', {
name: 'kawaii', name: 'kawaii',
description: 'kawaii', description: 'kawaii',
}, alice); }, alice);
@ -175,14 +181,14 @@ describe('export-clips', () => {
visibility: 'followers', visibility: 'followers',
}); });
res = await api('clips/add-note', { const res2 = await api('clips/add-note', {
clipId: clip.id, clipId: clip.id,
noteId: note.id, noteId: note.id,
}, alice); }, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res2.status, 204);
res = await api('i/export-clips', {}, alice); const res3 = await api('i/export-clips', {}, alice);
assert.strictEqual(res.status, 204); assert.strictEqual(res3.status, 204);
const exported = await pollFirstDriveFile(); const exported = await pollFirstDriveFile();
assert.strictEqual(exported[0].name, 'kawaii'); assert.strictEqual(exported[0].name, 'kawaii');

View File

@ -13,14 +13,14 @@ import { loadConfig } from '@/config.js';
import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js'; import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js';
import { secureRndstr } from '@/misc/secure-rndstr.js'; import { secureRndstr } from '@/misc/secure-rndstr.js';
import { jobQueue } from '@/boot/common.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'; import type * as misskey from 'misskey-js';
describe('Account Move', () => { describe('Account Move', () => {
let jq: INestApplicationContext; let jq: INestApplicationContext;
let url: URL; let url: URL;
let root: any; let root: misskey.entities.SignupResponse;
let alice: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse;
let bob: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse;
let carol: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse;
@ -93,8 +93,8 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_USER');
assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); assert.strictEqual(castAsError(res.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
}); });
test('Unable to add duplicated aliases to alsoKnownAs', async () => { test('Unable to add duplicated aliases to alsoKnownAs', async () => {
@ -103,8 +103,8 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'INVALID_PARAM'); assert.strictEqual(castAsError(res.body).error.code, 'INVALID_PARAM');
assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); assert.strictEqual(castAsError(res.body).error.id, '3d81ceae-475f-4600-b2a8-2bc116157532');
}); });
test('Unable to add itself', async () => { test('Unable to add itself', async () => {
@ -113,8 +113,8 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF'); assert.strictEqual(castAsError(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.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4');
}); });
test('Unable to add a nonexisting local account to alsoKnownAs', async () => { test('Unable to add a nonexisting local account to alsoKnownAs', async () => {
@ -123,16 +123,16 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res1.status, 400); assert.strictEqual(res1.status, 400);
assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER'); assert.strictEqual(castAsError(res1.body).error.code, 'NO_SUCH_USER');
assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); assert.strictEqual(castAsError(res1.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
const res2 = await api('i/update', { const res2 = await api('i/update', {
alsoKnownAs: ['@alice', 'nonexist'], alsoKnownAs: ['@alice', 'nonexist'],
}, bob); }, bob);
assert.strictEqual(res2.status, 400); assert.strictEqual(res2.status, 400);
assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER'); assert.strictEqual(castAsError(res2.body).error.code, 'NO_SUCH_USER');
assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); assert.strictEqual(castAsError(res2.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
}); });
test('Able to add two existing local account to alsoKnownAs', async () => { test('Able to add two existing local account to alsoKnownAs', async () => {
@ -241,8 +241,8 @@ describe('Account Move', () => {
}, root); }, root);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN'); assert.strictEqual(castAsError(res.body).error.code, 'NOT_ROOT_FORBIDDEN');
assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); assert.strictEqual(castAsError(res.body).error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24');
}); });
test('Unable to move to a nonexisting local account', async () => { test('Unable to move to a nonexisting local account', async () => {
@ -251,8 +251,8 @@ describe('Account Move', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_USER');
assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); assert.strictEqual(castAsError(res.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5');
}); });
test('Unable to move if alsoKnownAs is invalid', async () => { test('Unable to move if alsoKnownAs is invalid', async () => {
@ -261,8 +261,8 @@ describe('Account Move', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); assert.strictEqual(castAsError(res.body).error.code, 'DESTINATION_ACCOUNT_FORBIDS');
assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); assert.strictEqual(castAsError(res.body).error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4');
}); });
test('Relationships have been properly migrated', async () => { test('Relationships have been properly migrated', async () => {
@ -279,36 +279,44 @@ describe('Account Move', () => {
userId: alice.id, userId: alice.id,
}, alice); }, alice);
assert.strictEqual(aliceFollowings.status, 200); assert.strictEqual(aliceFollowings.status, 200);
assert.ok(aliceFollowings);
assert.strictEqual(aliceFollowings.body.length, 3); assert.strictEqual(aliceFollowings.body.length, 3);
const carolFollowings = await api('users/following', { const carolFollowings = await api('users/following', {
userId: carol.id, userId: carol.id,
}, carol); }, carol);
assert.strictEqual(carolFollowings.status, 200); assert.strictEqual(carolFollowings.status, 200);
assert.ok(carolFollowings);
assert.strictEqual(carolFollowings.body.length, 2); assert.strictEqual(carolFollowings.body.length, 2);
assert.strictEqual(carolFollowings.body[0].followeeId, bob.id); assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
assert.strictEqual(carolFollowings.body[1].followeeId, alice.id); assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
const blockings = await api('blocking/list', {}, dave); const blockings = await api('blocking/list', {}, dave);
assert.strictEqual(blockings.status, 200); assert.strictEqual(blockings.status, 200);
assert.ok(blockings);
assert.strictEqual(blockings.body.length, 2); assert.strictEqual(blockings.body.length, 2);
assert.strictEqual(blockings.body[0].blockeeId, bob.id); assert.strictEqual(blockings.body[0].blockeeId, bob.id);
assert.strictEqual(blockings.body[1].blockeeId, alice.id); assert.strictEqual(blockings.body[1].blockeeId, alice.id);
const mutings = await api('mute/list', {}, dave); const mutings = await api('mute/list', {}, dave);
assert.strictEqual(mutings.status, 200); assert.strictEqual(mutings.status, 200);
assert.ok(mutings);
assert.strictEqual(mutings.body.length, 2); assert.strictEqual(mutings.body.length, 2);
assert.strictEqual(mutings.body[0].muteeId, bob.id); assert.strictEqual(mutings.body[0].muteeId, bob.id);
assert.strictEqual(mutings.body[1].muteeId, alice.id); assert.strictEqual(mutings.body[1].muteeId, alice.id);
const rootLists = await api('users/lists/list', {}, root); const rootLists = await api('users/lists/list', {}, root);
assert.strictEqual(rootLists.status, 200); assert.strictEqual(rootLists.status, 200);
assert.ok(rootLists);
assert.ok(rootLists.body[0].userIds);
assert.strictEqual(rootLists.body[0].userIds.length, 2); 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 === bob.id));
assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id)); assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
const eveLists = await api('users/lists/list', {}, eve); const eveLists = await api('users/lists/list', {}, eve);
assert.strictEqual(eveLists.status, 200); assert.strictEqual(eveLists.status, 200);
assert.ok(eveLists);
assert.ok(eveLists.body[0].userIds);
assert.strictEqual(eveLists.body[0].userIds.length, 1); assert.strictEqual(eveLists.body[0].userIds.length, 1);
assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id)); assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
}); });
@ -347,8 +355,8 @@ describe('Account Move', () => {
}, bob); }, bob);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); assert.strictEqual(castAsError(res.body).error.code, 'DESTINATION_ACCOUNT_FORBIDS');
assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); assert.strictEqual(castAsError(res.body).error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4');
}); });
test('Follow and follower counts are properly adjusted', async () => { 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) => { ] as const)('Prohibit access after moving: %s', async (endpoint) => {
const res = await api(endpoint, {}, alice); const res = await api(endpoint, {}, alice);
assert.strictEqual(res.status, 403); assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); assert.ok(res.body);
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');
}); });
test('Prohibit access after moving: /antennas/update', async () => { test('Prohibit access after moving: /antennas/update', async () => {
@ -438,16 +447,19 @@ describe('Account Move', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 403); assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); assert.ok(res.body);
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');
}); });
test('Prohibit access after moving: /drive/files/create', async () => { test('Prohibit access after moving: /drive/files/create', async () => {
// FIXME: 一旦逃げておく
const res = await uploadFile(alice); const res = await uploadFile(alice);
assert.strictEqual(res.status, 403); assert.strictEqual(res.status, 403);
assert.strictEqual((res.body! as any as { error: misskey.api.APIError }).error.code, 'YOUR_ACCOUNT_MOVED'); assert.ok(res.body);
assert.strictEqual((res.body! as any as { error: misskey.api.APIError }).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');
}); });
test('Prohibit updating alsoKnownAs after moving', async () => { test('Prohibit updating alsoKnownAs after moving', async () => {
@ -456,8 +468,8 @@ describe('Account Move', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 403); assert.strictEqual(res.status, 403);
assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED');
assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31');
}); });
}); });
}); });

View File

@ -47,8 +47,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 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 === carolNote.id), false);
}); });
test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => {
@ -92,9 +92,9 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 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 === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => {
@ -108,9 +108,9 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => note.id === aliceNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some(note => 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 === carolNote.id), false);
}); });
}); });
@ -124,8 +124,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリプライが含まれない', async () => { test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
@ -138,8 +138,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリプライが含まれない', async () => { test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
@ -152,8 +152,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
@ -166,8 +166,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリノートが含まれない', async () => { test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
@ -180,8 +180,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
@ -193,8 +193,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, bob);
await api('following/delete', { userId: alice.id }, carol); await api('following/delete', { userId: alice.id }, carol);
@ -210,8 +210,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, bob);
await api('following/delete', { userId: alice.id }, carol); await api('following/delete', { userId: alice.id }, carol);
@ -228,8 +228,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリプライが含まれない', async () => { test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
const aliceNote = await post(alice, { text: 'hi' }); const aliceNote = await post(alice, { text: 'hi' });
@ -241,8 +241,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリプライが含まれない', async () => { test('通知にミュートしているユーザーからのリプライが含まれない', async () => {
@ -255,8 +255,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => {
@ -269,8 +269,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからのリノートが含まれない', async () => { test('通知にミュートしているユーザーからのリノートが含まれない', async () => {
@ -283,8 +283,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => {
@ -296,8 +296,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, bob);
await api('following/delete', { userId: alice.id }, carol); await api('following/delete', { userId: alice.id }, carol);
@ -313,8 +313,8 @@ describe('Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'userId' in notification && 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 === carol.id), false);
}); });
}); });
}); });

View File

@ -3,16 +3,18 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { Repository } from "typeorm";
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.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'; import type * as misskey from 'misskey-js';
describe('Note', () => { describe('Note', () => {
let Notes: any; let Notes: Repository<MiNote>;
let root: misskey.entities.SignupResponse; let root: misskey.entities.SignupResponse;
let alice: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse;
@ -61,8 +63,8 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE');
assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}, 1000 * 10); }, 1000 * 10);
test('存在しないファイルで怒られる', async () => { test('存在しないファイルで怒られる', async () => {
@ -72,8 +74,8 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE');
assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}); });
test('不正なファイルIDで怒られる', async () => { test('不正なファイルIDで怒られる', async () => {
@ -81,8 +83,8 @@ describe('Note', () => {
fileIds: ['kyoppie'], fileIds: ['kyoppie'],
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE');
assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306');
}); });
test('返信できる', async () => { test('返信できる', async () => {
@ -101,6 +103,7 @@ describe('Note', () => {
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.text, alicePost.text);
assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId); assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId);
assert.ok(res.body.createdNote.reply);
assert.strictEqual(res.body.createdNote.reply.text, bobPost.text); assert.strictEqual(res.body.createdNote.reply.text, bobPost.text);
}); });
@ -118,6 +121,7 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId);
assert.ok(res.body.createdNote.renote);
assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); 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(typeof res.body === 'object' && !Array.isArray(res.body), true);
assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.text, alicePost.text);
assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId);
assert.ok(res.body.createdNote.renote);
assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); assert.strictEqual(res.body.createdNote.renote.text, bobPost.text);
}); });
@ -218,7 +223,7 @@ describe('Note', () => {
}, bob); }, bob);
assert.strictEqual(bobReply.status, 400); 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 () => { test('visibility: specifiedなートに対してvisibility: specifiedで返信できる', async () => {
@ -256,7 +261,7 @@ describe('Note', () => {
}, bob); }, bob);
assert.strictEqual(bobReply.status, 400); 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 () => { test('文字数ぎりぎりで怒られない', async () => {
@ -333,6 +338,7 @@ describe('Note', () => {
assert.strictEqual(res.body.createdNote.text, post.text); assert.strictEqual(res.body.createdNote.text, post.text);
const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id });
assert.ok(noteDoc);
assert.deepStrictEqual(noteDoc.mentions, [bob.id]); assert.deepStrictEqual(noteDoc.mentions, [bob.id]);
}); });
@ -345,6 +351,7 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); 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.length, 1);
assert.strictEqual(res.body.createdNote.files[0].id, file.body!.id); assert.strictEqual(res.body.createdNote.files[0].id, file.body!.id);
}); });
@ -363,8 +370,9 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id); const myNote = res.body.find(note => note.id === createdNote.body.createdNote.id);
assert.notEqual(myNote, null); assert.ok(myNote);
assert.ok(myNote.files);
assert.strictEqual(myNote.files.length, 1); assert.strictEqual(myNote.files.length, 1);
assert.strictEqual(myNote.files[0].id, file.body!.id); assert.strictEqual(myNote.files[0].id, file.body!.id);
}); });
@ -389,7 +397,9 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); 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.length, 1);
assert.strictEqual(myNote.renote.files[0].id, file.body!.id); assert.strictEqual(myNote.renote.files[0].id, file.body!.id);
}); });
@ -415,7 +425,9 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id); 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.length, 1);
assert.strictEqual(myNote.reply.files[0].id, file.body!.id); assert.strictEqual(myNote.reply.files[0].id, file.body!.id);
}); });
@ -446,7 +458,10 @@ describe('Note', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); assert.strictEqual(Array.isArray(res.body), true);
const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); 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.length, 1);
assert.strictEqual(myNote.renote.reply.files[0].id, file.body!.id); assert.strictEqual(myNote.renote.reply.files[0].id, file.body!.id);
}); });
@ -474,7 +489,7 @@ describe('Note', () => {
priority: 0, priority: 0,
value: true, value: true,
}, },
} as any, },
}, root); }, root);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -498,7 +513,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(liftnsfw.status, 400); 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', { const oldaddnsfw = await api('drive/files/update', {
fileId: file.body!.id, fileId: file.body!.id,
@ -710,7 +725,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note1.status, 400); 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 () => { test('禁止ワードを含む投稿はエラーになる (正規表現)', async () => {
@ -727,7 +742,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note2.status, 400); 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 () => { test('禁止ワードを含む投稿はエラーになる (スペースアンド)', async () => {
@ -744,7 +759,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note2.status, 400); 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 () => { test('禁止ワードを含んでるリモートノートもエラーになる', async () => {
@ -786,7 +801,7 @@ describe('Note', () => {
priority: 1, priority: 1,
value: 0, value: 0,
}, },
} as any, },
}, root); }, root);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -807,7 +822,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note.status, 400); 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', { await api('admin/roles/unassign', {
userId: alice.id, userId: alice.id,
@ -840,7 +855,7 @@ describe('Note', () => {
priority: 1, priority: 1,
value: 0, value: 0,
}, },
} as any, },
}, root); }, root);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -863,7 +878,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(note.status, 400); 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', { await api('admin/roles/unassign', {
userId: alice.id, userId: alice.id,
@ -896,7 +911,7 @@ describe('Note', () => {
priority: 1, priority: 1,
value: 1, value: 1,
}, },
} as any, },
}, root); }, root);
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
@ -951,6 +966,7 @@ describe('Note', () => {
assert.strictEqual(deleteOneRes.status, 204); assert.strictEqual(deleteOneRes.status, 204);
let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id });
assert.ok(mainNote);
assert.strictEqual(mainNote.repliesCount, 1); assert.strictEqual(mainNote.repliesCount, 1);
const deleteTwoRes = await api('notes/delete', { const deleteTwoRes = await api('notes/delete', {
@ -959,6 +975,7 @@ describe('Note', () => {
assert.strictEqual(deleteTwoRes.status, 204); assert.strictEqual(deleteTwoRes.status, 204);
mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id });
assert.ok(mainNote);
assert.strictEqual(mainNote.repliesCount, 0); assert.strictEqual(mainNote.repliesCount, 0);
}); });
}); });
@ -980,7 +997,7 @@ describe('Note', () => {
}, alice); }, alice);
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); assert.strictEqual(castAsError(res.body).error.code, 'UNAVAILABLE');
}); });
afterAll(async () => { afterAll(async () => {
@ -992,7 +1009,7 @@ describe('Note', () => {
const res = await api('notes/translate', { noteId: 'foo', targetLang: 'ja' }, alice); const res = await api('notes/translate', { noteId: 'foo', targetLang: 'ja' }, alice);
assert.strictEqual(res.status, 400); 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 () => { test('不可視なノートは翻訳できない', async () => {
@ -1000,7 +1017,7 @@ describe('Note', () => {
const bobTranslateAttempt = await api('notes/translate', { noteId: aliceNote.id, targetLang: 'ja' }, bob); const bobTranslateAttempt = await api('notes/translate', { noteId: aliceNote.id, targetLang: 'ja' }, bob);
assert.strictEqual(bobTranslateAttempt.status, 400); 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 () => { test('text: null なノートを翻訳すると空のレスポンスが返ってくる', async () => {
@ -1016,7 +1033,7 @@ describe('Note', () => {
// NOTE: デフォルトでは登録されていないので落ちる // NOTE: デフォルトでは登録されていないので落ちる
assert.strictEqual(res.status, 400); assert.strictEqual(res.status, 400);
assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); assert.strictEqual(castAsError(res.body).error.code, 'UNAVAILABLE');
}); });
}); });
}); });

View File

@ -42,9 +42,9 @@ describe('Renote Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); assert.strictEqual(res.body.some(note => 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 === carolNote.id), true);
}); });
test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => {
@ -59,9 +59,9 @@ describe('Renote Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => note.id === bobNote.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); assert.strictEqual(res.body.some(note => 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 === carolNote.id), true);
}); });
// #12956 // #12956
@ -76,8 +76,8 @@ describe('Renote Mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 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 === bobRenote.id), true);
}); });
test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => {

View File

@ -33,9 +33,9 @@ describe('Note thread mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => note.id === bobNote.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolReply.id), false); assert.strictEqual(res.body.some(note => 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 === carolReplyWithoutMention.id), false);
}); });
test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => {
@ -93,8 +93,8 @@ describe('Note thread mute', () => {
assert.strictEqual(res.status, 200); assert.strictEqual(res.status, 200);
assert.strictEqual(Array.isArray(res.body), true); 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 => 'note' in notification && 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 === carolReplyWithoutMention.id), false);
// NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい // NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい
}); });

View File

@ -37,8 +37,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi');
}); });
test.concurrent('フォローしているユーザーのノートが含まれる', async () => { test.concurrent('フォローしているユーザーのノートが含まれる', async () => {
@ -53,8 +53,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => {
@ -69,9 +69,9 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => 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 === carolNote.id), false);
}); });
test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => {
@ -86,8 +86,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => {
@ -103,8 +103,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => {
@ -120,8 +120,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => {
@ -137,8 +137,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => {
@ -156,9 +156,9 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'hi');
}); });
test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => {
@ -175,8 +175,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}); });
test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => {
@ -191,8 +191,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 => 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 === bobNote2.id), true);
}); });
test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => {
@ -207,8 +207,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 => 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 === bobNote.id), true);
}); });
test.concurrent('自分の他人への返信が含まれる', async () => { test.concurrent('自分の他人への返信が含まれる', async () => {
@ -221,8 +221,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
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('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => {
@ -237,8 +237,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => {
@ -255,8 +255,8 @@ describe('Timelines', () => {
withRenotes: false, withRenotes: false,
}, alice); }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); assert.strictEqual(res.body.some(note => 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 === carolNote.id), false);
}); });
test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => {
@ -273,8 +273,8 @@ describe('Timelines', () => {
withRenotes: false, withRenotes: false,
}, alice); }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); assert.strictEqual(res.body.some(note => 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 === carolNote.id), false);
}); });
test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => {
@ -288,7 +288,7 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 () => { test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => {
@ -304,8 +304,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => {
@ -322,8 +322,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
@ -338,7 +338,7 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 () => { test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => {
@ -353,7 +353,7 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 () => { test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => {
@ -374,11 +374,11 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100, withFiles: true }, alice); 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 => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false);
}, 1000 * 10); });
test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); const [alice, bob] = await Promise.all([signup(), signup()]);
@ -392,7 +392,7 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 () => { test.concurrent('自分の visibility: specified なノートが含まれる', async () => {
@ -404,8 +404,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi');
}); });
test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => {
@ -419,8 +419,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi');
}); });
test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => { test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => {
@ -432,7 +432,7 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 () => { test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => {
@ -446,7 +446,7 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 () => { test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => {
@ -459,8 +459,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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 => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'ok'); assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'ok');
}); });
/* TODO /* TODO
@ -474,8 +474,8 @@ describe('Timelines', () => {
const res = await api('notes/timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'ok'); 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); 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); 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 => 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 === carolNote.id), false);
}); });
test.concurrent('他人の他人への返信が含まれない', async () => { test.concurrent('他人の他人への返信が含まれない', async () => {
@ -519,8 +519,8 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}); });
test.concurrent('他人のその人自身への返信が含まれる', async () => { test.concurrent('他人のその人自身への返信が含まれる', async () => {
@ -533,8 +533,8 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100 }, alice); 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 => 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 === bobNote2.id), true);
}); });
test.concurrent('チャンネル投稿が含まれない', async () => { test.concurrent('チャンネル投稿が含まれない', async () => {
@ -547,7 +547,7 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100 }, alice); 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 () => { test.concurrent('リモートユーザーのノートが含まれない', async () => {
@ -559,7 +559,7 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100 }, alice); 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); 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 => 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 === carolNote.id), false);
}); });
test.concurrent('ミュートしているユーザーのノートが含まれない', async () => { test.concurrent('ミュートしているユーザーのノートが含まれない', async () => {
@ -591,8 +591,8 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100 }, alice); 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 => 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 === carolNote.id), false);
}); });
test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => {
@ -608,8 +608,8 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => {
@ -626,8 +626,8 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote.id), false);
}); });
test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => {
@ -642,8 +642,8 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100 }, alice); 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 => 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 === bobNote.id), true);
}); });
test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => {
@ -656,7 +656,7 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100, withReplies: true }, alice); 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 () => { test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
@ -670,9 +670,9 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100, withFiles: true }, alice); 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 => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10); });
}); });
describe('Social TL', () => { describe('Social TL', () => {
@ -685,7 +685,7 @@ describe('Timelines', () => {
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); 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 () => { test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => {
@ -697,7 +697,7 @@ describe('Timelines', () => {
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); 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 () => { test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => {
@ -711,7 +711,7 @@ describe('Timelines', () => {
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); 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 () => { test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => {
@ -726,8 +726,8 @@ describe('Timelines', () => {
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); 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 => 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 === bobNote.id), true);
}); });
test.concurrent('他人の他人への返信が含まれない', async () => { test.concurrent('他人の他人への返信が含まれない', async () => {
@ -740,8 +740,8 @@ describe('Timelines', () => {
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); 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);
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote.id), true);
}); });
test.concurrent('リモートユーザーのノートが含まれない', async () => { test.concurrent('リモートユーザーのノートが含まれない', async () => {
@ -753,7 +753,7 @@ describe('Timelines', () => {
const res = await api('notes/local-timeline', { limit: 100 }, alice); 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 () => { test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => {
@ -768,7 +768,7 @@ describe('Timelines', () => {
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); 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 () => { test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => {
@ -783,7 +783,7 @@ describe('Timelines', () => {
const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); 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 () => { test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => {
@ -796,7 +796,7 @@ describe('Timelines', () => {
const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice); 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 () => { test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
@ -810,9 +810,9 @@ describe('Timelines', () => {
const res = await api('notes/hybrid-timeline', { limit: 100, withFiles: true }, alice); 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 => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10); });
}); });
describe('User List TL', () => { describe('User List TL', () => {
@ -828,7 +828,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 () => { test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => {
@ -843,7 +843,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 () => { test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => {
@ -858,7 +858,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 () => { test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => {
@ -874,7 +874,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 () => { test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => {
@ -890,8 +890,8 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 => 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 === bobNote2.id), true);
}); });
test.concurrent('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { test.concurrent('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => {
@ -908,7 +908,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 () => { test.concurrent('withReplies: false でリスインしているフォローしていないユーザーの他人への返信が含まれない', async () => {
@ -925,7 +925,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 () => { test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => {
@ -942,7 +942,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 () => { test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => {
@ -958,7 +958,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 () => { test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => {
@ -974,8 +974,8 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi');
}); });
test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => { test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => {
@ -990,8 +990,8 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi');
}); });
test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => { test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => {
@ -1007,7 +1007,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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 () => { 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); 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 => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10); });
test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); 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); 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);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi');
}); });
test.concurrent('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => { test.concurrent('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => {
@ -1056,7 +1056,7 @@ describe('Timelines', () => {
const res = await api('notes/user-list-timeline', { listId: list.id }, alice); 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); 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 () => { test.concurrent('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => {
@ -1082,7 +1082,7 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id }, alice); 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 () => { test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => {
@ -1096,8 +1096,8 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id }, alice); 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);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi');
}); });
test.concurrent('自身の visibility: followers なノートが含まれる', async () => { test.concurrent('自身の visibility: followers なノートが含まれる', async () => {
@ -1109,8 +1109,8 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: alice.id }, alice); 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.some(note => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi');
}); });
test.concurrent('チャンネル投稿が含まれない', async () => { test.concurrent('チャンネル投稿が含まれない', async () => {
@ -1123,7 +1123,7 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id }, alice); 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 () => { test.concurrent('[withReplies: false] 他人への返信が含まれない', async () => {
@ -1137,8 +1137,8 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id }, alice); 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 => 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 === bobNote2.id), false);
}); });
test.concurrent('[withReplies: true] 他人への返信が含まれる', async () => { test.concurrent('[withReplies: true] 他人への返信が含まれる', async () => {
@ -1152,8 +1152,8 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); 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 => 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 === bobNote2.id), true);
}); });
test.concurrent('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { 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); 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 => 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 === bobNote2.id), false);
}); });
test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => {
@ -1182,9 +1182,9 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id, withFiles: true }, alice); 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 => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10); });
test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]); 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); 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 () => { test.concurrent('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => {
@ -1209,7 +1209,7 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); 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 () => { test.concurrent('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => {
@ -1222,7 +1222,7 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, bob); 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 () => { test.concurrent('ミュートしているユーザーに関連する投稿が含まれない', async () => {
@ -1237,7 +1237,7 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id }, alice); 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 () => { test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => {
@ -1253,9 +1253,9 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id }, alice); 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 => 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 === bobNote2.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.id), true); assert.strictEqual(res.body.some(note => note.id === bobNote3.id), true);
}); });
test.concurrent('自身の visibility: specified なノートが含まれる', async () => { test.concurrent('自身の visibility: specified なノートが含まれる', async () => {
@ -1267,7 +1267,7 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: alice.id, withReplies: true }, alice); 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 () => { test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => {
@ -1279,7 +1279,7 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); 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 */ /** @see https://github.com/misskey-dev/misskey/issues/14000 */

View File

@ -231,7 +231,7 @@ describe('ユーザー', () => {
rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); rolePublic = await role(root, { isPublic: true, name: 'Public Role' });
await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root);
userRoleBadge = await signup({ username: 'userRoleBadge' }); 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); await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root);
userSilenced = await signup({ username: 'userSilenced' }); userSilenced = await signup({ username: 'userSilenced' });
await post(userSilenced, { text: 'test' }); await post(userSilenced, { text: 'test' });
@ -655,7 +655,16 @@ describe('ユーザー', () => {
iconUrl: roleBadge.iconUrl, iconUrl: roleBadge.iconUrl,
displayOrder: roleBadge.displayOrder, 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 () => { test('をID指定のリスト形式で取得することができる', async () => {
const parameters = { userIds: [] }; const parameters = { userIds: [] };

View File

@ -14,6 +14,7 @@ import type { InstanceActorService } from '@/core/InstanceActorService.js';
import type { LoggerService } from '@/core/LoggerService.js'; import type { LoggerService } from '@/core/LoggerService.js';
import type { MetaService } from '@/core/MetaService.js'; import type { MetaService } from '@/core/MetaService.js';
import type { UtilityService } from '@/core/UtilityService.js'; import type { UtilityService } from '@/core/UtilityService.js';
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { import type {
FollowRequestsRepository, FollowRequestsRepository,
@ -47,6 +48,7 @@ export class MockResolver extends Resolver {
{} as HttpRequestService, {} as HttpRequestService,
{} as ApRendererService, {} as ApRendererService,
{} as ApDbResolverService, {} as ApDbResolverService,
{} as FederatedInstanceService,
loggerService, loggerService,
); );
} }

View File

@ -23,10 +23,10 @@ describe('ApMfmService', () => {
describe('getNoteHtml', () => { describe('getNoteHtml', () => {
test('Do not provide _misskey_content for simple text', () => { test('Do not provide _misskey_content for simple text', () => {
const note: MiNote = { const note = {
text: 'テキスト #タグ @mention 🍊 :emoji: https://example.com', text: 'テキスト #タグ @mention 🍊 :emoji: https://example.com',
mentionedRemoteUsers: '[]', mentionedRemoteUsers: '[]',
} as any; };
const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); const { content, noMisskeyContent } = apMfmService.getNoteHtml(note);
@ -35,10 +35,10 @@ describe('ApMfmService', () => {
}); });
test('Provide _misskey_content for MFM', () => { test('Provide _misskey_content for MFM', () => {
const note: MiNote = { const note = {
text: '$[tada foo]', text: '$[tada foo]',
mentionedRemoteUsers: '[]', mentionedRemoteUsers: '[]',
} as any; };
const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); const { content, noMisskeyContent } = apMfmService.getNoteHtml(note);

View File

@ -75,62 +75,61 @@ describe('FetchInstanceMetadataService', () => {
test('Lock and update', async () => { test('Lock and update', async () => {
redisClient.set = mockRedis(); redisClient.set = mockRedis();
const now = Date.now(); 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(); }); httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalled(); expect(httpRequestService.getJson).toHaveBeenCalled();
}); });
test('Lock and don\'t update', async () => { test('Don\'t lock and update if recently updated', async () => {
redisClient.set = mockRedis(); redisClient.set = mockRedis();
const now = Date.now(); federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: new Date() } as any);
federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any);
httpRequestService.getJson.mockImplementation(() => { throw Error(); }); httpRequestService.getJson.mockImplementation(() => { throw Error(); });
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(tryLockSpy).toHaveBeenCalledTimes(0);
expect(unlockSpy).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
}); });
test('Do nothing when lock not acquired', async () => { test('Do nothing when lock not acquired', async () => {
redisClient.set = mockRedis(); redisClient.set = mockRedis();
const now = Date.now(); 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(); }); httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com'); await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(tryLockSpy).toHaveBeenCalledTimes(1);
expect(unlockSpy).toHaveBeenCalledTimes(0); expect(unlockSpy).toHaveBeenCalledTimes(0);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
}); });
test('Do when lock not acquired but forced', async () => { test('Do when forced', async () => {
redisClient.set = mockRedis(); redisClient.set = mockRedis();
const now = Date.now(); 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(); }); httpRequestService.getJson.mockImplementation(() => { throw Error(); });
await fetchInstanceMetadataService.tryLock('example.com'); await fetchInstanceMetadataService.tryLock('example.com');
const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock');
const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock');
await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true); await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(tryLockSpy).toHaveBeenCalledTimes(0); expect(tryLockSpy).toHaveBeenCalledTimes(0);
expect(unlockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1);
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0);
expect(httpRequestService.getJson).toHaveBeenCalled(); expect(httpRequestService.getJson).toHaveBeenCalled();
}); });
}); });

View File

@ -12,7 +12,7 @@ import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { afterAll, beforeAll, describe, test } from '@jest/globals'; import { afterAll, beforeAll, describe, test } from '@jest/globals';
import { GlobalModule } from '@/GlobalModule.js'; 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 { DI } from '@/di-symbols.js';
import { AiService } from '@/core/AiService.js'; import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
@ -28,6 +28,15 @@ const moduleMocker = new ModuleMocker(global);
describe('FileInfoService', () => { describe('FileInfoService', () => {
let app: TestingModule; let app: TestingModule;
let fileInfoService: FileInfoService; let fileInfoService: FileInfoService;
const strip = (fileInfo: FileInfo): Omit<Partial<FileInfo>, 'warnings' | 'blurhash' | 'sensitive' | 'porn'> => {
const fi: Partial<FileInfo> = fileInfo;
delete fi.warnings;
delete fi.sensitive;
delete fi.blurhash;
delete fi.porn;
return fi;
}
beforeAll(async () => { beforeAll(async () => {
app = await Test.createTestingModule({ app = await Test.createTestingModule({
@ -63,11 +72,7 @@ describe('FileInfoService', () => {
test('Empty file', async () => { test('Empty file', async () => {
const path = `${resources}/emptyfile`; const path = `${resources}/emptyfile`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 0, size: 0,
md5: 'd41d8cd98f00b204e9800998ecf8427e', md5: 'd41d8cd98f00b204e9800998ecf8427e',
@ -84,11 +89,7 @@ describe('FileInfoService', () => {
describe('IMAGE', () => { describe('IMAGE', () => {
test('Generic JPEG', async () => { test('Generic JPEG', async () => {
const path = `${resources}/192.jpg`; const path = `${resources}/192.jpg`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 5131, size: 5131,
md5: '8c9ed0677dd2b8f9f7472c3af247e5e3', md5: '8c9ed0677dd2b8f9f7472c3af247e5e3',
@ -104,11 +105,7 @@ describe('FileInfoService', () => {
test('Generic APNG', async () => { test('Generic APNG', async () => {
const path = `${resources}/anime.png`; const path = `${resources}/anime.png`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 1868, size: 1868,
md5: '08189c607bea3b952704676bb3c979e0', md5: '08189c607bea3b952704676bb3c979e0',
@ -124,11 +121,7 @@ describe('FileInfoService', () => {
test('Generic AGIF', async () => { test('Generic AGIF', async () => {
const path = `${resources}/anime.gif`; const path = `${resources}/anime.gif`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 2248, size: 2248,
md5: '32c47a11555675d9267aee1a86571e7e', md5: '32c47a11555675d9267aee1a86571e7e',
@ -144,11 +137,7 @@ describe('FileInfoService', () => {
test('PNG with alpha', async () => { test('PNG with alpha', async () => {
const path = `${resources}/with-alpha.png`; const path = `${resources}/with-alpha.png`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 3772, size: 3772,
md5: 'f73535c3e1e27508885b69b10cf6e991', md5: 'f73535c3e1e27508885b69b10cf6e991',
@ -164,11 +153,7 @@ describe('FileInfoService', () => {
test('Generic SVG', async () => { test('Generic SVG', async () => {
const path = `${resources}/image.svg`; const path = `${resources}/image.svg`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 505, size: 505,
md5: 'b6f52b4b021e7b92cdd04509c7267965', md5: 'b6f52b4b021e7b92cdd04509c7267965',
@ -185,11 +170,7 @@ describe('FileInfoService', () => {
test('SVG with XML definition', async () => { test('SVG with XML definition', async () => {
// https://github.com/misskey-dev/misskey/issues/4413 // https://github.com/misskey-dev/misskey/issues/4413
const path = `${resources}/with-xml-def.svg`; const path = `${resources}/with-xml-def.svg`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 544, size: 544,
md5: '4b7a346cde9ccbeb267e812567e33397', md5: '4b7a346cde9ccbeb267e812567e33397',
@ -205,11 +186,7 @@ describe('FileInfoService', () => {
test('Dimension limit', async () => { test('Dimension limit', async () => {
const path = `${resources}/25000x25000.png`; const path = `${resources}/25000x25000.png`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 75933, size: 75933,
md5: '268c5dde99e17cf8fe09f1ab3f97df56', md5: '268c5dde99e17cf8fe09f1ab3f97df56',
@ -225,11 +202,7 @@ describe('FileInfoService', () => {
test('Rotate JPEG', async () => { test('Rotate JPEG', async () => {
const path = `${resources}/rotate.jpg`; const path = `${resources}/rotate.jpg`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, { assert.deepStrictEqual(info, {
size: 12624, size: 12624,
md5: '68d5b2d8d1d1acbbce99203e3ec3857e', md5: '68d5b2d8d1d1acbbce99203e3ec3857e',
@ -247,11 +220,7 @@ describe('FileInfoService', () => {
describe('AUDIO', () => { describe('AUDIO', () => {
test('MP3', async () => { test('MP3', async () => {
const path = `${resources}/kick_gaba7.mp3`; const path = `${resources}/kick_gaba7.mp3`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -267,11 +236,7 @@ describe('FileInfoService', () => {
test('WAV', async () => { test('WAV', async () => {
const path = `${resources}/kick_gaba7.wav`; const path = `${resources}/kick_gaba7.wav`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -287,11 +252,7 @@ describe('FileInfoService', () => {
test('AAC', async () => { test('AAC', async () => {
const path = `${resources}/kick_gaba7.aac`; const path = `${resources}/kick_gaba7.aac`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -307,11 +268,7 @@ describe('FileInfoService', () => {
test('FLAC', async () => { test('FLAC', async () => {
const path = `${resources}/kick_gaba7.flac`; const path = `${resources}/kick_gaba7.flac`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -327,11 +284,7 @@ describe('FileInfoService', () => {
test('MPEG-4 AUDIO (M4A)', async () => { test('MPEG-4 AUDIO (M4A)', async () => {
const path = `${resources}/kick_gaba7.m4a`; const path = `${resources}/kick_gaba7.m4a`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;
@ -347,11 +300,7 @@ describe('FileInfoService', () => {
test('WEBM AUDIO', async () => { test('WEBM AUDIO', async () => {
const path = `${resources}/kick_gaba7.webm`; const path = `${resources}/kick_gaba7.webm`;
const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }));
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
delete info.width; delete info.width;
delete info.height; delete info.height;
delete info.orientation; delete info.orientation;

View File

@ -4,10 +4,8 @@
*/ */
import * as assert from 'assert'; import * as assert from 'assert';
import httpSignature from '@peertube/http-signature'; import { verifyDraftSignature, parseRequestSignature, genEd25519KeyPair, genRsaKeyPair, importPrivateKey } from '@misskey-dev/node-http-message-signatures';
import { createSignedGet, createSignedPost } from '@/core/activitypub/ApRequestService.js';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => {
return { return {
@ -24,38 +22,68 @@ export const buildParsedSignature = (signingString: string, signature: string, a
}; };
}; };
describe('ap-request', () => { async function getKeyPair(level: string) {
test('createSignedPost with verify', async () => { if (level === '00') {
const keypair = await genRsaKeyPair(); return await genRsaKeyPair();
const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; } else if (level === '01') {
const url = 'https://example.com/inbox'; return await genEd25519KeyPair();
const activity = { a: 1 }; }
const body = JSON.stringify(activity); throw new Error('Invalid level');
const headers = { }
'User-Agent': 'UA',
};
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); const req = await createSignedPost({ level, key, url, body, additionalHeaders: headers });
assert.deepStrictEqual(result, true);
});
test('createSignedGet with verify', async () => { const parsed = parseRequestSignature(req.request);
const keypair = await genRsaKeyPair(); expect(parsed.version).toBe('draft');
const key = { keyId: 'x', 'privateKeyPem': keypair.privateKey }; expect(Array.isArray(parsed.value)).toBe(false);
const url = 'https://example.com/outbox'; const verify = await verifyDraftSignature(parsed.value as any, keypair.publicKey);
const headers = { assert.deepStrictEqual(verify, true);
'User-Agent': 'UA', });
}; 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 parsed = parseRequestSignature(req.request);
expect(parsed.version).toBe('draft');
const result = httpSignature.verifySignature(parsed, keypair.publicKey); expect(Array.isArray(parsed.value)).toBe(false);
assert.deepStrictEqual(result, true); 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);
});
}); });
}); });

View File

@ -18,6 +18,7 @@ import { entities } from '../src/postgres.js';
import { loadConfig } from '../src/config.js'; import { loadConfig } from '../src/config.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
import { type Response } from 'node-fetch'; import { type Response } from 'node-fetch';
import { ApiError } from "@/server/api/error.js";
export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js'; export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js';
@ -48,27 +49,28 @@ export const successfulApiCall = async <E extends keyof misskey.Endpoints, P ext
const res = await api(endpoint, parameters, user); const res = await api(endpoint, parameters, user);
const status = assertion.status ?? (res.body == null ? 204 : 200); const status = assertion.status ?? (res.body == null ? 204 : 200);
assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true })); assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true }));
return res.body;
return res.body as misskey.api.SwitchCaseResponseType<E, P>;
}; };
export const failedApiCall = async <T, E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req']>(request: ApiRequest<E, P>, assertion: { export const failedApiCall = async <E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req']>(request: ApiRequest<E, P>, assertion: {
status: number, status: number,
code: string, code: string,
id: string id: string
}): Promise<T> => { }): Promise<void> => {
const { endpoint, parameters, user } = request; const { endpoint, parameters, user } = request;
const { status, code, id } = assertion; const { status, code, id } = assertion;
const res = await api(endpoint, parameters, user); const res = await api(endpoint, parameters, user);
assert.strictEqual(res.status, status, inspect(res.body)); assert.strictEqual(res.status, status, inspect(res.body));
assert.strictEqual(res.body.error.code, code, inspect(res.body)); assert.ok(res.body);
assert.strictEqual(res.body.error.id, id, inspect(res.body)); assert.strictEqual(castAsError(res.body as any).error.code, code, inspect(res.body));
return res.body; assert.strictEqual(castAsError(res.body as any).error.id, id, inspect(res.body));
}; };
export const api = async <E extends keyof misskey.Endpoints>(path: E, params: misskey.Endpoints[E]['req'], me?: UserToken): Promise<{ export const api = async <E extends keyof misskey.Endpoints, P extends misskey.Endpoints[E]['req']>(path: E, params: P, me?: UserToken): Promise<{
status: number, status: number,
headers: Headers, headers: Headers,
body: any body: misskey.api.SwitchCaseResponseType<E, P>
}> => { }> => {
const bodyAuth: Record<string, string> = {}; const bodyAuth: Record<string, string> = {};
const headers: Record<string, string> = { const headers: Record<string, string> = {
@ -89,13 +91,14 @@ export const api = async <E extends keyof misskey.Endpoints>(path: E, params: mi
}); });
const body = res.headers.get('content-type') === 'application/json; charset=utf-8' const body = res.headers.get('content-type') === 'application/json; charset=utf-8'
? await res.json() ? await res.json() as misskey.api.SwitchCaseResponseType<E, P>
: null; : null;
return { return {
status: res.status, status: res.status,
headers: res.headers, 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); 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][]) => { 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.'); throw new Error('server env update failed.');
} }
} }
// 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。
// FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する
export function castAsError(obj: Record<string, unknown>): { error: ApiError } {
return obj as { error: ApiError };
}

View File

@ -24,12 +24,12 @@
"@rollup/plugin-json": "6.1.0", "@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.7", "@rollup/plugin-replace": "5.0.7",
"@rollup/pluginutils": "5.1.0", "@rollup/pluginutils": "5.1.0",
"@syuilo/aiscript": "0.18.0", "@syuilo/aiscript": "0.19.0",
"@tabler/icons-webfont": "3.3.0", "@tabler/icons-webfont": "3.3.0",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.0.5", "@vitejs/plugin-vue": "5.0.5",
"@vue/compiler-sfc": "3.4.31", "@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", "astring": "1.8.6",
"broadcast-channel": "7.0.0", "broadcast-channel": "7.0.0",
"buraha": "0.0.1", "buraha": "0.0.1",

View File

@ -30,7 +30,7 @@ import * as os from '@/os.js';
import MkLoading from '@/components/global/MkLoading.vue'; import MkLoading from '@/components/global/MkLoading.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.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<{ const props = defineProps<{
code: string; code: string;

View File

@ -39,7 +39,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { claimAchievement } from '@/scripts/achievements.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'; import { MenuItem } from '@/types/menu.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{

View File

@ -42,6 +42,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/scripts/achievements.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 { $i } from '@/account.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
@ -63,7 +65,7 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro
const wait = ref(false); const wait = ref(false);
const connection = useStream().useChannel('main'); const connection = useStream().useChannel('main');
if (props.user.isFollowing == null) { if (props.user.isFollowing == null && $i) {
misskeyApi('users/show', { misskeyApi('users/show', {
userId: props.user.id, userId: props.user.id,
}) })
@ -78,6 +80,8 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
} }
async function onClick() { async function onClick() {
pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` });
wait.value = true; wait.value = true;
try { try {

View File

@ -62,7 +62,7 @@ import { computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.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 { i18n } from '@/i18n.js';
import * as os from '@/os.js'; import * as os from '@/os.js';

Some files were not shown because too many files have changed in this diff Show More