diff --git a/.config/docker_example.yml b/.config/docker_example.yml index f8124bc9d..af0a90dc9 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -51,6 +51,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── @@ -62,6 +79,22 @@ redis: #prefix: example-prefix #db: 1 +#redisForPubsub: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +#redisForJobQueue: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌─────────────────────────────┐ #───┘ Elasticsearch configuration └───────────────────────────── diff --git a/.config/example.yml b/.config/example.yml index 92b872662..8111b1992 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -51,6 +51,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── @@ -62,6 +79,22 @@ redis: #prefix: example-prefix #db: 1 +#redisForPubsub: +# host: localhost +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +#redisForJobQueue: +# host: localhost +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌─────────────────────────────┐ #───┘ Elasticsearch configuration └───────────────────────────── @@ -100,16 +133,20 @@ id: 'aid' #clusterLimit: 1 # Job concurrency per worker -# deliverJobConcurrency: 128 -# inboxJobConcurrency: 16 +#deliverJobConcurrency: 128 +#inboxJobConcurrency: 16 +#relashionshipJobConcurrency: 16 +# What's relashionshipJob?: +# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations. # Job rate limiter -# deliverJobPerSec: 128 -# inboxJobPerSec: 16 +#deliverJobPerSec: 128 +#inboxJobPerSec: 16 +#relashionshipJobPerSec: 64 # Job attempts -# deliverJobMaxAttempts: 12 -# inboxJobMaxAttempts: 8 +#deliverJobMaxAttempts: 12 +#inboxJobMaxAttempts: 8 # IP address family used for outgoing request (ipv4, ipv6 or dual) #outgoingAddressFamily: ipv4 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6accd4347..a47804ab0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,10 @@ "service": "app", "workspaceFolder": "/workspace", "features": { - "ghcr.io/devcontainers-contrib/features/pnpm:2": {} + "ghcr.io/devcontainers-contrib/features/pnpm:2": {}, + "ghcr.io/devcontainers/features/node:1": { + "version": "18.16.0" + } }, "forwardPorts": [3000], "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 8a363a15d..2af306e3d 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -51,6 +51,23 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── @@ -62,6 +79,22 @@ redis: #prefix: example-prefix #db: 1 +#redisForPubsub: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +#redisForJobQueue: +# host: redis +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌─────────────────────────────┐ #───┘ Elasticsearch configuration └───────────────────────────── diff --git a/.github/labeler.yml b/.github/labeler.yml index b4fd0dd5d..137be487c 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,12 +1,21 @@ -'⚙️Server': +'packages/backend': - packages/backend/**/* -'🖥️Client': -- packages/frontend/**/* - -'🧪Test': -- cypress/**/* +'packages/backend:test': - packages/backend/test/**/* -'‼️ wrong locales': -- any: ['locales/*.yml', '!locales/ja-JP.yml'] +'packages/frontend': +- packages/frontend/**/* + +'packages/frontend:test': +- cypress/**/* + +'packages/sw': +- packages/sw/**/* + +'packages/misskey-js': +- packages/misskey-js/**/* + +'packages/misskey-js:test': +- packages/misskey-js/test/**/* +- packages/misskey-js/test-d/**/* diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0739fee70..e78b82c47 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -19,5 +19,6 @@ https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md ## Checklist - [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) - [ ] Test working in a local environment +- [ ] (If needed) Add story of storybook - [ ] (If needed) Update CHANGELOG.md - [ ] (If possible) Add tests diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 6411d63bd..ed004c78d 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -16,7 +16,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - name: Install dependencies diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1c6615e17..0f3702f95 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,11 +17,11 @@ jobs: submodules: true - uses: pnpm/action-setup@v2 with: - version: 7 + version: 8 run_install: false - uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile @@ -48,7 +48,7 @@ jobs: run_install: false - uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile @@ -74,7 +74,7 @@ jobs: run_install: false - uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index eda28c5f7..d5e6769bc 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -2,15 +2,16 @@ name: Storybook on: push: - branches: - - master - - develop - pull_request_target: + branches-ignore: + - l10n_develop jobs: build: runs-on: ubuntu-latest + env: + NODE_OPTIONS: "--max_old_space_size=7168" + steps: - uses: actions/checkout@v3.3.0 with: @@ -19,12 +20,12 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: 7 + version: 8 run_install: false - name: Use Node.js 18.x uses: actions/setup-node@v3.6.0 with: - node-version: 18.x + node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile @@ -34,21 +35,38 @@ jobs: run: pnpm --filter misskey-js build - name: Build storybook run: pnpm --filter frontend build-storybook - env: - NODE_OPTIONS: "--max_old_space_size=7168" - name: Publish to Chromatic - id: chromatic - uses: chromaui/action@v1 - with: - exitOnceUploaded: true - projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - storybookBuildDir: storybook-static - workingDir: packages/frontend - - name: Compare on Chromatic - if: github.event_name == 'pull_request_target' - run: pnpm --filter frontend chromatic -d storybook-static --exit-once-uploaded --patch-build ${{ github.head_ref }}...${{ github.base_ref }} + if: github.ref == 'refs/heads/master' + run: pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static env: CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + - name: Publish to Chromatic + if: github.ref != 'refs/heads/master' + id: chromatic + run: | + DIFF="${{ github.event.before }} HEAD" + if [ "$DIFF" = "0000000000000000000000000000000000000000 HEAD" ]; then + DIFF="HEAD" + fi + CHROMATIC_PARAMETER="$(node packages/frontend/.storybook/changes.js $(git diff-tree --no-commit-id --name-only -r $(echo "$DIFF") | xargs))" + if [ "$CHROMATIC_PARAMETER" = " --skip" ]; then + echo "skip=true" >> $GITHUB_OUTPUT + fi + pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static $(echo "$CHROMATIC_PARAMETER") + env: + CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + - name: Notify that Chromatic will skip testing + uses: actions/github-script@v6.4.0 + if: github.ref != 'refs/heads/master' && github.ref != 'refs/heads/develop' && steps.chromatic.outputs.skip == 'true' + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + github.rest.repos.createCommitComment({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + body: 'Chromatic will skip testing but you may still have to [review the changes on Chromatic](https://www.chromatic.com/pullrequests?appId=6428f7d7b962f0b79f97d6e4).' + }) - name: Upload Artifacts uses: actions/upload-artifact@v3 with: diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 44b6b4ba7..d7be15bd4 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -24,7 +24,7 @@ jobs: POSTGRES_DB: test-misskey POSTGRES_HOST_AUTH_METHOD: trust redis: - image: redis:6 + image: redis:7 ports: - 56312:6379 @@ -35,7 +35,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: 7 + version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3.6.0 diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 18c1a31ae..4ea4ba462 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -22,7 +22,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v2 with: - version: 7 + version: 8 run_install: false - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3.6.0 @@ -63,7 +63,7 @@ jobs: POSTGRES_DB: test-misskey POSTGRES_HOST_AUTH_METHOD: trust redis: - image: redis:6 + image: redis:7 ports: - 56312:6379 @@ -106,7 +106,7 @@ jobs: install: false start: pnpm start:test wait-on: 'http://localhost:61812' - headless: false + headed: true browser: ${{ matrix.browser }} - uses: actions/upload-artifact@v2 if: failure() diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml new file mode 100644 index 000000000..5243a8377 --- /dev/null +++ b/.github/workflows/test-production.yml @@ -0,0 +1,42 @@ +name: Test (production install and build) + +on: + push: + branches: + - master + - develop + pull_request: + +env: + NODE_ENV: production + +jobs: + production: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + + steps: + - uses: actions/checkout@v3.3.0 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3.6.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - run: corepack enable + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Copy Configure + run: cp .github/misskey/test.yml .config/default.yml + - name: Build + run: pnpm build diff --git a/.node-version b/.node-version index 0e9dc6b58..6d80269a4 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v18.13.0 +18.16.0 diff --git a/.vscode/settings.json b/.vscode/settings.json index baffbe18e..71fb02a59 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,6 @@ "files.associations": { "*.test.ts": "typescript" }, + "jest.jestCommandLine": "pnpm run jest", "jest.autoRun": "off" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c90987d..43882432d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,36 +5,160 @@ - ### Client -- +- ### Server - --> - ## 13.x.x (unreleased) +### NOTE +- Node.js 18.6.0以上が必要になりました + +### General +- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加 +- ユーザーへの自分用メモ機能 + * ユーザーに対して、自分だけが見られるメモを追加できるようになりました。 + (自分自身に対してもメモを追加できます。) + * ユーザーメニューから追加できます。 + (デスクトップ表示ではusernameの右側のボタンからも追加可能) +- アカウントの引っ越し(フォロワー引き継ぎ)に対応 + * 一度引っ越したアカウントは利用に制限がかかります +- ロールタイムラインをロールごとに表示するかどうかの選択できるようになりました。 + * デフォルトがオフになるので、ロールタイムラインを表示する場合はオンにしてください。 +- カスタム絵文字のライセンスを複数でセットできるようになりました。 +- 管理者が予約ユーザー名を設定できるようになりました。 + +### Client +- 通知の表示をカスタマイズできるように +- コントロールパネルのカスタム絵文字ページおよびaboutのカスタム絵文字の検索インプットで、`:emojiname1::emojiname2:`のように検索して絵文字を検索できるように + * 絵文字ピッカーから入力可能になります +- データセーバーモードを追加 + * 画像が全て隠れた状態で表示されるようになります +- 1枚だけのメディアリストの画像のアスペクト比を画像に応じて縦長にするように +- Fix: リアクションをホバーした時のユーザーリストで猫耳が切れてしまっていた問題を修正 +- 新しい実績を追加 +- Renoteしたユーザーの一覧を見れるように + +### Server +- 環境変数MISSKEY_CONFIG_YMLで設定ファイルをdefault.ymlから変更可能に +- Fix: エクスポートデータの拡張子がunknownになる問題を修正 +- Fix: Content-Dispositionのパースでエラーが発生した場合にダウンロードが完了しない問題を修正 +- Fix: API: i/update avatarIdとbannerIdにnullを渡した時、画像がリセットされない問題を修正 +- Fix: 1:1ではない画像のリアクション通知バッジが左や上に寄ってしまっていたのを中央に来るように修正 +- Fix: .wav, .flacが再生できない問題を修正(新しくアップロードされたファイルのみ修正が適用されます) +- Fix: メモリの使用量を`used - buffers - cached`ではなく`total - available`で求めるように(環境によって正常に計測できていなかったため) + +## 13.11.3 + +### General +- 指定したロールを持つユーザーのノートのみが流れるロールタイムラインを追加 + - Deckのカラムとしても追加可能 +- カスタム絵文字関連の改善 + * ノートなどに含まれるemojis(populateEmojiの結果)は(プロキシされたURLではなく)オリジナルのURLを指すように + * MFMでx3/x4もしくはscale.x/yが2.5以上に指定されていた場合にはオリジナル品質の絵文字を使用するように +- カスタム絵文字でリアクションできないことがある問題を修正 + +### Client +- チャンネルのピン留めされたノートの順番が正しくない問題を修正 + +### Server +- フォローインポートなどでの大量のフォロー等操作をキューイングするように #10544 @nmkj-io +- Misskey Webでのサーバーサイドエラー画面を改善 +- Misskey Webでのサーバーサイドエラーのログが残るように +- ノート作成時のアンテナ追加パフォーマンスを改善 +- アンテナとロールTLのuntil/sinceプロパティが動くように + +## 13.11.2 + +### Note +- 13.11.0または13.11.1から13.11.2以降にアップデートする場合、Redisのカスタム絵文字のキャッシュを削除する必要があります(https://github.com/misskey-dev/misskey/issues/10502#issuecomment-1502790755 参照) + +### General +- チャンネルの検索用ページの追加 + +### Client +- 常に広告を見られるオプションを追加 +- ユーザーページの画像一覧が表示されない問題を修正 +- webhook, 連携アプリ一覧でコンテンツが重複して表示される問題を修正 +- iPhoneで絵文字ピッカーの表示が崩れる問題を修正 +- iPhoneでウィジェットドロワーの「ウィジェットを編集」が押しにくい問題を修正 +- 投稿フォームのデザインを調整 +- ギャラリーの人気の投稿が無限にページングされる問題を修正 + +### Server +- channels/search Endpoint APIの追加 +- APIパラメータサイズ上限を32kbから1mbに緩和 +- プッシュ通知送信時のパフォーマンスを改善 +- ローカルのカスタム絵文字のキャッシュが効いていなかった問題を修正 +- アンテナのノート、チャンネルのノート、通知が正常に作成できないことがある問題を修正 +- ストリーミングのLTLチャンネルでサーバー側にエラーログが出るのを修正 + +### Service Worker +- 「通知が既読になったらプッシュ通知を削除する」を復活 + * 「プッシュ通知が更新されました」の挙動を変えた(ホストとバージョンを表示するようにし、一定時間後の削除は行わないように) +- プッシュ通知が実績を解除 (achievementEarned) に対応 +- プッシュ通知のアクションから既存のクライアントの投稿フォームを開くことになった際の挙動を修正 +- たくさんのプッシュ通知を閉じた際、その通知の数だけnotifications/mark-all-as-readを叩くのをやめるように + +## 13.11.1 + +### General +- チャンネルの投稿を過去までさかのぼれるように + +### Client +- PWA時の絵文字ピッカーの位置をホームバーに重ならないように調整 +- リスト管理の画面でリストが無限に読み込まれる問題を修正 +- 自分のクリップが無限に読み込まれる問題を修正 +- チャンネルのお気に入りが無限に読み込まれる問題を修正 +- さがすのローカルユーザー(ピンどめ)が無限に生成される問題を修正 +- チャンネルを新規作成できない問題を修正 +- ユーザープレビューが表示されない問題を修正 + +### Server +- 通知読み込みでエラーが発生する場合がある問題を修正 +- リアクションできないことがある問題を修正 +- IDをaid以外に設定している場合の問題を修正 +- 連合しているインスタンスについて予期せず配送が全て停止されることがある問題を修正 + +## 13.11.0 + +### NOTE +- このバージョンからRedis 7.xが必要です。 +- アップデートを行うと全ての通知およびアンテナのノートはリセットされます。 + ### General - チャンネルをお気に入りに登録できるように + - タイムラインのアンテナ選択などでは、フォローしているアンテナの代わりにお気に入りしたアンテナが表示されるようになっています。チャンネルをお気に入りに登録するには、当該チャンネルのページ→概要→⭐️のボタンを押します。 - チャンネルにノートをピン留めできるように ### Client +- 投稿フォームのデザインを改善 - 検索ページでURLを入力した際に照会したときと同等の挙動をするように - ノートのリアクションを大きく表示するオプションを追加 +- ギャラリー一覧にメディア表示と同じように NSFW 設定を反映するように(ホバーで表示) - オブジェクトストレージの設定画面を分かりやすく +- 広告・お知らせが新規登録時に増殖しないように - 「にゃああああああああああああああ!!!!!!!!!!!!」 (`isCat`) 有効時にアバターに表示される猫耳について挙動を変更 - 「UIにぼかし効果を使用」 (`useBlurEffect`) で次の挙動が有効になります - 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように - - 猫耳の色がアバター上部のピクセルから決定されます(無効化時はアバター全体の平均色) - - 左耳は上からおよそ 10%, 左からおよそ 20% の位置で決定します - - 右耳は上からおよそ 10%, 左からおよそ 80% の位置で決定します - 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります +- Add Minimizing ("folding") of windows +- 「データセーバー」モードを追加 +- 非NSFWメディアが隠れている際にも「閲覧注意」が出てしまう問題を修正 ### Server +- PostgreSQLのレプリケーション対応 + - 設定ファイルの `dbReplications` および `dbSlaves` にて設定できます +- イベント用Redisを別サーバーに分離できるように +- ジョブキュー用Redisを別サーバーに分離できるように +- サーバーの全体的なパフォーマンスを向上 - ノート作成時のパフォーマンスを向上 - アンテナのタイムライン取得時のパフォーマンスを向上 - チャンネルのタイムライン取得時のパフォーマンスを向上 - 通知に関する全体的なパフォーマンスを向上 +- webhookがcontent-type text/plain;charset=UTF-8 で飛んでくる問題を修正 ## 13.10.3 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fece05d7a..f6b3804f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,6 +165,11 @@ pnpm jest -- foo.ts ### e2e tests TODO +## Environment Variable + +- `MISSKEY_CONFIG_YML`: Specify the file path of config.yml instead of default.yml (e.g. `2nd.yml`). +- `MISSKEY_WEBFINGER_USE_HTTP`: If it's set true, WebFinger requests will be http instead of https, useful for testing federation between servers in localhost. NEVER USE IN PRODUCTION. + ## Continuous integration Misskey uses GitHub Actions for executing automated tests. Configuration files are located in [`/.github/workflows`](/.github/workflows). @@ -245,7 +250,6 @@ You can override the default story by creating a impl story file (`MyComponent.s ```ts /* eslint-disable @typescript-eslint/explicit-function-return-type */ -/* eslint-disable import/no-duplicates */ import { StoryObj } from '@storybook/vue3'; import MyComponent from './MyComponent.vue'; export const Default = { diff --git a/Dockerfile b/Dockerfile index 8db7400c9..fb389659b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.4 -ARG NODE_VERSION=18.13.0-bullseye +ARG NODE_VERSION=18.16.0-bullseye # build assets & compile TypeScript diff --git a/README.md b/README.md index c12882ca3..2aae4bb86 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,25 @@ With Misskey's built in drive, you get cloud storage right in your social media, Misskey Documentation can be found at [Misskey Hub](https://misskey-hub.net/), some of the links and graphics above also lead to specific portions of it. ## Sponsors +
RSS3
+ +## Thanks + +Chromatic + +Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions. + +Codecov + +Thanks to [Codecov](https://about.codecov.io/for/open-source/) for providing the code coverage platform that helps us improve our test coverage. + +Crowdin + +Thanks to [Crowdin](https://crowdin.com/) for providing the localization platform that helps us translate Misskey into many languages. + +Docker + +Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production. diff --git a/chart/files/default.yml b/chart/files/default.yml index 4061ca3eb..188866924 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -72,16 +72,50 @@ db: #extra: # ssl: true +dbReplications: false + +# You can configure any number of replicas here +#dbSlaves: +# - +# host: +# port: +# db: +# user: +# pass: +# - +# host: +# port: +# db: +# user: +# pass: + # ┌─────────────────────┐ #───┘ Redis configuration └───────────────────────────────────── redis: host: localhost port: 6379 + #family: 0 # 0=Both, 4=IPv4, 6=IPv6 #pass: example-pass #prefix: example-prefix #db: 1 +#redisForPubsub: +# host: localhost +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + +#redisForJobQueue: +# host: localhost +# port: 6379 +# #family: 0 # 0=Both, 4=IPv4, 6=IPv6 +# #pass: example-pass +# #prefix: example-prefix +# #db: 1 + # ┌─────────────────────────────┐ #───┘ Elasticsearch configuration └───────────────────────────── diff --git a/cypress/e2e/basic.cy.js b/cypress/e2e/basic.cy.js index 8dc07c180..e271894ec 100644 --- a/cypress/e2e/basic.cy.js +++ b/cypress/e2e/basic.cy.js @@ -52,6 +52,11 @@ describe('After setup instance', () => { cy.intercept('POST', '/api/signup').as('signup'); cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); + cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); + cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); + cy.get('[data-cy-signup-rules-continue]').click(); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-username] input').type('alice'); cy.get('[data-cy-signup-submit]').should('be.disabled'); @@ -71,6 +76,11 @@ describe('After setup instance', () => { // ユーザー名が重複している場合の挙動確認 cy.get('[data-cy-signup]').click(); + cy.get('[data-cy-signup-rules-continue]').should('be.disabled'); + cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click(); + cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled'); + cy.get('[data-cy-signup-rules-continue]').click(); + cy.get('[data-cy-signup-username] input').type('alice'); cy.get('[data-cy-signup-password] input').type('alice1234'); cy.get('[data-cy-signup-password-retype] input').type('alice1234'); diff --git a/gulpfile.js b/gulpfile.js index a04ab4c1a..6507aad60 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -45,7 +45,7 @@ gulp.task('build:backend:script', () => { }); gulp.task('build:backend:style', () => { - return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css']) + return gulp.src(['./packages/backend/src/server/web/style.css', './packages/backend/src/server/web/bios.css', './packages/backend/src/server/web/cli.css', './packages/backend/src/server/web/error.css']) .pipe(cssnano({ zindex: false })) diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index c2910b90c..4b9062051 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1277,4 +1277,3 @@ _deck: _webhookSettings: name: "الإسم" active: "مفعّل" - diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 40af5a332..734943960 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -1356,4 +1356,3 @@ _deck: _webhookSettings: name: "নাম" active: "চালু" - diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index bc9e66249..2b1168f78 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -460,4 +460,3 @@ _deck: list: "Llistes" mentions: "Mencions" direct: "Publicacions directes" - diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 4b5919247..19815c6f3 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -779,4 +779,3 @@ _deck: _webhookSettings: name: "Jméno" active: "Zapnuto" - diff --git a/locales/da-DK.yml b/locales/da-DK.yml index d1fbec9f6..08c15ed09 100644 --- a/locales/da-DK.yml +++ b/locales/da-DK.yml @@ -1,3 +1,2 @@ --- _lang_: "Dansk" - diff --git a/locales/de-DE.yml b/locales/de-DE.yml index aed515d2b..095c71642 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -20,6 +20,7 @@ noNotes: "Keine Notizen gefunden" noNotifications: "Keine Benachrichtigungen gefunden" instance: "Instanz" settings: "Einstellungen" +notificationSettings: "Benachrichtigungseinstellungen" basicSettings: "Allgemeine Einstellungen" otherSettings: "Weitere Einstellungen" openInWindow: "In einem Fenster öffnen" @@ -67,7 +68,7 @@ import: "Import" export: "Export" files: "Dateien" download: "Herunterladen" -driveFileDeleteConfirm: "Möchtest du die Datei „{name}“ wirklich löschen? Notizen mit dieser Datei werden ebenso verschwinden." +driveFileDeleteConfirm: "Möchtest du die Datei „{name}“ wirklich löschen? Sie wird in allen Inhalten, die sie verwenden, auch verschwinden." unfollowConfirm: "Möchtest du {name} wirklich nicht mehr folgen?" exportRequested: "Du hast einen Export angefragt. Dies kann etwas Zeit in Anspruch nehmen. Sobald der Export abgeschlossen ist, wird er deiner Drive hinzugefügt." importRequested: "Du hast einen Import angefragt. Dies kann etwas Zeit in Anspruch nehmen." @@ -196,7 +197,7 @@ instanceInfo: "Instanzinformationen" statistics: "Statistiken" clearQueue: "Warteschlange leeren" clearQueueConfirmTitle: "Möchtest du die Warteschlange wirklich leeren?" -clearQueueConfirmText: "Hierdurch werden jegliche noch nicht gesendete Notizen nicht förderiert. Normalerweise wird dies nicht benötigt." +clearQueueConfirmText: "Hierdurch werden jegliche noch nicht gesendete Notizen nicht föderiert. Normalerweise wird dies nicht benötigt." clearCachedFiles: "Cache leeren" clearCachedFilesConfirm: "Sollen alle im Cache gespeicherten Dateien von anderen Instanzen wirklich gelöscht werden?" blockedInstances: "Blockierte Instanzen" @@ -500,12 +501,13 @@ objectStoragePrefixDesc: "Dateien werden in Ordnern unter diesem Prefix gespeich objectStorageEndpoint: "Endpoint" objectStorageEndpointDesc: "Im Falle von S3 leerlassen, für andere Anbieter den relevanten Endpoint im Format „“ oder „:“ angeben." objectStorageRegion: "Region" -objectStorageRegionDesc: "Gib eine Region wie z.B. „xx-east-1“ an. Falls dein Anbieter nicht zwischen Regionen unterscheidet, lass dieses Feld leer oder gib „us-east-1“ an." +objectStorageRegionDesc: "Gib eine Region wie z.B. „xx-east-1“ an. Falls dein Anbieter nicht zwischen Regionen unterscheidet, gib „us-east-1“ an. Lasse es leer bei Verwendung von AWS Konfigurationsdateien oder Umgebungsvariablen." objectStorageUseSSL: "SSL verwenden" objectStorageUseSSLDesc: "Deaktiviere dies, falls du für API-Verbindungen kein HTTPS verwenden wirst" objectStorageUseProxy: "Über Proxy verbinden" objectStorageUseProxyDesc: "Deaktiviere dies, falls du für Verbindungen zur API keinen Proxy verwenden wirst" objectStorageSetPublicRead: "Bei Upload auf \"public-read\" stellen" +s3ForcePathStyleDesc: "Ist s3ForcePathStyle aktiviert, so muss der Bucketname nicht im Hostnamen der URL, sondern im Pfad der URL angeben werden. Diese Option muss eventuell aktiviert werden, wenn Dienste wie z.B. eine selbstbetriebene Minio-Instanz verwendet werden." serverLogs: "Serverprotokolle" deleteAll: "Alle löschen" showFixedPostForm: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen" @@ -919,6 +921,7 @@ pushNotificationNotSupported: "Entweder dein Browser oder deine Instanz unterst sendPushNotificationReadMessage: "Push-Benachrichtigungen löschen, sobald die relevanten Benachrichtigungen oder Nachrichten gelesen wurden" sendPushNotificationReadMessageCaption: "Eine Push-Benachrichtigungen mit dem Inhalt \"{emptyPushNotificationMessage}\" wird kurz eingeblendet. Dies kann gegebenenfalls den Batterieverbrauch deines Gerätes erhöhen." windowMaximize: "Maximieren" +windowMinimize: "Minimieren" windowRestore: "Wiederherstellen" caption: "Beschreibung" loggedInAsBot: "Momentan als Bot angemeldet" @@ -960,7 +963,9 @@ copyErrorInfo: "Fehlerdetails kopieren" joinThisServer: "Bei dieser Instanz registrieren" exploreOtherServers: "Eine andere Instanz finden" letsLookAtTimeline: "Die Chronik durchstöbern" -disableFederationWarn: "Dies deaktiviert Föderation, aber alle Notizen bleiben, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird diese Option nicht benötigt." +disableFederationConfirm: "Föderation wirklich deaktivieren?" +disableFederationConfirmWarn: "Auch mit deaktivierter Föderation bleiben Notizen, sofern nicht umgestellt, öffentlich. In den meisten Fällen wird dies nicht benötigt." +disableFederationOk: "Deaktivieren" invitationRequiredToRegister: "Diese Instanz ist einladungsbasiert. Du musst einen validen Einladungscode eingeben, um dich zu registrieren." emailNotSupported: "Diese Instanz unterstützt das Versenden von Emails nicht" postToTheChannel: "In Kanal senden" @@ -983,6 +988,19 @@ retryAllQueuesConfirmText: "Dies wird zu einer temporären Erhöhung der Serverl enableChartsForRemoteUser: "Diagramme für Nutzer fremder Instanzen erstellen" enableChartsForFederatedInstances: "Diagramme für fremde Instanzen erstellen" showClipButtonInNoteFooter: "\"Clip\" zum Notizmenu hinzufügen" +largeNoteReactions: "Reaktionen vergrößert anzeigen" +noteIdOrUrl: "Notiz-ID oder URL" +accountMigration: "Konto-Umzug" +accountMoved: "Dieser Benutzer ist zu einem neuen Konto umgezogen:" +forceShowAds: "Werbung immer anzeigen" +_accountMigration: + moveTo: "Dieses Konto zu einem neuen umziehen" + moveToLabel: "Umzugsziel:" + moveAccountDescription: "Ein Umzug kann nicht rückgängig gemacht werden. Stelle zuerst sicher, dass du auf dem Umzugsziel einen Alias erstellt hast. Gib dann das Umzugsziel in folgendem Format ein: @person@instance.com" + moveFrom: "Von einem anderen Konto zu diesem umziehen" + moveFromLabel: "Umzugsursprung:" + moveFromDescription: "Stelle sicher, dass du auf dem Umzugsursprungskonto einen Alias zu diesem Konto erstellt hast, falls du die Follower des Ursprungskontos übertragen möchtest. Dies muss vor dem Umzug geschehen! Gib dann das Ursprungskonto in folgendem Format an: @person@instance.com" + migrationConfirm: "Dieses Konto wirklich zu {account} umziehen? Sobald der Umzug beginnt, kann er nicht rückgängig gemacht werden, und dieses Konto nicht wieder im ursprünglichen Zustand verwendet werden.\n\nÜberprüfe zusätzlich, dass du auf dem Umzugsziel einen Alias eingerichtet hast." _achievements: earnedAt: "Freigeschaltet am" _types: @@ -1255,7 +1273,7 @@ _role: gtlAvailable: "Kann auf die globale Chronik zugreifen" ltlAvailable: "Kann auf die lokale Chronik zugreifen" canPublicNote: "Kann öffentliche Notizen erstellen" - canInvite: "Kann Einladungscodes für diese Instanz erstellen" + canInvite: "Erstellung von Einladungscodes für diese Instanz" canManageCustomEmojis: "Benutzerdefinierte Emojis verwalten" driveCapacity: "Drive-Kapazität" pinMax: "Maximale Anzahl an angehefteten Notizen" @@ -1390,6 +1408,8 @@ _channel: following: "Gefolgt" usersCount: "{n} Teilnehmer" notesCount: "{n} Notizen" + nameAndDescription: "Name und Beschreibung" + nameOnly: "Nur Name" _menuDisplay: sideFull: "Seitlich" sideIcon: "Seitlich (Icons)" @@ -1408,7 +1428,7 @@ _instanceMute: instanceMuteDescription: "Schaltet alle Notizen/Renotes stumm, die von den gelisteten Instanzen stammen, inklusive Antworten von Benutzern an einen Benutzer einer stummgeschalteten Instanz." instanceMuteDescription2: "Instanzen getrennt durch Zeilenumbrüchen angeben" title: "Blendet Notizen von stummgeschalteten Instanzen aus." - heading: "Liste der stummzuschaltenden Instanzen" + heading: "Stummzuschaltende Instanzen" _theme: explore: "Farbschemata erforschen" install: "Farbschemata installieren" @@ -1680,7 +1700,7 @@ _visibility: followersDescription: "Nur für Follower sichtbar" specified: "Direkt" specifiedDescription: "Nur für bestimmte Benutzer sichtbar" - disableFederation: "Deförderiert" + disableFederation: "Deföderieren" disableFederationDescription: "Nicht an andere Instanzen übertragen" _postForm: replyPlaceholder: "Dieser Notiz antworten …" @@ -1870,6 +1890,7 @@ _deck: channel: "Kanal" mentions: "Erwähnungen" direct: "Direktnachrichten" + roleTimeline: "Rollenchronik" _dialog: charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" @@ -1893,4 +1914,3 @@ _webhookSettings: renote: "Wenn du ein Renote erhältst" reaction: "Wenn du eine Reaktion erhältst" mention: "Wenn du erwähnt wirst" - diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 634e36c29..32964e998 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -394,4 +394,3 @@ _deck: mentions: "Επισημάνσεις" _webhookSettings: name: "Όνομα" - diff --git a/locales/en-US.yml b/locales/en-US.yml index 6489919e8..f1b97200d 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -20,6 +20,7 @@ noNotes: "No notes" noNotifications: "No notifications" instance: "Instance" settings: "Settings" +notificationSettings: "Notification Settings" basicSettings: "Basic Settings" otherSettings: "Other Settings" openInWindow: "Open in window" @@ -67,7 +68,7 @@ import: "Import" export: "Export" files: "Files" download: "Download" -driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted." +driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? It will also vanish from all contents that use it." unfollowConfirm: "Are you sure you want to unfollow {name}?" exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed." importRequested: "You've requested an import. This may take a while." @@ -500,12 +501,13 @@ objectStoragePrefixDesc: "Files will be stored under directories with this prefi objectStorageEndpoint: "Endpoint" objectStorageEndpointDesc: "Leave this empty if you are using AWS S3, otherwise specify the endpoint as '' or ':', depending on the service you are using." objectStorageRegion: "Region" -objectStorageRegionDesc: "Specify a region like 'xx-east-1'. If your service does not distinguish between regions, leave this blank or enter 'us-east-1'." +objectStorageRegionDesc: "Specify a region like 'xx-east-1'. If your service does not distinguish between regions, enter 'us-east-1'. Leave empty if using AWS configuration files or environment variables." objectStorageUseSSL: "Use SSL" objectStorageUseSSLDesc: "Turn this off if you are not going to use HTTPS for API connections" objectStorageUseProxy: "Connect over Proxy" objectStorageUseProxyDesc: "Turn this off if you are not going to use a Proxy for API connections" objectStorageSetPublicRead: "Set \"public-read\" on upload" +s3ForcePathStyleDesc: "If s3ForcePathStyle is enabled, the bucket name has to included in the path of the URL as opposed to the hostname of the URL. You may need to enable this setting when using services such as a self-hosted Minio instance." serverLogs: "Server logs" deleteAll: "Delete all" showFixedPostForm: "Display the posting form at the top of the timeline" @@ -903,6 +905,7 @@ remoteOnly: "Remote only" failedToUpload: "Upload failed" cannotUploadBecauseInappropriate: "This file could not be uploaded because parts of it have been detected as potentially NSFW." cannotUploadBecauseNoFreeSpace: "Upload failed due to lack of Drive capacity." +cannotUploadBecauseExceedsFileSizeLimit: "This file could not be uploaded because it exceeds the maximum allowed size." beta: "Beta" enableAutoSensitive: "Automatic NSFW-Marking" enableAutoSensitiveDescription: "Allows automatic detection and marking of NSFW media through Machine Learning where possible. Even if this option is disabled, it may be enabled instance-wide." @@ -917,8 +920,9 @@ unsubscribePushNotification: "Disable push notifications" pushNotificationAlreadySubscribed: "Push notifications are already enabled" pushNotificationNotSupported: "Your browser or instance does not support push notifications" sendPushNotificationReadMessage: "Delete push notifications once the relevant notifications or messages have been read" -sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the battery usage of your device, if applicable." +sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\" will be displayed for a short time. This may increase the power consumption of your device." windowMaximize: "Maximize" +windowMinimize: "Minimize" windowRestore: "Restore" caption: "Caption" loggedInAsBot: "Currently logged in as bot" @@ -960,7 +964,9 @@ copyErrorInfo: "Copy error details" joinThisServer: "Sign up at this instance" exploreOtherServers: "Look for another instance" letsLookAtTimeline: "Have a look at the timeline" -disableFederationWarn: "This will disable federation, but posts will continue to be public unless set otherwise. You usually do not need to use this setting." +disableFederationConfirm: "Really disable federation?" +disableFederationConfirmWarn: "Even if defederated, posts will continue to be public unless set otherwise. You usually do not need to do this." +disableFederationOk: "Disable" invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up." emailNotSupported: "This instance does not support sending emails" postToTheChannel: "Post to channel" @@ -983,6 +989,19 @@ retryAllQueuesConfirmText: "This will temporarily increase the server load." enableChartsForRemoteUser: "Generate remote user data charts" enableChartsForFederatedInstances: "Generate remote instance data charts" showClipButtonInNoteFooter: "Add \"Clip\" to note action menu" +largeNoteReactions: "Enlargen displayed reactions" +noteIdOrUrl: "Note ID or URL" +accountMigration: "Account Migration" +accountMoved: "This user has moved to a new account:" +forceShowAds: "Always show ads" +_accountMigration: + moveTo: "Migrate this account to a different one" + moveToLabel: "Account to move to:" + moveAccountDescription: "This action is irreversible. First, create an alias for this account on the account you wish to move to. Then, enter the account to move to in the following format: @person@instance.com" + moveFrom: "Migrate another account to this one" + moveFromLabel: "Account to move from:" + moveFromDescription: "Create an alias for the account to move from on this account if you wish to transfer its followers. This has to be done before the transfer! Then, enter the account to move to in the following format: @person@instance.com" + migrationConfirm: "Really migrate this account to {account}? Once started, this process cannot be stopped or taken back, and you will not be able to use this account in its original state anymore.\n\nAlso, confirm you've created an alias at the account to migrate to." _achievements: earnedAt: "Unlocked at" _types: @@ -1390,6 +1409,8 @@ _channel: following: "Followed" usersCount: "{n} Participants" notesCount: "{n} Notes" + nameAndDescription: "Name and description" + nameOnly: "Name only" _menuDisplay: sideFull: "Side" sideIcon: "Side (Icons)" @@ -1680,7 +1701,7 @@ _visibility: followersDescription: "Make visible to your followers only" specified: "Direct" specifiedDescription: "Make visible for specified users only" - disableFederation: "Unfederated" + disableFederation: "Defederate" disableFederationDescription: "Don't transmit to other instances" _postForm: replyPlaceholder: "Reply to this note..." @@ -1852,7 +1873,7 @@ _deck: swapRight: "Swap with the right column" swapUp: "Swap with the above column" swapDown: "Swap with the below column" - stackLeft: "Stack with the left column" + stackLeft: "Stack on left column" popRight: "Pop column to the right" profile: "Profile" newProfile: "New profile" @@ -1870,6 +1891,7 @@ _deck: channel: "Channel" mentions: "Mentions" direct: "Direct notes" + roleTimeline: "Role Timeline" _dialog: charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." @@ -1893,4 +1915,3 @@ _webhookSettings: renote: "When renoted" reaction: "When receiving a reaction" mention: "When being mentioned" - diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 70327c5ea..29cf9b3ec 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -506,6 +506,7 @@ objectStorageUseSSLDesc: "Desactive esto si no va a usar HTTPS para la conexión objectStorageUseProxy: "Conectarse a través de Proxy" objectStorageUseProxyDesc: "Desactive esto si no va a usar Proxy para la conexión de Almacenamiento de objetos" objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir " +s3ForcePathStyleDesc: "Si s3ForcePathStyle esta habilitado el nombre del bucket debe ser especificado como parte de la URL en lugar del nombre de host en la URL. Puede ser necesario activar esta opción cuando se utilice, por ejemplo, Minio en un servidor propio." serverLogs: "Registros del servidor" deleteAll: "Eliminar todos" showFixedPostForm: "Mostrar el formulario de las entradas encima de la línea de tiempo" @@ -919,6 +920,7 @@ pushNotificationNotSupported: "El navegador o la instancia no admiten notificaci sendPushNotificationReadMessage: "Eliminar las notificaciones push después de leer las notificaciones y los mensajes" sendPushNotificationReadMessageCaption: "La notificación \"{emptyPushNotificationMessage}\" aparecerá momentáneamente. Esto puede aumentar el consumo de batería del dispositivo." windowMaximize: "Maximizar" +windowMinimize: "Minimizar" windowRestore: "Regresar" caption: "Pie de foto" loggedInAsBot: "Inicio sesión como cuenta bot." @@ -960,7 +962,9 @@ copyErrorInfo: "Copiar detalles del error" joinThisServer: "Registrarse en esta instancia" exploreOtherServers: "Buscar otra instancia" letsLookAtTimeline: "Mirar la línea de tiempo local" -disableFederationWarn: "Esto desactivará la federación, pero las publicaciones segurán siendo públicas al menos que se configure diferente. Usualmente no necesitas usar esta configuración." +disableFederationConfirm: "¿Estas seguro que quieres desactivar la federación?" +disableFederationConfirmWarn: "Aunque no exista federación los posts no serán marcados como privados. En la mayoría de los casos, no es necesario hacer los posts no federar." +disableFederationOk: "Desactivar." invitationRequiredToRegister: "Esta instancia está configurada sólo por invitación, tienes que ingresar un código de invitación válido." emailNotSupported: "Esta instancia no soporta el envío de correo electrónico" postToTheChannel: "Publicar en el canal" @@ -980,6 +984,21 @@ drivecleaner: "Limpiador del Drive" retryAllQueuesNow: "Reintentar inmediatamente todas las colas" retryAllQueuesConfirmTitle: "Desea ¿reintentar inmediatamente todas las colas?" retryAllQueuesConfirmText: "La carga del servidor está incrementándose temporalmente " +enableChartsForRemoteUser: "Generar gráficas de usuarios remotos." +enableChartsForFederatedInstances: "Generar gráficos de servidores remotos" +showClipButtonInNoteFooter: "Añadir \"Clip\" al menú de notas" +largeNoteReactions: "Agrandar las reacciones de las notas" +noteIdOrUrl: "ID o URL de la nota" +accountMigration: "Migración de cuenta" +accountMoved: "Este usuario se ha mudado a una nueva cuenta:" +_accountMigration: + moveTo: "Mover esta cuenta a una nueva" + moveToLabel: "Cuenta destino:" + moveAccountDescription: "Esta operación no puede deshacerse. En primer lugar, asegúrese de haber creado un alias para esta cuenta en la cuenta a la que se va a trasladar. Después de crear el alias, introduzca la cuenta a la que se está trasladando de la siguiente manera: @person@instance.com" + moveFrom: "Trasladar de otra cuenta a ésta" + moveFromLabel: "Cuenta desde la que se realiza el traslado:" + moveFromDescription: "Si quieres transferir seguidores de otra cuenta a esta cuenta y trasladarlos, tendrás que crear un alias aquí. Asegúrate de crearlo antes de realizar el traslado. Introduce la cuenta desde la que estás moviendo los seguidores así: @person@instance.com" + migrationConfirm: "¿Estás seguro de que quieres mover esta cuenta a {account}? Una vez trasladada, no podrás deshacer el traslado y no podrás volver a utilizar la cuenta original.\n\nAdemás, compruebe que ha configurado un alias en el destino del traslado." _achievements: earnedAt: "Desbloqueado el" _types: @@ -1276,6 +1295,8 @@ _role: followersMoreThanOrEq: "Tiene X o más seguidores" followingLessThanOrEq: "Sigue X o menos cuentas" followingMoreThanOrEq: "Sigue X o más cuentas" + notesLessThanOrEq: "El número de notas es inferior o igual a" + notesMoreThanOrEq: "El número de notas es superior o igual a" and: "Condicional AND" or: "Condicional OR" not: "Condicional NOT" @@ -1875,6 +1896,16 @@ _drivecleaner: orderBySizeDesc: "Más grandes" orderByCreatedAtAsc: "Más antiguos" _webhookSettings: + createWebhook: "Crear Webhook" name: "Nombre" + secret: "Secreto" + events: "Eventos de webhook" active: "Activado" - + _events: + follow: "Cuando se sigue a alguien" + followed: "Cuando se es seguido" + note: "Cuando se publica una nota" + reply: "Cuando se recibe una respuesta" + renote: "Cuando reciba un \"re-note\"" + reaction: "Cuando se recibe una reacción" + mention: "Cuando hay una mención" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 11573e0ce..e0767fdc0 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -1470,4 +1470,3 @@ _deck: _webhookSettings: name: "Nom" active: "Activé" - diff --git a/locales/hr-HR.yml b/locales/hr-HR.yml index cd21505a4..ed97d539c 100644 --- a/locales/hr-HR.yml +++ b/locales/hr-HR.yml @@ -1,2 +1 @@ --- - diff --git a/locales/ht-HT.yml b/locales/ht-HT.yml index cd21505a4..ed97d539c 100644 --- a/locales/ht-HT.yml +++ b/locales/ht-HT.yml @@ -1,2 +1 @@ --- - diff --git a/locales/id-ID.yml b/locales/id-ID.yml index e5a057477..97c0b02fe 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "Hapus tanda konten sensitif" enterFileName: "Masukkan nama berkas" mute: "Bisukan" unmute: "Hapus bisukan" +renoteMute: "Matikan renote" +renoteUnmute: "Batal mematikan renote" block: "Blokir" unblock: "Buka blokir" suspend: "Bekukan" @@ -393,11 +395,15 @@ about: "Informasi" aboutMisskey: "Tentang Misskey" administrator: "Admin" token: "Token" +totp: "Aplikasi autentikator" +totpDescription: "Gunakan aplikasi autentikator untuk mendapatkan kata sandi sekali pakai" moderator: "Moderator" moderation: "Moderasi" nUsersMentioned: "{n} pengguna disebut" +securityKeyAndPasskey: "Security key dan passkey" securityKey: "Kunci keamanan" lastUsed: "Terakhir digunakan" +lastUsedAt: "Penggunaan terakhir: {t}" unregister: "Batalkan pendaftaran" passwordLessLogin: "Setel login tanpa kata sandi" resetPassword: "Atur ulang kata sandi" @@ -844,6 +850,7 @@ tenMinutes: "10 Menit" oneHour: "1 Jam" oneDay: "1 Hari" oneWeek: "1 Bulan" +oneMonth: "satu bulan" reflectMayTakeTime: "Mungkin perlu beberapa saat untuk dicerminkan." failedToFetchAccountInformation: "Gagal untuk mendapatkan informasi akun" rateLimitExceeded: "Batas sudah terlampaui" @@ -901,6 +908,7 @@ pushNotificationNotSupported: "Browser atau instansi kamu tidak mendukung pember sendPushNotificationReadMessage: "Hapus pemberitahuan push ketika pemberitahuan relevan atau pesan telah dibaca" sendPushNotificationReadMessageCaption: "Pemberitahuan berisi teks「{emptyPushNotificationMessage}」akan ditampilkan dalam waktu pendek. Ini mungkin dapat menambah pemakaian baterai pada perangkat kamu." windowMaximize: "Maksimalkan" +windowMinimize: "Minimalkan" windowRestore: "Kembalikan" caption: "Keterangan" loggedInAsBot: "Sedang login sebagai bot" @@ -939,6 +947,12 @@ collapseRenotes: "Tutup renote yang sudah kamu lihat" internalServerError: "Kesalahan internal peladen" internalServerErrorDescription: "Peladen sedang mengalami galat tak terduga" copyErrorInfo: "Salin detil galat" +joinThisServer: "Gabung server ini" +exploreOtherServers: "Cari server lain" +letsLookAtTimeline: "LIhat timeline" +disableFederationConfirm: "Matikan federasi?" +disableFederationConfirmWarn: "Mematikan federasi tidak membuat kiriman menjadi privat. Umumnya, mematikan federasi tidak diperlukan." +disableFederationOk: "Matikan federasi" _achievements: earnedAt: "Terbuka pada" _types: @@ -1806,4 +1820,3 @@ _deck: _webhookSettings: name: "Nama" active: "Aktif" - diff --git a/locales/it-IT.yml b/locales/it-IT.yml index ddd1e5e90..f43adcb35 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -20,6 +20,7 @@ noNotes: "Nessuna nota!" noNotifications: "Nessuna notifica" instance: "Istanza" settings: "Impostazioni" +notificationSettings: "Preferenze di notifica" basicSettings: "Impostazioni generali" otherSettings: "Altre impostazioni" openInWindow: "Apri in una finestra" @@ -170,7 +171,7 @@ proxyAccountDescription: "Un profilo proxy funziona come follower per i profili host: "Server remoto" selectUser: "Seleziona profilo" recipient: "Destinatario" -annotation: "Descrizione" +annotation: "Annotazione preventiva" federation: "Federazione" instances: "Istanza" registeredAt: "Registrato presso" @@ -212,7 +213,7 @@ intro: "L'installazione di Misskey è terminata! Si prega di creare il profilo a done: "Fine" processing: "In elaborazione" preview: "Anteprima" -default: "Medio" +default: "Predefinito" defaultValueIs: "Predefinito: {value}" noCustomEmojis: "Nessun emoji" noJobs: "Nessun lavoro" @@ -237,14 +238,14 @@ more: "Di più!" featured: "Tendenze" usernameOrUserId: "Nome utente o ID utente" noSuchUser: "Nessun utente trovato" -lookup: "Cerca" +lookup: "Ricerca remota" announcements: "Annunci" imageUrl: "URL dell'immagine" remove: "Elimina" removed: "Eliminato con successo" removeAreYouSure: "Vuoi davvero eliminare \"{x}\"?" deleteAreYouSure: "Eliminare \"{x}\"?" -resetAreYouSure: "Reimposta" +resetAreYouSure: "Ripristinare?" saved: "Salvato" messaging: "Messaggi" upload: "Carica" @@ -409,7 +410,7 @@ lastUsedAt: "Uso più recente: {t}" unregister: "Annulla l'iscrizione" passwordLessLogin: "Accedi senza password" passwordLessLoginDescription: "Accedi senza password, usando la chiave di sicurezza" -resetPassword: "Reimposta password" +resetPassword: "Ripristina la password" newPasswordIs: "La tua nuova password è「{password}」" reduceUiAnimation: "Ridurre le animazioni dell'interfaccia" share: "Condividi" @@ -506,6 +507,7 @@ objectStorageUseSSLDesc: "Disabilita quest'opzione se non utilizzi HTTPS per le objectStorageUseProxy: "Usa proxy" objectStorageUseProxyDesc: "Disabilita quest'opzione se non usi proxy per la connessione API." objectStorageSetPublicRead: "Imposta \"visibilità pubblica\" al momento di caricare" +s3ForcePathStyleDesc: "L'attivazione di s3ForcePathStyle impone di specificare il nome del bucket come parte del percorso nell'URL anziché del nome host. Potrebbe tornare utile quando si utilizzano applicazioni come Minio." serverLogs: "Log del server" deleteAll: "Cancella cronologia" showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" @@ -564,7 +566,7 @@ invisibleNote: "Nota invisibile" enableInfiniteScroll: "Abilita scorrimento infinito" visibility: "Visibilità" poll: "Sondaggio" -useCw: "Nascondere media" +useCw: "Content Warning" enablePlayer: "Visualizza" disablePlayer: "Chiudi" expandTweet: "Espandi tweet" @@ -579,7 +581,7 @@ plugins: "Estensioni" preferencesBackups: "Backup delle impostazioni" deck: "Deck" undeck: "Esci dal deck" -useBlurEffectForModal: "Utilizza effetto sfocatura per i modali" +useBlurEffectForModal: "Utilizza effetto sfocatura per le finestre modali" useFullReactionPicker: "Usa la totalità del pannello di reazioni" width: "Larghezza" height: "Altezza" @@ -596,7 +598,7 @@ notificationType: "Tipo di notifiche" edit: "Modifica" emailServer: "Server email" enableEmail: "Abilita consegna email" -emailConfigInfo: "Utilizzato per verificare il tuo indirizzo di posta elettronica e per reimpostare la tua password" +emailConfigInfo: "Utilizzato per verificare il tuo indirizzo di posta elettronica e per ripristinare la password" email: "Email" emailAddress: "Indirizzo di posta elettronica" smtpConfig: "Impostazioni del server SMTP" @@ -785,7 +787,7 @@ gallery: "Galleria" recentPosts: "Le più recenti" popularPosts: "Le più visualizzate" shareWithNote: "Condividere in nota" -ads: "Pubblicità" +ads: "Banner" expiration: "Scadenza" startingperiod: "Periodo di inizio" memo: "Promemoria" @@ -814,7 +816,7 @@ translatedFrom: "Tradotto da {x}" accountDeletionInProgress: "È in corso l'eliminazione del profilo" usernameInfo: "Un nome per identificare univocamente il tuo profilo sull'istanza. Puoi utilizzare caratteri alfanumerici maiuscoli, minuscoli e il trattino basso (_). Non potrai cambiare nome utente in seguito." aiChanMode: "Modalità Ai" -keepCw: "Mantieni il CW" +keepCw: "Mantieni il Content Warning" pubSub: "Publish/Subscribe del profilo" lastCommunication: "La comunicazione più recente" resolved: "Risolto" @@ -919,6 +921,7 @@ pushNotificationNotSupported: "Il client o il server non supporta le notifiche p sendPushNotificationReadMessage: "Elimina le notifiche push dopo la relativa lettura" sendPushNotificationReadMessageCaption: "Se possibile, verrà mostrata brevemente una notifica con il testo \"{emptyPushNotificationMessage}\". Potrebbe influire negativamente sulla durata della batteria." windowMaximize: "Ingrandisci" +windowMinimize: "Contrai finestra" windowRestore: "Ripristina" caption: "Didascalia" loggedInAsBot: "Connessione come Bot" @@ -960,16 +963,18 @@ copyErrorInfo: "Copia le informazioni sull'errore" joinThisServer: "Registrati su questa istanza" exploreOtherServers: "Trova altre istanze" letsLookAtTimeline: "Sbircia la timeline" -disableFederationWarn: "Disabilita la federazione. Questo cambiamento non rende le pubblicazioni private. Di solito non è necessario abilitare questa opzione." -invitationRequiredToRegister: "L'accesso a questo nodo è solo ad invito. Devi inserire un codice d'invito valido. Puoi richiedere un codice all'amministratore." +disableFederationConfirm: "Vuoi davvero disattivare la federazione?" +disableFederationConfirmWarn: "Anche se defederate, le Note continueranno ad essere pubbliche, se non diversamente specificato. Di solito, non è necessario far questo." +disableFederationOk: "Disabilita federazione" +invitationRequiredToRegister: "L'accesso a questa istanza è solo ad invito. Può registrarsi solo chi ha un codice fornito dall'amministrazione." emailNotSupported: "L'istanza non supporta l'invio di email" -postToTheChannel: "Pubblica sul canale" +postToTheChannel: "Pubblica nel canale" cannotBeChangedLater: "Non sarà più modificabile" reactionAcceptance: "Accettazione reazioni" likeOnly: "Solo i Like" likeOnlyForRemote: "Solo Like remoti" rolesAssignedToMe: "I miei ruoli" -resetPasswordConfirm: "Vuoi reimpostare la password?" +resetPasswordConfirm: "Vuoi davvero ripristinare la password?" sensitiveWords: "Parole sensibili" sensitiveWordsDescription: "Imposta automaticamente \"Home\" alla visibilità delle Note che contengono una qualsiasi parola tra queste configurate. Puoi separarle per riga." notesSearchNotAvailable: "Non è possibile cercare tra le Note." @@ -980,6 +985,22 @@ drivecleaner: "Drive cleaner" retryAllQueuesNow: "Ritenta di consumare tutte le code" retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?" retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente." +enableChartsForRemoteUser: "Abilita i grafici per i profili remoti" +enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate" +showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note" +largeNoteReactions: "Ingrandisci le reazioni" +noteIdOrUrl: "ID della Nota o URL" +accountMigration: "Migrazione del profilo" +accountMoved: "Questo profilo ha migrato altrove:" +forceShowAds: "Mostra sempre i banner" +_accountMigration: + moveTo: "Migrare questo profilo verso un un altro" + moveToLabel: "Profilo verso cui migrare" + moveAccountDescription: "Questa attività è irreversibile! Innanzitutto, assicurati di aver creato, nella istanza di destinazione, un alias con l'indirizzo di questo profilo. Successivamente, indica qui il profilo di destinazione in questo modo: @persona@istanza.it" + moveFrom: "Migra un altro profilo dentro a questo" + moveFromLabel: "Profilo da cui migrare:" + moveFromDescription: "Se desideri spostare i profili follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@istanza.it" + migrationConfirm: "Vuoi davvero migrare questo profilo su {account}? L'azione è irreversibile e non potrai più utilizzare questo profilo nel suo stato originale.\nInoltre, assicurati di aver già creato un alias sull'account a cui ti stai trasferendo." _achievements: earnedAt: "Data di conseguimento" _types: @@ -1276,6 +1297,8 @@ _role: followersMoreThanOrEq: "Ha più di N follower" followingLessThanOrEq: "Segue N profili o meno" followingMoreThanOrEq: "Segue N profili o più" + notesLessThanOrEq: "Conteggio Note inferiore o uguale a" + notesMoreThanOrEq: "Conteggio Note maggiore o uguale a" and: "E" or: "O" not: "NON" @@ -1314,8 +1337,8 @@ _ad: hide: "Nascondi" _forgotPassword: enterEmail: "Inserisci l'indirizzo di posta elettronica che hai registrato nel tuo profilo. Il collegamento necessario per ripristinare la password verrà inviato a questo indirizzo." - ifNoEmail: "Se nessun indirizzo e-mail è stato registrato, si prega di contattare l'amministratore·trice dell'istanza." - contactAdmin: "Poiché questa istanza non permette l'utilizzo di una mail, si prega di contattare l'amministratore·trice dell'istanza per poter ripristinare la password." + ifNoEmail: "Se il tuo indirizzo email non risulta registrato, contatta l'amministrazione dell'istanza." + contactAdmin: "Poiché questa istanza non permette di impostare l'indirizzo mail, contatta l'amministrazione per ripristinare la password.\n" _gallery: my: "Le mie pubblicazioni" liked: "Pubblicazioni che mi piacciono" @@ -1385,6 +1408,8 @@ _channel: following: "Seguiti" usersCount: "{n} partecipanti" notesCount: "{n} note" + nameAndDescription: "Nome e descrizione" + nameOnly: "Solo il nome" _menuDisplay: sideFull: "Laterale" sideIcon: "Laterale (solo icone)" @@ -1670,12 +1695,12 @@ _visibility: public: "Pubblica" publicDescription: "Visibile per tutti sul Fediverso" home: "Home" - homeDescription: "Visibile solo sulla timeline \"Home\"" + homeDescription: "Visibile solo sulla timeline locale" followers: "Follower" - followersDescription: "Visibile solo per i tuoi follower" + followersDescription: "Visibile solo ai tuoi follower" specified: "Nota diretta" specifiedDescription: "Visibile solo ai profili menzionati" - disableFederation: "Interrompi la federazione" + disableFederation: "Federazione disabilitata" disableFederationDescription: "Non spedire attività alle altre istanze remote" _postForm: replyPlaceholder: "Rispondi a questa nota..." @@ -1870,11 +1895,21 @@ _dialog: charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({corrente})" _disabledTimeline: title: "Timeline disabilitata" - description: "Il tuo ruolo non ha i permessi per accedere a questa timeline" + description: "Il ruolo in cui sei non ti permette di leggere questa timeline" _drivecleaner: orderBySizeDesc: "Dal più grande al più piccolo" orderByCreatedAtAsc: "Dal più vecchio al più recente" _webhookSettings: + createWebhook: "Creazione Webhook" name: "Nome" + secret: "Segreto" + events: "Quando eseguire il Webhook" active: "Attivo" - + _events: + follow: "Quando segui un profilo" + followed: "Quando ti segue un profilo" + note: "Quando pubblichi una Nota" + reply: "Quando rispondono ad una Nota" + renote: "Quando la Nota è Rinotata" + reaction: "Quando ricevo una reazione" + mention: "Quando mi menzionano" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a4f1d802c..3225ff621 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -20,6 +20,7 @@ noNotes: "ノートはありません" noNotifications: "通知はありません" instance: "サーバー" settings: "設定" +notificationSettings: "通知の設定" basicSettings: "基本設定" otherSettings: "その他の設定" openInWindow: "ウィンドウで開く" @@ -262,14 +263,16 @@ noMoreHistory: "これより過去の履歴はありません" startMessaging: "チャットを開始" nUsersRead: "{n}人が読みました" agreeTo: "{0}に同意" +agree: "同意する" agreeBelow: "下記に同意する" basicNotesBeforeCreateAccount: "基本的な注意事項" -tos: "利用規約" +termsOfService: "利用規約" start: "始める" home: "ホーム" remoteUserCaution: "リモートユーザーのため、情報が不完全です。" activity: "アクティビティ" images: "画像" +image: "画像" birthday: "誕生日" yearsOld: "{age}歳" registeredDate: "登録日" @@ -473,6 +476,8 @@ createAccount: "アカウントを作成" existingAccount: "既存のアカウント" regenerate: "再生成" fontSize: "フォントサイズ" +mediaListWithOneImageAppearance: "画像が1枚のみのメディアリストの高さ" +limitTo: "{x}を上限に" noFollowRequests: "フォロー申請はありません" openImageInNewTab: "画像を新しいタブで開く" dashboard: "ダッシュボード" @@ -698,6 +703,8 @@ contact: "連絡先" useSystemFont: "システムのデフォルトのフォントを使う" clips: "クリップ" experimentalFeatures: "実験的機能" +experimental: "実験的" +ThisIsExperimentalFeature: "これは実験的な機能です。仕様が変更されたり、正常に動作しなかったりする可能性があります。" developer: "開発者" makeExplorable: "アカウントを見つけやすくする" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。" @@ -904,6 +911,7 @@ remoteOnly: "リモートのみ" failedToUpload: "アップロード失敗" cannotUploadBecauseInappropriate: "不適切な内容を含む可能性があると判定されたためアップロードできません。" cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" +cannotUploadBecauseExceedsFileSizeLimit: "ファイルサイズの制限を超えているためアップロードできません。" beta: "ベータ" enableAutoSensitive: "自動NSFW判定" enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアにNSFWフラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。" @@ -917,9 +925,10 @@ subscribePushNotification: "プッシュ通知を有効化" unsubscribePushNotification: "プッシュ通知を停止する" pushNotificationAlreadySubscribed: "プッシュ通知は有効です" pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応" -sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" -sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" +sendPushNotificationReadMessage: "通知が既読になったらプッシュ通知を削除する" +sendPushNotificationReadMessageCaption: "端末の電池消費量が増加する可能性があります。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "元に戻す" caption: "キャプション" loggedInAsBot: "Botアカウントでログイン中" @@ -936,6 +945,7 @@ didYouLikeMisskey: "Misskeyを気に入っていただけましたか?" pleaseDonate: "Misskeyは{host}が使用している無料のソフトウェアです。これからも開発を続けられるように、ぜひ寄付をお願いします!" roles: "ロール" role: "ロール" +noRole: "ロールはありません" normalUser: "一般ユーザー" undefined: "未定義" assign: "アサイン" @@ -945,6 +955,10 @@ manageCustomEmojis: "カスタム絵文字の管理" youCannotCreateAnymore: "これ以上作成することはできません。" cannotPerformTemporary: "一時的に利用できません" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" +invalidParamError: "パラメータエラー" +invalidParamErrorDescription: "リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる等の可能性もあります。" +permissionDeniedError: "操作が拒否されました" +permissionDeniedErrorDescription: "このアカウントにはこの操作を行うための権限がありません。" preset: "プリセット" selectFromPresets: "プリセットから選択" achievements: "実績" @@ -961,7 +975,9 @@ copyErrorInfo: "エラー情報をコピー" joinThisServer: "このサーバーに登録する" exploreOtherServers: "他のサーバーを探す" letsLookAtTimeline: "タイムラインを見てみる" -disableFederationWarn: "連合が無効になっています。無効にしても投稿が非公開にはなりません。ほとんどの場合、このオプションを有効にする必要はありません。" +disableFederationConfirm: "連合なしにしますか?" +disableFederationConfirmWarn: "連合なしにしても投稿は非公開になりません。ほとんどの場合、連合なしにする必要はありません。" +disableFederationOk: "連合なしにする" invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。" emailNotSupported: "このサーバーではメール配信はサポートされていません" postToTheChannel: "チャンネルに投稿" @@ -986,6 +1002,52 @@ enableChartsForFederatedInstances: "リモートサーバーのチャートを showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" largeNoteReactions: "ノートのリアクションを大きく表示" noteIdOrUrl: "ノートIDまたはURL" +video: "動画" +videos: "動画" +dataSaver: "データセーバー" +accountMigration: "アカウントの移行" +accountMoved: "このユーザーは新しいアカウントに移行しました:" +accountMovedShort: "このアカウントは移行されています" +operationForbidden: "この操作はできません" +forceShowAds: "常に広告を表示する" +addMemo: "メモを追加" +editMemo: "メモを編集" +reactionsList: "リアクション一覧" +renotesList: "Renote一覧" +notificationDisplay: "通知の表示" +leftTop: "左上" +rightTop: "右上" +leftBottom: "左下" +rightBottom: "右下" +stackAxis: "スタック方向" +vertical: "縦" +horizontal: "横" +position: "位置" +serverRules: "サーバールール" +pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。" +pleaseAgreeAllToContinue: "続けるには、全ての「同意する」にチェックが入っている必要があります。" +continue: "続ける" +preservedUsernames: "予約ユーザー名" +preservedUsernamesDescription: "予約するユーザー名を改行で列挙します。ここで指定されたユーザー名はアカウント作成時に使えなくなりますが、管理者によるアカウント作成時はこの制限を受けません。また、既に存在するアカウントも影響を受けません。" + +_serverRules: + description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。" + +_accountMigration: + moveFrom: "別のアカウントからこのアカウントに移行" + moveFromSub: "別のアカウントへエイリアスを作成" + moveFromLabel: "移行元のアカウント #{n}" + moveFromDescription: "別のアカウントからこのアカウントに移行したい場合、ここでエイリアスを作成しておく必要があります。\n移行元のアカウントをこのように入力してください: @username@server.example.com\n削除するには、入力欄を空にして保存します(非推奨)。" + moveTo: "このアカウントを新しいアカウントへ移行" + moveToLabel: "移行先のアカウント:" + moveCannotBeUndone: "アカウントを移行すると、取り消すことはできません。" + moveAccountDescription: "新しいアカウントへ移行します。\n ・フォロワーが新しいアカウントを自動でフォローします\n ・このアカウントからのフォローは全て解除されます\n ・このアカウントではノートの作成などができなくなります\n\nフォロワーの移行は自動ですが、フォローの移行は手動で行う必要があります。移行前にこのアカウントでフォローエクスポートし、移行後すぐに移行先アカウントでインポートを行なってください。\nリスト・ミュート・ブロックについても同様ですので、手動で移行する必要があります。\n\n(この説明はこのサーバー(Misskey v13.12.0以降)の仕様です。Mastodonなどの他のActivityPubソフトウェアでは挙動が異なる場合があります。)" + moveAccountHowTo: "アカウントの移行には、まずは移行先のアカウントでこのアカウントに対しエイリアスを作成します。\nエイリアス作成後、移行先のアカウントを次のように入力してください: @username@server.example.com" + startMigration: "移行する" + migrationConfirm: "本当にこのアカウントを {account} に移行しますか?一度移行すると取り消せず、二度とこのアカウントを元の状態で使用できなくなります。" + movedAndCannotBeUndone: "\nアカウントは移行されています。\n移行を取り消すことはできません。" + postMigrationNote: "このアカウントからのフォロー解除は移行操作から24時間後に実行されます。\nこのアカウントのフォロー・フォロワー数は0になっています。フォロワーの解除はされないため、あなたのフォロワーはこのアカウントのフォロワー向け投稿を引き続き閲覧できます。" + movedTo: "移行先のアカウント:" _achievements: earnedAt: "獲得日時" @@ -1158,6 +1220,9 @@ _achievements: _client30min: title: "ひとやすみ" description: "クライアントを起動してから30分以上経過した" + _client60min: + title: "Misskeyの見すぎ" + description: "クライアントを起動してから60分以上経過した" _noteDeletedWithin1min: title: "いまのなし" description: "投稿してから1分以内にその投稿を削除した" @@ -1247,6 +1312,8 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして表示" descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。" + isExplorable: "ロールタイムラインを公開" + descriptionOfIsExplorable: "オンにすると、ロールのタイムラインを公開します。ロールの公開がオフの場合、タイムラインの公開はされません。" displayOrder: "表示順" descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" @@ -1412,6 +1479,8 @@ _channel: following: "フォロー中" usersCount: "{n}人が参加中" notesCount: "{n}投稿があります" + nameAndDescription: "名前と説明" + nameOnly: "名前のみ" _menuDisplay: sideFull: "横" @@ -1925,6 +1994,7 @@ _deck: channel: "チャンネル" mentions: "あなた宛て" direct: "ダイレクト" + roleTimeline: "ロールタイムライン" _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" @@ -1952,4 +2022,3 @@ _webhookSettings: renote: "Renoteされたとき" reaction: "リアクションがあったとき" mention: "メンションされたとき" - diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 5b1b312b7..4117a25b0 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -16,22 +16,23 @@ cancel: "やめとく" noThankYou: "やめとく" enterUsername: "ユーザー名を入れてや" renotedBy: "{user}がRenoteしたで" -noNotes: "ノートなんてあらへんで" -noNotifications: "通知なんてあらへんで" +noNotes: "ノートはあらへん" +noNotifications: "通知はあらへん" instance: "サーバー" settings: "設定" +notificationSettings: "通知の設定" basicSettings: "基本設定" otherSettings: "ほかの設定" openInWindow: "ウィンドウで開くで" profile: "プロフィール" timeline: "タイムライン" -noAccountDescription: "自己紹介食ってもた" +noAccountDescription: "自己紹介はあらへん" login: "ログイン" loggingIn: "ログインしよるで" logout: "ログアウト" signup: "新規登録" uploading: "アップロードしとるで" -save: "保存" +save: "とっとく" users: "ユーザー" addUser: "ユーザーを追加や" favorite: "お気に入り" @@ -81,9 +82,9 @@ followsYou: "フォローされとるで" createList: "リスト作る" manageLists: "リストの管理" error: "エラー" -somethingHappened: "なんかアカンことが起こったで" +somethingHappened: "なんかあかんわ" retry: "もっぺんやる?" -pageLoadError: "ページの読み込みに失敗してもうたわ…" +pageLoadError: "ページが読み込めんかったわ。" pageLoadErrorDescription: "これは普通ならネットワークかブラウザキャッシュが悪さしてるんよ。キャッシュをほかすか、もうちょっとだけ待ってくれへん?" serverIsDead: "サーバーからの応答がないで。もうちょい待ってから試してみてな。" youShouldUpgradeClient: "このページを表示するには、リロードして新しいバージョンのクライアントを使ってなー。" @@ -108,8 +109,8 @@ inChannelQuote: "チャンネル内引用" pinnedNote: "ピン留めされとるノート" pinned: "ピン留めしとく" you: "あんた" -clickToShow: "押したら見えるで" -sensitive: "ちょっとアカンやつやで" +clickToShow: "押したら出ら" +sensitive: "気いつけて見いや" add: "増やす" reaction: "リアクション" reactions: "リアクション" @@ -122,8 +123,8 @@ unmarkAsSensitive: "そこまでアカンことないやろ" enterFileName: "ファイル名を入れてや" mute: "ミュート" unmute: "ミュートやめたる" -renoteMute: "リノートは見いひん" -renoteUnmute: "リノートもやっぱ見るわ" +renoteMute: "Renoteは見いひん" +renoteUnmute: "Renoteもやっぱ見るわ" block: "ブロック" unblock: "ブロックやめたる" suspend: "凍結" @@ -141,14 +142,14 @@ editWidgetsExit: "編集終ったで" customEmojis: "カスタム絵文字" emoji: "絵文字" emojis: "絵文字" -emojiName: "絵文字名" +emojiName: "絵文字はんの名前" emojiUrl: "絵文字画像URL" addEmoji: "絵文字を追加" settingGuide: "ええ感じの設定" cacheRemoteFiles: "リモートのファイルをキャッシュする" -cacheRemoteFilesDescription: "この設定を切っとくと、リモートファイルをキャッシュせず直リンクするようになるで。サーバーの容量は節約できるけど、サムネイルが作られんくなるから通信量が増えるで。" +cacheRemoteFilesDescription: "この設定を切っとったら、リモートファイルをキャッシュせんと直リンクするようになるで。サーバーの容量は節約できるけど、サムネイルを作らんなるから通信量が増えるで。" flagAsBot: "Botにするで" -flagAsBotDescription: "もしこのアカウントをプログラム使うて運用するんやったら、このフラグをオンにしてや。オンにすれば、反応がバーッて連鎖するのを避けるために開発者が使うたり、Misskeyのシステム上での扱いがBotに合ったもんになるからな。" +flagAsBotDescription: "もしこのアカウントをプログラム使うて運用するんやったら、このフラグをオンにしてや。オンにすれば、反応がバーッて連鎖せんように開発者が使うたり、Misskeyのシステム上での扱いがBotに合ったもんになるからな。" flagAsCat: "Catやで" flagAsCatDescription: "ワレ、猫ちゃんならこのフラグをつけてみ?" flagShowTimelineReplies: "タイムラインにノートへの返信を表示するで" @@ -194,10 +195,10 @@ network: "ネットワーク" disk: "ディスク" instanceInfo: "サーバー情報" statistics: "統計" -clearQueue: "キューにさいなら" -clearQueueConfirmTitle: "キューをクリアしまっか?" -clearQueueConfirmText: "未配達の投稿は配送されなくなるで。ふつうこの操作を行う必要は無いんやけどな。" -clearCachedFiles: "キャッシュにさいなら" +clearQueue: "キューをほかす" +clearQueueConfirmTitle: "キューをほかしとこか?" +clearQueueConfirmText: "未配達の投稿は配送されんなるで。ふつうこの操作を行う必要は無いんやけどな。" +clearCachedFiles: "キャッシュをほかす" clearCachedFilesConfirm: "キャッシュされとるリモートファイルをみんなほかしてええか?" blockedInstances: "ブロックしたサーバー" blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定してな。ブロックされてもうたサーバーとはもう金輪際やり取りできひんくなるで。ついでにそのサブドメインもブロックするで。" @@ -206,7 +207,7 @@ mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" noUsers: "ユーザーはおらん" editProfile: "プロフィールをいじる" -noteDeleteConfirm: "このノートを削除しまっか?" +noteDeleteConfirm: "このノートをほかしてええか?" pinLimitExceeded: "これ以上ピン留めできひん" intro: "Misskeyのインストールが完了したで!管理者アカウントを作ってや。" done: "でけた" @@ -226,9 +227,9 @@ notResponding: "応答してへんで" instanceFollowing: "サーバーのフォロー" instanceFollowers: "サーバーのフォロワー\n" instanceUsers: "サーバーのユーザー" -changePassword: "パスワード変える" +changePassword: "パスワードをいじる" security: "セキュリティ" -retypedNotMatch: "入れたやつ同じになってないで。" +retypedNotMatch: "入れたやつ合うてへんわ。" currentPassword: "今のパスワード" newPassword: "次のパスワード" newPasswordRetype: "今度のパスワード(もっぺん入れて)" @@ -258,7 +259,7 @@ uploadFromUrlRequested: "アップロードしたい言うといたで" uploadFromUrlMayTakeTime: "アップロード終わるんにちょい時間かかるかもしれへんわ。" explore: "みつける" messageRead: "もう読んだ" -noMoreHistory: "これより過去の履歴はあらへんで" +noMoreHistory: "これより昔のんはあらへんで" startMessaging: "チャットやるで" nUsersRead: "{n}人が読んでもうた" agreeTo: "{0}に同意したで" @@ -294,14 +295,14 @@ createFolder: "フォルダー作る" renameFolder: "フォルダー名を変える" deleteFolder: "フォルダーをほかす" addFile: "ファイルを追加" -emptyDrive: "ドライブにはなんも残っとらん" +emptyDrive: "ドライブは空っぽや" emptyFolder: "このフォルダーは空や" -unableToDelete: "消そうおもってんけどな、あかんかったわ" +unableToDelete: "消せんかったわ" inputNewFileName: "今度のファイル名は何にするん?" inputNewDescription: "新しいキャプションを入れてや" inputNewFolderName: "今度のフォルダ名は何にするん?" circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーや。" -hasChildFilesOrFolders: "このフォルダ、まだなんか入っとるから消されへん" +hasChildFilesOrFolders: "このフォルダは空っぽちゃうから消されへん" copyUrl: "URLをコピー" rename: "名前を変えるで" avatar: "アイコン" @@ -506,6 +507,7 @@ objectStorageUseSSLDesc: "API接続にhttpsを使わん場合はオフにする objectStorageUseProxy: "Proxyを使う" objectStorageUseProxyDesc: "API接続にproxy使わんのやったら切ってくれへん?" objectStorageSetPublicRead: "アップロードした時に'public-read'を設定してや" +s3ForcePathStyleDesc: "s3ForcePathStyleを使たらバケット名をURLのホスト名やなくてパスの一部として必ず指定させるようになるで。セルフホストされたMinioとかを使うてるんやったら有効にせなあかん場合があるで。" serverLogs: "サーバーログ" deleteAll: "全部ほかす" showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?" @@ -643,7 +645,7 @@ reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" forwardReport: "リモートサーバーに通報を転送するで" -forwardReportIsAnonymous: "リモートインスタンスからはあんたの情報は見れへんくって、匿名のシステムアカウントとして表示されるで。" +forwardReportIsAnonymous: "リモートサーバーからはあんたの情報は見えんなって、匿名のシステムアカウントとして表示されるで。" send: "送信" abuseMarkAsResolved: "対応したで" openInNewTab: "新しいタブで開く" @@ -739,7 +741,7 @@ capacity: "容量" inUse: "使用中" editCode: "コードを編集" apply: "適用" -receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" +receiveAnnouncementFromInstance: "サーバーからのお知らせを受け取る" emailNotification: "メール通知" publish: "公開" inChannelSearch: "チャンネル内検索" @@ -767,7 +769,7 @@ active: "アクティブ" offline: "オフライン" notRecommended: "あんま推奨しやんで" botProtection: "Botプロテクション" -instanceBlocking: "インスタンスブロック" +instanceBlocking: "サーバーブロック" selectAccount: "アカウントを選んでなー" switchAccount: "アカウントを変えるで" enabled: "有効" @@ -851,8 +853,8 @@ themeColor: "テーマカラー" size: "大きさ" numberOfColumn: "列の数" searchByGoogle: "探す" -instanceDefaultLightTheme: "インスタンスの最初の明るいテーマ" -instanceDefaultDarkTheme: "インスタンスの最初の暗いテーマ" +instanceDefaultLightTheme: "サーバーおすすめの明るいテーマ" +instanceDefaultDarkTheme: "サーバーおすすめのの暗いテーマ" instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入するで。" mutePeriod: "ミュートする期間" period: "期限" @@ -866,7 +868,7 @@ reflectMayTakeTime: "反映されるまで時間がかかることがあるで" failedToFetchAccountInformation: "アカウントの取得に失敗したみたいや…" rateLimitExceeded: "レート制限が超えたみたいやで" cropImage: "画像のクロップ" -cropImageAsk: "画像をクロップしたってええか?" +cropImageAsk: "画像をクロップしてもええか?" cropYes: "切り抜いたる" cropNo: "切り抜かへん" file: "ファイル" @@ -901,11 +903,11 @@ sensitiveMediaDetection: "センシティブなメディアの検出" localOnly: "ローカルのみ" remoteOnly: "リモートのみ" failedToUpload: "アップロードに失敗してもうたわ…" -cannotUploadBecauseInappropriate: "不適切な内容を含むかもしれへんって判定されたでアップロードできまへん。" -cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いでアップロードできまへん。" +cannotUploadBecauseInappropriate: "不適切な内容を含むかもしれへんって判定されたからアップロードできへんわ。" +cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いからアップロードできへんわ。" beta: "ベータ" enableAutoSensitive: "自動NSFW判定" -enableAutoSensitiveDescription: "使える時は、機械学習を使って自動でメディアにNSFWフラグを設定するで。この機能をオフにしても、インスタンスによっては自動で設定されることがあるで。" +enableAutoSensitiveDescription: "使える時は、機械学習を使って自動でメディアにNSFWフラグを設定するで。この機能をオフにしても、サーバーによっては自動で設定されることがあるで。" activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかとかを判定して積極的に行うで。オフにすると単に文字列として正しいかどうかだけチェックするで。" navbar: "ナビゲーションバー" shuffle: "シャッフルするで" @@ -915,10 +917,11 @@ pushNotification: "プッシュ通知" subscribePushNotification: "プッシュ通知をオンにするで" unsubscribePushNotification: "プッシュ通知を止めるで" pushNotificationAlreadySubscribed: "プッシュ通知はオンになってるで" -pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に対応してないみたいやで。" +pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に対応してないみたいやで。" sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を消すで" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」っていう表示が一瞬表示されるようになるで。端末の電池使用量が増える可能性があるで。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "元に戻す" caption: "キャプション" loggedInAsBot: "Botアカウントでログイン中やで" @@ -931,7 +934,7 @@ numberOfLikes: "いいね数" show: "表示" neverShow: "今後表示しない" remindMeLater: "また後で" -didYouLikeMisskey: "Misskeyを気に入っとっただけましたん?" +didYouLikeMisskey: "Misskey気に入ってくれた?" pleaseDonate: "Misskeyは{host}が使用している無料のソフトウェアやで。これからも開発を続けれるように、寄付したってな~。" roles: "ロール" role: "ロール" @@ -941,7 +944,7 @@ assign: "アサイン" unassign: "アサインを解除" color: "色" manageCustomEmojis: "カスタム絵文字の管理" -youCannotCreateAnymore: "これ以上作れなさそうや" +youCannotCreateAnymore: "これ以上作れなさそうやわ" cannotPerformTemporary: "一時的に利用できへんで" cannotPerformTemporaryDescription: "操作回数が制限を超えたから一時的に利用できへんくなったで。ちょっと時間置いてからもう一回やってやー。" preset: "プリセット" @@ -960,7 +963,9 @@ copyErrorInfo: "エラー情報をコピー" joinThisServer: "このサーバーに登録するわ" exploreOtherServers: "他のサーバー見てみる" letsLookAtTimeline: "タイムライン見てみーや" -disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。" +disableFederationConfirm: "連合なしにしとくか?" +disableFederationConfirmWarn: "連合なしにしても投稿は非公開にはならへんで。大体の場合は連合なしにする必要はないで。" +disableFederationOk: "連合なしにしとく" invitationRequiredToRegister: "今このサーバー招待制になってもうてんねん。招待コードを持っとるんやったら登録できるで。" emailNotSupported: "このサーバーはメール配信がサポートされてへんみたいやわ" postToTheChannel: "チャンネルに投稿" @@ -983,6 +988,19 @@ retryAllQueuesConfirmText: "一時的にサーバー重なるかもしれへん enableChartsForRemoteUser: "リモートユーザーのチャートを作る" enableChartsForFederatedInstances: "リモートサーバーのチャートを作る" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" +largeNoteReactions: "ノートのリアクションを大きする" +noteIdOrUrl: "ノートIDかURL" +accountMigration: "アカウントのお引っ越し" +accountMoved: "このユーザーはさらのアカウントに引っ越したで:" +forceShowAds: "常に広告を表示しとく" +_accountMigration: + moveTo: "このアカウントをさらのアカウントに引っ越すで" + moveToLabel: "引っ越し先のアカウント:" + moveAccountDescription: "この操作は戻されへんで。まず引っ越し先のアカウントでこのアカウントへのエイリアスが作れたか確認してきなはれや。エイリアスができてたら、引っ越し先のアカウントをこんな風に入力してくれへんか?:@person@instance.com" + moveFrom: "別のアカウントからこのアカウントに引っ越す" + moveFromLabel: "引っ越し元のアカウント:" + moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したかったら、ここでエイリアスを作っとく必要があるで。必ずお引っ越しを実行する前に作っとかなあかんで!引っ越し元のアカウントをこんな風に入力してくれへんか?:@person@instance.com" + migrationConfirm: "ほんまにこのアカウントを {account} に引っ越すんか?一回引っ越してもうたら取り消されへんし、二度とこのアカウントを元に戻されへんくなるで。\nそれと、引っ越し先のアカウントでエイリアスが作れたかちゃ~んと確認しーや?" _achievements: earnedAt: "貰った日ぃ" _types: @@ -1043,7 +1061,7 @@ _achievements: _login7: title: "ビギナーⅡ" description: "通算7日ログインした" - flavor: "慣れてきたんちゃう?" + flavor: "慣れてきたんとちゃう?" _login15: title: "ビギナーⅢ" description: "通算15日ログインした" @@ -1147,7 +1165,7 @@ _achievements: _iLoveMisskey: title: "Misskey好きやねん" description: "\"I ❤ #Misskey\"を投稿した" - flavor: "Misskeyを使ってくれてありがとうな~ by 開発チーム" + flavor: "Misskeyを使ってくれておおきにな~ by 開発チーム" _foundTreasure: title: "なんでも鑑定団" description: "隠されたお宝を発見した" @@ -1173,7 +1191,7 @@ _achievements: description: "ホームタイムラインの流速が20npmを超す" _viewInstanceChart: title: "アナリスト" - description: "インスタンスのチャートを表示した" + description: "サーバーのチャートを表示した" _outputHelloWorldOnScratchpad: title: "Hello, world!" description: "スクラッチパッドで hello worldを出力した" @@ -1210,7 +1228,7 @@ _achievements: _loggedInOnNewYearsDay: title: "あけましておめでとうございます!" description: "元旦にログインした" - flavor: "今年も弊インスタンスをよろしくお願いします" + flavor: "今年も弊サーバーをよろしゅう頼みますわ" _cookieClicked: title: "クッキー叩くやつ" description: "クッキー叩いてもうた" @@ -1225,8 +1243,8 @@ _role: name: "ロール名" description: "ロールの説明" permission: "ロールの権限" - descriptionOfPermission: "モデレーターは基本的なモデレーションに関わる操作を行えるで。\n管理者はインスタンスの全ての設定を変更できるで。" - assignTarget: "アサインターゲット" + descriptionOfPermission: "モデレーターは基本的なモデレーションに関わる操作を行えるで。\n管理者はサーバーの全ての設定を変更できるで。" + assignTarget: "アサイン" descriptionOfAssignTarget: "マニュアルは誰がこのロールに含まれてるかを手動で管理するで。\nコンディショナルは条件を設定して、それに合うユーザーが自動で含まれるようになるで。" manual: "マニュアル" conditional: "コンディショナル" @@ -1255,7 +1273,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" - canInvite: "インスタンス招待コードの発行" + canInvite: "サーバー招待コードの発行" canManageCustomEmojis: "カスタム絵文字の管理" driveCapacity: "ドライブ容量" pinMax: "ノートのピン留めの最大数" @@ -1267,7 +1285,7 @@ _role: userListMax: "ユーザーリストの作成可能数" userEachUserListsMax: "ユーザーリスト内のユーザーの最大数" rateLimitFactor: "レートリミット" - descriptionOfRateLimitFactor: "ちっちゃいほど制限が緩くなって、大きいほど制限されるで。" + descriptionOfRateLimitFactor: "ちっちゃいほど制限が緩なって、大きいほど制限されるで。" canHideAds: "広告を表示させへん" canSearchNotes: "ノート検索を使わすかどうか" _condition: @@ -1320,7 +1338,7 @@ _ad: _forgotPassword: enterEmail: "アカウントに登録したメールアドレスをここに入力してや。そのアドレス宛に、パスワードリセット用のリンクが送られるから待っててな~。" ifNoEmail: "メールアドレスを登録してへんのやったら、管理者まで教えてな~。" - contactAdmin: "このインスタンスはメールに対応してへんから、パスワードリセットをしたいときは管理者まで教えてな~。" + contactAdmin: "このサーバーはメールに対応してへんから、パスワードリセットをしたいときは管理者まで教えてな~。" _gallery: my: "あんたの投稿" liked: "いいねした投稿" @@ -1390,6 +1408,8 @@ _channel: following: "フォロー中やで" usersCount: "{n}人が参加中やで" notesCount: "{n}こ投稿があるで" + nameAndDescription: "名前と説明" + nameOnly: "名前だけ" _menuDisplay: sideFull: "横" sideIcon: "横(アイコン)" @@ -1405,10 +1425,10 @@ _wordMute: hard: "ハード" mutedNotes: "ミュートされたノート" _instanceMute: - instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートにするで。" + instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートにするで。" instanceMuteDescription2: "改行で区切って設定するんやで" - title: "設定したインスタンスのノートを隠すで。" - heading: "ミュートするインスタンス" + title: "設定したサーバーのノートを隠すで。" + heading: "ミュートするサーバー" _theme: explore: "テーマを探す" install: "テーマのインストール" @@ -1630,7 +1650,7 @@ _widgets: digitalClock: "デジタル時計" unixClock: "UNIX時計" federation: "連合" - instanceCloud: "インスタンスクラウド" + instanceCloud: "サーバークラウド" postForm: "投稿フォーム" slideshow: "スライドショー" button: "ボタン" @@ -1681,7 +1701,7 @@ _visibility: specified: "ダイレクト" specifiedDescription: "選んだユーザーのみに公開するで" disableFederation: "連合なし" - disableFederationDescription: "他インスタンスへは送らんとくわ" + disableFederationDescription: "他サーバーへは送らんとくわ" _postForm: replyPlaceholder: "このノートに返信..." quotePlaceholder: "このノートを引用..." @@ -1870,6 +1890,7 @@ _deck: channel: "チャンネル" mentions: "あんた宛て" direct: "ダイレクト" + roleTimeline: "ロールタイムライン" _dialog: charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}" charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}" @@ -1893,4 +1914,3 @@ _webhookSettings: renote: "Renoteされるとき~!" reaction: "リアクションがあるとき~!" mention: "メンションがあるとき~!" - diff --git a/locales/jbo-EN.yml b/locales/jbo-EN.yml index cd21505a4..ed97d539c 100644 --- a/locales/jbo-EN.yml +++ b/locales/jbo-EN.yml @@ -1,2 +1 @@ --- - diff --git a/locales/kab-KAB.yml b/locales/kab-KAB.yml index 8b43041e4..18fd8f5a5 100644 --- a/locales/kab-KAB.yml +++ b/locales/kab-KAB.yml @@ -103,4 +103,3 @@ _deck: _columns: notifications: "Ilɣuyen" list: "Tibdarin" - diff --git a/locales/kn-IN.yml b/locales/kn-IN.yml index 63a75302a..ef66f3fbd 100644 --- a/locales/kn-IN.yml +++ b/locales/kn-IN.yml @@ -83,4 +83,3 @@ _deck: notifications: "ಅಧಿಸೂಚನೆಗಳು" tl: "ಸಮಯಸಾಲು" mentions: "ಹೆಸರಿಸಿದ" - diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 31b4200c2..3ff00482d 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -958,7 +958,6 @@ copyErrorInfo: "오류 정보 복사" joinThisServer: "이 서버에 가입" exploreOtherServers: "다른 서버 둘러보기" letsLookAtTimeline: "타임라인 구경하기" -disableFederationWarn: "연합이 비활성화됩니다. 비활성화해도 게시물이 비공개가 되지는 않습니다. 대부분의 경우 이 옵션을 활성화할 필요가 없습니다." invitationRequiredToRegister: "현재 이 서버는 비공개입니다. 회원가입을 하시려면 초대 코드가 필요합니다." emailNotSupported: "이 서버에서는 메일 전송을 지원하지 않습니다" postToTheChannel: "채널에 게시하기" @@ -1851,4 +1850,3 @@ _dialog: _webhookSettings: name: "이름" active: "활성화" - diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 5736fa67a..3f97b3982 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -163,11 +163,15 @@ instanceInfo: "ອີນສະແຕນ" statistics: "ສະຖິຕິ" clearQueue: "ລ້າງຄິວ" clearCachedFiles: "ລຶບລ້າງແຄສ" +noUsers: "ບໍ່ພົບຜູ້ໃຊ້" editProfile: "ແກ້ໄຂໂປຣໄຟລ໌" done: "ສຳເລັດ" processing: "ກຳລັງປະມວນຜົນ" preview: "ສະແດງເປັນຕົວຢ່າງ" default: "ຄ່າເລີ່ມຕົ້ນ" +defaultValueIs: "ຄ່າເລີ່ມຕົ້ນ: {value}" +noCustomEmojis: "ບໍ່ມີອີໂມຈິ" +noJobs: "ບໍ່ມີຊິ້ນວຽກ" federating: "ສະຫະພັນ" blocked: "ບລັອກແລ້ວ " suspended: "ໂຈະ" @@ -182,6 +186,9 @@ changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ" security: "ຄວາມປອດໄພ" retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ" currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ" +newPassword: "ລະຫັດຜ່ານໃໝ່" +newPasswordRetype: "ໃສ່ລະຫັດຜ່ານໃໝ່ອີກເທື່ອໜຶ່ງ" +attachFile: "ແນບໄຟລ໌" more: "ເພີ່ມເຕີມ!" featured: "ໄຮໄລທ໌" usernameOrUserId: "ຊື່ຜູ້ໃຊ້ ຫຼື id ຜູ້ໃຊ້" @@ -196,25 +203,31 @@ saved: "ບັນທຶກແລ້ວ" messaging: "ແຊ໋ດ" upload: "ອັບໂຫຼດ" keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" +fromDrive: "ຈາກ Drive" fromUrl: "ຈາກ URL" uploadFromUrl: "ອັບໂຫຼດຈາກ URL" uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ" +uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດ" messageRead: "ອ່ານແລ້ວ" startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" nUsersRead: "ອ່ານໂດຍ {n}" tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ" start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ" home: "ໜ້າຫຼັກ" +activity: "ກິດຈະກຳ" images: "ຮູບພາບ" birthday: "ວັນເກີດ" yearsOld: "{age} ປີ" registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" location: "ທີ່ຕັ້ງ" theme: "ແທ໋ມ" +themeForLightMode: "ຮູບແບບສີສັນເພື່ອໃຊ້ໃນໂໝດແສງ" +themeForDarkMode: "ຮູບແບບສີສັນທີ່ຈະໃຊ້ຢູ່ໃນໂໝດມືດ" light: "ສະຫວ່າງ" dark: "ມືດ" lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ" darkThemes: "ຮູບແບບສີສັນມືດ" +syncDeviceDarkMode: "ຊິງຄ໌ໂໝດມືດກັບການຕັ້ງຄ່າທົ່ວອຸປະກອນ" drive: "ຂັບ" fileName: "ຊື່ໄຟລ໌" selectFile: "ເລືອກໄຟລ໌" @@ -265,6 +278,9 @@ invite: "ເຊີນ" driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ" pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້" +turnstileSiteKey: "ກະແຈໄຊທ໌" +turnstileSecretKey: "ກະແຈລັບ" +name: "ຊື່" userList: "ລາຍການ" about: "ກ່ຽວກັບ" aboutMisskey: "ກ່ຽວກັບ Misskey" @@ -326,6 +342,7 @@ _widgets: instanceInfo: "ອີນສະແຕນ" notifications: "ການແຈ້ງເຕືອນ" timeline: "​ເສັ້ນກຳ​ນົດ​ເວ​ລາ​" + activity: "ກິດຈະກຳ" federation: "ສະຫະພັນ" _userList: chooseList: "ເລືອກບັນຊີລາຍການ" @@ -335,6 +352,7 @@ _visibility: home: "ໜ້າຫຼັກ" followers: "ຜູ້ຕິດຕາມ" _profile: + name: "ຊື່" username: "ຊື່ຜູ້ໃຊ້" _exportOrImport: followingList: "ກຳລັງຕິດຕາມ" @@ -368,4 +386,5 @@ _deck: list: "ລາຍການ" channel: "ຊ່ອງ" mentions: "ກ່າວເຖິງ" - +_webhookSettings: + name: "ຊື່" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index efbb83c70..31c28a66e 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -485,4 +485,3 @@ _deck: mentions: "Vermeldingen" _webhookSettings: name: "Naam" - diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 36a0a2e0e..83e189b9c 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -1,3 +1,2 @@ --- _lang_: "Norsk Bokmål" - diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index cc71e8777..517e8431f 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1379,4 +1379,3 @@ _deck: _webhookSettings: name: "Nazwa" active: "Właczono" - diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 870ad5015..8a7232fa2 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -557,4 +557,3 @@ _deck: direct: "Notas diretas" _webhookSettings: name: "Nome" - diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 89f8afac9..bbf0160de 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -703,4 +703,3 @@ _deck: mentions: "Mențiuni" _webhookSettings: name: "Nume" - diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 6a1756f8a..8a09d3030 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -950,7 +950,6 @@ copyErrorInfo: "Скопировать код ошибки" joinThisServer: "Присоединяйтесь к этому серверу" exploreOtherServers: "Искать другие сервера" letsLookAtTimeline: "Давайте посмотрим на ленту" -disableFederationWarn: "Объединение отключено. Если вы отключите это, сообщение не будет приватным. В большинстве случаев вам не нужно включать эту опцию." _achievements: earnedAt: "Разблокировано в" _types: @@ -1839,4 +1838,3 @@ _dialog: _webhookSettings: name: "Название" active: "Вкл." - diff --git a/locales/si-LK.yml b/locales/si-LK.yml index cd21505a4..ed97d539c 100644 --- a/locales/si-LK.yml +++ b/locales/si-LK.yml @@ -1,2 +1 @@ --- - diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index ff6075b70..7c7406015 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -1477,4 +1477,3 @@ _deck: _webhookSettings: name: "Názov" active: "Zapnuté" - diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 6ea5f77c2..6369adb45 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -343,9 +343,16 @@ recentlyRegisteredUsers: "Nyligen registrerade användare" userList: "Listor" aboutMisskey: "Om Misskey" administrator: "Administratör" +passwordLessLogin: "Lösenordsfri inloggning" +passwordLessLoginDescription: "Tillåter lösenordsfri inloggning med endast en säkerhetsnyckel eller en passkey." +resetPassword: "Återställ Lösenord" newPasswordIs: "Det nya lösenordet är \"{password}\"" share: "Dela" enable: "Aktivera" +weakPassword: "Svagt Lösenord" +normalPassword: "Medel Lösenord" +strongPassword: "Starkt Lösenord" +signinFailed: "Kan inte logga in. Det angivna användarnamnet eller lösenordet är felaktigt." serviceworkerInfo: "Måste vara aktiverad för pushnotiser." enableInfiniteScroll: "Ladda mer automatiskt" enablePlayer: "Öppna videospelare" @@ -354,11 +361,14 @@ enableEmail: "Aktivera epost-utskick" smtpHost: "Värd" smtpUser: "Användarnamn" smtpPass: "Lösenord" +emptyToDisableSmtpAuth: "Lämna användarnamn och lösenord tomt för att avaktivera SMTP verifiering" clearCache: "Rensa cache" +onlineUsersCount: "{n} användare är online" enabled: "Aktiverad" user: "Användare" global: "Global" squareAvatars: "Visa fyrkantiga profilbilder" +incorrectPassword: "Fel lösenord." searchByGoogle: "Sök" file: "Filer" enableAutoSensitive: "Automatisk NSFW markering" @@ -368,6 +378,15 @@ subscribePushNotification: "Aktivera pushnotiser" unsubscribePushNotification: "Avaktivera pushnotiser" pushNotificationAlreadySubscribed: "Pushnotiser är redan aktiverade" pushNotificationNotSupported: "Din webbläsare eller instans har inte stöd för pushnotiser" +windowMaximize: "Maximera" +windowMinimize: "Minimera" +windowRestore: "Återställ" +resetPasswordConfirm: "Återställ verkligen ditt lösenord?" +_achievements: + _types: + _open3windows: + title: "Flera Fönster" + description: "Ha minst 3 fönster öppna samtidigt" _email: _follow: title: "följde dig" @@ -384,6 +403,7 @@ _sfx: chat: "Chatt" antenna: "Antenner" _2fa: + passwordToTOTP: "Skriv in ditt lösenord" renewTOTPCancel: "Nej tack" _antennaSources: all: "Alla noter" @@ -444,4 +464,3 @@ _deck: mentions: "Omnämningar" _webhookSettings: active: "Aktiverad" - diff --git a/locales/th-TH.yml b/locales/th-TH.yml index a8b478439..a863103e2 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "ยกเลิกทำเครื่องหมายเ enterFileName: "พิมพ์ชื่อไฟล์" mute: "ปิดเสียง" unmute: "ยกเลิกการปิดเสียง" +renoteMute: "ปิดเสียงรีโน้ต" +renoteUnmute: "เปิดเสียง รีโน้ต" block: "บล็อค" unblock: "เลิกปิดกั้น" suspend: "ถูกระงับ" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "แสดงตอบกลับ ในไทม์ flagShowTimelineRepliesDescription: "แสดงการตอบกลับของผู้ใช้งานไปยังโน้ตของผู้ใช้งานรายอื่นๆในไทม์ไลน์หากได้เปิดเอาไว้" autoAcceptFollowed: "อนุมัติคำขอติดตามโดยอัตโนมัติทันที จากผู้ใช้งานที่คุณกำลังติดตาม" addAccount: "เพิ่มบัญชี" +reloadAccountsList: "รีโหลดรายการบัญชีใหม่" loginFailed: "การเข้าสู่ระบบไม่สำเร็จ" showOnRemote: "ดูบนอินสแตนซ์ระยะไกล" general: "ทั่วไป" @@ -503,6 +506,7 @@ objectStorageUseSSLDesc: "ปิดการทำงานนี้ไว้ objectStorageUseProxy: "เชื่อมต่อผ่านพร็อกซี" objectStorageUseProxyDesc: "ปิดสิ่งนี้ไว้ถ้าหากคุณจะไม่ใช้ Proxy สำหรับการเชื่อมต่อ API" objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในการอัปโหลด" +s3ForcePathStyleDesc: "ถ้าหากเปิดใช้งาน s3ForcePathStyle ชื่อบัคเก็ตนั้นอาจจะต้องรวมอยู่ในเส้นทางของ URL ซึ่งตรงข้ามกับชื่อโฮสต์ของ URL คุณอาจจะต้องเปิดใช้งานการตั้งค่านี้เมื่อใช้บริการต่างๆ เช่น อินสแตนซ์ Minio ที่โฮสต์เองนะ" serverLogs: "บันทึกของเซิร์ฟเวอร์" deleteAll: "ลบทั้งหมด" showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" @@ -545,7 +549,9 @@ userSilenced: "ผู้ใช้รายนี้กำลังถูกป yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" tokenRevoked: "โทเค็นไม่ถูกต้อง" +tokenRevokedDescription: "โทเค็นนี้หมดอายุแล้วนะค่ะกรุณาเข้าสู่ระบบอีกครั้งนะ" accountDeleted: "ลบบัญชีแล้ว" +accountDeletedDescription: "บัญชีนี้ถูกลบไปแล้วนะ" menu: "เมนู" divider: "ตัวแบ่ง" addItem: "เพิ่มรายการ" @@ -914,6 +920,7 @@ pushNotificationNotSupported: "เบราว์เซอร์หรืออ sendPushNotificationReadMessage: "ลบการแจ้งเตือนแบบพุชเมื่ออ่านการแจ้งเตือนหรือข้อความที่เกี่ยวข้องแล้ว" sendPushNotificationReadMessageCaption: "การแจ้งเตือนที่มีข้อความ \"{emptyPushNotificationMessage}\" จะแสดงขึ้นมาในช่วงระยะเวลาสั้นๆ การดำเนินการนี้อาจทำให้เพิ่มการใช้งานแบตเตอรี่ของอุปกรณ์ถ้าหากมีนะ" windowMaximize: "ขยายใหญ่สุดแล้ว" +windowMinimize: "ย่อเล็กที่สุด" windowRestore: "เลิกทำ" caption: "รายละเอียด" loggedInAsBot: "ล็อกอินเป็นบอตอยู่ในขณะนี้" @@ -955,12 +962,17 @@ copyErrorInfo: "คัดลอกรายละเอียดข้อผิ joinThisServer: "ลงชื่อสมัครใช้ในอินสแตนซ์นี้" exploreOtherServers: "มองหาอินสแตนซ์อื่น" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" -disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ" +disableFederationConfirm: "ปิดใช้งานสหพันธ์จริงๆหรอแน่ใจแล้วนะ?" +disableFederationConfirmWarn: "แม้ว่าจะถูกยกเลิกเอาไว้โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป เว้นแต่ว่า...จะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องทำตรงนี้หรอกนะค่ะ" +disableFederationOk: "ปิดการใช้งาน" invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" postToTheChannel: "โพสต์ลงช่อง" cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" +reactionAcceptance: "การยอมรับรีแอคชั่น" likeOnly: "ที่ชอบเท่านั้น" +likeOnlyForRemote: "ไลค์สำหรับอินสแตนซ์ระยะไกลเท่านั้น" +rolesAssignedToMe: "บทบาทที่ได้รับมอบหมายให้ฉัน" resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?" sensitiveWords: "คำที่ละเอียดอ่อน" sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ" @@ -972,6 +984,22 @@ drivecleaner: "ทำความสะอาดไดรฟ์" retryAllQueuesNow: "ลองเรียกใช้คิวทั้งหมดอีกครั้ง" retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริงๆหรอแน่ใจนะ?" retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ" +enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล" +enableChartsForFederatedInstances: "สร้างแผนภูมิข้อมูลอินสแตนซ์ระยะไกล" +showClipButtonInNoteFooter: "เพิ่ม \"คลิป\" เพื่อบันทึกเมนูการทำงาน" +largeNoteReactions: "ขยายรีแอคชั่นการแสดงผล" +noteIdOrUrl: "โน้ต ID หรือ URL" +accountMigration: "การโยกย้ายบัญชี" +accountMoved: "ผู้ใช้รายนี้ได้ย้ายไปยังบัญชีใหม่แล้ว:" +forceShowAds: "แสดงโฆษณาเสมอ" +_accountMigration: + moveTo: "ย้ายข้อมูลบัญชีนี้ไปยังบัญชีอีกหนึ่ง" + moveToLabel: "บัญชีที่จะย้ายไปที่:" + moveAccountDescription: "การกระทำนี้ไม่สามารถย้อนกลับได้นะ ขั้นตอนแรก ต้องสร้างนามแฝงสำหรับบัญชีนี้ในบัญชีที่คุณต้องการย้ายไป หลังจากนั้นแล้ว ป้อนบัญชีที่จะย้ายไปในรูปแบบดังต่อไปนี้: @person@instance.com" + moveFrom: "ย้ายข้อมูลบัญชีอื่นไปยังอีกบัญชีนี้หนึ่ง" + moveFromLabel: "บัญชีที่จะย้ายจาก:" + moveFromDescription: "สร้างนามแฝงสำหรับบัญชีที่จะย้ายจากบัญชีนี้ ถ้าหากคุณต้องการโอนผู้ติดตาม สิ่งนี้ต้องทำก่อนโอนก่อนนะค่ะ! หลังจากนั้น ป้อนบัญชีที่จะย้ายไปในรูปแบบต่อไปนี้: @person@instance.com" + migrationConfirm: "ย้ายข้อมูลบัญชีนี้ไปที่ {account} จริงๆนะ เมื่อมีการเริ่มต้นแล้ว กระบวนการนี้จะไม่สามารถหยุดหรือนำกลับคืนมาได้ และคุณจะไม่สามารถใช้บัญชีนี้ในสถานะดั้งเดิมได้อีกต่อไป\n\nนอกจากนี้ เพื่อให้แน่ใจยืนยันว่าคุณได้สร้างนามแฝงในบัญชีที่จะย้ายข้อมูลนะค่ะ" _achievements: earnedAt: "ได้รับเมื่อ" _types: @@ -1268,6 +1296,8 @@ _role: followersMoreThanOrEq: "จำนวนผู้ติดตามมากกว่าหรือเท่ากับ\n" followingLessThanOrEq: "จำนวนบัญชีต่อไปนี้คือ น้อยกว่าหรือเท่ากับ" followingMoreThanOrEq: "จำนวนบัญชีต่อไปนี้คือ มากกว่าหรือเท่ากับ" + notesLessThanOrEq: "จำนวนโพสต์น้อยกว่าเท่ากับ" + notesMoreThanOrEq: "จำนวนโพสต์มากกว่าเท่ากับ" and: "และ" or: "หรือ" not: "ไม่" @@ -1867,6 +1897,16 @@ _drivecleaner: orderBySizeDesc: "ขนาดไฟล์จากมากไปหาน้อย" orderByCreatedAtAsc: "วันที่จากน้อยไปหามาก" _webhookSettings: + createWebhook: "สร้าง Webhook" name: "ชื่อ" + secret: "ความลับ" + events: "อีเว้นท์ Webhook" active: "เปิดใช้งาน" - + _events: + follow: "เมื่อกำลังติดตามผู้ใช้" + followed: "เมื่อกำลังติดตามแล้ว" + note: "เมื่อกำลังโพสต์โน้ต" + reply: "เมื่อได้รับการตอบกลับ" + renote: "รีโน้ตแล้วเมื่อ" + reaction: "เมื่อได้รับรีแอคชั่น" + mention: "เมื่อกำลังถูกกล่าวถึง" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index 0f53dbafc..7bd8188a4 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -60,4 +60,3 @@ _deck: _columns: notifications: "Bildirim" tl: "Zaman çizelgesi" - diff --git a/locales/ug-CN.yml b/locales/ug-CN.yml index 5b825d7bf..65ef84125 100644 --- a/locales/ug-CN.yml +++ b/locales/ug-CN.yml @@ -2,4 +2,3 @@ _lang_: "ياپونچە" search: "ئىزدەش" searchByGoogle: "ئىزدەش" - diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 7b2ee6d89..895d2e1f1 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -1641,4 +1641,3 @@ _deck: _webhookSettings: name: "Ім'я" active: "Увімкнено" - diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index f81445473..ebd046b6d 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1707,4 +1707,3 @@ _dialog: _webhookSettings: name: "Tên" active: "Đã bật" - diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 244528cc2..62f6bc6c3 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -16,16 +16,17 @@ cancel: "取消" noThankYou: "不用,谢谢" enterUsername: "输入用户名" renotedBy: "由 {user} 转贴" -noNotes: "没有帖子" +noNotes: "没有帖文" noNotifications: "无通知" instance: "服务器" settings: "设置" +notificationSettings: "通知设置" basicSettings: "基本设置" otherSettings: "其他设置" openInWindow: "在新窗口中打开" profile: "个人资料" timeline: "时间线" -noAccountDescription: "这个人很懒,没有写自我介绍" +noAccountDescription: "此用户尚无自我介绍" login: "登录" loggingIn: "正在登录..." logout: "登出" @@ -60,7 +61,7 @@ youGotNewFollower: "你有新的关注者" receiveFollowRequest: "您收到了关注请求" followRequestAccepted: "您的关注请求被通过了" mention: "提及" -mentions: "提及" +mentions: "提到我的" directNotes: "私信" importAndExport: "导入和导出" import: "导入" @@ -85,7 +86,7 @@ somethingHappened: "出现了一些问题!" retry: "重试" pageLoadError: "页面加载失败。" pageLoadErrorDescription: "这通常是由于网络或浏览器缓存的原因。请清除缓存或等待片刻后重试。" -serverIsDead: "服务器没有响应。 请稍等片刻,然后重试。" +serverIsDead: "没有服务器响应。 请稍后再试。" youShouldUpgradeClient: "请重新加载并使用新版本的客户端查看此页面。" enterListName: "输入列表名称" privacy: "隐私" @@ -95,7 +96,7 @@ follow: "关注" followRequest: "关注申请" followRequests: "关注申请" unfollow: "取消关注" -followRequestPending: "发送关注请求" +followRequestPending: "关注请求批准中" enterEmoji: "输入表情符号" renote: "转发" unrenote: "取消转发" @@ -119,7 +120,7 @@ rememberNoteVisibility: "保存上次设置的可见性" attachCancel: "删除附件" markAsSensitive: "标记为敏感内容" unmarkAsSensitive: "取消标记为敏感内容" -enterFileName: "请输入文件名" +enterFileName: "输入文件名" mute: "屏蔽" unmute: "解除屏蔽" renoteMute: "屏蔽转帖" @@ -142,18 +143,18 @@ customEmojis: "自定义表情符号" emoji: "表情符号" emojis: "表情符号" emojiName: "表情符号名称" -emojiUrl: "表情符号地址" +emojiUrl: "emoji 地址" addEmoji: "添加表情符号" settingGuide: "推荐配置" -cacheRemoteFiles: "远程文件缓存" +cacheRemoteFiles: "缓存远程文件" cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" flagAsBot: "这是一个机器人账号" -flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此帐户识别为机器人。" +flagAsBotDescription: "如果此账户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此账户识别为机器人。" flagAsCat: "将这个账户设定为一只猫" flagAsCatDescription: "如果您想表明此帐户是一只猫,请打开此标志。\n开启后,会在您的头像上出现猫耳朵,并将你的帖子中的「na」替换为「nya」,日文同理。" flagShowTimelineReplies: "在时间线上显示帖子的回复" flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的帖子外,还会显示其他用户对帖子的回复。" -autoAcceptFollowed: "自动允许关注者的关注" +autoAcceptFollowed: "自动允许来自我关注的用户对我的关注请求" addAccount: "添加账户" reloadAccountsList: "更新账户列表" loginFailed: "登录失败" @@ -203,7 +204,7 @@ blockedInstances: "被阻拦的服务器" blockedInstancesDescription: "设定要阻拦的服务器,以换行来进行分割。被阻拦的服务器将无法与本服务器进行交换通讯。" muteAndBlock: "屏蔽/拉黑" mutedUsers: "已屏蔽用户" -blockedUsers: "被拉黑的用户" +blockedUsers: "已拉黑的用户" noUsers: "无用户" editProfile: "编辑资料" noteDeleteConfirm: "要删除该帖子吗?" @@ -336,7 +337,7 @@ enableLocalTimeline: "启用本地时间线功能" enableGlobalTimeline: "启用全局时间线" disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和数据图表也可以继续使用。" registration: "注册" -enableRegistration: "允许新用户注册" +enableRegistration: "允许任何人注册" invite: "邀请" driveCapacityPerLocalAccount: "每个用户的网盘空间" driveCapacityPerRemoteAccount: "每个远程用户的网盘容量" @@ -354,7 +355,7 @@ pinnedNotes: "已置顶的帖子" hcaptcha: "hCaptcha" enableHcaptcha: "启用 hCaptcha" hcaptchaSiteKey: "网站密钥" -hcaptchaSecretKey: "hCaptcha 密钥(SecretKey)" +hcaptchaSecretKey: "密钥" recaptcha: "reCAPTCHA" enableRecaptcha: "启用 reCAPTCHA\n(请注意, 此功能在中国大陆不可用. 如果启用, 可能导致无法正常使用登录或注册等功能)" recaptchaSiteKey: "网站密钥" @@ -506,6 +507,7 @@ objectStorageUseSSLDesc: "如果不使用https进行API连接,请关闭。" objectStorageUseProxy: "使用代理" objectStorageUseProxyDesc: "如果您不使用代理进行API连接,请将其关闭。" objectStorageSetPublicRead: "上传时设置为public-read" +s3ForcePathStyleDesc: "启用 s3ForcePathStyle 会强制将存储桶名称指定为 URL 中路径的一部分,而不是主机名。使用自托管 Minio 等时可能需要启用。" serverLogs: "服务器日志" deleteAll: "全部删除" showFixedPostForm: "在时间线顶部显示发帖框" @@ -919,6 +921,7 @@ pushNotificationNotSupported: "浏览器或服务器不支持推送通知消息" sendPushNotificationReadMessage: "删除已读推送通知消息" sendPushNotificationReadMessageCaption: "“{emptyPushNotificationMessage}”的通知消息将会显示。您终端设备的电池消耗可能会增加。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "还原" caption: "标题" loggedInAsBot: "以Bot账户登录" @@ -960,7 +963,9 @@ copyErrorInfo: "复制错误信息" joinThisServer: "在本服务器上注册" exploreOtherServers: "探索其他服务器" letsLookAtTimeline: "时间线" -disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。" +disableFederationConfirm: "确定要禁用联合?" +disableFederationConfirmWarn: "禁用联合不会将帖子设为私有。在大多数情况下,不需要禁用联合。" +disableFederationOk: "联合禁用" invitationRequiredToRegister: "此服务器目前只允许拥有邀请码的人注册。" emailNotSupported: "此服务器不支持发送邮件" postToTheChannel: "发布到频道" @@ -983,6 +988,19 @@ retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加" enableChartsForRemoteUser: "生成远程用户的图表" enableChartsForFederatedInstances: "生成远程服务器的图表" showClipButtonInNoteFooter: "在贴文下方显示便签按钮" +largeNoteReactions: "使用大图标来显示回应" +noteIdOrUrl: "帖子ID或URL" +accountMigration: "账户迁移" +accountMoved: "此用户已迁移账户" +forceShowAds: "总是显示广告" +_accountMigration: + moveTo: "把这个账户迁移到新的账户" + moveToLabel: "迁移后的账户" + moveAccountDescription: "此操作无法取消。请先确认您已在迁移后的账户上,为此账户创造了别名。创造别名后,请如以下输入您的迁移后的账户:@person@instance.com" + moveFrom: "从别的账号迁移到此账户" + moveFromLabel: "迁移前的账户" + moveFromDescription: "如果迁移时需要继承其他账户的关注者,请在此创造别名。此操作需要在实行迁移之前完成!请如已下输入需要迁移的账户:@person@instance.com" + migrationConfirm: "确定要把此账户迁移到{account}吗?一旦确定后,此操作无法取消,此账户也无法以原来的状态使用。\n同时,请确认迁移后的账户,已创造别名。" _achievements: earnedAt: "达成时间" _types: @@ -1255,7 +1273,7 @@ _role: gtlAvailable: "查看全局时间线" ltlAvailable: "查看本地时间线" canPublicNote: "允许公开发帖" - canInvite: "发放实例邀请码" + canInvite: "发放服务器邀请码" canManageCustomEmojis: "管理自定义表情符号" driveCapacity: "网盘容量" pinMax: "帖子置顶数量限制" @@ -1390,6 +1408,8 @@ _channel: following: "正在关注" usersCount: "有{n}人参与" notesCount: "有{n}个帖子" + nameAndDescription: "名称与描述" + nameOnly: "仅名称" _menuDisplay: sideFull: "横向" sideIcon: "横向(图标)" @@ -1629,7 +1649,7 @@ _widgets: photos: "照片" digitalClock: "数字时钟" unixClock: "UNIX时钟" - federation: "联邦宇宙" + federation: "联合" instanceCloud: "服务器云" postForm: "投稿窗口" slideshow: "幻灯片展示" @@ -1870,6 +1890,7 @@ _deck: channel: "频道" mentions: "提及" direct: "指定用户" + roleTimeline: "角色时间线" _dialog: charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" @@ -1893,4 +1914,3 @@ _webhookSettings: renote: "被转发时" reaction: "被回应时" mention: "被提及时" - diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index c0dcda583..09253db2d 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -20,6 +20,7 @@ noNotes: "無貼文。" noNotifications: "沒有通知" instance: "實例" settings: "設定" +notificationSettings: "通知選項" basicSettings: "基本設定" otherSettings: "其他設定" openInWindow: "在新視窗開啟" @@ -506,6 +507,7 @@ objectStorageUseSSLDesc: "如果不使用https進行API連接,請關閉" objectStorageUseProxy: "使用網路代理" objectStorageUseProxyDesc: "如果不使用代理進行API連接,請關閉" objectStorageSetPublicRead: "上傳時設定為\"public-read\"" +s3ForcePathStyleDesc: "啟用 s3ForcePathStyle 會強制將儲存槽名稱指定為 URL 中路徑的一部分,而不是主機名。 使用自託管 Minio 之類的可能需要啟用。" serverLogs: "伺服器日誌" deleteAll: "刪除所有記錄" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" @@ -560,7 +562,7 @@ inboxUrl: "收件夾URL" addedRelays: "已加入的中繼" serviceworkerInfo: "您需要啟用推送通知" deletedNote: "已删除的貼文" -invisibleNote: "隱藏的貼文" +invisibleNote: "私密的貼文" enableInfiniteScroll: "啟用自動滾動頁面模式" visibility: "可見性" poll: "投票" @@ -919,6 +921,7 @@ pushNotificationNotSupported: "瀏覽器或實例不支援推播通知" sendPushNotificationReadMessage: "通知與訊息如果已讀的話,就將推播通知刪除" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會增加設備的電池消耗。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "復原" caption: "標題" loggedInAsBot: "以機器人帳戶登入中" @@ -960,7 +963,9 @@ copyErrorInfo: "複製錯誤資訊" joinThisServer: "在此伺服器上註冊" exploreOtherServers: "探索其他伺服器" letsLookAtTimeline: "看看時間軸" -disableFederationWarn: "聯邦被停用了。即使停用也不會讓您的貼文不公開,在大多數情況下,不需要啟用這個選項。" +disableFederationConfirm: "要停止聯邦功能嗎?" +disableFederationConfirmWarn: "即使停止了聯邦功能,貼文也不會變成私密的。在大部分的情況下,沒有必要停止聯邦功能。" +disableFederationOk: "停止聯邦功能" invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" emailNotSupported: "這個伺服器不支援寄送郵件" postToTheChannel: "發布到頻道" @@ -983,6 +988,19 @@ retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" enableChartsForRemoteUser: "生成遠端用戶的圖表" enableChartsForFederatedInstances: "生成遠端伺服器的圖表" showClipButtonInNoteFooter: "將摘錄添加至貼文" +largeNoteReactions: "將貼文的反應放大顯示" +noteIdOrUrl: "貼文ID或URL" +accountMigration: "遷移帳戶" +accountMoved: "這個使用者已遷移至新的帳戶:" +forceShowAds: "總是顯示廣告" +_accountMigration: + moveTo: "將這個帳戶遷移至新的帳戶" + moveToLabel: "要遷移到的帳戶:" + moveAccountDescription: "這個操作不可撤銷。首先,請確認已在要遷移到的帳戶中為這個帳戶建立了一個別名。建立別名之後,像這樣輸入你要遷移到的帳戶:@person@instance.com" + moveFrom: "從其他帳戶遷移到這個帳戶" + moveFromLabel: "要遷移過來的帳戶:" + moveFromDescription: "如果你想把跟隨者從別的帳戶遷移過來,必須先在這裡建立別名。請務必在執行遷移之前建立別名!請像這樣輸入要遷移的帳戶:@person@instance.com" + migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。" _achievements: earnedAt: "獲得日期" _types: @@ -1390,6 +1408,8 @@ _channel: following: "關注中" usersCount: "有{n}人參與" notesCount: "有{n}個貼文" + nameAndDescription: "名稱與說明" + nameOnly: "僅名稱" _menuDisplay: sideFull: "側向" sideIcon: "側向(圖示)" @@ -1870,6 +1890,7 @@ _deck: channel: "頻道" mentions: "提及" direct: "指定使用者" + roleTimeline: "角色時間軸" _dialog: charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}" charactersBelow: "低於最少字數!現在 {current} / 限制 {max}" @@ -1893,4 +1914,3 @@ _webhookSettings: renote: "當被轉發時" reaction: "當獲得反應時" mention: "當被提到時" - diff --git a/package.json b/package.json index 8506d8aa2..3dd67f085 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "13.10.3", + "version": "13.11.3", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@8.1.0", + "packageManager": "pnpm@8.3.1", "workspaces": [ "packages/frontend", "packages/backend", @@ -16,6 +16,7 @@ "scripts": { "build-pre": "node ./scripts/build-pre.js", "build": "pnpm build-pre && pnpm -r build && pnpm gulp", + "build-storybook": "pnpm --filter frontend build-storybook", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/index.js", "start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/index.js", "init": "pnpm migrate", @@ -50,19 +51,19 @@ "gulp-replace": "1.1.4", "gulp-terser": "2.1.0", "js-yaml": "4.1.0", - "typescript": "5.0.2" + "typescript": "5.0.4" }, "devDependencies": { "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", - "@typescript-eslint/eslint-plugin": "5.57.0", - "@typescript-eslint/parser": "5.57.0", + "@typescript-eslint/eslint-plugin": "5.59.0", + "@typescript-eslint/parser": "5.59.0", "cross-env": "7.0.3", - "cypress": "12.9.0", - "eslint": "8.37.0", + "cypress": "12.10.0", + "eslint": "8.38.0", "start-server-and-test": "2.0.0" }, "optionalDependencies": { - "@tensorflow/tfjs-core": "4.2.0" + "@tensorflow/tfjs-core": "4.4.0" } } diff --git a/packages/backend/assets/tabler-badges/medal.png b/packages/backend/assets/tabler-badges/medal.png new file mode 100644 index 000000000..44dc7b8c1 Binary files /dev/null and b/packages/backend/assets/tabler-badges/medal.png differ diff --git a/packages/backend/check_connect.js b/packages/backend/check_connect.js index ed429c025..ef0a350fb 100644 --- a/packages/backend/check_connect.js +++ b/packages/backend/check_connect.js @@ -1,8 +1,15 @@ +import Redis from 'ioredis'; import { loadConfig } from './built/config.js'; -import { createRedisConnection } from './built/redis.js'; const config = loadConfig(); -const redis = createRedisConnection(config); +const redis = new Redis({ + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:`, + db: config.redis.db ?? 0, +}); redis.on('connect', () => redis.disconnect()); redis.on('error', (e) => { diff --git a/packages/backend/migration/1680702787050-UserMemo.js b/packages/backend/migration/1680702787050-UserMemo.js new file mode 100644 index 000000000..7446bf8da --- /dev/null +++ b/packages/backend/migration/1680702787050-UserMemo.js @@ -0,0 +1,18 @@ +export class UserMemo1680702787050 { + name = 'UserMemo1680702787050' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "user_memo" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "targetUserId" character varying(32) NOT NULL, "memo" character varying(2048) NOT NULL, CONSTRAINT "PK_e9aaa58f7d3699a84d79078f4d9" PRIMARY KEY ("id")); COMMENT ON COLUMN "user_memo"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "user_memo"."targetUserId" IS 'The ID of target user.'; COMMENT ON COLUMN "user_memo"."memo" IS 'Memo.'`); + await queryRunner.query(`CREATE INDEX "IDX_650b49c5639b5840ee6a2b8f83" ON "user_memo" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_66ac4a82894297fd09ba61f3d3" ON "user_memo" ("targetUserId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_faef300913c738265638ba3ebc" ON "user_memo" ("userId", "targetUserId") `); + await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_memo" ADD CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_66ac4a82894297fd09ba61f3d35"`); + await queryRunner.query(`ALTER TABLE "user_memo" DROP CONSTRAINT "FK_650b49c5639b5840ee6a2b8f83e"`); + await queryRunner.query(`DROP TABLE "user_memo"`); + } +} diff --git a/packages/backend/migration/1680775031481-avatar-url-and-banner-url.js b/packages/backend/migration/1680775031481-avatar-url-and-banner-url.js new file mode 100644 index 000000000..7c5fe7ac5 --- /dev/null +++ b/packages/backend/migration/1680775031481-avatar-url-and-banner-url.js @@ -0,0 +1,17 @@ +export class AvatarUrlAndBannerUrl1680775031481 { + name = 'AvatarUrlAndBannerUrl1680775031481' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "avatarUrl" character varying(512)`); + await queryRunner.query(`ALTER TABLE "user" ADD "bannerUrl" character varying(512)`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`); + await queryRunner.query(`ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerUrl"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarUrl"`); + } +} diff --git a/packages/backend/migration/1680931179228-account-move.js b/packages/backend/migration/1680931179228-account-move.js new file mode 100644 index 000000000..821318d1b --- /dev/null +++ b/packages/backend/migration/1680931179228-account-move.js @@ -0,0 +1,17 @@ +export class AccountMove1680931179228 { + name = 'AccountMove1680931179228' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedToUri" character varying(512)`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`); + await queryRunner.query(`ALTER TABLE "user" ADD "alsoKnownAs" text`); + await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAs"`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToUri"`); + } +} diff --git a/packages/backend/migration/1681400427971-serverRules.js b/packages/backend/migration/1681400427971-serverRules.js new file mode 100644 index 000000000..2364e8e1d --- /dev/null +++ b/packages/backend/migration/1681400427971-serverRules.js @@ -0,0 +1,11 @@ +export class ServerRules1681400427971 { + name = 'ServerRules1681400427971' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "serverRules" character varying(280) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "serverRules"`); + } +} diff --git a/packages/backend/migration/1681870960239-RoleTLSetting.js b/packages/backend/migration/1681870960239-RoleTLSetting.js new file mode 100644 index 000000000..2280f44ea --- /dev/null +++ b/packages/backend/migration/1681870960239-RoleTLSetting.js @@ -0,0 +1,12 @@ +export class RoleTLSetting1681870960239 { + name = 'RoleTLSetting1681870960239' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "isExplorable" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "isExplorable"`); + } + +} diff --git a/packages/backend/migration/1682190963894-movedAt.js b/packages/backend/migration/1682190963894-movedAt.js new file mode 100644 index 000000000..1f8f030a5 --- /dev/null +++ b/packages/backend/migration/1682190963894-movedAt.js @@ -0,0 +1,13 @@ +export class MovedAt1682190963894 { + name = 'MovedAt1682190963894' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "movedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."movedAt" IS 'When the user moved to another account'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedAt"`); + } +} diff --git a/packages/backend/migration/1682754135458-preservedUsernames.js b/packages/backend/migration/1682754135458-preservedUsernames.js new file mode 100644 index 000000000..46a0826f4 --- /dev/null +++ b/packages/backend/migration/1682754135458-preservedUsernames.js @@ -0,0 +1,11 @@ +export class PreservedUsernames1682754135458 { + name = 'PreservedUsernames1682754135458' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "preservedUsernames" character varying(1024) array NOT NULL DEFAULT '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "preservedUsernames"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 4666563dc..f11511075 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,42 +22,42 @@ "test-and-coverage": "pnpm jest-and-coverage" }, "optionalDependencies": { - "@swc/core-android-arm64": "^1.3.11", - "@swc/core-darwin-arm64": "^1.3.42", - "@swc/core-darwin-x64": "^1.3.42", - "@swc/core-linux-arm-gnueabihf": "^1.3.42", - "@swc/core-linux-arm64-gnu": "^1.3.42", - "@swc/core-linux-arm64-musl": "^1.3.42", - "@swc/core-linux-x64-gnu": "^1.3.42", - "@swc/core-linux-x64-musl": "^1.3.42", - "@swc/core-win32-arm64-msvc": "^1.3.42", - "@swc/core-win32-ia32-msvc": "^1.3.42", - "@swc/core-win32-x64-msvc": "^1.3.42", - "@tensorflow/tfjs": "4.2.0", - "@tensorflow/tfjs-node": "4.2.0" + "@swc/core-android-arm64": "1.3.11", + "@swc/core-darwin-arm64": "1.3.51", + "@swc/core-darwin-x64": "1.3.51", + "@swc/core-linux-arm-gnueabihf": "1.3.51", + "@swc/core-linux-arm64-gnu": "1.3.51", + "@swc/core-linux-arm64-musl": "1.3.51", + "@swc/core-linux-x64-gnu": "1.3.51", + "@swc/core-linux-x64-musl": "1.3.51", + "@swc/core-win32-arm64-msvc": "1.3.51", + "@swc/core-win32-ia32-msvc": "1.3.51", + "@swc/core-win32-x64-msvc": "1.3.51", + "@tensorflow/tfjs": "4.4.0", + "@tensorflow/tfjs-node": "4.4.0" }, "dependencies": { - "@aws-sdk/client-s3": "3.301.0", - "@aws-sdk/lib-storage": "3.301.0", - "@aws-sdk/node-http-handler": "3.296.0", - "@bull-board/api": "5.0.0", - "@bull-board/fastify": "5.0.0", - "@bull-board/ui": "5.0.0", + "@aws-sdk/client-s3": "3.315.0", + "@aws-sdk/lib-storage": "3.315.0", + "@aws-sdk/node-http-handler": "3.310.0", + "@bull-board/api": "5.0.1", + "@bull-board/fastify": "5.0.1", + "@bull-board/ui": "5.0.1", "@discordapp/twemoji": "14.1.2", "@fastify/accepts": "4.1.0", "@fastify/cookie": "8.3.0", "@fastify/cors": "8.2.1", "@fastify/http-proxy": "9.0.0", - "@fastify/multipart": "7.5.0", - "@fastify/static": "6.9.0", + "@fastify/multipart": "7.6.0", + "@fastify/static": "6.10.1", "@fastify/view": "7.4.1", - "@nestjs/common": "9.3.12", - "@nestjs/core": "9.3.12", - "@nestjs/testing": "9.3.12", + "@nestjs/common": "9.4.0", + "@nestjs/core": "9.4.0", + "@nestjs/testing": "9.4.0", "@peertube/http-signature": "1.7.0", "@sinonjs/fake-timers": "10.0.2", "@swc/cli": "0.1.62", - "@swc/core": "1.3.42", + "@swc/core": "1.3.51", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "5.3.1", @@ -82,16 +82,16 @@ "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", "got": "12.6.0", - "happy-dom": "8.9.0", + "happy-dom": "9.8.2", "hpagent": "1.2.0", - "ioredis": "4.28.5", + "ioredis": "5.3.2", "ip-cidr": "3.1.0", "is-svg": "4.3.2", "js-yaml": "4.1.0", "jsdom": "21.1.1", "json5": "2.2.3", "jsonld": "8.1.1", - "jsrsasign": "10.7.0", + "jsrsasign": "10.8.2", "mfm-js": "0.23.3", "mime-types": "2.1.35", "misskey-js": "workspace:*", @@ -119,7 +119,7 @@ "reflect-metadata": "0.1.13", "rename": "1.0.4", "rndstr": "1.0.0", - "rss-parser": "3.12.0", + "rss-parser": "3.13.0", "rxjs": "7.8.0", "s-age": "1.1.2", "sanitize-html": "2.10.0", @@ -136,8 +136,8 @@ "tsc-alias": "1.8.5", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typeorm": "0.3.11", - "typescript": "5.0.2", + "typeorm": "0.3.15", + "typescript": "5.0.4", "ulid": "2.3.0", "unzipper": "0.10.11", "uuid": "9.0.0", @@ -149,7 +149,7 @@ }, "devDependencies": { "@jest/globals": "29.5.0", - "@swc/jest": "0.2.24", + "@swc/jest": "0.2.26", "@types/accepts": "1.3.5", "@types/archiver": "5.3.2", "@types/bcryptjs": "2.4.2", @@ -159,7 +159,6 @@ "@types/content-disposition": "0.5.5", "@types/escape-regexp": "0.0.1", "@types/fluent-ffmpeg": "2.1.21", - "@types/ioredis": "4.28.10", "@types/jest": "29.5.0", "@types/js-yaml": "4.0.5", "@types/jsdom": "21.1.1", @@ -190,11 +189,11 @@ "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.57.0", - "@typescript-eslint/parser": "5.57.0", + "@typescript-eslint/eslint-plugin": "5.59.0", + "@typescript-eslint/parser": "5.59.0", "aws-sdk-client-mock": "^2.1.1", "cross-env": "7.0.3", - "eslint": "8.37.0", + "eslint": "8.38.0", "eslint-plugin-import": "2.27.5", "execa": "6.1.0", "jest": "29.5.0", diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 801f1db74..4574429c4 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -1,19 +1,16 @@ import { setTimeout } from 'node:timers/promises'; import { Global, Inject, Module } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; -import { createRedisConnection } from '@/redis.js'; import { DI } from './di-symbols.js'; import { loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; import { RepositoryModule } from './models/RepositoryModule.js'; import type { Provider, OnApplicationShutdown } from '@nestjs/common'; -const config = loadConfig(); - const $config: Provider = { provide: DI.config, - useValue: config, + useValue: loadConfig(), }; const $db: Provider = { @@ -28,18 +25,47 @@ const $db: Provider = { const $redis: Provider = { provide: DI.redis, useFactory: (config) => { - const redisClient = createRedisConnection(config); - return redisClient; + return new Redis.Redis({ + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:`, + db: config.redis.db ?? 0, + }); }, inject: [DI.config], }; -const $redisSubscriber: Provider = { - provide: DI.redisSubscriber, +const $redisForPub: Provider = { + provide: DI.redisForPub, useFactory: (config) => { - const redisSubscriber = createRedisConnection(config); - redisSubscriber.subscribe(config.host); - return redisSubscriber; + const redis = new Redis.Redis({ + port: config.redisForPubsub.port, + host: config.redisForPubsub.host, + family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, + password: config.redisForPubsub.pass, + keyPrefix: `${config.redisForPubsub.prefix}:`, + db: config.redisForPubsub.db ?? 0, + }); + return redis; + }, + inject: [DI.config], +}; + +const $redisForSub: Provider = { + provide: DI.redisForSub, + useFactory: (config) => { + const redis = new Redis.Redis({ + port: config.redisForPubsub.port, + host: config.redisForPubsub.host, + family: config.redisForPubsub.family == null ? 0 : config.redisForPubsub.family, + password: config.redisForPubsub.pass, + keyPrefix: `${config.redisForPubsub.prefix}:`, + db: config.redisForPubsub.db ?? 0, + }); + redis.subscribe(config.host); + return redis; }, inject: [DI.config], }; @@ -47,14 +73,15 @@ const $redisSubscriber: Provider = { @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $redis, $redisSubscriber], - exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule], + providers: [$config, $db, $redis, $redisForPub, $redisForSub], + exports: [$config, $db, $redis, $redisForPub, $redisForSub, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( @Inject(DI.db) private db: DataSource, @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.redisSubscriber) private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPub) private redisForPub: Redis.Redis, + @Inject(DI.redisForSub) private redisForSub: Redis.Redis, ) {} async onApplicationShutdown(signal: string): Promise { @@ -69,7 +96,8 @@ export class GlobalModule implements OnApplicationShutdown { await Promise.all([ this.db.destroy(), this.redisClient.disconnect(), - this.redisSubscriber.disconnect(), + this.redisForPub.disconnect(), + this.redisForSub.disconnect(), ]); } } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 73f45e92e..4499475ee 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; /** @@ -25,6 +25,14 @@ export type Source = { disableCache?: boolean; extra?: { [x: string]: string }; }; + dbReplications?: boolean; + dbSlaves?: { + host: string; + port: number; + db: string; + user: string; + pass: string; + }[]; redis: { host: string; port: number; @@ -33,6 +41,22 @@ export type Source = { db?: number; prefix?: string; }; + redisForPubsub?: { + host: string; + port: number; + family?: number; + pass: string; + db?: number; + prefix?: string; + }; + redisForJobQueue?: { + host: string; + port: number; + family?: number; + pass: string; + db?: number; + prefix?: string; + }; elasticsearch: { host: string; port: number; @@ -60,8 +84,10 @@ export type Source = { deliverJobConcurrency?: number; inboxJobConcurrency?: number; + relashionshipJobConcurrency?: number; deliverJobPerSec?: number; inboxJobPerSec?: number; + relashionshipJobPerSec?: number; deliverJobMaxAttempts?: number; inboxJobMaxAttempts?: number; @@ -91,6 +117,8 @@ export type Mixin = { mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; + redisForPubsub: NonNullable; + redisForJobQueue: NonNullable; }; export type Config = Source & Mixin; @@ -106,10 +134,11 @@ const dir = `${_dirname}/../../../.config`; /** * Path of configuration file */ -const path = process.env.NODE_ENV === 'test' - ? `${dir}/test.yml` - : `${dir}/default.yml`; - +const path = process.env.MISSKEY_CONFIG_YML + ? resolve(dir, process.env.MISSKEY_CONFIG_YML) + : process.env.NODE_ENV === 'test' + ? resolve(dir, 'test.yml') + : resolve(dir, 'default.yml'); export function loadConfig() { const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json'); @@ -151,6 +180,8 @@ export function loadConfig() { : null; if (!config.redis.prefix) config.redis.prefix = mixin.host; + if (config.redisForPubsub == null) config.redisForPubsub = config.redis; + if (config.redisForJobQueue == null) config.redisForJobQueue = config.redis; return Object.assign(config, mixin); } diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index 6c7f21421..ee1a9a309 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -56,6 +56,11 @@ export const FILE_TYPE_BROWSERSAFE = [ 'audio/webm', 'audio/aac', + + // see https://github.com/misskey-dev/misskey/pull/10686 + 'audio/flac', + 'audio/wav', + // backward compatibility 'audio/x-flac', 'audio/vnd.wave', ]; diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts new file mode 100644 index 000000000..ab11785e2 --- /dev/null +++ b/packages/backend/src/core/AccountMoveService.ts @@ -0,0 +1,347 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull, In, MoreThan, Not } from 'typeorm'; + +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, Muting, MutingsRepository, UserListJoiningsRepository, UsersRepository } from '@/models/index.js'; +import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; +import type { User } from '@/models/entities/User.js'; + +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { RelayService } from '@/core/RelayService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { ProxyAccountService } from '@/core/ProxyAccountService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; +import { MetaService } from '@/core/MetaService.js'; +import InstanceChart from '@/core/chart/charts/instance.js'; +import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; + +@Injectable() +export class AccountMoveService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.userListJoiningsRepository) + private userListJoiningsRepository: UserListJoiningsRepository, + + @Inject(DI.instancesRepository) + private instancesRepository: InstancesRepository, + + private userEntityService: UserEntityService, + private idService: IdService, + private apPersonService: ApPersonService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private globalEventService: GlobalEventService, + private proxyAccountService: ProxyAccountService, + private perUserFollowingChart: PerUserFollowingChart, + private federatedInstanceService: FederatedInstanceService, + private instanceChart: InstanceChart, + private metaService: MetaService, + private relayService: RelayService, + private cacheService: CacheService, + private queueService: QueueService, + ) { + } + + /** + * Move a local account to a new account. + * + * After delivering Move activity, its local followers unfollow the old account and then follow the new one. + */ + @bindThis + public async moveFromLocal(src: LocalUser, dst: LocalUser | RemoteUser): Promise { + const srcUri = this.userEntityService.getUserUri(src); + const dstUri = this.userEntityService.getUserUri(dst); + + // add movedToUri to indicate that the user has moved + const update = {} as Partial; + update.alsoKnownAs = src.alsoKnownAs?.includes(dstUri) ? src.alsoKnownAs : src.alsoKnownAs?.concat([dstUri]) ?? [dstUri]; + update.movedToUri = dstUri; + update.movedAt = new Date(); + await this.usersRepository.update(src.id, update); + Object.assign(src, update); + + // Update cache + this.cacheService.uriPersonCache.set(srcUri, src); + + const srcPerson = await this.apRendererService.renderPerson(src); + const updateAct = this.apRendererService.addContext(this.apRendererService.renderUpdate(srcPerson, src)); + await this.apDeliverManagerService.deliverToFollowers(src, updateAct); + this.relayService.deliverToRelays(src, updateAct); + + // Deliver Move activity to the followers of the old account + const moveAct = this.apRendererService.addContext(this.apRendererService.renderMove(src, dst)); + await this.apDeliverManagerService.deliverToFollowers(src, moveAct); + + // Publish meUpdated event + const iObj = await this.userEntityService.pack(src.id, src, { detail: true, includeSecrets: true }); + this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj); + + // Unfollow after 24 hours + const followings = await this.followingsRepository.findBy({ + followerId: src.id, + }); + this.queueService.createDelayedUnfollowJob(followings.map(following => ({ + from: { id: src.id }, + to: { id: following.followeeId }, + })), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24); + + await this.postMoveProcess(src, dst); + + return iObj; + } + + @bindThis + public async postMoveProcess(src: User, dst: User): Promise { + // Copy blockings and mutings, and update lists + try { + await Promise.all([ + this.copyBlocking(src, dst), + this.copyMutings(src, dst), + this.updateLists(src, dst), + ]); + } catch { + /* skip if any error happens */ + } + + // follow the new account + const proxy = await this.proxyAccountService.fetch(); + const followings = await this.followingsRepository.findBy({ + followeeId: src.id, + followerHost: IsNull(), // follower is local + followerId: proxy ? Not(proxy.id) : undefined, + }); + const followJobs = followings.map(following => ({ + from: { id: following.followerId }, + to: { id: dst.id }, + })) as RelationshipJobData[]; + + // Decrease following count instead of unfollowing. + try { + await this.adjustFollowingCounts(followJobs.map(job => job.from.id), src); + } catch { + /* skip if any error happens */ + } + + // Should be queued because this can cause a number of follow per one move. + this.queueService.createFollowJob(followJobs); + } + + @bindThis + public async copyBlocking(src: ThinUser, dst: ThinUser): Promise { + // Followers shouldn't overlap with blockers, but the destination account, different from the blockee (i.e., old account), may have followed the local user before moving. + // So block the destination account here. + const srcBlockings = await this.blockingsRepository.findBy({ blockeeId: src.id }); + const dstBlockings = await this.blockingsRepository.findBy({ blockeeId: dst.id }); + const blockerIds = dstBlockings.map(blocking => blocking.blockerId); + // reblock the destination account + const blockJobs: RelationshipJobData[] = []; + for (const blocking of srcBlockings) { + if (blockerIds.includes(blocking.blockerId)) continue; // skip if already blocked + blockJobs.push({ from: { id: blocking.blockerId }, to: { id: dst.id } }); + } + // no need to unblock the old account because it may be still functional + this.queueService.createBlockJob(blockJobs); + } + + @bindThis + public async copyMutings(src: ThinUser, dst: ThinUser): Promise { + // Insert new mutings with the same values except mutee + const oldMutings = await this.mutingsRepository.findBy([ + { muteeId: src.id, expiresAt: IsNull() }, + { muteeId: src.id, expiresAt: MoreThan(new Date()) }, + ]); + if (oldMutings.length === 0) return; + + // Check if the destination account is already indefinitely muted by the muter + const existingMutingsMuterUserIds = await this.mutingsRepository.findBy( + { muteeId: dst.id, expiresAt: IsNull() }, + ).then(mutings => mutings.map(muting => muting.muterId)); + + const newMutings: Map = new Map(); + + // 重複しないようにIDを生成 + const genId = (): string => { + let id: string; + do { + id = this.idService.genId(); + } while (newMutings.has(id)); + return id; + }; + for (const muting of oldMutings) { + if (existingMutingsMuterUserIds.includes(muting.muterId)) continue; // skip if already muted indefinitely + newMutings.set(genId(), { + ...muting, + createdAt: new Date(), + muteeId: dst.id, + }); + } + + const arrayToInsert = Array.from(newMutings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.mutingsRepository.insert(arrayToInsert); + } + + /** + * Update lists while moving accounts. + * - No removal of the old account from the lists + * - Users number limit is not checked + * + * @param src ThinUser (old account) + * @param dst User (new account) + * @returns Promise + */ + @bindThis + public async updateLists(src: ThinUser, dst: User): Promise { + // Return if there is no list to be updated. + const oldJoinings = await this.userListJoiningsRepository.find({ + where: { + userId: src.id, + }, + }); + if (oldJoinings.length === 0) return; + + const existingUserListIds = await this.userListJoiningsRepository.find({ + where: { + userId: dst.id, + }, + }).then(joinings => joinings.map(joining => joining.userListId)); + + const newJoinings: Map = new Map(); + + // 重複しないようにIDを生成 + const genId = (): string => { + let id: string; + do { + id = this.idService.genId(); + } while (newJoinings.has(id)); + return id; + }; + for (const joining of oldJoinings) { + if (existingUserListIds.includes(joining.userListId)) continue; // skip if dst exists in this user's list + newJoinings.set(genId(), { + createdAt: new Date(), + userId: dst.id, + userListId: joining.userListId, + }); + } + + const arrayToInsert = Array.from(newJoinings.entries()).map(entry => ({ ...entry[1], id: entry[0] })); + await this.userListJoiningsRepository.insert(arrayToInsert); + + // Have the proxy account follow the new account in the same way as UserListService.push + if (this.userEntityService.isRemoteUser(dst)) { + const proxy = await this.proxyAccountService.fetch(); + if (proxy) { + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]); + } + } + } + + @bindThis + private async adjustFollowingCounts(localFollowerIds: string[], oldAccount: User): Promise { + if (localFollowerIds.length === 0) return; + + // Set the old account's following and followers counts to 0. + await this.usersRepository.update({ id: oldAccount.id }, { followersCount: 0, followingCount: 0 }); + + // Decrease following counts of local followers by 1. + await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1); + + // Decrease follower counts of local followees by 1. + const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id }); + if (oldFollowings.length > 0) { + await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1); + } + + // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account. + if (this.userEntityService.isRemoteUser(oldAccount)) { + this.federatedInstanceService.fetch(oldAccount.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } + + // FIXME: expensive? + for (const followerId of localFollowerIds) { + this.perUserFollowingChart.update({ id: followerId, host: null }, oldAccount, false); + } + } + + /** + * dstユーザーのalsoKnownAsをfetchPersonしていき、本当にmovedToUrlをdstに指定するユーザーが存在するのかを調べる + * + * @param dst movedToUrlを指定するユーザー + * @param check + * @param instant checkがtrueであるユーザーが最初に見つかったら即座にreturnするかどうか + * @returns Promise + */ + @bindThis + public async validateAlsoKnownAs( + dst: LocalUser | RemoteUser, + check: (oldUser: LocalUser | RemoteUser | null, newUser: LocalUser | RemoteUser) => boolean | Promise = () => true, + instant = false, + ): Promise { + let resultUser: LocalUser | RemoteUser | null = null; + + if (this.userEntityService.isRemoteUser(dst)) { + if ((new Date()).getTime() - (dst.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + await this.apPersonService.updatePerson(dst.uri); + } + dst = await this.apPersonService.fetchPerson(dst.uri) ?? dst; + } + + if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) return null; + + const dstUri = this.userEntityService.getUserUri(dst); + + for (const srcUri of dst.alsoKnownAs) { + try { + let src = await this.apPersonService.fetchPerson(srcUri); + if (!src) continue; // oldAccountを探してもこのサーバーに存在しない場合はフォロー関係もないということなのでスルー + + if (this.userEntityService.isRemoteUser(dst)) { + if ((new Date()).getTime() - (src.lastFetchedAt?.getTime() ?? 0) > 10 * 1000) { + await this.apPersonService.updatePerson(srcUri); + } + + src = await this.apPersonService.fetchPerson(srcUri) ?? src; + } + + if (src.movedToUri === dstUri) { + if (await check(resultUser, src)) { + resultUser = src; + } + if (instant && resultUser) return resultUser; + } + } catch { + /* skip if any error happens */ + } + } + + return resultUser; + } +} diff --git a/packages/backend/src/core/AccountUpdateService.ts b/packages/backend/src/core/AccountUpdateService.ts index d8ba7b169..b146fc66b 100644 --- a/packages/backend/src/core/AccountUpdateService.ts +++ b/packages/backend/src/core/AccountUpdateService.ts @@ -29,7 +29,7 @@ export class AccountUpdateService { public async publishToFollowers(userId: User['id']) { const user = await this.usersRepository.findOneBy({ id: userId }); if (user == null) throw new Error('user not found'); - + // フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信 if (this.userEntityService.isLocalUser(user)) { const content = this.apRendererService.addContext(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user)); diff --git a/packages/backend/src/core/AchievementService.ts b/packages/backend/src/core/AchievementService.ts index 1ca38d8bb..9e223f149 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -64,6 +64,7 @@ export const ACHIEVEMENT_TYPES = [ 'iLoveMisskey', 'foundTreasure', 'client30min', + 'client60min', 'noteDeletedWithin1min', 'postedAtLateNight', 'postedAt0min0sec', diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 4bd3f39af..2d4226a32 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { Antenna } from '@/models/entities/Antenna.js'; import type { Note } from '@/models/entities/Note.js'; import type { User } from '@/models/entities/User.js'; @@ -27,8 +27,8 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @@ -52,12 +52,12 @@ export class AntennaService implements OnApplicationShutdown { this.antennasFetched = false; this.antennas = []; - this.redisSubscriber.on('message', this.onRedisMessage); + this.redisForSub.on('message', this.onRedisMessage); } @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onRedisMessage); + this.redisForSub.off('message', this.onRedisMessage); } @bindThis @@ -91,14 +91,24 @@ export class AntennaService implements OnApplicationShutdown { } @bindThis - public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise { - this.redisClient.xadd( - `antennaTimeline:${antenna.id}`, - 'MAXLEN', '~', '200', - `${this.idService.parse(note.id).date.getTime()}-*`, - 'note', note.id); - - this.globalEventService.publishAntennaStream(antenna.id, 'note', note); + public async addNoteToAntennas(note: Note, noteUser: { id: User['id']; username: string; host: string | null; }): Promise { + const antennas = await this.getAntennas(); + const antennasWithMatchResult = await Promise.all(antennas.map(antenna => this.checkHitAntenna(antenna, note, noteUser).then(hit => [antenna, hit] as const))); + const matchedAntennas = antennasWithMatchResult.filter(([, hit]) => hit).map(([antenna]) => antenna); + + const redisPipeline = this.redisClient.pipeline(); + + for (const antenna of matchedAntennas) { + redisPipeline.xadd( + `antennaTimeline:${antenna.id}`, + 'MAXLEN', '~', '200', + '*', + 'note', note.id); + + this.globalEventService.publishAntennaStream(antenna.id, 'note', note); + } + + redisPipeline.exec(); } // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts index ee179b7f0..8dd805552 100644 --- a/packages/backend/src/core/AppLockService.ts +++ b/packages/backend/src/core/AppLockService.ts @@ -1,7 +1,7 @@ import { promisify } from 'node:util'; import { Inject, Injectable } from '@nestjs/common'; import redisLock from 'redis-lock'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index 887baeb2c..cf1e81ffc 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; -import type { UserProfile, UsersRepository } from '@/models/index.js'; +import * as Redis from 'ioredis'; +import type { BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import type { LocalUser, User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; @@ -16,18 +16,41 @@ export class CacheService implements OnApplicationShutdown { public localUserByIdCache: MemoryKVCache; public uriPersonCache: MemoryKVCache; public userProfileCache: RedisKVCache; - public userMutingsCache: RedisKVCache; + public userMutingsCache: RedisKVCache>; + public userBlockingCache: RedisKVCache>; + public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ + public renoteMutingsCache: RedisKVCache>; + public userFollowingsCache: RedisKVCache>; + public userFollowingChannelsCache: RedisKVCache>; constructor( @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + @Inject(DI.channelFollowingsRepository) + private channelFollowingsRepository: ChannelFollowingsRepository, + private userEntityService: UserEntityService, ) { //this.onMessage = this.onMessage.bind(this); @@ -36,10 +59,64 @@ export class CacheService implements OnApplicationShutdown { this.localUserByNativeTokenCache = new MemoryKVCache(Infinity); this.localUserByIdCache = new MemoryKVCache(Infinity); this.uriPersonCache = new MemoryKVCache(Infinity); - this.userProfileCache = new RedisKVCache(this.redisClient, 'userProfile', 1000 * 60 * 60 * 24, 1000 * 60); - this.userMutingsCache = new RedisKVCache(this.redisClient, 'userMutings', 1000 * 60 * 60 * 24, 1000 * 60); - this.redisSubscriber.on('message', this.onMessage); + this.userProfileCache = new RedisKVCache(this.redisClient, 'userProfile', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), // TODO: date型の考慮 + }); + + this.userMutingsCache = new RedisKVCache>(this.redisClient, 'userMutings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userBlockingCache = new RedisKVCache>(this.redisClient, 'userBlocking', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userBlockedCache = new RedisKVCache>(this.redisClient, 'userBlocked', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.renoteMutingsCache = new RedisKVCache>(this.redisClient, 'renoteMutings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userFollowingsCache = new RedisKVCache>(this.redisClient, 'userFollowings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userFollowingChannelsCache = new RedisKVCache>(this.redisClient, 'userFollowingChannels', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.channelFollowingsRepository.find({ where: { followerId: key }, select: ['followeeId'] }).then(xs => new Set(xs.map(x => x.followeeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -90,6 +167,6 @@ export class CacheService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 5c867e6cf..8775536e4 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; import { AntennaService } from './AntennaService.js'; @@ -40,7 +41,7 @@ import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService import { UserBlockingService } from './UserBlockingService.js'; import { CacheService } from './CacheService.js'; import { UserFollowingService } from './UserFollowingService.js'; -import { UserKeypairStoreService } from './UserKeypairStoreService.js'; +import { UserKeypairService } from './UserKeypairService.js'; import { UserListService } from './UserListService.js'; import { UserMutingService } from './UserMutingService.js'; import { UserSuspendService } from './UserSuspendService.js'; @@ -119,6 +120,7 @@ import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; @@ -161,7 +163,7 @@ const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthentic const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService }; const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService }; -const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useExisting: UserKeypairStoreService }; +const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; @@ -242,6 +244,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ], providers: [ LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, @@ -284,7 +287,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserBlockingService, CacheService, UserFollowingService, - UserKeypairStoreService, + UserKeypairService, UserListService, UserMutingService, UserSuspendService, @@ -359,6 +362,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, @@ -401,7 +405,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserBlockingService, $CacheService, $UserFollowingService, - $UserKeypairStoreService, + $UserKeypairService, $UserListService, $UserMutingService, $UserSuspendService, @@ -477,6 +481,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting exports: [ QueueModule, LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, @@ -519,7 +524,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserBlockingService, CacheService, UserFollowingService, - UserKeypairStoreService, + UserKeypairService, UserListService, UserMutingService, UserSuspendService, @@ -593,6 +598,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, @@ -635,7 +641,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserBlockingService, $CacheService, $UserFollowingService, - $UserKeypairStoreService, + $UserKeypairService, $UserListService, $UserMutingService, $UserSuspendService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 1c3b60e5d..93557ce61 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -1,24 +1,29 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource, In, IsNull } from 'typeorm'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { Emoji } from '@/models/entities/Emoji.js'; -import type { EmojisRepository, Note } from '@/models/index.js'; +import type { EmojisRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; -import { MemoryKVCache } from '@/misc/cache.js'; +import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; import type { Config } from '@/config.js'; -import { ReactionService } from '@/core/ReactionService.js'; import { query } from '@/misc/prelude/url.js'; +import type { Serialized } from '@/server/api/stream/types.js'; @Injectable() export class CustomEmojiService { private cache: MemoryKVCache; + public localEmojisCache: RedisSingleCache>; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.config) private config: Config, @@ -32,9 +37,22 @@ export class CustomEmojiService { private idService: IdService, private emojiEntityService: EmojiEntityService, private globalEventService: GlobalEventService, - private reactionService: ReactionService, ) { this.cache = new MemoryKVCache(1000 * 60 * 60 * 12); + + this.localEmojisCache = new RedisSingleCache>(this.redisClient, 'localEmojis', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60 * 3, // 3m + fetcher: () => this.emojisRepository.find({ where: { host: IsNull() } }).then(emojis => new Map(emojis.map(emoji => [emoji.name, emoji]))), + toRedisConverter: (value) => JSON.stringify(Array.from(value.values())), + fromRedisConverter: (value) => { + if (!Array.isArray(JSON.parse(value))) return undefined; // 古いバージョンの壊れたキャッシュが残っていることがある(そのうち消す) + return new Map(JSON.parse(value).map((x: Serialized) => [x.name, { + ...x, + updatedAt: x.updatedAt ? new Date(x.updatedAt) : null, + }])); + }, + }); } @bindThis @@ -60,7 +78,7 @@ export class CustomEmojiService { }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); if (data.host == null) { - await this.db.queryResultCache?.remove(['meta_emojis']); + this.localEmojisCache.refresh(); this.globalEventService.publishBroadcastStream('emojiAdded', { emoji: await this.emojiEntityService.packDetailed(emoji.id), @@ -70,6 +88,162 @@ export class CustomEmojiService { return emoji; } + @bindThis + public async update(id: Emoji['id'], data: { + name?: string; + category?: string | null; + aliases?: string[]; + license?: string | null; + }): Promise { + const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); + const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); + if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); + + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + name: data.name, + category: data.category, + aliases: data.aliases, + license: data.license, + }); + + this.localEmojisCache.refresh(); + + const updated = await this.emojiEntityService.packDetailed(emoji.id); + + if (emoji.name === data.name) { + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: [updated], + }); + } else { + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: [await this.emojiEntityService.packDetailed(emoji)], + }); + + this.globalEventService.publishBroadcastStream('emojiAdded', { + emoji: updated, + }); + } + } + + @bindThis + public async addAliasesBulk(ids: Emoji['id'][], aliases: string[]) { + const emojis = await this.emojisRepository.findBy({ + id: In(ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + aliases: [...new Set(emoji.aliases.concat(aliases))], + }); + } + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async setAliasesBulk(ids: Emoji['id'][], aliases: string[]) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + aliases: aliases, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async removeAliasesBulk(ids: Emoji['id'][], aliases: string[]) { + const emojis = await this.emojisRepository.findBy({ + id: In(ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.update(emoji.id, { + updatedAt: new Date(), + aliases: emoji.aliases.filter(x => !aliases.includes(x)), + }); + } + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async setCategoryBulk(ids: Emoji['id'][], category: string | null) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + category: category, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async setLicenseBulk(ids: Emoji['id'][], license: string | null) { + await this.emojisRepository.update({ + id: In(ids), + }, { + updatedAt: new Date(), + license: license, + }); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiUpdated', { + emojis: await this.emojiEntityService.packDetailedMany(ids), + }); + } + + @bindThis + public async delete(id: Emoji['id']) { + const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); + + await this.emojisRepository.delete(emoji.id); + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: [await this.emojiEntityService.packDetailed(emoji)], + }); + } + + @bindThis + public async deleteBulk(ids: Emoji['id'][]) { + const emojis = await this.emojisRepository.findBy({ + id: In(ids), + }); + + for (const emoji of emojis) { + await this.emojisRepository.delete(emoji.id); + } + + this.localEmojisCache.refresh(); + + this.globalEventService.publishBroadcastStream('emojiDeleted', { + emojis: await this.emojiEntityService.packDetailedMany(emojis), + }); + } + @bindThis private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { // クエリに使うホスト @@ -84,7 +258,7 @@ export class CustomEmojiService { } @bindThis - private parseEmojiStr(emojiName: string, noteUserHost: string | null) { + public parseEmojiStr(emojiName: string, noteUserHost: string | null) { const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/); if (!match) return { name: null, host: null }; @@ -116,16 +290,7 @@ export class CustomEmojiService { const emoji = await this.cache.fetch(`${name} ${host}`, queryOrNull); if (emoji == null) return null; - - const isLocal = emoji.host == null; - const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - const url = isLocal - ? emojiUrl - : this.config.proxyRemoteFiles - ? `${this.config.mediaProxy}/emoji.webp?${query({ url: emojiUrl })}` - : emojiUrl; - - return url; + return emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) } /** @@ -143,30 +308,6 @@ export class CustomEmojiService { return res; } - @bindThis - public aggregateNoteEmojis(notes: Note[]) { - let emojis: { name: string | null; host: string | null; }[] = []; - for (const note of notes) { - emojis = emojis.concat(note.emojis - .map(e => this.parseEmojiStr(e, note.userHost))); - if (note.renote) { - emojis = emojis.concat(note.renote.emojis - .map(e => this.parseEmojiStr(e, note.renote!.userHost))); - if (note.renote.user) { - emojis = emojis.concat(note.renote.user.emojis - .map(e => this.parseEmojiStr(e, note.renote!.userHost))); - } - } - const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; - emojis = emojis.concat(customReactions); - if (note.user) { - emojis = emojis.concat(note.user.emojis - .map(e => this.parseEmojiStr(e, note.userHost))); - } - } - return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; - } - /** * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します */ diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 2acb5f230..327283106 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -36,8 +36,5 @@ export class DeleteAccountService { await this.usersRepository.update(user.id, { isDeleted: true, }); - - // Terminate streaming - this.globalEventService.publishUserEvent(user.id, 'terminate', {}); } } diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index bd999c67d..bd535c603 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -86,9 +86,13 @@ export class DownloadService { const contentDisposition = res.headers['content-disposition']; if (contentDisposition != null) { - const parsed = parse(contentDisposition); - if (parsed.parameters.filename) { - filename = parsed.parameters.filename; + try { + const parsed = parse(contentDisposition); + if (parsed.parameters.filename) { + filename = parsed.parameters.filename; + } + } catch (e) { + this.logger.warn(`Failed to parse content-disposition: ${contentDisposition}`, { stack: e }); } } }).on('downloadProgress', (progress: Got.Progress) => { diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index c6258474e..7f66f1137 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -59,6 +59,8 @@ type AddFileArgs = { uri?: string | null; /** Mark file as sensitive */ sensitive?: boolean | null; + /** Extension to force */ + ext?: string | null; requestIp?: string | null; requestHeaders?: Record | null; @@ -125,7 +127,7 @@ export class DriveService { /*** * Save file * @param path Path for original - * @param name Name for original + * @param name Name for original (should be extention corrected) * @param type Content-Type for original * @param hash Hash for original * @param size Size for original @@ -151,7 +153,7 @@ export class DriveService { } // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 - // 許可されているファイル形式でしか拡張子をつけない + // 許可されているファイル形式でしかURLに拡張子をつけない if (!FILE_TYPE_BROWSERSAFE.includes(type)) { ext = ''; } @@ -173,7 +175,7 @@ export class DriveService { //#region Uploads this.registerLogger.info(`uploading original: ${key}`); const uploads = [ - this.upload(key, fs.createReadStream(path), type, ext, name), + this.upload(key, fs.createReadStream(path), type, null, name), ]; if (alts.webpublic) { @@ -189,7 +191,7 @@ export class DriveService { thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext)); + uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext, `${name}.thumbnail`)); } await Promise.all(uploads); @@ -396,8 +398,9 @@ export class DriveService { ); } + // Expire oldest file (without avatar or banner) of remote user @bindThis - private async deleteOldFile(user: RemoteUser) { + private async expireOldFile(user: RemoteUser, driveCapacity: number) { const q = this.driveFilesRepository.createQueryBuilder('file') .where('file.userId = :userId', { userId: user.id }) .andWhere('file.isLink = FALSE'); @@ -410,12 +413,17 @@ export class DriveService { q.andWhere('file.id != :bannerId', { bannerId: user.bannerId }); } + //This selete is hard coded, be careful if change database schema + q.addSelect('SUM("file"."size") OVER (ORDER BY "file"."id" DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)', 'acc_usage'); q.orderBy('file.id', 'ASC'); - const oldFile = await q.getOne(); + const fileList = await q.getRawMany(); + const exceedFileIds = fileList.filter((x: any) => x.acc_usage > driveCapacity).map((x: any) => x.file_id); - if (oldFile) { - this.deleteFile(oldFile, true); + for (const fileId of exceedFileIds) { + const file = await this.driveFilesRepository.findOneBy({ id: fileId }); + if (file == null) continue; + this.deleteFile(file, true); } } @@ -437,6 +445,7 @@ export class DriveService { sensitive = null, requestIp = null, requestHeaders = null, + ext = null, }: AddFileArgs): Promise { let skipNsfwCheck = false; const instance = await this.metaService.fetch(); @@ -468,7 +477,7 @@ export class DriveService { // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 // extを付加してデータベースの文字数制限に当たることはまずない (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', - info.type.ext, + ext ?? info.type.ext, ); if (user && !force) { @@ -489,22 +498,19 @@ export class DriveService { //#region Check drive usage if (user && !isLink) { const usage = await this.driveFileEntityService.calcDriveUsageOf(user); + const isLocalUser = this.userEntityService.isLocalUser(user); const policies = await this.roleService.getUserPolicies(user.id); const driveCapacity = 1024 * 1024 * policies.driveCapacityMb; this.registerLogger.debug('drive capacity override applied'); this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); - this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); - // If usage limit exceeded - if (usage + info.size > driveCapacity) { - if (this.userEntityService.isLocalUser(user)) { + if (driveCapacity < usage + info.size) { + if (isLocalUser) { throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); - } else { - // (アバターまたはバナーを含まず)最も古いファイルを削除する - this.deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser); } + await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as RemoteUser, driveCapacity - info.size); } } //#endregion diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 2c6d3ac50..2049bd4c6 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -1,7 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; import type { InstancesRepository } from '@/models/index.js'; import type { Instance } from '@/models/entities/Instance.js'; -import { MemoryKVCache } from '@/misc/cache.js'; +import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -9,23 +10,41 @@ import { bindThis } from '@/decorators.js'; @Injectable() export class FederatedInstanceService { - private cache: MemoryKVCache; + public federatedInstanceCache: RedisKVCache; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, private utilityService: UtilityService, private idService: IdService, ) { - this.cache = new MemoryKVCache(1000 * 60 * 60); + this.federatedInstanceCache = new RedisKVCache(this.redisClient, 'federatedInstance', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60 * 3, // 3m + fetcher: (key) => this.instancesRepository.findOneBy({ host: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => { + const parsed = JSON.parse(value); + if (parsed == null) return null; + return { + ...parsed, + firstRetrievedAt: new Date(parsed.firstRetrievedAt), + latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, + infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, + }; + }, + }); } @bindThis public async fetch(host: string): Promise { host = this.utilityService.toPuny(host); - const cached = this.cache.get(host); + const cached = await this.federatedInstanceCache.get(host); if (cached) return cached; const index = await this.instancesRepository.findOneBy({ host }); @@ -37,24 +56,27 @@ export class FederatedInstanceService { firstRetrievedAt: new Date(), }).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); - this.cache.set(host, i); + this.federatedInstanceCache.set(host, i); return i; } else { - this.cache.set(host, index); + this.federatedInstanceCache.set(host, index); return index; } } @bindThis - public async updateCachePartial(host: string, data: Partial): Promise { - host = this.utilityService.toPuny(host); + public async update(id: Instance['id'], data: Partial): Promise { + const result = await this.instancesRepository.createQueryBuilder().update() + .set(data) + .where('id = :id', { id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); + + const updated = result.raw[0]; - const cached = this.cache.get(host); - if (cached == null) return; - - this.cache.set(host, { - ...cached, - ...data, - }); + this.federatedInstanceCache.set(updated.host, updated); } } diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index bbc8b4332..8103d5afe 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -10,6 +10,7 @@ import { DI } from '@/di-symbols.js'; import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { DOMWindow } from 'jsdom'; type NodeInfo = { @@ -42,6 +43,7 @@ export class FetchInstanceMetadataService { private appLockService: AppLockService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, + private federatedInstanceService: FederatedInstanceService, ) { this.logger = this.loggerService.getLogger('metadata', 'cyan'); } @@ -96,7 +98,7 @@ export class FetchInstanceMetadataService { if (favicon) updates.faviconUrl = favicon; if (themeColor) updates.themeColor = themeColor; - await this.instancesRepository.update(instance.id, updates); + await this.federatedInstanceService.update(instance.id, updates); this.logger.succ(`Successfuly updated metadata of ${instance.host}`); } catch (e) { diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index e39b134b7..b6cae5ea7 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -5,7 +5,7 @@ import * as stream from 'node:stream'; import * as util from 'node:util'; import { Injectable } from '@nestjs/common'; import { FSWatcher } from 'chokidar'; -import { fileTypeFromFile } from 'file-type'; +import * as fileType from 'file-type'; import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; @@ -301,21 +301,34 @@ export class FileInfoService { return fs.promises.access(path).then(() => true, () => false); } + @bindThis + public fixMime(mime: string | fileType.MimeType): string { + // see https://github.com/misskey-dev/misskey/pull/10686 + if (mime === "audio/x-flac") { + return "audio/flac"; + } + if (mime === "audio/vnd.wave") { + return "audio/wav"; + } + + return mime; + } + /** * Detect MIME Type and extension */ @bindThis public async detectType(path: string): Promise<{ - mime: string; - ext: string | null; -}> { + mime: string; + ext: string | null; + }> { // Check 0 byte const fileSize = await this.getFileSize(path); if (fileSize === 0) { return TYPE_OCTET_STREAM; } - const type = await fileTypeFromFile(path); + const type = await fileType.fileTypeFromFile(path); if (type) { // XMLはSVGかもしれない @@ -324,7 +337,7 @@ export class FileInfoService { } return { - mime: type.mime, + mime: this.fixMime(type.mime), ext: type.ext, }; } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index d261a6c65..0ed524114 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; import type { UserList } from '@/models/entities/UserList.js'; @@ -14,12 +14,13 @@ import type { MainStreamTypes, NoteStreamTypes, UserListStreamTypes, - UserStreamTypes, + RoleTimelineStreamTypes, } from '@/server/api/stream/types.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; +import { Role } from '@/models'; @Injectable() export class GlobalEventService { @@ -27,8 +28,8 @@ export class GlobalEventService { @Inject(DI.config) private config: Config, - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.redisForPub) + private redisForPub: Redis.Redis, ) { } @@ -38,7 +39,7 @@ export class GlobalEventService { { type: type, body: null } : { type: type, body: value }; - this.redisClient.publish(this.config.host, JSON.stringify({ + this.redisForPub.publish(this.config.host, JSON.stringify({ channel: channel, message: message, })); @@ -49,11 +50,6 @@ export class GlobalEventService { this.publish('internal', type, typeof value === 'undefined' ? null : value); } - @bindThis - public publishUserEvent(userId: User['id'], type: K, value?: UserStreamTypes[K]): void { - this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); - } - @bindThis public publishBroadcastStream(type: K, value?: BroadcastTypes[K]): void { this.publish('broadcast', type, typeof value === 'undefined' ? null : value); @@ -87,6 +83,11 @@ export class GlobalEventService { this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishRoleTimelineStream(roleId: Role['id'], type: K, value?: RoleTimelineStreamTypes[K]): void { + this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishNotesStream(note: Packed<'Note'>): void { this.publish('notesStream', null, note); diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index 94084ad84..8aa6ccfc4 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -3,10 +3,11 @@ import { ulid } from 'ulid'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { genAid, parseAid } from '@/misc/id/aid.js'; -import { genMeid } from '@/misc/id/meid.js'; -import { genMeidg } from '@/misc/id/meidg.js'; +import { genMeid, parseMeid } from '@/misc/id/meid.js'; +import { genMeidg, parseMeidg } from '@/misc/id/meidg.js'; import { genObjectId } from '@/misc/id/object-id.js'; import { bindThis } from '@/decorators.js'; +import { parseUlid } from '@/misc/id/ulid.js'; @Injectable() export class IdService { @@ -37,11 +38,10 @@ export class IdService { public parse(id: string): { date: Date; } { switch (this.method) { case 'aid': return parseAid(id); - // TODO - //case 'meid': - //case 'meidg': - //case 'ulid': - //case 'objectid': + case 'objectid': + case 'meid': return parseMeid(id); + case 'meidg': return parseMeidg(id); + case 'ulid': return parseUlid(id); default: throw new Error('unrecognized id generation method'); } } diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts index 898fb4ce8..4fb3fc5b4 100644 --- a/packages/backend/src/core/InstanceActorService.ts +++ b/packages/backend/src/core/InstanceActorService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import type { LocalUser } from '@/models/entities/User.js'; import type { UsersRepository } from '@/models/index.js'; -import { MemoryCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import { DI } from '@/di-symbols.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; import { bindThis } from '@/decorators.js'; @@ -11,7 +11,7 @@ const ACTOR_USERNAME = 'instance.actor' as const; @Injectable() export class InstanceActorService { - private cache: MemoryCache; + private cache: MemorySingleCache; constructor( @Inject(DI.usersRepository) @@ -19,7 +19,7 @@ export class InstanceActorService { private createSystemUserService: CreateSystemUserService, ) { - this.cache = new MemoryCache(Infinity); + this.cache = new MemorySingleCache(Infinity); } @bindThis diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 4b792c083..0b861be8d 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { Meta } from '@/models/entities/Meta.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; @@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown { private intervalId: NodeJS.Timer; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.db) private db: DataSource, @@ -33,7 +33,7 @@ export class MetaService implements OnApplicationShutdown { }, 1000 * 60 * 5); } - this.redisSubscriber.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -122,6 +122,6 @@ export class MetaService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { clearInterval(this.intervalId); - this.redisSubscriber.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 83290b310..50081f831 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1,7 +1,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; import { In, DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; @@ -20,7 +20,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js import { checkWordMute } from '@/misc/check-word-mute.js'; import type { Channel } from '@/models/entities/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { MemoryCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { UserProfile } from '@/models/entities/UserProfile.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -47,7 +47,7 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; -const mutedWordsCache = new MemoryCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); +const mutedWordsCache = new MemorySingleCache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -329,7 +329,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.redisClient.xadd( `channelTimeline:${data.channel.id}`, 'MAXLEN', '~', '1000', - `${this.idService.parse(note.id).date.getTime()}-*`, + '*', 'note', note.id); } @@ -493,26 +493,7 @@ export class NoteCreateService implements OnApplicationShutdown { } }); - // Antenna - for (const antenna of (await this.antennaService.getAntennas())) { - this.antennaService.checkHitAntenna(antenna, note, user).then(hit => { - if (hit) { - this.antennaService.addNoteToAntenna(antenna, note, user); - } - }); - } - - // Channel - if (note.channelId) { - this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => { - for (const following of followings) { - this.noteReadService.insertNoteUnread(following.followerId, note, { - isSpecified: false, - isMentioned: false, - }); - } - }); - } + this.antennaService.addNoteToAntennas(note, user); if (data.reply) { this.saveReply(data.reply, note); @@ -566,6 +547,8 @@ export class NoteCreateService implements OnApplicationShutdown { this.globalEventService.publishNotesStream(noteObj); + this.roleService.addNoteToRoleTimeline(noteObj); + this.webhookService.getActiveWebhooks().then(webhooks => { webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); for (const webhook of webhooks) { diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index 7c6808fbd..1129bd159 100644 --- a/packages/backend/src/core/NoteReadService.ts +++ b/packages/backend/src/core/NoteReadService.ts @@ -1,28 +1,20 @@ import { setTimeout } from 'node:timers/promises'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In, IsNull, Not } from 'typeorm'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; -import type { Channel } from '@/models/entities/Channel.js'; import type { Packed } from '@/misc/json-schema.js'; import type { Note } from '@/models/entities/Note.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { UsersRepository, NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository, FollowingsRepository, ChannelFollowingsRepository } from '@/models/index.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; -import { NotificationService } from './NotificationService.js'; -import { AntennaService } from './AntennaService.js'; -import { PushNotificationService } from './PushNotificationService.js'; @Injectable() export class NoteReadService implements OnApplicationShutdown { #shutdownController = new AbortController(); constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.noteUnreadsRepository) private noteUnreadsRepository: NoteUnreadsRepository, @@ -32,18 +24,8 @@ export class NoteReadService implements OnApplicationShutdown { @Inject(DI.noteThreadMutingsRepository) private noteThreadMutingsRepository: NoteThreadMutingsRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - - private userEntityService: UserEntityService, private idService: IdService, private globalEventService: GlobalEventService, - private notificationService: NotificationService, - private antennaService: AntennaService, - private pushNotificationService: PushNotificationService, ) { } @@ -54,7 +36,6 @@ export class NoteReadService implements OnApplicationShutdown { isMentioned: boolean; }): Promise { //#region ミュートしているなら無視 - // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする const mute = await this.mutingsRepository.findBy({ muterId: userId, }); @@ -74,7 +55,6 @@ export class NoteReadService implements OnApplicationShutdown { userId: userId, isSpecified: params.isSpecified, isMentioned: params.isMentioned, - noteChannelId: note.channelId, noteUserId: note.userId, }; @@ -92,9 +72,6 @@ export class NoteReadService implements OnApplicationShutdown { if (params.isSpecified) { this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id); } - if (note.channelId) { - this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id); - } }, () => { /* aborted, ignore it */ }); } @@ -102,22 +79,9 @@ export class NoteReadService implements OnApplicationShutdown { public async read( userId: User['id'], notes: (Note | Packed<'Note'>)[], - info?: { - following: Set; - followingChannels: Set; - }, ): Promise { - const followingChannels = info?.followingChannels ? info.followingChannels : new Set((await this.channelFollowingsRepository.find({ - where: { - followerId: userId, - }, - select: ['followeeId'], - })).map(x => x.followeeId)); - - const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const readMentions: (Note | Packed<'Note'>)[] = []; const readSpecifiedNotes: (Note | Packed<'Note'>)[] = []; - const readChannelNotes: (Note | Packed<'Note'>)[] = []; for (const note of notes) { if (note.mentions && note.mentions.includes(userId)) { @@ -125,17 +89,13 @@ export class NoteReadService implements OnApplicationShutdown { } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { readSpecifiedNotes.push(note); } - - if (note.channelId && followingChannels.has(note.channelId)) { - readChannelNotes.push(note); - } } - if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) { + if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0)) { // Remove the record await this.noteUnreadsRepository.delete({ userId: userId, - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]), + noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), }); // TODO: ↓まとめてクエリしたい @@ -159,16 +119,6 @@ export class NoteReadService implements OnApplicationShutdown { this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); } }); - - this.noteUnreadsRepository.countBy({ - userId: userId, - noteChannelId: Not(IsNull()), - }).then(channelNoteCount => { - if (channelNoteCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllChannels'); - } - }); } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 9c179f931..a245908c9 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -1,5 +1,5 @@ import { setTimeout } from 'node:timers/promises'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; @@ -43,6 +43,7 @@ export class NotificationService implements OnApplicationShutdown { @bindThis public async readAllNotification( userId: User['id'], + force = false, ) { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); @@ -57,7 +58,7 @@ export class NotificationService implements OnApplicationShutdown { this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId); - if (latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) { + if (force || latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) { return this.postReadAllNotifications(userId); } } @@ -65,6 +66,7 @@ export class NotificationService implements OnApplicationShutdown { @bindThis private postReadAllNotifications(userId: User['id']) { this.globalEventService.publishMainStream(userId, 'readAllNotifications'); + this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); } @bindThis @@ -73,7 +75,7 @@ export class NotificationService implements OnApplicationShutdown { type: Notification['type'], data: Partial, ): Promise { - const profile = await this.cacheService.userProfileCache.fetch(notifieeId, () => this.userProfilesRepository.findOneByOrFail({ userId: notifieeId })); + const profile = await this.cacheService.userProfileCache.fetch(notifieeId); const isMuted = profile.mutingNotificationTypes.includes(type); if (isMuted) return null; @@ -82,8 +84,8 @@ export class NotificationService implements OnApplicationShutdown { return null; } - const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId, () => this.mutingsRepository.findBy({ muterId: notifieeId }).then(xs => xs.map(x => x.muteeId))); - if (mutings.includes(data.notifierId)) { + const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); + if (mutings.has(data.notifierId)) { return null; } } @@ -95,10 +97,10 @@ export class NotificationService implements OnApplicationShutdown { ...data, } as Notification; - this.redisClient.xadd( + const redisIdPromise = this.redisClient.xadd( `notificationTimeline:${notifieeId}`, 'MAXLEN', '~', '300', - `${this.idService.parse(notification.id).date.getTime()}-*`, + '*', 'data', JSON.stringify(notification)); const packed = await this.notificationEntityService.pack(notification, notifieeId, {}); @@ -109,7 +111,7 @@ export class NotificationService implements OnApplicationShutdown { // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); - if (latestReadNotificationId && (latestReadNotificationId >= notification.id)) return; + if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return; this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 69020f7e8..a4c569bde 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -1,12 +1,14 @@ import { Inject, Injectable } from '@nestjs/common'; import push from 'web-push'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Packed } from '@/misc/json-schema'; import { getNoteSummary } from '@/misc/get-note-summary.js'; -import type { SwSubscriptionsRepository } from '@/models/index.js'; +import type { SwSubscription, SwSubscriptionsRepository } from '@/models/index.js'; import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; +import { RedisKVCache } from '@/misc/cache.js'; // Defined also packages/sw/types.ts#L13 type PushNotificationsTypes = { @@ -15,6 +17,7 @@ type PushNotificationsTypes = { antenna: { id: string, name: string }; note: Packed<'Note'>; }; + 'readAllNotifications': undefined; }; // Reduce length because push message servers have character limits @@ -40,15 +43,27 @@ function truncateBody(type: T, body: Pus @Injectable() export class PushNotificationService { + private subscriptionsCache: RedisKVCache; + constructor( @Inject(DI.config) private config: Config, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.swSubscriptionsRepository) private swSubscriptionsRepository: SwSubscriptionsRepository, private metaService: MetaService, ) { + this.subscriptionsCache = new RedisKVCache(this.redisClient, 'userSwSubscriptions', { + lifetime: 1000 * 60 * 60 * 1, // 1h + memoryCacheLifetime: 1000 * 60 * 3, // 3m + fetcher: (key) => this.swSubscriptionsRepository.findBy({ userId: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), + }); } @bindThis @@ -62,12 +77,13 @@ export class PushNotificationService { meta.swPublicKey, meta.swPrivateKey); - // Fetch - const subscriptions = await this.swSubscriptionsRepository.findBy({ - userId: userId, - }); + const subscriptions = await this.subscriptionsCache.fetch(userId); for (const subscription of subscriptions) { + if ([ + 'readAllNotifications', + ].includes(type) && !subscription.sendReadMessage) continue; + const pushSubscription = { endpoint: subscription.endpoint, keys: { diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index edd843977..d4905a5f8 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -3,18 +3,18 @@ import Bull from 'bull'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Provider } from '@nestjs/common'; -import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData } from '../queue/types.js'; +import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData, DbJobMap } from '../queue/types.js'; function q(config: Config, name: string, limitPerSec = -1) { return new Bull(name, { redis: { - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - db: config.redis.db ?? 0, + port: config.redisForJobQueue.port, + host: config.redisForJobQueue.host, + family: config.redisForJobQueue.family == null ? 0 : config.redisForJobQueue.family, + password: config.redisForJobQueue.pass, + db: config.redisForJobQueue.db ?? 0, }, - prefix: config.redis.prefix ? `${config.redis.prefix}:queue` : 'queue', + prefix: config.redisForJobQueue.prefix ? `${config.redisForJobQueue.prefix}:queue` : 'queue', limiter: limitPerSec > 0 ? { max: limitPerSec, duration: 1000, @@ -41,7 +41,8 @@ export type SystemQueue = Bull.Queue>; export type EndedPollNotificationQueue = Bull.Queue; export type DeliverQueue = Bull.Queue; export type InboxQueue = Bull.Queue; -export type DbQueue = Bull.Queue; +export type DbQueue = Bull.Queue>; +export type RelationshipQueue = Bull.Queue; export type ObjectStorageQueue = Bull.Queue; export type WebhookDeliverQueue = Bull.Queue; @@ -75,6 +76,12 @@ const $db: Provider = { inject: [DI.config], }; +const $relationship: Provider = { + provide: 'queue:relationship', + useFactory: (config: Config) => q(config, 'relationship', config.relashionshipJobPerSec ?? 64), + inject: [DI.config], +}; + const $objectStorage: Provider = { provide: 'queue:objectStorage', useFactory: (config: Config) => q(config, 'objectStorage'), @@ -96,6 +103,7 @@ const $webhookDeliver: Provider = { $deliver, $inbox, $db, + $relationship, $objectStorage, $webhookDeliver, ], @@ -105,6 +113,7 @@ const $webhookDeliver: Provider = { $deliver, $inbox, $db, + $relationship, $objectStorage, $webhookDeliver, ], diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 498ceced7..3590846d7 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -6,9 +6,10 @@ import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; -import type { ThinUser } from '../queue/types.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; +import type { DbJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; import type httpSignature from '@peertube/http-signature'; +import Bull from 'bull'; @Injectable() export class QueueService { @@ -21,6 +22,7 @@ export class QueueService { @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, + @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, ) {} @@ -56,7 +58,7 @@ export class QueueService { activity: activity, signature, }; - + return this.inboxQueue.add(data, { attempts: this.config.inboxJobMaxAttempts ?? 8, timeout: 5 * 60 * 1000, // 5min @@ -71,7 +73,7 @@ export class QueueService { @bindThis public createDeleteDriveFilesJob(user: ThinUser) { return this.dbQueue.add('deleteDriveFiles', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -81,7 +83,7 @@ export class QueueService { @bindThis public createExportCustomEmojisJob(user: ThinUser) { return this.dbQueue.add('exportCustomEmojis', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -91,7 +93,7 @@ export class QueueService { @bindThis public createExportNotesJob(user: ThinUser) { return this.dbQueue.add('exportNotes', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -101,7 +103,7 @@ export class QueueService { @bindThis public createExportFavoritesJob(user: ThinUser) { return this.dbQueue.add('exportFavorites', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -111,7 +113,7 @@ export class QueueService { @bindThis public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) { return this.dbQueue.add('exportFollowing', { - user: user, + user: { id: user.id }, excludeMuting, excludeInactive, }, { @@ -123,7 +125,7 @@ export class QueueService { @bindThis public createExportMuteJob(user: ThinUser) { return this.dbQueue.add('exportMuting', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -133,7 +135,7 @@ export class QueueService { @bindThis public createExportBlockingJob(user: ThinUser) { return this.dbQueue.add('exportBlocking', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -143,7 +145,7 @@ export class QueueService { @bindThis public createExportUserListsJob(user: ThinUser) { return this.dbQueue.add('exportUserLists', { - user: user, + user: { id: user.id }, }, { removeOnComplete: true, removeOnFail: true, @@ -153,7 +155,7 @@ export class QueueService { @bindThis public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importFollowing', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -161,10 +163,16 @@ export class QueueService { }); } + @bindThis + public createImportFollowingToDbJob(user: ThinUser, targets: string[]) { + const jobs = targets.map(rel => this.generateToDbJobData('importFollowingToDb', { user, target: rel })); + return this.dbQueue.addBulk(jobs); + } + @bindThis public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importMuting', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -175,7 +183,7 @@ export class QueueService { @bindThis public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importBlocking', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -183,10 +191,32 @@ export class QueueService { }); } + @bindThis + public createImportBlockingToDbJob(user: ThinUser, targets: string[]) { + const jobs = targets.map(rel => this.generateToDbJobData('importBlockingToDb', { user, target: rel })); + return this.dbQueue.addBulk(jobs); + } + + @bindThis + private generateToDbJobData>(name: T, data: D): { + name: string, + data: D, + opts: Bull.JobOptions, + } { + return { + name, + data, + opts: { + removeOnComplete: true, + removeOnFail: true, + }, + }; + } + @bindThis public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importUserLists', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -197,7 +227,7 @@ export class QueueService { @bindThis public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) { return this.dbQueue.add('importCustomEmojis', { - user: user, + user: { id: user.id }, fileId: fileId, }, { removeOnComplete: true, @@ -208,7 +238,7 @@ export class QueueService { @bindThis public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) { return this.dbQueue.add('deleteAccount', { - user: user, + user: { id: user.id }, soft: opts.soft, }, { removeOnComplete: true, @@ -216,6 +246,58 @@ export class QueueService { }); } + @bindThis + public createFollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string, silent?: boolean }[]) { + const jobs = followings.map(rel => this.generateRelationshipJobData('follow', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[]) { + const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createDelayedUnfollowJob(followings: { from: ThinUser, to: ThinUser, requestId?: string }[], delay: number) { + const jobs = followings.map(rel => this.generateRelationshipJobData('unfollow', rel, { delay })); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createBlockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { + const jobs = blockings.map(rel => this.generateRelationshipJobData('block', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + public createUnblockJob(blockings: { from: ThinUser, to: ThinUser, silent?: boolean }[]) { + const jobs = blockings.map(rel => this.generateRelationshipJobData('unblock', rel)); + return this.relationshipQueue.addBulk(jobs); + } + + @bindThis + private generateRelationshipJobData(name: 'follow' | 'unfollow' | 'block' | 'unblock', data: RelationshipJobData, opts: Bull.JobOptions = {}): { + name: string, + data: RelationshipJobData, + opts: Bull.JobOptions, + } { + return { + name, + data: { + from: { id: data.from.id }, + to: { id: data.to.id }, + silent: data.silent, + requestId: data.requestId, + }, + opts: { + removeOnComplete: true, + removeOnFail: true, + ...opts, + }, + }; + } + @bindThis public createDeleteObjectStorageFileJob(key: string) { return this.objectStorageQueue.add('deleteFile', { @@ -246,7 +328,7 @@ export class QueueService { createdAt: Date.now(), eventId: uuid(), }; - + return this.webhookDeliverQueue.add(data, { attempts: 4, timeout: 1 * 60 * 1000, // 1min @@ -264,7 +346,7 @@ export class QueueService { //deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); this.deliverQueue.clean(0, 'delayed'); - + this.inboxQueue.once('cleaned', (jobs, status) => { //inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); }); diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index b3aea878d..a274b19e4 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -1,7 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; +import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { RemoteUser, User } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; @@ -20,6 +19,7 @@ import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; const FALLBACK = '❤'; @@ -60,9 +60,6 @@ export class ReactionService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -74,6 +71,7 @@ export class ReactionService { private utilityService: UtilityService, private metaService: MetaService, + private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private userBlockingService: UserBlockingService, @@ -104,7 +102,6 @@ export class ReactionService { if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { reaction = '❤️'; } else { - // TODO: cache reaction = await this.toDbReaction(reaction, user.host); } @@ -158,20 +155,22 @@ export class ReactionService { // カスタム絵文字リアクションだったら絵文字情報も送る const decodedReaction = this.decodeReaction(reaction); - const emoji = await this.emojisRepository.findOne({ - where: { - name: decodedReaction.name, - host: decodedReaction.host ?? IsNull(), - }, - select: ['name', 'host', 'originalUrl', 'publicUrl'], - }); + const customEmoji = decodedReaction.name == null ? null : decodedReaction.host == null + ? (await this.customEmojiService.localEmojisCache.fetch()).get(decodedReaction.name) + : await this.emojisRepository.findOne( + { + where: { + name: decodedReaction.name, + host: decodedReaction.host, + }, + }); this.globalEventService.publishNoteStream(note.id, 'reacted', { reaction: decodedReaction.reaction, - emoji: emoji != null ? { - name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, + emoji: customEmoji != null ? { + name: customEmoji.host ? `${customEmoji.name}@${customEmoji.host}` : `${customEmoji.name}@.`, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) - url: emoji.publicUrl || emoji.originalUrl, + url: customEmoji.publicUrl || customEmoji.originalUrl, } : null, userId: user.id, }); @@ -310,10 +309,12 @@ export class ReactionService { const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/); if (custom) { const name = custom[1]; - const emoji = await this.emojisRepository.findOneBy({ - host: reacterHost ?? IsNull(), - name, - }); + const emoji = reacterHost == null + ? (await this.customEmojiService.localEmojisCache.fetch()).get(name) + : await this.emojisRepository.findOneBy({ + host: reacterHost, + name, + }); if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`; } diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 4df7fb3bf..9d34d82be 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -3,7 +3,7 @@ import { IsNull } from 'typeorm'; import type { LocalUser, User } from '@/models/entities/User.js'; import type { RelaysRepository, UsersRepository } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; -import { MemoryCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { Relay } from '@/models/entities/Relay.js'; import { QueueService } from '@/core/QueueService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; @@ -16,7 +16,7 @@ const ACTOR_USERNAME = 'relay.actor' as const; @Injectable() export class RelayService { - private relaysCache: MemoryCache; + private relaysCache: MemorySingleCache; constructor( @Inject(DI.usersRepository) @@ -30,7 +30,7 @@ export class RelayService { private createSystemUserService: CreateSystemUserService, private apRendererService: ApRendererService, ) { - this.relaysCache = new MemoryCache(1000 * 60 * 10); + this.relaysCache = new MemorySingleCache(1000 * 60 * 10); } @bindThis diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index b72dce518..ff68c2421 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -4,7 +4,7 @@ import chalk from 'chalk'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UsersRepository } from '@/models/index.js'; -import type { RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -33,7 +33,7 @@ export class RemoteUserResolveService { } @bindThis - public async resolveUser(username: string, host: string | null): Promise { + public async resolveUser(username: string, host: string | null): Promise { const usernameLower = username.toLowerCase(); if (host == null) { @@ -44,7 +44,7 @@ export class RemoteUserResolveService { } else { return u; } - }); + }) as LocalUser; } host = this.utilityService.toPuny(host); @@ -57,7 +57,7 @@ export class RemoteUserResolveService { } else { return u; } - }); + }) as LocalUser; } const user = await this.usersRepository.findOneBy({ usernameLower, host }) as RemoteUser | null; @@ -109,7 +109,7 @@ export class RemoteUserResolveService { if (u == null) { throw new Error('user not found'); } else { - return u; + return u as LocalUser | RemoteUser; } }); } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 52e6292a1..3878c147d 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { In } from 'typeorm'; import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; -import { MemoryKVCache, MemoryCache } from '@/misc/cache.js'; +import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -13,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { Packed } from '@/misc/json-schema'; import type { OnApplicationShutdown } from '@nestjs/common'; export type RolePolicies = { @@ -57,15 +58,18 @@ export const DEFAULT_POLICIES: RolePolicies = { @Injectable() export class RoleService implements OnApplicationShutdown { - private rolesCache: MemoryCache; + private rolesCache: MemorySingleCache; private roleAssignmentByUserIdCache: MemoryKVCache; public static AlreadyAssignedError = class extends Error {}; public static NotAssignedError = class extends Error {}; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -84,10 +88,10 @@ export class RoleService implements OnApplicationShutdown { ) { //this.onMessage = this.onMessage.bind(this); - this.rolesCache = new MemoryCache(Infinity); - this.roleAssignmentByUserIdCache = new MemoryKVCache(Infinity); + this.rolesCache = new MemorySingleCache(1000 * 60 * 60 * 1); + this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 1); - this.redisSubscriber.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -398,8 +402,27 @@ export class RoleService implements OnApplicationShutdown { this.globalEventService.publishInternalEvent('userRoleUnassigned', existing); } + @bindThis + public async addNoteToRoleTimeline(note: Packed<'Note'>): Promise { + const roles = await this.getUserRoles(note.userId); + + const redisPipeline = this.redisClient.pipeline(); + + for (const role of roles) { + redisPipeline.xadd( + `roleTimeline:${role.id}`, + 'MAXLEN', '~', '1000', + '*', + 'note', note.id); + + this.globalEventService.publishRoleTimelineStream(role.id, 'note', note); + } + + redisPipeline.exec(); + } + @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index d7bc05b8b..2b8387f89 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -13,8 +13,9 @@ import { UsedUsername } from '@/models/entities/UsedUsername.js'; import generateUserToken from '@/misc/generate-native-user-token.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import UsersChart from './chart/charts/users.js'; -import { UtilityService } from './UtilityService.js'; +import UsersChart from '@/core/chart/charts/users.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class SignupService { @@ -34,6 +35,7 @@ export class SignupService { private utilityService: UtilityService, private userEntityService: UserEntityService, private idService: IdService, + private metaService: MetaService, private usersChart: UsersChart, ) { } @@ -44,6 +46,7 @@ export class SignupService { password?: string | null; passwordHash?: UserProfile['password'] | null; host?: string | null; + ignorePreservedUsernames?: boolean; }) { const { username, password, passwordHash, host } = opts; let hash = passwordHash; @@ -76,6 +79,14 @@ export class SignupService { if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { throw new Error('USED_USERNAME'); } + + if (!opts.ignorePreservedUsernames) { + const instance = await this.metaService.fetch(true); + const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + if (isPreserved) { + throw new Error('USED_USERNAME'); + } + } const keyPair = await new Promise((res, rej) => generateKeyPair('rsa', { diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 040b6de2e..3ca22f8bb 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -1,39 +1,29 @@ -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import Redis from 'ioredis'; +import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { IdService } from '@/core/IdService.js'; import type { User } from '@/models/entities/User.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import { QueueService } from '@/core/QueueService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; +import type { FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; import Logger from '@/logger.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { WebhookService } from '@/core/WebhookService.js'; import { bindThis } from '@/decorators.js'; -import { MemoryKVCache } from '@/misc/cache.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; @Injectable() -export class UserBlockingService implements OnApplicationShutdown { +export class UserBlockingService implements OnModuleInit { private logger: Logger; - - // キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ - private blockingsByUserIdCache: MemoryKVCache; + private userFollowingService: UserFollowingService; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, + private moduleRef: ModuleRef, @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, @@ -47,56 +37,29 @@ export class UserBlockingService implements OnApplicationShutdown { @Inject(DI.userListJoiningsRepository) private userListJoiningsRepository: UserListJoiningsRepository, + private cacheService: CacheService, private userEntityService: UserEntityService, private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, private webhookService: WebhookService, private apRendererService: ApRendererService, - private perUserFollowingChart: PerUserFollowingChart, private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('user-block'); + } - this.blockingsByUserIdCache = new MemoryKVCache(Infinity); - - this.redisSubscriber.on('message', this.onMessage); + onModuleInit() { + this.userFollowingService = this.moduleRef.get('UserFollowingService'); } @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as StreamMessages['internal']['payload']; - switch (type) { - case 'blockingCreated': { - const cached = this.blockingsByUserIdCache.get(body.blockerId); - if (cached) { - this.blockingsByUserIdCache.set(body.blockerId, [...cached, ...[body.blockeeId]]); - } - break; - } - case 'blockingDeleted': { - const cached = this.blockingsByUserIdCache.get(body.blockerId); - if (cached) { - this.blockingsByUserIdCache.set(body.blockerId, cached.filter(x => x !== body.blockeeId)); - } - break; - } - default: - break; - } - } - } - - @bindThis - public async block(blocker: User, blockee: User) { + public async block(blocker: User, blockee: User, silent = false) { await Promise.all([ - this.cancelRequest(blocker, blockee), - this.cancelRequest(blockee, blocker), - this.unFollow(blocker, blockee), - this.unFollow(blockee, blocker), + this.cancelRequest(blocker, blockee, silent), + this.cancelRequest(blockee, blocker, silent), + this.userFollowingService.unfollow(blocker, blockee, silent), + this.userFollowingService.unfollow(blockee, blocker, silent), this.removeFromList(blockee, blocker), ]); @@ -111,6 +74,9 @@ export class UserBlockingService implements OnApplicationShutdown { await this.blockingsRepository.insert(blocking); + this.cacheService.userBlockingCache.refresh(blocker.id); + this.cacheService.userBlockedCache.refresh(blockee.id); + this.globalEventService.publishInternalEvent('blockingCreated', { blockerId: blocker.id, blockeeId: blockee.id, @@ -123,7 +89,7 @@ export class UserBlockingService implements OnApplicationShutdown { } @bindThis - private async cancelRequest(follower: User, followee: User) { + private async cancelRequest(follower: User, followee: User, silent = false) { const request = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, @@ -144,11 +110,10 @@ export class UserBlockingService implements OnApplicationShutdown { }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); } - if (this.userEntityService.isLocalUser(follower)) { + if (this.userEntityService.isLocalUser(follower) && !silent) { this.userEntityService.pack(followee, follower, { detail: true, }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); @@ -173,54 +138,6 @@ export class UserBlockingService implements OnApplicationShutdown { } } - @bindThis - private async unFollow(follower: User, followee: User) { - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, - }); - - if (following == null) { - return; - } - - await Promise.all([ - this.followingsRepository.delete(following.id), - this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), - this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), - this.perUserFollowingChart.update(follower, followee, false), - ]); - - // Publish unfollow event - if (this.userEntityService.isLocalUser(follower)) { - this.userEntityService.pack(followee, follower, { - detail: true, - }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); - this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } - }); - } - - // リモートにフォローをしていたらUndoFollow送信 - if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - this.queueService.deliver(follower, content, followee.inbox, false); - } - - // リモートからフォローをされていたらRejectFollow送信 - if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); - this.queueService.deliver(followee, content, follower.inbox, false); - } - } - @bindThis private async removeFromList(listOwner: User, user: User) { const userLists = await this.userListsRepository.findBy({ @@ -254,6 +171,9 @@ export class UserBlockingService implements OnApplicationShutdown { await this.blockingsRepository.delete(blocking.id); + this.cacheService.userBlockingCache.refresh(blocker.id); + this.cacheService.userBlockedCache.refresh(blockee.id); + this.globalEventService.publishInternalEvent('blockingDeleted', { blockerId: blocker.id, blockeeId: blockee.id, @@ -268,17 +188,6 @@ export class UserBlockingService implements OnApplicationShutdown { @bindThis public async checkBlocked(blockerId: User['id'], blockeeId: User['id']): Promise { - const blockedUserIds = await this.blockingsByUserIdCache.fetch(blockerId, () => this.blockingsRepository.find({ - where: { - blockerId, - }, - select: ['blockeeId'], - }).then(records => records.map(record => record.blockeeId))); - return blockedUserIds.includes(blockeeId); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + return (await this.cacheService.userBlockingCache.fetch(blockerId)).has(blockeeId); } } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index b51b553c7..7d90bc2c0 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -1,5 +1,6 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; @@ -18,7 +19,11 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { bindThis } from '@/decorators.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { MetaService } from '@/core/MetaService.js'; +import { CacheService } from '@/core/CacheService.js'; +import type { Config } from '@/config.js'; import Logger from '../logger.js'; +import { IsNull } from 'typeorm'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; const logger = new Logger('following/create'); @@ -36,8 +41,15 @@ type Remote = RemoteUser | { type Both = Local | Remote; @Injectable() -export class UserFollowingService { +export class UserFollowingService implements OnModuleInit { + private userBlockingService: UserBlockingService; + constructor( + private moduleRef: ModuleRef, + + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -53,8 +65,8 @@ export class UserFollowingService { @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, + private cacheService: CacheService, private userEntityService: UserEntityService, - private userBlockingService: UserBlockingService, private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, @@ -63,17 +75,22 @@ export class UserFollowingService { private federatedInstanceService: FederatedInstanceService, private webhookService: WebhookService, private apRendererService: ApRendererService, + private accountMoveService: AccountMoveService, private perUserFollowingChart: PerUserFollowingChart, private instanceChart: InstanceChart, ) { } + onModuleInit() { + this.userBlockingService = this.moduleRef.get('UserBlockingService'); + } + @bindThis - public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise { + public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string, silent = false): Promise { const [follower, followee] = await Promise.all([ this.usersRepository.findOneByOrFail({ id: _follower.id }), this.usersRepository.findOneByOrFail({ id: _followee.id }), - ]); + ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser]; // check blocking const [blocking, blocked] = await Promise.all([ @@ -123,13 +140,27 @@ export class UserFollowingService { if (followed) autoAccept = true; } + // Automatically accept if the follower is an account who has moved and the locked followee had accepted the old account. + if (followee.isLocked && !autoAccept) { + autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs( + follower, + (oldSrc, newSrc) => this.followingsRepository.exist({ + where: { + followeeId: followee.id, + followerId: newSrc.id, + }, + }), + true, + )); + } + if (!autoAccept) { await this.createFollowRequest(follower, followee, requestId); return; } } - await this.insertFollowingDoc(followee, follower); + await this.insertFollowingDoc(followee, follower, silent); if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); @@ -145,6 +176,7 @@ export class UserFollowingService { follower: { id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'] }, + silent = false, ): Promise { if (follower.id === followee.id) return; @@ -172,6 +204,8 @@ export class UserFollowingService { } }); + this.cacheService.userFollowingsCache.refresh(follower.id); + const req = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, @@ -193,39 +227,46 @@ export class UserFollowingService { this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); - //#region Increment counts - await Promise.all([ - this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), - this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + const [followeeUser, followerUser] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: followee.id }), + this.usersRepository.findOneByOrFail({ id: follower.id }), ]); - //#endregion - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, true); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, true); - } - }); + // Neither followee nor follower has moved. + if (!followeeUser.movedToUri && !followerUser.movedToUri) { + //#region Increment counts + await Promise.all([ + this.usersRepository.increment({ id: follower.id }, 'followingCount', 1), + this.usersRepository.increment({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion + + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetch(follower.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, true); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetch(followee.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, true); + } + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, true); } - //#endregion - - this.perUserFollowingChart.update(follower, followee, true); // Publish follow event - if (this.userEntityService.isLocalUser(follower)) { + if (this.userEntityService.isLocalUser(follower) && !silent) { this.userEntityService.pack(followee.id, follower, { detail: true, }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); this.globalEventService.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); @@ -267,26 +308,33 @@ export class UserFollowingService { }, silent = false, ): Promise { - const following = await this.followingsRepository.findOneBy({ - followerId: follower.id, - followeeId: followee.id, + const following = await this.followingsRepository.findOne({ + relations: { + follower: true, + followee: true, + }, + where: { + followerId: follower.id, + followeeId: followee.id, + } }); - if (following == null) { + if (following === null || !following.follower || !following.followee) { logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); return; } await this.followingsRepository.delete(following.id); - this.decrementFollowing(follower, followee); + this.cacheService.userFollowingsCache.refresh(follower.id); + + this.decrementFollowing(following.follower, following.followee); // Publish unfollow event if (!silent && this.userEntityService.isLocalUser(follower)) { this.userEntityService.pack(followee.id, follower, { detail: true, }).then(async packed => { - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); @@ -299,50 +347,87 @@ export class UserFollowingService { } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser), follower)); this.queueService.deliver(follower, content, followee.inbox, false); } if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) { // local user has null host - const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee)); + const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower as PartialRemoteUser, followee as PartialLocalUser), followee)); this.queueService.deliver(followee, content, follower.inbox, false); } } @bindThis private async decrementFollowing( - follower: { id: User['id']; host: User['host']; }, - followee: { id: User['id']; host: User['host']; }, + follower: User, + followee: User, ): Promise { this.globalEventService.publishInternalEvent('unfollow', { followerId: follower.id, followeeId: followee.id }); - //#region Decrement following / followers counts - await Promise.all([ - this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), - this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), - ]); - //#endregion + // Neither followee nor follower has moved. + if (!follower.movedToUri && !followee.movedToUri) { + //#region Decrement following / followers counts + await Promise.all([ + this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1), + this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1), + ]); + //#endregion - //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, false); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); + //#region Update instance stats + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetch(follower.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, false); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetch(followee.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } + //#endregion + + this.perUserFollowingChart.update(follower, followee, false); + } else { + // Adjust following/followers counts + for (const user of [follower, followee]) { + if (user.movedToUri) continue; // No need to update if the user has already moved. + + const nonMovedFollowees = await this.followingsRepository.count({ + relations: { + followee: true, + }, + where: { + followerId: user.id, + followee: { + movedToUri: IsNull(), + } + } + }); + const nonMovedFollowers = await this.followingsRepository.count({ + relations: { + follower: true, + }, + where: { + followeeId: user.id, + follower: { + movedToUri: IsNull(), + } + } + }); + await this.usersRepository.update( + { id: user.id }, + { followingCount: nonMovedFollowees, followersCount: nonMovedFollowers }, + ); + } + + // TODO: adjust charts } - //#endregion - - this.perUserFollowingChart.update(follower, followee, false); } @bindThis @@ -398,7 +483,7 @@ export class UserFollowingService { } if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)); + const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower as PartialLocalUser, followee as PartialRemoteUser, requestId ?? `${this.config.url}/follows/${followRequest.id}`)); this.queueService.deliver(follower, content, followee.inbox, false); } } @@ -413,7 +498,7 @@ export class UserFollowingService { }, ): Promise { if (this.userEntityService.isRemoteUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower as PartialLocalUser | PartialRemoteUser, followee as PartialRemoteUser), follower)); if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので this.queueService.deliver(follower, content, followee.inbox, false); @@ -458,7 +543,7 @@ export class UserFollowingService { await this.insertFollowingDoc(followee, follower); if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); + const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee as PartialLocalUser, request.requestId!), followee)); this.queueService.deliver(followee, content, follower.inbox, false); } @@ -545,15 +630,22 @@ export class UserFollowingService { */ @bindThis private async removeFollow(followee: Both, follower: Both): Promise { - const following = await this.followingsRepository.findOneBy({ - followeeId: followee.id, - followerId: follower.id, + const following = await this.followingsRepository.findOne({ + relations: { + followee: true, + follower: true, + }, + where: { + followeeId: followee.id, + followerId: follower.id, + } }); - if (!following) return; + if (!following || !following.followee || !following.follower) return; await this.followingsRepository.delete(following.id); - this.decrementFollowing(follower, followee); + + this.decrementFollowing(following.follower, following.followee); } /** @@ -579,7 +671,6 @@ export class UserFollowingService { detail: true, }); - this.globalEventService.publishUserEvent(follower.id, 'unfollow', packedFollowee); this.globalEventService.publishMainStream(follower.id, 'unfollow', packedFollowee); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); diff --git a/packages/backend/src/core/UserKeypairService.ts b/packages/backend/src/core/UserKeypairService.ts new file mode 100644 index 000000000..72c35c529 --- /dev/null +++ b/packages/backend/src/core/UserKeypairService.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { User } from '@/models/entities/User.js'; +import type { UserKeypairsRepository } from '@/models/index.js'; +import { RedisKVCache } from '@/misc/cache.js'; +import type { UserKeypair } from '@/models/entities/UserKeypair.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class UserKeypairService { + private cache: RedisKVCache; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.userKeypairsRepository) + private userKeypairsRepository: UserKeypairsRepository, + ) { + this.cache = new RedisKVCache(this.redisClient, 'userKeypair', { + lifetime: 1000 * 60 * 60 * 24, // 24h + memoryCacheLifetime: Infinity, + fetcher: (key) => this.userKeypairsRepository.findOneByOrFail({ userId: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => JSON.parse(value), + }); + } + + @bindThis + public async getUserKeypair(userId: User['id']): Promise { + return await this.cache.fetch(userId); + } +} diff --git a/packages/backend/src/core/UserKeypairStoreService.ts b/packages/backend/src/core/UserKeypairStoreService.ts deleted file mode 100644 index 872a0335e..000000000 --- a/packages/backend/src/core/UserKeypairStoreService.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import type { User } from '@/models/entities/User.js'; -import type { UserKeypairsRepository } from '@/models/index.js'; -import { MemoryKVCache } from '@/misc/cache.js'; -import type { UserKeypair } from '@/models/entities/UserKeypair.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class UserKeypairStoreService { - private cache: MemoryKVCache; - - constructor( - @Inject(DI.userKeypairsRepository) - private userKeypairsRepository: UserKeypairsRepository, - ) { - this.cache = new MemoryKVCache(Infinity); - } - - @bindThis - public async getUserKeypair(userId: User['id']): Promise { - return await this.cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId })); - } -} diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index bc726a1fe..08cc907eb 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class UserListService { @@ -29,6 +30,7 @@ export class UserListService { private roleService: RoleService, private globalEventService: GlobalEventService, private proxyAccountService: ProxyAccountService, + private queueService: QueueService, ) { } @@ -47,14 +49,14 @@ export class UserListService { userId: target.id, userListId: list.id, } as UserListJoining); - + this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); - + // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする if (this.userEntityService.isRemoteUser(target)) { const proxy = await this.proxyAccountService.fetch(); if (proxy) { - this.userFollowingService.follow(proxy, target); + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]); } } } diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts index e98f11709..d201ec6c0 100644 --- a/packages/backend/src/core/UserMutingService.ts +++ b/packages/backend/src/core/UserMutingService.ts @@ -1,34 +1,47 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, MutingsRepository } from '@/models/index.js'; +import { In } from 'typeorm'; +import type { MutingsRepository, Muting } from '@/models/index.js'; import { IdService } from '@/core/IdService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() export class UserMutingService { constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, private idService: IdService, - private queueService: QueueService, - private globalEventService: GlobalEventService, + private cacheService: CacheService, ) { } @bindThis - public async mute(user: User, target: User): Promise { + public async mute(user: User, target: User, expiresAt: Date | null = null): Promise { await this.mutingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), + expiresAt: expiresAt ?? null, muterId: user.id, muteeId: target.id, }); + + this.cacheService.userMutingsCache.refresh(user.id); + } + + @bindThis + public async unmute(mutings: Muting[]): Promise { + if (mutings.length === 0) return; + + await this.mutingsRepository.delete({ + id: In(mutings.map(m => m.id)), + }); + + const muterIds = [...new Set(mutings.map(m => m.muterId))]; + for (const muterId of muterIds) { + this.cacheService.userMutingsCache.refresh(muterId); + } } } diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index d00bb89c7..b197d335d 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -35,7 +35,7 @@ export class UserSuspendService { if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにDelete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user)); + const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); const queue: string[] = []; @@ -65,7 +65,7 @@ export class UserSuspendService { if (this.userEntityService.isLocalUser(user)) { // 知り得る全SharedInboxにUndo Delete配信 - const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user)); + const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user)); const queue: string[] = []; diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts index 69df2d0c1..3ee799064 100644 --- a/packages/backend/src/core/WebfingerService.ts +++ b/packages/backend/src/core/WebfingerService.ts @@ -43,7 +43,8 @@ export class WebfingerService { const m = query.match(/^([^@]+)@(.*)/); if (m) { const hostname = m[2]; - return `https://${hostname}/.well-known/webfinger?` + urlQuery({ resource: `acct:${query}` }); + const useHttp = process.env.MISSKEY_WEBFINGER_USE_HTTP && process.env.MISSKEY_WEBFINGER_USE_HTTP.toLowerCase() === 'true'; + return `http${useHttp ? '' : 's'}://${hostname}/.well-known/webfinger?${urlQuery({ resource: `acct:${query}` })}`; } throw new Error(`Invalid query (${query})`); diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index ac1e413de..57baade77 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import type { WebhooksRepository } from '@/models/index.js'; import type { Webhook } from '@/models/entities/Webhook.js'; import { DI } from '@/di-symbols.js'; @@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown { private webhooks: Webhook[] = []; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.webhooksRepository) private webhooksRepository: WebhooksRepository, ) { //this.onMessage = this.onMessage.bind(this); - this.redisSubscriber.on('message', this.onMessage); + this.redisForSub.on('message', this.onMessage); } @bindThis @@ -82,6 +82,6 @@ export class WebhookService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + this.redisForSub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index 4b032be89..2b404ebec 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -8,7 +8,7 @@ import type { UserPublickey } from '@/models/entities/UserPublickey.js'; import { CacheService } from '@/core/CacheService.js'; import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; -import { RemoteUser, User } from '@/models/entities/User.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { getApId } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; import type { IObject } from './type.js'; @@ -101,7 +101,7 @@ export class ApDbResolverService { * AP Person => Misskey User in DB */ @bindThis - public async getUserFromApId(value: string | IObject): Promise { + public async getUserFromApId(value: string | IObject): Promise { const parsed = this.parseUri(value); if (parsed.local) { @@ -109,11 +109,11 @@ export class ApDbResolverService { return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ id: parsed.id, - }).then(x => x ?? undefined)) ?? null; + }).then(x => x ?? undefined)) as LocalUser | undefined ?? null; } else { return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ uri: parsed.uri, - })); + })) as RemoteUser | null; } } diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 70a6d32fe..62a2a33a1 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -186,7 +186,7 @@ class DeliverManager { for (const following of followers) { const inbox = following.followerSharedInbox ?? following.followerInbox; - inboxes.set(inbox, following.followerSharedInbox === null); + inboxes.set(inbox, following.followerSharedInbox != null); } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 055bffe73..efef777fb 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -13,16 +13,18 @@ import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; import { MetaService } from '@/core/MetaService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import { IdService } from '@/core/IdService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { CacheService } from '@/core/CacheService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { RemoteUser } from '@/models/entities/User.js'; -import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; +import { getApHrefNullable, getApId, getApIds, getApType, getOneApHrefNullable, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; import { ApDbResolverService } from './ApDbResolverService.js'; @@ -31,7 +33,7 @@ import { ApAudienceService } from './ApAudienceService.js'; import { ApPersonService } from './models/ApPersonService.js'; import { ApQuestionService } from './models/ApQuestionService.js'; import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate } from './type.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; @Injectable() export class ApInboxService { @@ -76,11 +78,13 @@ export class ApInboxService { private apNoteService: ApNoteService, private apPersonService: ApPersonService, private apQuestionService: ApQuestionService, + private accountMoveService: AccountMoveService, + private cacheService: CacheService, private queueService: QueueService, ) { this.logger = this.apLoggerService.logger; } - + @bindThis public async performActivity(actor: RemoteUser, activity: IObject) { if (isCollectionOrOrderedCollection(activity)) { @@ -139,6 +143,8 @@ export class ApInboxService { await this.block(actor, activity); } else if (isFlag(activity)) { await this.flag(actor, activity); + } else if (isMove(activity)) { + await this.move(actor, activity); } else { this.logger.warn(`unrecognized activity type: ${activity.type}`); } @@ -147,15 +153,16 @@ export class ApInboxService { @bindThis private async follow(actor: RemoteUser, activity: IFollow): Promise { const followee = await this.apDbResolverService.getUserFromApId(activity.object); - + if (followee == null) { return 'skip: followee not found'; } - + if (followee.host != null) { return 'skip: フォローしようとしているユーザーはローカルユーザーではありません'; } - + + // don't queue because the sender may attempt again when timeout await this.userFollowingService.follow(actor, followee, activity.id); return 'ok'; } @@ -183,16 +190,16 @@ export class ApInboxService { const uri = activity.id ?? activity; this.logger.info(`Accept: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(err => { this.logger.error(`Resolution failed: ${err}`); throw err; }); - + if (isFollow(object)) return await this.acceptFollow(actor, object); - + return `skip: Unknown Accept type: ${getApType(object)}`; } @@ -225,18 +232,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.addPinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -405,10 +412,10 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + // 削除対象objectのtype let formerType: string | undefined; - + if (typeof activity.object === 'string') { // typeが不明だけど、どうせ消えてるのでremote resolveしない formerType = undefined; @@ -420,19 +427,19 @@ export class ApInboxService { formerType = toSingle(object.type); } } - + const uri = getApId(activity.object); - + // type不明でもactorとobjectが同じならばそれはPersonに違いない if (!formerType && actor.uri === uri) { formerType = 'Person'; } - + // それでもなかったらおそらくNote if (!formerType) { formerType = 'Note'; } - + if (validPost.includes(formerType)) { return await this.deleteNote(actor, uri); } else if (validActor.includes(formerType)) { @@ -445,44 +452,44 @@ export class ApInboxService { @bindThis private async deleteActor(actor: RemoteUser, uri: string): Promise { this.logger.info(`Deleting the Actor: ${uri}`); - + if (actor.uri !== uri) { return `skip: delete actor ${actor.uri} !== ${uri}`; } - + const user = await this.usersRepository.findOneBy({ id: actor.id }); if (user == null) { return 'skip: actor not found'; } else if (user.isDeleted) { return 'skip: already deleted'; } - + const job = await this.queueService.createDeleteAccountJob(actor); - + await this.usersRepository.update(actor.id, { isDeleted: true, }); - + return `ok: queued ${job.name} ${job.id}`; } @bindThis private async deleteNote(actor: RemoteUser, uri: string): Promise { this.logger.info(`Deleting the Note: ${uri}`); - + const unlock = await this.appLockService.getApLock(uri); - + try { const note = await this.apDbResolverService.getNoteFromApId(uri); - + if (note == null) { return 'message not found'; } - + if (note.userId !== actor.id) { return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; } - + await this.noteDeleteService.delete(actor, note); return 'ok: note deleted'; } finally { @@ -536,23 +543,23 @@ export class ApInboxService { @bindThis private async rejectFollow(actor: RemoteUser, activity: IFollow): Promise { // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある - + const follower = await this.apDbResolverService.getUserFromApId(activity.actor); - + if (follower == null) { return 'skip: follower not found'; } - + if (!this.userEntityService.isLocalUser(follower)) { return 'skip: follower is not a local user'; } - + // relay const match = activity.id?.match(/follow-relay\/(\w+)/); if (match) { return await this.relayService.relayRejected(match[1]); } - + await this.userFollowingService.remoteReject(actor, follower); return 'ok'; } @@ -562,18 +569,18 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + if (activity.target == null) { throw new Error('target is null'); } - + if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); if (note == null) throw new Error('note not found'); await this.notePiningService.removePinned(actor, note.id); return; } - + throw new Error(`unknown target: ${activity.target}`); } @@ -582,24 +589,25 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { throw new Error('invalid actor'); } - + const uri = activity.id ?? activity; - + this.logger.info(`Undo: ${uri}`); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + + // don't queue because the sender may attempt again when timeout if (isFollow(object)) return await this.undoFollow(actor, object); if (isBlock(object)) return await this.undoBlock(actor, object); if (isLike(object)) return await this.undoLike(actor, object); if (isAnnounce(object)) return await this.undoAnnounce(actor, object); if (isAccept(object)) return await this.undoAccept(actor, object); - + return `skip: unknown object type ${getApType(object)}`; } @@ -609,17 +617,17 @@ export class ApInboxService { if (follower == null) { return 'skip: follower not found'; } - + const following = await this.followingsRepository.findOneBy({ followerId: follower.id, followeeId: actor.id, }); - + if (following) { await this.userFollowingService.unfollow(follower, actor); return 'ok: unfollowed'; } - + return 'skip: フォローされていない'; } @@ -708,16 +716,16 @@ export class ApInboxService { if ('actor' in activity && actor.uri !== activity.actor) { return 'skip: invalid actor'; } - + this.logger.debug('Update'); - + const resolver = this.apResolverService.createResolver(); - + const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); throw e; }); - + if (isActor(object)) { await this.apPersonService.updatePerson(actor.uri!, resolver, object); return 'ok: Person updated'; @@ -728,4 +736,13 @@ export class ApInboxService { return `skip: Unknown type: ${getApType(object)}`; } } + + @bindThis + private async move(actor: RemoteUser, activity: IMove): Promise { + // fetch the new and old accounts + const targetUri = getApHrefNullable(activity.target); + if (!targetUri) return 'skip: invalid activity target'; + + return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do'; + } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 0d03e3d90..60e19bfca 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid'; import * as mfm from 'mfm-js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { PartialLocalUser, LocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import type { IMentionedRemoteUsers, Note } from '@/models/entities/Note.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import type { Relay } from '@/models/entities/Relay.js'; @@ -14,16 +14,18 @@ import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { Emoji } from '@/models/entities/Emoji.js'; import type { Poll } from '@/models/entities/Poll.js'; import type { PollVote } from '@/models/entities/PollVote.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; import { MfmService } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { UserKeypair } from '@/models/entities/UserKeypair.js'; import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, EmojisRepository, PollsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import { LdSignatureService } from './LdSignatureService.js'; import { ApMfmService } from './ApMfmService.js'; -import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, 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 { IIdentifier } from './models/identifier.js'; @Injectable() @@ -50,10 +52,11 @@ export class ApRendererService { @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, + private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, private ldSignatureService: LdSignatureService, - private userKeypairStoreService: UserKeypairStoreService, + private userKeypairService: UserKeypairService, private apMfmService: ApMfmService, private mfmService: MfmService, ) { @@ -63,7 +66,7 @@ export class ApRendererService { public renderAccept(object: any, user: { id: User['id']; host: null }): IAccept { return { type: 'Accept', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, }; } @@ -72,7 +75,7 @@ export class ApRendererService { public renderAdd(user: LocalUser, target: any, object: any): IAdd { return { type: 'Add', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), target, object, }; @@ -80,7 +83,7 @@ export class ApRendererService { @bindThis public renderAnnounce(object: any, note: Note): IAnnounce { - const attributedTo = `${this.config.url}/users/${note.userId}`; + const attributedTo = this.userEntityService.genLocalUserUri(note.userId); let to: string[] = []; let cc: string[] = []; @@ -100,7 +103,7 @@ export class ApRendererService { return { id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, + actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Announce', published: note.createdAt.toISOString(), to, @@ -123,7 +126,7 @@ export class ApRendererService { return { type: 'Block', id: `${this.config.url}/blocks/${block.id}`, - actor: `${this.config.url}/users/${block.blockerId}`, + actor: this.userEntityService.genLocalUserUri(block.blockerId), object: block.blockee.uri, }; } @@ -132,7 +135,7 @@ export class ApRendererService { public renderCreate(object: IObject, note: Note): ICreate { const activity = { id: `${this.config.url}/notes/${note.id}/activity`, - actor: `${this.config.url}/users/${note.userId}`, + actor: this.userEntityService.genLocalUserUri(note.userId), type: 'Create', published: note.createdAt.toISOString(), object, @@ -148,7 +151,7 @@ export class ApRendererService { public renderDelete(object: IObject | string, user: { id: User['id']; host: null }): IDelete { return { type: 'Delete', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), }; @@ -185,7 +188,7 @@ export class ApRendererService { public renderFlag(user: LocalUser, object: IObject | string, content: string): IFlag { return { type: 'Flag', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), content, object, }; @@ -196,7 +199,7 @@ export class ApRendererService { return { id: `${this.config.url}/activities/follow-relay/${relay.id}`, type: 'Follow', - actor: `${this.config.url}/users/${relayActor.id}`, + actor: this.userEntityService.genLocalUserUri(relayActor.id), object: 'https://www.w3.org/ns/activitystreams#Public', }; } @@ -207,21 +210,21 @@ export class ApRendererService { */ @bindThis public async renderFollowUser(id: User['id']) { - const user = await this.usersRepository.findOneByOrFail({ id: id }); - return this.userEntityService.isLocalUser(user) ? `${this.config.url}/users/${user.id}` : user.uri; + const user = await this.usersRepository.findOneByOrFail({ id: id }) as PartialLocalUser | PartialRemoteUser; + return this.userEntityService.getUserUri(user); } @bindThis public renderFollow( - follower: { id: User['id']; host: User['host']; uri: User['host'] }, - followee: { id: User['id']; host: User['host']; uri: User['host'] }, + follower: PartialLocalUser | PartialRemoteUser, + followee: PartialLocalUser | PartialRemoteUser, requestId?: string, ): IFollow { return { id: requestId ?? `${this.config.url}/follows/${follower.id}/${followee.id}`, type: 'Follow', - actor: this.userEntityService.isLocalUser(follower) ? `${this.config.url}/users/${follower.id}` : follower.uri!, - object: this.userEntityService.isLocalUser(followee) ? `${this.config.url}/users/${followee.id}` : followee.uri!, + actor: this.userEntityService.getUserUri(follower)!, + object: this.userEntityService.getUserUri(followee)!, }; } @@ -249,7 +252,7 @@ export class ApRendererService { return { id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`, type: 'Key', - owner: `${this.config.url}/users/${user.id}`, + owner: this.userEntityService.genLocalUserUri(user.id), publicKeyPem: createPublicKey(key.publicKey).export({ type: 'spki', format: 'pem', @@ -272,11 +275,7 @@ export class ApRendererService { if (reaction.startsWith(':')) { const name = reaction.replaceAll(':', ''); - // TODO: cache - const emoji = await this.emojisRepository.findOneBy({ - name, - host: IsNull(), - }); + const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); if (emoji) object.tag = [this.renderEmoji(emoji)]; } @@ -285,14 +284,30 @@ export class ApRendererService { } @bindThis - public renderMention(mention: User): IApMention { + public renderMention(mention: PartialLocalUser | PartialRemoteUser): IApMention { return { type: 'Mention', - href: this.userEntityService.isRemoteUser(mention) ? mention.uri! : `${this.config.url}/users/${(mention as LocalUser).id}`, + href: this.userEntityService.getUserUri(mention)!, name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as LocalUser).username}`, }; } + @bindThis + public renderMove( + src: PartialLocalUser | PartialRemoteUser, + dst: PartialLocalUser | PartialRemoteUser, + ): IMove { + const actor = this.userEntityService.getUserUri(src)!; + const target = this.userEntityService.getUserUri(dst)!; + return { + id: `${this.config.url}/moves/${src.id}/${dst.id}`, + actor, + type: 'Move', + object: actor, + target, + }; + } + @bindThis public async renderNote(note: Note, dive = true): Promise { const getPromisedFiles = async (ids: string[]) => { @@ -336,7 +351,7 @@ export class ApRendererService { } } - const attributedTo = `${this.config.url}/users/${note.userId}`; + const attributedTo = this.userEntityService.genLocalUserUri(note.userId); const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); @@ -361,7 +376,7 @@ export class ApRendererService { }) : []; const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); - const mentionTags = mentionedUsers.map(u => this.renderMention(u)); + const mentionTags = mentionedUsers.map(u => this.renderMention(u as LocalUser | RemoteUser)); const files = await getPromisedFiles(note.fileIds); @@ -435,7 +450,7 @@ export class ApRendererService { @bindThis public async renderPerson(user: LocalUser) { - const id = `${this.config.url}/users/${user.id}`; + const id = this.userEntityService.genLocalUserUri(user.id); const isSystem = !!user.username.match(/\./); const [avatar, banner, profile] = await Promise.all([ @@ -473,7 +488,7 @@ export class ApRendererService { ...hashtagTags, ]; - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); const person = { type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person', @@ -499,6 +514,14 @@ export class ApRendererService { attachment: attachment.length ? attachment : undefined, } as any; + if (user.movedToUri) { + person.movedTo = user.movedToUri; + } + + if (user.alsoKnownAs) { + person.alsoKnownAs = user.alsoKnownAs; + } + if (profile.birthday) { person['vcard:bday'] = profile.birthday; } @@ -515,7 +538,7 @@ export class ApRendererService { return { type: 'Question', id: `${this.config.url}/questions/${note.id}`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), content: note.text ?? '', [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({ name: text, @@ -532,7 +555,7 @@ export class ApRendererService { public renderReject(object: any, user: { id: User['id'] }): IReject { return { type: 'Reject', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, }; } @@ -541,7 +564,7 @@ export class ApRendererService { public renderRemove(user: { id: User['id'] }, target: any, object: any): IRemove { return { type: 'Remove', - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), target, object, }; @@ -562,7 +585,7 @@ export class ApRendererService { return { type: 'Undo', ...(id ? { id } : {}), - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), object, published: new Date().toISOString(), }; @@ -572,7 +595,7 @@ export class ApRendererService { public renderUpdate(object: any, user: { id: User['id'] }): IUpdate { return { id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), type: 'Update', to: ['https://www.w3.org/ns/activitystreams#Public'], object, @@ -584,14 +607,14 @@ export class ApRendererService { public renderVote(user: { id: User['id'] }, vote: PollVote, note: Note, poll: Poll, pollOwner: RemoteUser): ICreate { return { id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`, - actor: `${this.config.url}/users/${user.id}`, + actor: this.userEntityService.genLocalUserUri(user.id), type: 'Create', to: [pollOwner.uri], published: new Date().toISOString(), object: { id: `${this.config.url}/users/${user.id}#votes/${vote.id}`, type: 'Note', - attributedTo: `${this.config.url}/users/${user.id}`, + attributedTo: this.userEntityService.genLocalUserUri(user.id), to: [pollOwner.uri], inReplyTo: note.uri, name: poll.choices[vote.choice], @@ -640,7 +663,7 @@ export class ApRendererService { @bindThis public async attachLdSignature(activity: any, user: { id: User['id']; host: null; }): Promise { - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); const ldSignature = this.ldSignatureService.use(); ldSignature.debug = false; @@ -701,13 +724,9 @@ export class ApRendererService { private async getEmojis(names: string[]): Promise { if (names == null || names.length === 0) return []; - const emojis = await Promise.all( - names.map(name => this.emojisRepository.findOneBy({ - name, - host: IsNull(), - })), - ); + const allEmojis = await this.customEmojiService.localEmojisCache.fetch(); + const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull); - return emojis.filter(emoji => emoji != null) as Emoji[]; + return emojis; } } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 71fbc2947..5005612ab 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -4,7 +4,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { User } from '@/models/entities/User.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; @@ -131,7 +131,7 @@ export class ApRequestService { @Inject(DI.config) private config: Config, - private userKeypairStoreService: UserKeypairStoreService, + private userKeypairService: UserKeypairService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, ) { @@ -143,7 +143,7 @@ export class ApRequestService { public async signedPost(user: { id: User['id'] }, url: string, object: any) { const body = JSON.stringify(object); - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); const req = ApRequestCreator.createSignedPost({ key: { @@ -170,7 +170,7 @@ export class ApRequestService { */ @bindThis public async signedGet(url: string, user: { id: User['id'] }) { - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); const req = ApRequestCreator.createSignedGet({ key: { diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index df7bb4640..d3e0345c9 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { LocalUser } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { InstanceActorService } from '@/core/InstanceActorService.js'; import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -151,7 +151,7 @@ export class Resolver { return Promise.all( [parsed.id, parsed.rest].map(id => this.usersRepository.findOneByOrFail({ id })), ) - .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee, url))); + .then(([follower, followee]) => this.apRendererService.addContext(this.apRendererService.renderFollow(follower as LocalUser | RemoteUser, followee as LocalUser | RemoteUser, url))); default: throw new Error(`resolveLocal: type ${parsed.type} unhandled`); } diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 3b671af12..0043907c2 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -12,6 +12,7 @@ import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; +import { checkHttps } from '@/misc/check-https.js'; @Injectable() export class ApImageService { @@ -48,8 +49,8 @@ export class ApImageService { throw new Error('invalid image: url not privided'); } - if (!image.url.startsWith('https://')) { - throw new Error('invalid image: unexpected shcema of url: ' + image.url); + if (!checkHttps(image.url)) { + throw new Error('invalid image: unexpected schema of url: ' + image.url); } this.logger.info(`Creating the Image: ${image.url}`); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 28bcbc6fa..a9a1f926d 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -1,5 +1,6 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import promiseLimit from 'promise-limit'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { PollsRepository, EmojisRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -31,6 +32,7 @@ import { ApQuestionService } from './ApQuestionService.js'; import { ApImageService } from './ApImageService.js'; import type { Resolver } from '../ApResolverService.js'; import type { IObject, IPost } from '../type.js'; +import { checkHttps } from '@/misc/check-https.js'; @Injectable() export class ApNoteService { @@ -70,7 +72,7 @@ export class ApNoteService { } @bindThis - public validateNote(object: any, uri: string) { + public validateNote(object: IObject, uri: string) { const expectHost = this.utilityService.extractDbHost(uri); if (object == null) { @@ -84,9 +86,10 @@ export class ApNoteService { if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); } - - if (object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.attributedTo)}`); + + const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)); + if (object.attributedTo && actualHost !== expectHost) { + return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } return null; @@ -128,13 +131,13 @@ export class ApNoteService { this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - if (note.id && !note.id.startsWith('https://')) { + if (note.id && !checkHttps(note.id)) { throw new Error('unexpected shcema of note.id: ' + note.id); } const url = getOneApHrefNullable(note.url); - if (url && !url.startsWith('https://')) { + if (url && !checkHttps(url)) { throw new Error('unexpected shcema of note url: ' + url); } @@ -341,15 +344,17 @@ export class ApNoteService { if (!tags) return []; const eomjiTags = toArray(tags).filter(isEmoji); + + const existingEmojis = await this.emojisRepository.findBy({ + host, + name: In(eomjiTags.map(tag => tag.name!.replaceAll(':', ''))), + }); return await Promise.all(eomjiTags.map(async tag => { - const name = tag.name!.replace(/^:/, '').replace(/:$/, ''); + const name = tag.name!.replaceAll(':', ''); tag.icon = toSingle(tag.icon); - const exists = await this.emojisRepository.findOneBy({ - host, - name, - }); + const exists = existingEmojis.find(x => x.name === name); if (exists) { if ((tag.updated != null && exists.updatedAt == null) diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 67e907c27..eea1d1b84 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -3,9 +3,9 @@ import promiseLimit from 'promise-limit'; import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import type { BlockingsRepository, MutingsRepository, FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import type { RemoteUser } from '@/models/entities/User.js'; +import type { LocalUser, RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; import { truncate } from '@/misc/truncate.js'; import type { CacheService } from '@/core/CacheService.js'; @@ -31,6 +31,7 @@ import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { MetaService } from '@/core/MetaService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -41,6 +42,8 @@ import type { ApLoggerService } from '../ApLoggerService.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import type { ApImageService } from './ApImageService.js'; import type { IActor, IObject } from '../type.js'; +import type { AccountMoveService } from '@/core/AccountMoveService.js'; +import { checkHttps } from '@/misc/check-https.js'; const nameLength = 128; const summaryLength = 2048; @@ -49,6 +52,7 @@ const summaryLength = 2048; export class ApPersonService implements OnModuleInit { private utilityService: UtilityService; private userEntityService: UserEntityService; + private driveFileEntityService: DriveFileEntityService; private idService: IdService; private globalEventService: GlobalEventService; private metaService: MetaService; @@ -64,6 +68,7 @@ export class ApPersonService implements OnModuleInit { private usersChart: UsersChart; private instanceChart: InstanceChart; private apLoggerService: ApLoggerService; + private accountMoveService: AccountMoveService; private logger: Logger; constructor( @@ -113,6 +118,7 @@ export class ApPersonService implements OnModuleInit { onModuleInit() { this.utilityService = this.moduleRef.get('UtilityService'); this.userEntityService = this.moduleRef.get('UserEntityService'); + this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.idService = this.moduleRef.get('IdService'); this.globalEventService = this.moduleRef.get('GlobalEventService'); this.metaService = this.moduleRef.get('MetaService'); @@ -128,9 +134,16 @@ export class ApPersonService implements OnModuleInit { this.usersChart = this.moduleRef.get('UsersChart'); this.instanceChart = this.moduleRef.get('InstanceChart'); this.apLoggerService = this.moduleRef.get('ApLoggerService'); + this.accountMoveService = this.moduleRef.get('AccountMoveService'); this.logger = this.apLoggerService.logger; } + 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; + } + /** * Validate and convert to actor object * @param x Fetched object @@ -138,7 +151,7 @@ export class ApPersonService implements OnModuleInit { */ @bindThis private validateActor(x: IObject, uri: string): IActor { - const expectHost = this.utilityService.toPuny(new URL(uri).hostname); + const expectHost = this.punyHost(uri); if (x == null) { throw new Error('invalid Actor: object is null'); @@ -179,7 +192,7 @@ export class ApPersonService implements OnModuleInit { x.summary = truncate(x.summary, summaryLength); } - const idHost = this.utilityService.toPuny(new URL(x.id!).hostname); + const idHost = this.punyHost(x.id); if (idHost !== expectHost) { throw new Error('invalid Actor: id has different host'); } @@ -189,7 +202,7 @@ export class ApPersonService implements OnModuleInit { throw new Error('invalid Actor: publicKey.id is not a string'); } - const publicKeyIdHost = this.utilityService.toPuny(new URL(x.publicKey.id).hostname); + const publicKeyIdHost = this.punyHost(x.publicKey.id); if (publicKeyIdHost !== expectHost) { throw new Error('invalid Actor: publicKey.id has different host'); } @@ -199,27 +212,27 @@ export class ApPersonService implements OnModuleInit { } /** - * Personをフェッチします。 + * uriからUser(Person)をフェッチします。 * - * Misskeyに対象のPersonが登録されていればそれを返します。 + * Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。 */ @bindThis - public async fetchPerson(uri: string, resolver?: Resolver): Promise { + public async fetchPerson(uri: string): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); - const cached = this.cacheService.uriPersonCache.get(uri); + const cached = this.cacheService.uriPersonCache.get(uri) as LocalUser | RemoteUser | null; if (cached) return cached; // URIがこのサーバーを指しているならデータベースからフェッチ - if (uri.startsWith(this.config.url + '/')) { + if (uri.startsWith(`${this.config.url}/`)) { const id = uri.split('/').pop(); - const u = await this.usersRepository.findOneBy({ id }); + const u = await this.usersRepository.findOneBy({ id }) as LocalUser; if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } //#region このサーバーに既に登録されていたらそれを返す - const exist = await this.usersRepository.findOneBy({ uri }); + const exist = await this.usersRepository.findOneBy({ uri }) as LocalUser | RemoteUser; if (exist) { this.cacheService.uriPersonCache.set(uri, exist); @@ -234,7 +247,7 @@ export class ApPersonService implements OnModuleInit { * Personを作成します。 */ @bindThis - public async createPerson(uri: string, resolver?: Resolver): Promise { + public async createPerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); if (uri.startsWith(this.config.url)) { @@ -249,7 +262,7 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Creating the Person: ${person.id}`); - const host = this.utilityService.toPuny(new URL(object.id).hostname); + const host = this.punyHost(object.id); const { fields } = this.analyzeAttachments(person.attachment ?? []); @@ -261,8 +274,8 @@ export class ApPersonService implements OnModuleInit { const url = getOneApHrefNullable(person.url); - if (url && !url.startsWith('https://')) { - throw new Error('unexpected shcema of person url: ' + url); + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); } // Create user @@ -278,6 +291,9 @@ export class ApPersonService implements OnModuleInit { lastFetchedAt: new Date(), name: truncate(person.name, nameLength), isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo, + movedAt: person.movedTo ? new Date() : null, + alsoKnownAs: person.alsoKnownAs, isExplorable: !!person.discoverable, username: person.preferredUsername, usernameLower: person.preferredUsername!.toLowerCase(), @@ -356,54 +372,69 @@ export class ApPersonService implements OnModuleInit { const avatarId = avatar ? avatar.id : null; const bannerId = banner ? banner.id : null; + const avatarUrl = avatar ? this.driveFileEntityService.getPublicUrl(avatar, 'avatar') : null; + const bannerUrl = banner ? this.driveFileEntityService.getPublicUrl(banner) : null; + const avatarBlurhash = avatar ? avatar.blurhash : null; + const bannerBlurhash = banner ? banner.blurhash : null; await this.usersRepository.update(user!.id, { avatarId, bannerId, + avatarUrl, + bannerUrl, + avatarBlurhash, + bannerBlurhash, }); - user!.avatarId = avatarId; - user!.bannerId = bannerId; - //#endregion + user!.avatarId = avatarId; + user!.bannerId = bannerId; + user!.avatarUrl = avatarUrl; + user!.bannerUrl = bannerUrl; + user!.avatarBlurhash = avatarBlurhash; + user!.bannerBlurhash = bannerBlurhash; + //#endregion - //#region カスタム絵文字取得 - const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { - this.logger.info(`extractEmojis: ${err}`); - return [] as Emoji[]; - }); + //#region カスタム絵文字取得 + const emojis = await this.apNoteService.extractEmojis(person.tag ?? [], host).catch(err => { + this.logger.info(`extractEmojis: ${err}`); + return [] as Emoji[]; + }); - const emojiNames = emojis.map(emoji => emoji.name); + const emojiNames = emojis.map(emoji => emoji.name); - await this.usersRepository.update(user!.id, { - emojis: emojiNames, - }); - //#endregion + await this.usersRepository.update(user!.id, { + emojis: emojiNames, + }); + //#endregion - await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); + await this.updateFeatured(user!.id, resolver).catch(err => this.logger.error(err)); - return user!; + return user!; } /** * Personの情報を更新します。 * Misskeyに対象のPersonが登録されていなければ無視します。 + * もしアカウントの移行が確認された場合、アカウント移行処理を行います。 + * * @param uri URI of Person * @param resolver Resolver * @param hint Hint of Person object (この値が正当なPersonの場合、Remote resolveをせずに更新に利用します) + * @param movePreventUris ここに指定されたURIがPersonのmovedToに指定されていたり10回より多く回っている場合これ以上アカウント移行を行わない(無限ループ防止) */ @bindThis - public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject): Promise { + public async updatePerson(uri: string, resolver?: Resolver | null, hint?: IObject, movePreventUris: string[] = []): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(this.config.url + '/')) { + if (uri.startsWith(`${this.config.url}/`)) { return; } //#region このサーバーに既に登録されているか - const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser; + const exist = await this.usersRepository.findOneBy({ uri }) as RemoteUser | null; - if (exist == null) { + if (exist === null) { return; } //#endregion @@ -442,8 +473,8 @@ export class ApPersonService implements OnModuleInit { const url = getOneApHrefNullable(person.url); - if (url && !url.startsWith('https://')) { - throw new Error('unexpected shcema of person url: ' + url); + if (url && !checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); } const updates = { @@ -458,15 +489,30 @@ export class ApPersonService implements OnModuleInit { isBot: getApType(object) === 'Service', isCat: (person as any).isCat === true, isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo ?? null, + alsoKnownAs: person.alsoKnownAs ?? null, isExplorable: !!person.discoverable, - } as Partial; + } as Partial & Pick; + + const moving = + // 移行先がない→ある + (!exist.movedToUri && updates.movedToUri) || + // 移行先がある→別のもの + (exist.movedToUri !== updates.movedToUri && exist.movedToUri && updates.movedToUri); + // 移行先がある→ない、ない→ないは無視 + + if (moving) updates.movedAt = new Date(); if (avatar) { updates.avatarId = avatar.id; + updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + updates.avatarBlurhash = avatar.blurhash; } if (banner) { updates.bannerId = banner.id; + updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + updates.bannerBlurhash = banner.blurhash; } // Update user @@ -500,6 +546,31 @@ export class ApPersonService implements OnModuleInit { }); await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); + + const updated = { ...exist, ...updates }; + + this.cacheService.uriPersonCache.set(uri, updated); + + // 移行処理を行う + if (updated.movedAt && ( + // 初めて移行する場合はmovedAtがnullなので移行処理を許可 + exist.movedAt == null || + // 以前のmovingから14日以上経過した場合のみ移行処理を許可 + // (Mastodonのクールダウン期間は30日だが若干緩めに設定しておく) + exist.movedAt.getTime() + 1000 * 60 * 60 * 24 * 14 < updated.movedAt.getTime() + )) { + this.logger.info(`Start to process Move of @${updated.username}@${updated.host} (${uri})`); + return this.processRemoteMove(updated, movePreventUris) + .then(result => { + this.logger.info(`Processing Move Finished [${result}] @${updated.username}@${updated.host} (${uri})`); + return result; + }) + .catch(e => { + this.logger.info(`Processing Move Failed @${updated.username}@${updated.host} (${uri})`, { stack: e }); + }); + } + + return 'skip'; } /** @@ -509,7 +580,7 @@ export class ApPersonService implements OnModuleInit { * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolvePerson(uri: string, resolver?: Resolver): Promise { + public async resolvePerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); //#region このサーバーに既に登録されていたらそれを返す @@ -584,4 +655,53 @@ export class ApPersonService implements OnModuleInit { } }); } + + /** + * リモート由来のアカウント移行処理を行います + * @param src 移行元アカウント(リモートかつupdatePerson後である必要がある、というかこれ自体がupdatePersonで呼ばれる前提) + * @param movePreventUris ここに列挙されたURIにsrc.movedToUriが含まれる場合、移行処理はしない(無限ループ防止) + */ + @bindThis + private async processRemoteMove(src: RemoteUser, movePreventUris: string[] = []): Promise { + if (!src.movedToUri) return 'skip: no movedToUri'; + if (src.uri === src.movedToUri) return 'skip: movedTo itself (src)'; // ??? + if (movePreventUris.length > 10) return 'skip: too many moves'; + + // まずサーバー内で検索して様子見 + let dst = await this.fetchPerson(src.movedToUri); + + if (dst && this.userEntityService.isLocalUser(dst)) { + // targetがローカルユーザーだった場合データベースから引っ張ってくる + dst = await this.usersRepository.findOneByOrFail({ uri: src.movedToUri }) as LocalUser; + } else if (dst) { + if (movePreventUris.includes(src.movedToUri)) return 'skip: circular move'; + + // targetを見つけたことがあるならtargetをupdatePersonする + await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]); + dst = await this.fetchPerson(src.movedToUri) ?? dst; + } else { + if (src.movedToUri.startsWith(`${this.config.url}/`)) { + // ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている + return 'failed: movedTo is local but not found'; + } + + // targetが知らない人だったらresolvePerson + // (uriが存在しなかったり応答がなかったりする場合resolvePersonはthrow Errorする) + dst = await this.resolvePerson(src.movedToUri); + } + + if (dst.movedToUri === dst.uri) return 'skip: movedTo itself (dst)'; // ??? + if (src.movedToUri !== dst.uri) return 'skip: missmatch uri'; // ??? + if (dst.movedToUri === src.uri) return 'skip: dst.movedToUri === src.uri'; + if (!dst.alsoKnownAs || dst.alsoKnownAs.length === 0) { + return 'skip: dst.alsoKnownAs is empty'; + } + if (!dst.alsoKnownAs?.includes(src.uri)) { + return 'skip: alsoKnownAs does not include from.uri'; + } + + await this.accountMoveService.postMoveProcess(src, dst); + + return 'ok'; + } } diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 885194633..625135da6 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -157,6 +157,8 @@ export interface IActor extends IObject { name?: string; preferredUsername?: string; manuallyApprovesFollowers?: boolean; + movedTo?: string; + alsoKnownAs?: string[]; discoverable?: boolean; inbox: string; sharedInbox?: string; // 後方互換性のため @@ -300,6 +302,11 @@ export interface IFlag extends IActivity { type: 'Flag'; } +export interface IMove extends IActivity { + type: 'Move'; + target: IObject | string; +} + export const isCreate = (object: IObject): object is ICreate => getApType(object) === 'Create'; export const isDelete = (object: IObject): object is IDelete => getApType(object) === 'Delete'; export const isUpdate = (object: IObject): object is IUpdate => getApType(object) === 'Update'; @@ -314,3 +321,4 @@ export const isLike = (object: IObject): object is ILike => getApType(object) == export const isAnnounce = (object: IObject): object is IAnnounce => getApType(object) === 'Announce'; export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; +export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 72e9b2554..987002606 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -84,7 +84,7 @@ export class ChannelEntityService { } : {}), ...(detailed ? { - pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me), + pinnedNotes: (await this.noteEntityService.packMany(pinnedNotes, me)).sort((a, b) => channel.pinnedNoteIds.indexOf(a.id) - channel.pinnedNoteIds.indexOf(b.id)), } : {}), }; } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 2d40f444c..d82f36d97 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -266,6 +266,7 @@ export class DriveFileEntityService { fileIds: DriveFile['id'][], options?: PackOptions, ): Promise['id'], Packed<'DriveFile'> | null>> { + if (fileIds.length === 0) return new Map(); const files = await this.driveFilesRepository.findBy({ id: In(fileIds) }); const packedFiles = await this.packMany(files, options); const map = new Map['id'], Packed<'DriveFile'> | null>(packedFiles.map(f => [f.id, f])); @@ -280,6 +281,7 @@ export class DriveFileEntityService { fileIds: DriveFile['id'][], options?: PackOptions, ): Promise[]> { + if (fileIds.length === 0) return []; const filesMap = await this.packManyByIdsMap(fileIds, options); return fileIds.map(id => filesMap.get(id)).filter(isNotNull); } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 566060069..26debd6ad 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -183,6 +183,11 @@ export class NoteEntityService implements OnModuleInit { // 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない } + // パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない + if (note.createdAt.getTime() + 1000 > Date.now()) { + return undefined; + } + const reaction = await this.noteReactionsRepository.findOneBy({ userId: meId, noteId: note.id, @@ -283,7 +288,7 @@ export class NoteEntityService implements OnModuleInit { }, options); const meId = me ? me.id : null; - const note = typeof src === 'object' ? src : await this.notesRepository.findOneByOrFail({ id: src }); + const note = typeof src === 'object' ? src : await this.notesRepository.findOneOrFail({ where: { id: src }, relations: ['user'] }); const host = note.userHost; let text = note.text; @@ -395,7 +400,8 @@ export class NoteEntityService implements OnModuleInit { const myReactionsMap = new Map(); if (meId) { const renoteIds = notes.filter(n => n.renoteId != null).map(n => n.renoteId!); - const targets = [...notes.map(n => n.id), ...renoteIds]; + // パフォーマンスのためノートが作成されてから1秒以上経っていない場合はリアクションを取得しない + const targets = [...notes.filter(n => n.createdAt.getTime() + 1000 < Date.now()).map(n => n.id), ...renoteIds]; const myReactions = await this.noteReactionsRepository.findBy({ userId: meId, noteId: In(targets), @@ -406,10 +412,10 @@ export class NoteEntityService implements OnModuleInit { } } - await this.customEmojiService.prefetchEmojis(this.customEmojiService.aggregateNoteEmojis(notes)); + await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)); // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); - const packedFiles = await this.driveFileEntityService.packManyByIdsMap(fileIds); + const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, @@ -420,6 +426,30 @@ export class NoteEntityService implements OnModuleInit { }))); } + @bindThis + public aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null; }[] = []; + for (const note of notes) { + emojis = emojis.concat(note.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); + if (note.renote) { + emojis = emojis.concat(note.renote.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); + if (note.renote.user) { + emojis = emojis.concat(note.renote.user.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.renote!.userHost))); + } + } + const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat(note.user.emojis + .map(e => this.customEmojiService.parseEmojiStr(e, note.userHost))); + } + } + return emojis.filter(x => x.name != null && x.host != null) as { name: string; host: string; }[]; + } + @bindThis public async countSameRenotes(userId: string, renoteId: string, excludeNoteId: string | undefined): Promise { // 指定したユーザーの指定したノートのリノートがいくつあるか数える diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 6b9a9d332..0dc63d969 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -108,27 +108,30 @@ export class NotificationEntityService implements OnModuleInit { ) { if (notifications.length === 0) return []; - const noteIds = notifications.map(x => x.noteId).filter(isNotNull); + let validNotifications = notifications; + + const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull); const notes = noteIds.length > 0 ? await this.notesRepository.find({ where: { id: In(noteIds) }, - relations: ['user', 'user.avatar', 'user.banner', 'reply', 'reply.user', 'reply.user.avatar', 'reply.user.banner', 'renote', 'renote.user', 'renote.user.avatar', 'renote.user.banner'], + relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], }) : []; const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { detail: true, }); const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); - const userIds = notifications.map(x => x.notifierId).filter(isNotNull); + validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId)); + + const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull); const users = userIds.length > 0 ? await this.usersRepository.find({ where: { id: In(userIds) }, - relations: ['avatar', 'banner'], }) : []; const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { detail: false, }); const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); - return await Promise.all(notifications.map(x => this.pack(x, meId, {}, { + return await Promise.all(validNotifications.map(x => this.pack(x, meId, {}, { packedNotes, packedUsers, }))); diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index e111a10b7..54818782d 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -59,6 +59,7 @@ export class RoleEntityService { isPublic: role.isPublic, isAdministrator: role.isAdministrator, isModerator: role.isModerator, + isExplorable: role.isExplorable, asBadge: role.asBadge, canEditMembersByModerator: role.canEditMembersByModerator, displayOrder: role.displayOrder, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index e8474c7e0..7c9a11ee8 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, Not } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; @@ -9,13 +9,13 @@ import type { Packed } from '@/misc/json-schema.js'; import type { Promiseable } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; -import { MemoryKVCache } from '@/misc/cache.js'; -import type { Instance } from '@/models/entities/Instance.js'; -import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import type { LocalUser, PartialLocalUser, PartialRemoteUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { AntennaService } from '../AntennaService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js'; @@ -25,7 +25,7 @@ import type { PageEntityService } from './PageEntityService.js'; type IsUserDetailed = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; type IsMeAndIsUserDetailed = - Detailed extends true ? + Detailed extends true ? ExpectsMe extends true ? Packed<'MeDetailed'> : ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : Packed<'UserDetailed'> : @@ -34,26 +34,27 @@ type IsMeAndIsUserDetailed(user: T): user is T & { host: null; }; +function isLocalUser(user: T): user is (T & { host: null; }); function isLocalUser(user: User | { host: User['host'] }): boolean { return user.host == null; } function isRemoteUser(user: User): user is RemoteUser; -function isRemoteUser(user: T): user is T & { host: string; }; +function isRemoteUser(user: T): user is (T & { host: string; }); function isRemoteUser(user: User | { host: User['host'] }): boolean { return !isLocalUser(user); } @Injectable() export class UserEntityService implements OnModuleInit { + private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; private driveFileEntityService: DriveFileEntityService; private pageEntityService: PageEntityService; private customEmojiService: CustomEmojiService; private antennaService: AntennaService; private roleService: RoleService; - private userInstanceCache: MemoryKVCache; + private federatedInstanceService: FederatedInstanceService; constructor( private moduleRef: ModuleRef, @@ -111,6 +112,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, + + @Inject(DI.userMemosRepository) + private userMemosRepository: UserMemoRepository, //private noteEntityService: NoteEntityService, //private driveFileEntityService: DriveFileEntityService, @@ -119,16 +123,17 @@ export class UserEntityService implements OnModuleInit { //private antennaService: AntennaService, //private roleService: RoleService, ) { - this.userInstanceCache = new MemoryKVCache(1000 * 60 * 60 * 3); } onModuleInit() { + this.apPersonService = this.moduleRef.get('ApPersonService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.antennaService = this.moduleRef.get('AntennaService'); this.roleService = this.moduleRef.get('RoleService'); + this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); } //#region Validators @@ -234,22 +239,10 @@ export class UserEntityService implements OnModuleInit { return false; // TODO } - @bindThis - public async getHasUnreadChannel(userId: User['id']): Promise { - const channels = await this.channelFollowingsRepository.findBy({ followerId: userId }); - - const unread = channels.length > 0 ? await this.noteUnreadsRepository.findOneBy({ - userId: userId, - noteChannelId: In(channels.map(x => x.followeeId)), - }) : null; - - return unread != null; - } - @bindThis public async getHasUnreadNotification(userId: User['id']): Promise { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - + const latestNotificationIdsRes = await this.redisClient.xrevrange( `notificationTimeline:${userId}`, '+', @@ -281,32 +274,22 @@ export class UserEntityService implements OnModuleInit { ); } - @bindThis - public async getAvatarUrl(user: User): Promise { - if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); - } else if (user.avatarId) { - const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); - return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user); - } else { - return this.getIdenticonUrl(user); - } - } - - @bindThis - public getAvatarUrlSync(user: User): string { - if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user); - } else { - return this.getIdenticonUrl(user); - } - } - @bindThis public getIdenticonUrl(user: User): string { return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; } + @bindThis + public getUserUri(user: LocalUser | PartialLocalUser | RemoteUser | PartialRemoteUser): string { + return this.isRemoteUser(user) + ? user.uri : this.genLocalUserUri(user.id); + } + + @bindThis + public genLocalUserUri(userId: string): string { + return `${this.config.url}/users/${userId}`; + } + public async pack( src: User['id'] | User, me?: { id: User['id'] } | null | undefined, @@ -321,19 +304,23 @@ export class UserEntityService implements OnModuleInit { includeSecrets: false, }, options); - let user: User; + const user = typeof src === 'object' ? src : await this.usersRepository.findOneByOrFail({ id: src }); - if (typeof src === 'object') { - user = src; - if (src.avatar === undefined && src.avatarId) src.avatar = await this.driveFilesRepository.findOneBy({ id: src.avatarId }) ?? null; - if (src.banner === undefined && src.bannerId) src.banner = await this.driveFilesRepository.findOneBy({ id: src.bannerId }) ?? null; - } else { - user = await this.usersRepository.findOneOrFail({ - where: { id: src }, - relations: { - avatar: true, - banner: true, - }, + // migration + if (user.avatarId != null && user.avatarUrl === null) { + const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); + user.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + this.usersRepository.update(user.id, { + avatarUrl: user.avatarUrl, + avatarBlurhash: avatar.blurhash, + }); + } + if (user.bannerId != null && user.bannerUrl === null) { + const banner = await this.driveFilesRepository.findOneByOrFail({ id: user.bannerId }); + user.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + this.usersRepository.update(user.id, { + bannerUrl: user.bannerUrl, + bannerBlurhash: banner.blurhash, }); } @@ -368,14 +355,11 @@ export class UserEntityService implements OnModuleInit { name: user.name, username: user.username, host: user.host, - avatarUrl: this.getAvatarUrlSync(user), - avatarBlurhash: user.avatar?.blurhash ?? null, + avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), + avatarBlurhash: user.avatarBlurhash, isBot: user.isBot ?? falsy, isCat: user.isCat ?? falsy, - instance: user.host ? this.userInstanceCache.fetch(user.host, - () => this.instancesRepository.findOneBy({ host: user.host! }), - v => v != null, - ).then(instance => instance ? { + instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { name: instance.name, softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, @@ -395,11 +379,16 @@ export class UserEntityService implements OnModuleInit { ...(opts.detail ? { url: profile!.url, uri: user.uri, + movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null, + alsoKnownAs: user.alsoKnownAs + ? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))) + .then(xs => xs.length === 0 ? null : xs.filter(x => x != null) as string[]) + : null, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, - bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner) : null, - bannerBlurhash: user.banner?.blurhash ?? null, + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, isLocked: user.isLocked, isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSuspended: user.isSuspended ?? falsy, @@ -436,6 +425,10 @@ export class UserEntityService implements OnModuleInit { isAdministrator: role.isAdministrator, displayOrder: role.displayOrder, }))), + memo: meId == null ? null : await this.userMemosRepository.findOneBy({ + userId: meId, + targetUserId: user.id, + }).then(row => row?.memo ?? null), } : {}), ...(opts.detail && isMe ? { @@ -463,7 +456,7 @@ export class UserEntityService implements OnModuleInit { }).then(count => count > 0), hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), hasUnreadAntenna: this.getHasUnreadAntenna(user.id), - hasUnreadChannel: this.getHasUnreadChannel(user.id), + hasUnreadChannel: false, // 後方互換性のため hasUnreadNotification: this.getHasUnreadNotification(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), mutedWords: profile!.mutedWords, diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index 1688bb2ba..bb190cf60 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -40,7 +40,7 @@ export class ServerStatsService implements OnApplicationShutdown { const stats = { cpu: roundCpu(cpu), mem: { - used: round(memStats.used - memStats.buffers - memStats.cached), + used: round(memStats.total - memStats.available), active: round(memStats.active), }, net: { diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 56ce755a1..190d8d65c 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -2,7 +2,8 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), redis: Symbol('redis'), - redisSubscriber: Symbol('redisSubscriber'), + redisForPub: Symbol('redisForPub'), + redisForSub: Symbol('redisForSub'), //#region Repositories usersRepository: Symbol('usersRepository'), @@ -69,5 +70,6 @@ export const DI = { roleAssignmentsRepository: Symbol('roleAssignmentsRepository'), flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), + userMemosRepository: Symbol('userMemosRepository'), //#endregion }; diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 870dfd237..561092964 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -1,29 +1,29 @@ -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; -// redis通すとDateのインスタンスはstringに変換されるので -type Serialized = { - [K in keyof T]: - T[K] extends Date - ? string - : T[K] extends (Date | null) - ? (string | null) - : T[K] extends Record - ? Serialized - : T[K]; -}; - export class RedisKVCache { private redisClient: Redis.Redis; private name: string; private lifetime: number; private memoryCache: MemoryKVCache; + private fetcher: (key: string) => Promise; + private toRedisConverter: (value: T) => string; + private fromRedisConverter: (value: string) => T | undefined; - constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], lifetime: RedisKVCache['lifetime'], memoryCacheLifetime: number) { + constructor(redisClient: RedisKVCache['redisClient'], name: RedisKVCache['name'], opts: { + lifetime: RedisKVCache['lifetime']; + memoryCacheLifetime: number; + fetcher: RedisKVCache['fetcher']; + toRedisConverter: RedisKVCache['toRedisConverter']; + fromRedisConverter: RedisKVCache['fromRedisConverter']; + }) { this.redisClient = redisClient; this.name = name; - this.lifetime = lifetime; - this.memoryCache = new MemoryKVCache(memoryCacheLifetime); + this.lifetime = opts.lifetime; + this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); + this.fetcher = opts.fetcher; + this.toRedisConverter = opts.toRedisConverter; + this.fromRedisConverter = opts.fromRedisConverter; } @bindThis @@ -32,25 +32,25 @@ export class RedisKVCache { if (this.lifetime === Infinity) { await this.redisClient.set( `kvcache:${this.name}:${key}`, - JSON.stringify(value), + this.toRedisConverter(value), ); } else { await this.redisClient.set( `kvcache:${this.name}:${key}`, - JSON.stringify(value), - 'ex', Math.round(this.lifetime / 1000), + this.toRedisConverter(value), + 'EX', Math.round(this.lifetime / 1000), ); } } @bindThis - public async get(key: string): Promise | T | undefined> { + public async get(key: string): Promise { const memoryCached = this.memoryCache.get(key); if (memoryCached !== undefined) return memoryCached; const cached = await this.redisClient.get(`kvcache:${this.name}:${key}`); if (cached == null) return undefined; - return JSON.parse(cached); + return this.fromRedisConverter(cached); } @bindThis @@ -60,29 +60,113 @@ export class RedisKVCache { } /** - * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します - * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします - */ + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + */ @bindThis - public async fetch(key: string, fetcher: () => Promise, validator?: (cachedValue: Serialized | T) => boolean): Promise | T> { + public async fetch(key: string): Promise { const cachedValue = await this.get(key); if (cachedValue !== undefined) { - if (validator) { - if (validator(cachedValue)) { - // Cache HIT - return cachedValue; - } - } else { - // Cache HIT - return cachedValue; - } + // Cache HIT + return cachedValue; } // Cache MISS - const value = await fetcher(); + const value = await this.fetcher(key); this.set(key, value); return value; } + + @bindThis + public async refresh(key: string) { + const value = await this.fetcher(key); + this.set(key, value); + + // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする + } +} + +export class RedisSingleCache { + private redisClient: Redis.Redis; + private name: string; + private lifetime: number; + private memoryCache: MemorySingleCache; + private fetcher: () => Promise; + private toRedisConverter: (value: T) => string; + private fromRedisConverter: (value: string) => T | undefined; + + constructor(redisClient: RedisSingleCache['redisClient'], name: RedisSingleCache['name'], opts: { + lifetime: RedisSingleCache['lifetime']; + memoryCacheLifetime: number; + fetcher: RedisSingleCache['fetcher']; + toRedisConverter: RedisSingleCache['toRedisConverter']; + fromRedisConverter: RedisSingleCache['fromRedisConverter']; + }) { + this.redisClient = redisClient; + this.name = name; + this.lifetime = opts.lifetime; + this.memoryCache = new MemorySingleCache(opts.memoryCacheLifetime); + this.fetcher = opts.fetcher; + this.toRedisConverter = opts.toRedisConverter; + this.fromRedisConverter = opts.fromRedisConverter; + } + + @bindThis + public async set(value: T): Promise { + this.memoryCache.set(value); + if (this.lifetime === Infinity) { + await this.redisClient.set( + `singlecache:${this.name}`, + this.toRedisConverter(value), + ); + } else { + await this.redisClient.set( + `singlecache:${this.name}`, + this.toRedisConverter(value), + 'EX', Math.round(this.lifetime / 1000), + ); + } + } + + @bindThis + public async get(): Promise { + const memoryCached = this.memoryCache.get(); + if (memoryCached !== undefined) return memoryCached; + + const cached = await this.redisClient.get(`singlecache:${this.name}`); + if (cached == null) return undefined; + return this.fromRedisConverter(cached); + } + + @bindThis + public async delete(): Promise { + this.memoryCache.delete(); + await this.redisClient.del(`singlecache:${this.name}`); + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + */ + @bindThis + public async fetch(): Promise { + const cachedValue = await this.get(); + if (cachedValue !== undefined) { + // Cache HIT + return cachedValue; + } + + // Cache MISS + const value = await this.fetcher(); + this.set(value); + return value; + } + + @bindThis + public async refresh() { + const value = await this.fetcher(); + this.set(value); + + // TODO: イベント発行して他プロセスのメモリキャッシュも更新できるようにする + } } // TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? @@ -173,12 +257,12 @@ export class MemoryKVCache { } } -export class MemoryCache { +export class MemorySingleCache { private cachedAt: number | null = null; private value: T | undefined; private lifetime: number; - constructor(lifetime: MemoryCache['lifetime']) { + constructor(lifetime: MemorySingleCache['lifetime']) { this.lifetime = lifetime; } diff --git a/packages/backend/src/misc/check-https.ts b/packages/backend/src/misc/check-https.ts new file mode 100644 index 000000000..b33f01997 --- /dev/null +++ b/packages/backend/src/misc/check-https.ts @@ -0,0 +1,4 @@ +export function checkHttps(url: string) { + return url.startsWith('https://') || + (url.startsWith('http://') && process.env.NODE_ENV !== 'production'); +} diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 93a9929aa..9e206ee98 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -3,6 +3,8 @@ import * as crypto from 'node:crypto'; +export const aidRegExp = /^[0-9a-z]{10}$/; + const TIME2000 = 946684800000; let counter = crypto.randomBytes(2).readUInt16LE(0); diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts index 30bbdf169..337416b05 100644 --- a/packages/backend/src/misc/id/meid.ts +++ b/packages/backend/src/misc/id/meid.ts @@ -1,5 +1,8 @@ const CHARS = '0123456789abcdef'; +// same as object-id +export const meidRegExp = /^[0-9a-f]{24}$/; + function getTime(time: number) { if (time < 0) time = 0; if (time === 0) { @@ -24,3 +27,9 @@ function getRandom() { export function genMeid(date: Date): string { return getTime(date.getTime()) + getRandom(); } + +export function parseMeid(id: string): { date: Date; } { + return { + date: new Date(parseInt(id.slice(0, 12), 16) - 0x800000000000), + }; +} diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts index d4aaaea1b..19d0bc1fd 100644 --- a/packages/backend/src/misc/id/meidg.ts +++ b/packages/backend/src/misc/id/meidg.ts @@ -3,6 +3,7 @@ const CHARS = '0123456789abcdef'; // 4bit Fixed hex value 'g' // 44bit UNIX Time ms in Hex // 48bit Random value in Hex +export const meidgRegExp = /^g[0-9a-f]{23}$/; function getTime(time: number) { if (time < 0) time = 0; @@ -26,3 +27,9 @@ function getRandom() { export function genMeidg(date: Date): string { return 'g' + getTime(date.getTime()) + getRandom(); } + +export function parseMeidg(id: string): { date: Date; } { + return { + date: new Date(parseInt(id.slice(1, 12), 16)), + }; +} diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts index 392ea4330..aec3447bd 100644 --- a/packages/backend/src/misc/id/object-id.ts +++ b/packages/backend/src/misc/id/object-id.ts @@ -1,5 +1,8 @@ const CHARS = '0123456789abcdef'; +// same as meid +export const objectIdRegExp = /^[0-9a-f]{24}$/; + function getTime(time: number) { if (time < 0) time = 0; if (time === 0) { @@ -24,3 +27,9 @@ function getRandom() { export function genObjectId(date: Date): string { return getTime(date.getTime()) + getRandom(); } + +export function parseObjectId(id: string): { date: Date; } { + return { + date: new Date(parseInt(id.slice(0, 8), 16) * 1000), + }; +} diff --git a/packages/backend/src/misc/id/ulid.ts b/packages/backend/src/misc/id/ulid.ts new file mode 100644 index 000000000..e8aa75289 --- /dev/null +++ b/packages/backend/src/misc/id/ulid.ts @@ -0,0 +1,14 @@ +// Crockford's Base32 +// https://github.com/ulid/spec#encoding +const CHARS = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +export const ulidRegExp = /^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/; + +export function parseUlid(id: string): { date: Date; } { + const timestamp = id.slice(0, 10); + let time = 0; + for (let i = 0; i < 10; i++) { + time = time * 32 + CHARS.indexOf(timestamp[i]); + } + return { date: new Date(time) }; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 7be7b8190..588c98b58 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite, UserMemo } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -388,6 +388,12 @@ const $roleAssignmentsRepository: Provider = { inject: [DI.db], }; +const $userMemosRepository: Provider = { + provide: DI.userMemosRepository, + useFactory: (db: DataSource) => db.getRepository(UserMemo), + inject: [DI.db], +}; + @Module({ imports: [ ], @@ -456,6 +462,7 @@ const $roleAssignmentsRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $userMemosRepository, ], exports: [ $usersRepository, @@ -522,6 +529,7 @@ const $roleAssignmentsRepository: Provider = { $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, + $userMemosRepository, ], }) export class RepositoryModule {} diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts index 2e4f90b57..6d44e4edc 100644 --- a/packages/backend/src/models/entities/Meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -405,4 +405,16 @@ export class Meta { default: { }, }) public policies: Record; + + @Column('varchar', { + length: 280, + array: true, + default: '{}', + }) + public serverRules: string[]; + + @Column('varchar', { + length: 1024, array: true, default: '{ "admin", "administrator", "root", "system", "maintainer", "host", "mod", "moderator", "owner", "superuser", "staff", "auth", "i", "me", "everyone", "all", "mention", "mentions", "example", "user", "users", "account", "accounts", "official", "help", "helps", "support", "supports", "info", "information", "informations", "announce", "announces", "announcement", "announcements", "notice", "notification", "notifications", "dev", "developer", "developers", "tech", "misskey" }', + }) + public preservedUsernames: string[]; } diff --git a/packages/backend/src/models/entities/Role.ts b/packages/backend/src/models/entities/Role.ts index eca9bcf27..61f40d59d 100644 --- a/packages/backend/src/models/entities/Role.ts +++ b/packages/backend/src/models/entities/Role.ts @@ -151,6 +151,11 @@ export class Role { }) public isAdministrator: boolean; + @Column('boolean', { + default: false, + }) + public isExplorable: boolean; + @Column('boolean', { default: false, }) diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts index 0a8b89ea0..8e10f999b 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -68,6 +68,25 @@ export class User { }) public followingCount: number; + @Column('varchar', { + length: 512, + nullable: true, + comment: 'The URI of the new account of the User', + }) + public movedToUri: string | null; + + @Column('timestamp with time zone', { + nullable: true, + comment: 'When the user moved to another account', + }) + public movedAt: Date | null; + + @Column('simple-array', { + nullable: true, + comment: 'URIs the user is known as too', + }) + public alsoKnownAs: string[] | null; + @Column('integer', { default: 0, comment: 'The count of notes.', @@ -100,6 +119,26 @@ export class User { @JoinColumn() public banner: DriveFile | null; + @Column('varchar', { + length: 512, nullable: true, + }) + public avatarUrl: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public bannerUrl: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public avatarBlurhash: string | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public bannerBlurhash: string | null; + @Index() @Column('varchar', { length: 128, array: true, default: '{}', @@ -220,11 +259,23 @@ export type LocalUser = User & { uri: null; } +export type PartialLocalUser = Partial & { + id: User['id']; + host: null; + uri: null; +} + export type RemoteUser = User & { host: string; uri: string; } +export type PartialRemoteUser = Partial & { + id: User['id']; + host: string; + uri: string; +} + export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; export const passwordSchema = { type: 'string', minLength: 1 } as const; export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; diff --git a/packages/backend/src/models/entities/UserMemo.ts b/packages/backend/src/models/entities/UserMemo.ts new file mode 100644 index 000000000..7dc34b434 --- /dev/null +++ b/packages/backend/src/models/entities/UserMemo.ts @@ -0,0 +1,42 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; + +@Entity() +@Index(['userId', 'targetUserId'], { unique: true }) +export class UserMemo { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The ID of target user.', + }) + public targetUserId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public targetUser: User | null; + + @Column('varchar', { + length: 2048, + comment: 'Memo.', + }) + public memo: string; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 48d6e15f2..b8ba28db9 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -55,6 +55,7 @@ import { UserPending } from '@/models/entities/UserPending.js'; import { UserProfile } from '@/models/entities/UserProfile.js'; import { UserPublickey } from '@/models/entities/UserPublickey.js'; import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; +import { UserMemo } from '@/models/entities/UserMemo.js'; import { Webhook } from '@/models/entities/Webhook.js'; import { Channel } from '@/models/entities/Channel.js'; import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js'; @@ -129,6 +130,7 @@ export { RoleAssignment, Flash, FlashLike, + UserMemo, }; export type AbuseUserReportsRepository = Repository; @@ -195,3 +197,4 @@ export type RolesRepository = Repository; export type RoleAssignmentsRepository = Repository; export type FlashsRepository = Repository; export type FlashLikesRepository = Repository; +export type UserMemoRepository = Repository; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index e8a7212c5..42b5d53ac 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -72,6 +72,23 @@ export const packedUserDetailedNotMeOnlySchema = { format: 'uri', nullable: true, optional: false, }, + movedToUri: { + type: 'string', + format: 'uri', + nullable: true, + optional: false, + }, + alsoKnownAs: { + type: 'array', + nullable: true, + optional: false, + items: { + type: 'string', + format: 'id', + nullable: false, + optional: false, + }, + }, createdAt: { type: 'string', nullable: false, optional: false, @@ -238,6 +255,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + memo: { + type: 'string', + nullable: false, optional: true, + }, //#endregion }, } as const; @@ -311,10 +332,6 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, - hasUnreadChannel: { - type: 'boolean', - nullable: false, optional: false, - }, hasUnreadNotification: { type: 'boolean', nullable: false, optional: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index efeca46b4..f3d404e6c 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -70,6 +70,7 @@ import { Role } from '@/models/entities/Role.js'; import { RoleAssignment } from '@/models/entities/RoleAssignment.js'; import { Flash } from '@/models/entities/Flash.js'; import { FlashLike } from '@/models/entities/FlashLike.js'; +import { UserMemo } from '@/models/entities/UserMemo.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -183,6 +184,7 @@ export const entities = [ RoleAssignment, Flash, FlashLike, + UserMemo, ...charts, ]; @@ -200,6 +202,22 @@ export function createPostgresDataSource(config: Config) { statement_timeout: 1000 * 10, ...config.db.extra, }, + replication: config.dbReplications ? { + master: { + host: config.db.host, + port: config.db.port, + username: config.db.user, + password: config.db.pass, + database: config.db.db, + }, + slaves: config.dbSlaves!.map(rep => ({ + host: rep.host, + port: rep.port, + username: rep.user, + password: rep.pass, + database: rep.db, + })), + } : undefined, synchronize: process.env.NODE_ENV === 'test', dropSchema: process.env.NODE_ENV === 'test', cache: !config.db.disableCache && process.env.NODE_ENV !== 'test' ? { // dbをcloseしても何故かredisのコネクションが内部的に残り続けるようで、テストの際に支障が出るため無効にする(キャッシュも含めてテストしたいため本当は有効にしたいが...) diff --git a/packages/backend/src/queue/DbQueueProcessorsService.ts b/packages/backend/src/queue/DbQueueProcessorsService.ts index 910e2bea4..233a36dd0 100644 --- a/packages/backend/src/queue/DbQueueProcessorsService.ts +++ b/packages/backend/src/queue/DbQueueProcessorsService.ts @@ -52,8 +52,10 @@ export class DbQueueProcessorsService { q.process('exportBlocking', (job, done) => this.exportBlockingProcessorService.process(job, done)); q.process('exportUserLists', (job, done) => this.exportUserListsProcessorService.process(job, done)); q.process('importFollowing', (job, done) => this.importFollowingProcessorService.process(job, done)); + q.process('importFollowingToDb', (job) => this.importFollowingProcessorService.processDb(job)); q.process('importMuting', (job, done) => this.importMutingProcessorService.process(job, done)); q.process('importBlocking', (job, done) => this.importBlockingProcessorService.process(job, done)); + q.process('importBlockingToDb', (job) => this.importBlockingProcessorService.processDb(job)); q.process('importUserLists', (job, done) => this.importUserListsProcessorService.process(job, done)); q.process('importCustomEmojis', (job, done) => this.importCustomEmojisProcessorService.process(job, done)); q.process('deleteAccount', (job) => this.deleteAccountProcessorService.process(job)); diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 6a8f35cdd..4db9b3854 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -4,6 +4,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DbQueueProcessorsService } from './DbQueueProcessorsService.js'; +import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js'; import { ObjectStorageQueueProcessorsService } from './ObjectStorageQueueProcessorsService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -32,6 +33,7 @@ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessor import { TickChartsProcessorService } from './processors/TickChartsProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; import { ExportFavoritesProcessorService } from './processors/ExportFavoritesProcessorService.js'; +import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; @Module({ imports: [ @@ -61,9 +63,11 @@ import { ExportFavoritesProcessorService } from './processors/ExportFavoritesPro DeleteAccountProcessorService, DeleteFileProcessorService, CleanRemoteFilesProcessorService, + RelationshipProcessorService, SystemQueueProcessorsService, ObjectStorageQueueProcessorsService, DbQueueProcessorsService, + RelationshipQueueProcessorsService, WebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 8457747cb..706110f6f 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -13,6 +13,7 @@ import { EndedPollNotificationProcessorService } from './processors/EndedPollNot import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; +import { RelationshipQueueProcessorsService } from './RelationshipQueueProcessorsService.js'; @Injectable() export class QueueProcessorService { @@ -27,6 +28,7 @@ export class QueueProcessorService { private systemQueueProcessorsService: SystemQueueProcessorsService, private objectStorageQueueProcessorsService: ObjectStorageQueueProcessorsService, private dbQueueProcessorsService: DbQueueProcessorsService, + private relationshipQueueProcessorsService: RelationshipQueueProcessorsService, private webhookDeliverProcessorService: WebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private deliverProcessorService: DeliverProcessorService, @@ -52,14 +54,15 @@ export class QueueProcessorService { }; } } - + const systemLogger = this.logger.createSubLogger('system'); const deliverLogger = this.logger.createSubLogger('deliver'); const webhookLogger = this.logger.createSubLogger('webhook'); const inboxLogger = this.logger.createSubLogger('inbox'); const dbLogger = this.logger.createSubLogger('db'); + const relationshipLogger = this.logger.createSubLogger('relationship'); const objectStorageLogger = this.logger.createSubLogger('objectStorage'); - + this.queueService.systemQueue .on('waiting', (jobId) => systemLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) @@ -67,7 +70,7 @@ export class QueueProcessorService { .on('failed', (job, err) => systemLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => systemLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => systemLogger.warn(`stalled id=${job.id}`)); - + this.queueService.deliverQueue .on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) @@ -75,7 +78,7 @@ export class QueueProcessorService { .on('failed', (job, err) => deliverLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) .on('error', (job: any, err: Error) => deliverLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => deliverLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); - + this.queueService.inboxQueue .on('waiting', (jobId) => inboxLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) @@ -83,7 +86,7 @@ export class QueueProcessorService { .on('failed', (job, err) => inboxLogger.warn(`failed(${err}) ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => inboxLogger.warn(`stalled ${getJobInfo(job)} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); - + this.queueService.dbQueue .on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) @@ -91,7 +94,15 @@ export class QueueProcessorService { .on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); - + + this.queueService.relationshipQueue + .on('waiting', (jobId) => relationshipLogger.debug(`waiting id=${jobId}`)) + .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => relationshipLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) + .on('error', (job: any, err: Error) => relationshipLogger.error(`error ${err}`, { job, e: renderError(err) })) + .on('stalled', (job) => relationshipLogger.warn(`stalled id=${job.id}`)); + this.queueService.objectStorageQueue .on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) @@ -99,7 +110,7 @@ export class QueueProcessorService { .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) .on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); - + this.queueService.webhookDeliverQueue .on('waiting', (jobId) => webhookLogger.debug(`waiting id=${jobId}`)) .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) @@ -107,26 +118,27 @@ export class QueueProcessorService { .on('failed', (job, err) => webhookLogger.warn(`failed(${err}) ${getJobInfo(job)} to=${job.data.to}`)) .on('error', (job: any, err: Error) => webhookLogger.error(`error ${err}`, { job, e: renderError(err) })) .on('stalled', (job) => webhookLogger.warn(`stalled ${getJobInfo(job)} to=${job.data.to}`)); - + this.queueService.deliverQueue.process(this.config.deliverJobConcurrency ?? 128, (job) => this.deliverProcessorService.process(job)); this.queueService.inboxQueue.process(this.config.inboxJobConcurrency ?? 16, (job) => this.inboxProcessorService.process(job)); this.queueService.endedPollNotificationQueue.process((job, done) => this.endedPollNotificationProcessorService.process(job, done)); this.queueService.webhookDeliverQueue.process(64, (job) => this.webhookDeliverProcessorService.process(job)); this.dbQueueProcessorsService.start(this.queueService.dbQueue); + this.relationshipQueueProcessorsService.start(this.queueService.relationshipQueue); this.objectStorageQueueProcessorsService.start(this.queueService.objectStorageQueue); - + this.queueService.systemQueue.add('tickCharts', { }, { repeat: { cron: '55 * * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('resyncCharts', { }, { repeat: { cron: '0 0 * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('cleanCharts', { }, { repeat: { cron: '0 0 * * *' }, @@ -138,19 +150,19 @@ export class QueueProcessorService { repeat: { cron: '0 0 * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('clean', { }, { repeat: { cron: '0 0 * * *' }, removeOnComplete: true, }); - + this.queueService.systemQueue.add('checkExpiredMutings', { }, { repeat: { cron: '*/5 * * * *' }, removeOnComplete: true, }); - + this.systemQueueProcessorsService.start(this.queueService.systemQueue); } } diff --git a/packages/backend/src/queue/RelationshipQueueProcessorsService.ts b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts new file mode 100644 index 000000000..736b4fa80 --- /dev/null +++ b/packages/backend/src/queue/RelationshipQueueProcessorsService.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import { RelationshipProcessorService } from './processors/RelationshipProcessorService.js'; +import type Bull from 'bull'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; + +@Injectable() +export class RelationshipQueueProcessorsService { + constructor( + @Inject(DI.config) + private config: Config, + + private relationshipProcessorService: RelationshipProcessorService, + ) { + } + + @bindThis + public start(q: Bull.Queue): void { + const maxJobs = this.config.relashionshipJobConcurrency ?? 16; + q.process('follow', maxJobs, (job) => this.relationshipProcessorService.processFollow(job)); + q.process('unfollow', maxJobs, (job) => this.relationshipProcessorService.processUnfollow(job)); + q.process('block', maxJobs, (job) => this.relationshipProcessorService.processBlock(job)); + q.process('unblock', maxJobs, (job) => this.relationshipProcessorService.processUnblock(job)); + } +} diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts index f4cd560fc..2476d71a5 100644 --- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts @@ -4,10 +4,10 @@ import { DI } from '@/di-symbols.js'; import type { MutingsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import { bindThis } from '@/decorators.js'; @Injectable() export class CheckExpiredMutingsProcessorService { @@ -20,7 +20,7 @@ export class CheckExpiredMutingsProcessorService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, + private userMutingService: UserMutingService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings'); @@ -37,13 +37,7 @@ export class CheckExpiredMutingsProcessorService { .getMany(); if (expired.length > 0) { - await this.mutingsRepository.delete({ - id: In(expired.map(m => m.id)), - }); - - for (const m of expired) { - this.globalEventService.publishUserEvent(m.muterId, 'unmute', m.mutee!); - } + await this.userMutingService.unmute(expired); } this.logger.succ('All expired mutings checked.'); diff --git a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts index fa0c1733f..604497cf5 100644 --- a/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts +++ b/packages/backend/src/queue/processors/DeleteDriveFilesProcessorService.ts @@ -7,7 +7,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -31,7 +31,7 @@ export class DeleteDriveFilesProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Deleting drive files of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index a9af22ad0..f293bd4d7 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -7,7 +7,7 @@ import { MetaService } from '@/core/MetaService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; -import { MemoryCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; @@ -22,7 +22,7 @@ import type { DeliverJobData } from '../types.js'; @Injectable() export class DeliverProcessorService { private logger: Logger; - private suspendedHostsCache: MemoryCache; + private suspendedHostsCache: MemorySingleCache; private latest: string | null; constructor( @@ -46,7 +46,7 @@ export class DeliverProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('deliver'); - this.suspendedHostsCache = new MemoryCache(1000 * 60 * 60); + this.suspendedHostsCache = new MemorySingleCache(1000 * 60 * 60); } @bindThis @@ -79,10 +79,7 @@ export class DeliverProcessorService { // Update stats this.federatedInstanceService.fetch(host).then(i => { if (i.isNotResponding) { - this.instancesRepository.update(i.id, { - isNotResponding: false, - }); - this.federatedInstanceService.updateCachePartial(host, { + this.federatedInstanceService.update(i.id, { isNotResponding: false, }); } @@ -101,10 +98,7 @@ export class DeliverProcessorService { // Update stats this.federatedInstanceService.fetch(host).then(i => { if (!i.isNotResponding) { - this.instancesRepository.update(i.id, { - isNotResponding: true, - }); - this.federatedInstanceService.updateCachePartial(host, { + this.federatedInstanceService.update(i.id, { isNotResponding: true, }); } @@ -123,10 +117,7 @@ export class DeliverProcessorService { // 相手が閉鎖していることを明示しているため、配送停止する if (job.data.isSharedInbox && res.statusCode === 410) { this.federatedInstanceService.fetch(host).then(i => { - this.instancesRepository.update(i.id, { - isSuspended: true, - }); - this.federatedInstanceService.updateCachePartial(host, { + this.federatedInstanceService.update(i.id, { isSuspended: true, }); }); diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index 7f2c2d08b..c7b54070d 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -36,7 +36,7 @@ export class ExportBlockingProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting blocking of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -106,7 +106,7 @@ export class ExportBlockingProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'blocking-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index e9330772b..f2f2383a8 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; @Injectable() export class ExportFavoritesProcessorService { @@ -42,7 +42,7 @@ export class ExportFavoritesProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting favorites of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -121,7 +121,7 @@ export class ExportFavoritesProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'favorites-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 064b126e4..fa9c1ac1e 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -12,7 +12,7 @@ import type { Following } from '@/models/entities/Following.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbExportFollowingData } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -40,7 +40,7 @@ export class ExportFollowingProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting following of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -110,7 +110,7 @@ export class ExportFollowingProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'following-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index 94c7ea8a4..b14bf5f5b 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -39,7 +39,7 @@ export class ExportMutingProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting muting of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -110,7 +110,7 @@ export class ExportMutingProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'mute-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 2f74dd63c..e4f12ad10 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -13,7 +13,7 @@ import type { Note } from '@/models/entities/Note.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; @Injectable() export class ExportNotesProcessorService { @@ -39,7 +39,7 @@ export class ExportNotesProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting notes of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -117,7 +117,7 @@ export class ExportNotesProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'notes-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index 6400161b8..54bde4404 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -11,7 +11,7 @@ import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserJobData } from '../types.js'; +import type { DbJobDataWithUser } from '../types.js'; import { bindThis } from '@/decorators.js'; @Injectable() @@ -39,7 +39,7 @@ export class ExportUserListsProcessorService { } @bindThis - public async process(job: Bull.Job, done: () => void): Promise { + public async process(job: Bull.Job, done: () => void): Promise { this.logger.info(`Exporting user lists of ${job.data.user.id} ...`); const user = await this.usersRepository.findOneBy({ id: job.data.user.id }); @@ -86,7 +86,7 @@ export class ExportUserListsProcessorService { this.logger.succ(`Exported to: ${path}`); const fileName = 'user-lists-' + dateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.csv'; - const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true }); + const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); } finally { diff --git a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts index b8d9b3a52..3f075b02d 100644 --- a/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportBlockingProcessorService.ts @@ -1,38 +1,31 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, DriveFilesRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; +import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; -import { UserBlockingService } from '@/core/UserBlockingService.js'; import { DownloadService } from '@/core/DownloadService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserImportJobData } from '../types.js'; +import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; import { bindThis } from '@/decorators.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class ImportBlockingProcessorService { private logger: Logger; constructor( - @Inject(DI.config) - private config: Config, - @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + private queueService: QueueService, private utilityService: UtilityService, - private userBlockingService: UserBlockingService, private remoteUserResolveService: RemoteUserResolveService, private downloadService: DownloadService, private queueLoggerService: QueueLoggerService, @@ -59,46 +52,50 @@ export class ImportBlockingProcessorService { } const csv = await this.downloadService.downloadTextFile(file.url); + const targets = csv.trim().split('\n'); + this.queueService.createImportBlockingToDbJob({ id: user.id }, targets); - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (target == null) { - throw `cannot resolve user: @${username}@${host}`; - } - - // skip myself - if (target.id === job.data.user.id) continue; - - this.logger.info(`Block[${linenum}] ${target.id} ...`); - - await this.userBlockingService.block(user, target); - } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); - } - } - - this.logger.succ('Imported'); + this.logger.succ('Import jobs created'); done(); } + + @bindThis + public async processDb(job: Bull.Job): Promise { + const line = job.data.target; + const user = job.data.user; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + if (!host) return; + + let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) return; + + if (target == null) { + target = await this.remoteUserResolveService.resolveUser(username, host); + } + + if (target == null) { + throw `Unable to resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) return; + + this.logger.info(`Block ${target.id} ...`); + + this.queueService.createBlockJob([{ from: { id: user.id }, to: { id: target.id }, silent: true }]); + } catch (e) { + this.logger.warn(`Error: ${e}`); + } + } } diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index ed96e9a52..cf78d8330 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -86,6 +86,10 @@ export class ImportCustomEmojisProcessorService { continue; } const emojiInfo = record.emoji; + if (!/^[a-zA-Z0-9_]+$/.test(emojiInfo.name)) { + this.logger.error(`invalid emojiname: ${emojiInfo.name}`); + continue; + } const emojiPath = outputPath + '/' + record.fileName; await this.emojisRepository.delete({ name: emojiInfo.name, diff --git a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts index 037a6f245..aa5cf12c5 100644 --- a/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportFollowingProcessorService.ts @@ -2,34 +2,30 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, DriveFilesRepository } from '@/models/index.js'; -import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; -import type { DbUserImportJobData } from '../types.js'; +import type { DbUserImportJobData, DbUserImportToDbJobData } from '../types.js'; import { bindThis } from '@/decorators.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class ImportFollowingProcessorService { private logger: Logger; constructor( - @Inject(DI.config) - private config: Config, - @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + private queueService: QueueService, private utilityService: UtilityService, - private userFollowingService: UserFollowingService, private remoteUserResolveService: RemoteUserResolveService, private downloadService: DownloadService, private queueLoggerService: QueueLoggerService, @@ -56,46 +52,50 @@ export class ImportFollowingProcessorService { } const csv = await this.downloadService.downloadTextFile(file.url); + const targets = csv.trim().split('\n'); + this.queueService.createImportFollowingToDbJob({ id: user.id }, targets); - let linenum = 0; - - for (const line of csv.trim().split('\n')) { - linenum++; - - try { - const acct = line.split(',')[0].trim(); - const { username, host } = Acct.parse(acct); - - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ - host: IsNull(), - usernameLower: username.toLowerCase(), - }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), - usernameLower: username.toLowerCase(), - }); - - if (host == null && target == null) continue; - - if (target == null) { - target = await this.remoteUserResolveService.resolveUser(username, host); - } - - if (target == null) { - throw `cannot resolve user: @${username}@${host}`; - } - - // skip myself - if (target.id === job.data.user.id) continue; - - this.logger.info(`Follow[${linenum}] ${target.id} ...`); - - this.userFollowingService.follow(user, target); - } catch (e) { - this.logger.warn(`Error in line:${linenum} ${e}`); - } - } - - this.logger.succ('Imported'); + this.logger.succ('Import jobs created'); done(); } + + @bindThis + public async processDb(job: Bull.Job): Promise { + const line = job.data.target; + const user = job.data.user; + + try { + const acct = line.split(',')[0].trim(); + const { username, host } = Acct.parse(acct); + + if (!host) return; + + let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) : await this.usersRepository.findOneBy({ + host: this.utilityService.toPuny(host), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) return; + + if (target == null) { + target = await this.remoteUserResolveService.resolveUser(username, host); + } + + if (target == null) { + throw `Unable to resolve user: @${username}@${host}`; + } + + // skip myself + if (target.id === job.data.user.id) return; + + this.logger.info(`Follow ${target.id} ...`); + + this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true }]); + } catch (e) { + this.logger.warn(`Error: ${e}`); + } + } } diff --git a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts index 83d382057..379994ee7 100644 --- a/packages/backend/src/queue/processors/ImportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportMutingProcessorService.ts @@ -66,11 +66,13 @@ export class ImportMutingProcessorService { const acct = line.split(',')[0].trim(); const { username, host } = Acct.parse(acct); - let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ + if (!host) continue; + + let target = this.utilityService.isSelfHost(host) ? await this.usersRepository.findOneBy({ host: IsNull(), usernameLower: username.toLowerCase(), }) : await this.usersRepository.findOneBy({ - host: this.utilityService.toPuny(host!), + host: this.utilityService.toPuny(host), usernameLower: username.toLowerCase(), }); diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index ed7f38d01..ab8b1e9e2 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -84,9 +84,9 @@ export class InboxProcessorService { // HTTP-Signature keyIdを元にDBから取得 let authUser: { - user: RemoteUser; - key: UserPublickey | null; - } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); + user: RemoteUser; + key: UserPublickey | null; + } | null = await this.apDbResolverService.getAuthUserFromKeyId(signature.keyId); // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 if (authUser == null) { @@ -174,13 +174,10 @@ export class InboxProcessorService { // Update stats this.federatedInstanceService.fetch(authUser.user.host).then(i => { - this.instancesRepository.update(i.id, { + this.federatedInstanceService.update(i.id, { latestRequestReceivedAt: new Date(), isNotResponding: false, }); - this.federatedInstanceService.updateCachePartial(host, { - isNotResponding: false, - }); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); diff --git a/packages/backend/src/queue/processors/RelationshipProcessorService.ts b/packages/backend/src/queue/processors/RelationshipProcessorService.ts new file mode 100644 index 000000000..ff454df45 --- /dev/null +++ b/packages/backend/src/queue/processors/RelationshipProcessorService.ts @@ -0,0 +1,69 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type Bull from 'bull'; + +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { bindThis } from '@/decorators.js'; +import type Logger from '@/logger.js'; + +import { QueueLoggerService } from '../QueueLoggerService.js'; +import { RelationshipJobData } from '../types.js'; +import type { UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; + +@Injectable() +export class RelationshipProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + private queueLoggerService: QueueLoggerService, + private userFollowingService: UserFollowingService, + private userBlockingService: UserBlockingService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('follow-block'); + } + + @bindThis + public async processFollow(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to follow ${job.data.to.id}`); + await this.userFollowingService.follow(job.data.from, job.data.to, job.data.requestId, job.data.silent); + return 'ok'; + } + + @bindThis + public async processUnfollow(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to unfollow ${job.data.to.id}`); + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: job.data.from.id }), + this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + ]) as [LocalUser | RemoteUser, LocalUser | RemoteUser]; + await this.userFollowingService.unfollow(follower, followee, job.data.silent); + return 'ok'; + } + + @bindThis + public async processBlock(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to block ${job.data.to.id}`); + const [blockee, blocker] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: job.data.from.id }), + this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + ]); + await this.userBlockingService.block(blockee, blocker, job.data.silent); + return 'ok'; + } + + @bindThis + public async processUnblock(job: Bull.Job): Promise { + this.logger.info(`${job.data.from.id} is trying to unblock ${job.data.to.id}`); + const [blockee, blocker] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: job.data.from.id }), + this.usersRepository.findOneByOrFail({ id: job.data.to.id }), + ]); + await this.userBlockingService.unblock(blockee, blocker); + return 'ok'; + } +} diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts index 39b1b9565..84a5c21c4 100644 --- a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts @@ -39,6 +39,7 @@ export class WebhookDeliverProcessorService { 'X-Misskey-Host': this.config.host, 'X-Misskey-Hook-Id': job.data.webhookId, 'X-Misskey-Hook-Secret': job.data.secret, + 'Content-Type': 'application/json', }, body: JSON.stringify({ hookId: job.data.webhookId, diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 5d650c686..23c973d44 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -21,9 +21,39 @@ export type InboxJobData = { signature: httpSignature.IParsedSignature; }; -export type DbJobData = DbUserJobData | DbUserImportJobData | DbUserDeleteJobData; +export type RelationshipJobData = { + from: ThinUser; + to: ThinUser; + silent?: boolean; + requestId?: string; +} -export type DbUserJobData = { +export type DbJobData = DbJobMap[T]; + +export type DbJobMap = { + deleteDriveFiles: DbJobDataWithUser; + exportCustomEmojis: DbJobDataWithUser; + exportNotes: DbJobDataWithUser; + exportFavorites: DbJobDataWithUser; + exportFollowing: DbExportFollowingData; + exportMuting: DbJobDataWithUser; + exportBlocking: DbJobDataWithUser; + exportUserLists: DbJobDataWithUser; + importFollowing: DbUserImportJobData; + importFollowingToDb: DbUserImportToDbJobData; + importMuting: DbUserImportJobData; + importBlocking: DbUserImportJobData; + importBlockingToDb: DbUserImportToDbJobData; + importUserLists: DbUserImportJobData; + importCustomEmojis: DbUserImportJobData; + deleteAccount: DbUserDeleteJobData; +} + +export type DbJobDataWithUser = { + user: ThinUser; +} + +export type DbExportFollowingData = { user: ThinUser; excludeMuting: boolean; excludeInactive: boolean; @@ -39,6 +69,11 @@ export type DbUserImportJobData = { fileId: DriveFile['id']; }; +export type DbUserImportToDbJobData = { + user: ThinUser; + target: string; +}; + export type ObjectStorageJobData = ObjectStorageFileJobData | Record; export type ObjectStorageFileJobData = { diff --git a/packages/backend/src/redis.ts b/packages/backend/src/redis.ts deleted file mode 100644 index 690f4715d..000000000 --- a/packages/backend/src/redis.ts +++ /dev/null @@ -1,13 +0,0 @@ -import Redis from 'ioredis'; -import { Config } from '@/config.js'; - -export function createRedisConnection(config: Config): Redis.Redis { - return new Redis({ - port: config.redis.port, - host: config.redis.host, - family: config.redis.family == null ? 0 : config.redis.family, - password: config.redis.pass, - keyPrefix: `${config.redis.prefix}:`, - db: config.redis.db ?? 0, - }); -} diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index da8d0114e..e675d9cf1 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -6,13 +6,13 @@ import { Brackets, In, IsNull, LessThan, Not } from 'typeorm'; import accepts from 'accepts'; import vary from 'vary'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository } from '@/models/index.js'; +import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/index.js'; import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { LocalUser, User } from '@/models/entities/User.js'; -import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; +import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; +import { UserKeypairService } from '@/core/UserKeypairService.js'; import type { Following } from '@/models/entities/Following.js'; import { countIf } from '@/misc/prelude/array.js'; import type { Note } from '@/models/entities/Note.js'; @@ -54,11 +54,14 @@ export class ActivityPubServerService { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + private utilityService: UtilityService, private userEntityService: UserEntityService, private apRendererService: ApRendererService, private queueService: QueueService, - private userKeypairStoreService: UserKeypairStoreService, + private userKeypairService: UserKeypairService, private queryService: QueryService, ) { //this.createServer = this.createServer.bind(this); @@ -205,22 +208,22 @@ export class ActivityPubServerService { reply.code(400); return; } - + const page = request.query.page === 'true'; - + const user = await this.usersRepository.findOneBy({ id: userId, host: IsNull(), }); - + if (user == null) { reply.code(404); return; } - + //#region Check ff visibility const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - + if (profile.ffVisibility === 'private') { reply.code(403); reply.header('Cache-Control', 'public, max-age=30'); @@ -231,31 +234,31 @@ export class ActivityPubServerService { return; } //#endregion - + const limit = 10; const partOf = `${this.config.url}/users/${userId}/following`; - + if (page) { const query = { followerId: user.id, } as FindOptionsWhere; - + // カーソルが指定されている場合 if (cursor) { query.id = LessThan(cursor); } - + // Get followings const followings = await this.followingsRepository.find({ where: query, take: limit + 1, order: { id: -1 }, }); - + // 「次のページ」があるかどうか const inStock = followings.length === limit + 1; if (inStock) followings.pop(); - + const renderedFollowees = await Promise.all(followings.map(following => this.apRendererService.renderFollowUser(following.followeeId))); const rendered = this.apRendererService.renderOrderedCollectionPage( `${partOf}?${url.query({ @@ -269,7 +272,7 @@ export class ActivityPubServerService { cursor: followings[followings.length - 1].id, })}` : undefined, ); - + this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } else { @@ -330,33 +333,33 @@ export class ActivityPubServerService { reply.code(400); return; } - + const untilId = request.query.until_id; if (untilId != null && typeof untilId !== 'string') { reply.code(400); return; } - + const page = request.query.page === 'true'; - + if (countIf(x => x != null, [sinceId, untilId]) > 1) { reply.code(400); return; } - + const user = await this.usersRepository.findOneBy({ id: userId, host: IsNull(), }); - + if (user == null) { reply.code(404); return; } - + const limit = 20; const partOf = `${this.config.url}/users/${userId}/outbox`; - + if (page) { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) .andWhere('note.userId = :userId', { userId: user.id }) @@ -365,11 +368,11 @@ export class ActivityPubServerService { .orWhere('note.visibility = \'home\''); })) .andWhere('note.localOnly = FALSE'); - + const notes = await query.take(limit).getMany(); - + if (sinceId) notes.reverse(); - + const activities = await Promise.all(notes.map(note => this.packActivity(note))); const rendered = this.apRendererService.renderOrderedCollectionPage( `${partOf}?${url.query({ @@ -387,7 +390,7 @@ export class ActivityPubServerService { until_id: notes[notes.length - 1].id, })}` : undefined, ); - + this.setResponseType(request, reply); return (this.apRendererService.addContext(rendered)); } else { @@ -457,7 +460,7 @@ export class ActivityPubServerService { // note fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { vary(reply.raw, 'Accept'); - + const note = await this.notesRepository.findOneBy({ id: request.params.note, visibility: In(['public', 'home']), @@ -540,7 +543,7 @@ export class ActivityPubServerService { return; } - const keypair = await this.userKeypairStoreService.getUserKeypair(user.id); + const keypair = await this.userKeypairService.getUserKeypair(user.id); if (this.userEntityService.isLocalUser(user)) { reply.header('Cache-Control', 'public, max-age=180'); @@ -627,7 +630,42 @@ export class ActivityPubServerService { id: request.params.followee, host: Not(IsNull()), }), - ]); + ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null]; + + if (follower == null || followee == null) { + reply.code(404); + return; + } + + reply.header('Cache-Control', 'public, max-age=180'); + this.setResponseType(request, reply); + return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee))); + }); + + // follow + fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => { + // This may be used before the follow is completed, so we do not + // check if the following exists and only check if the follow request exists. + + const followRequest = await this.followRequestsRepository.findOneBy({ + id: request.params.followRequestId, + }); + + if (followRequest == null) { + reply.code(404); + return; + } + + const [follower, followee] = await Promise.all([ + this.usersRepository.findOneBy({ + id: followRequest.followerId, + host: IsNull(), + }), + this.usersRepository.findOneBy({ + id: followRequest.followeeId, + host: Not(IsNull()), + }), + ]) as [LocalUser | RemoteUser | null, LocalUser | RemoteUser | null]; if (follower == null || followee == null) { reply.code(404); diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 794fa76d9..98329ddff 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -297,7 +297,8 @@ export class FileServerService { } else if ('badge' in request.query) { const mask = (await sharpBmp(file.path, file.mime)) .resize(96, 96, { - fit: 'inside', + fit: 'contain', + position: 'centre', withoutEnlargement: false, }) .greyscale() @@ -453,7 +454,8 @@ export class FileServerService { fileRole: 'original', file, filename: file.name, - mime: file.type, + // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる + mime: this.fileInfoService.fixMime(file.type), ext: null, path, }; diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 66c1faaac..666a91fce 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -4,7 +4,7 @@ import type { NotesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { MemoryCache } from '@/misc/cache.js'; +import { MemorySingleCache } from '@/misc/cache.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import NotesChart from '@/core/chart/charts/notes.js'; @@ -118,7 +118,7 @@ export class NodeinfoServerService { }; }; - const cache = new MemoryCache>>(1000 * 60 * 10); + const cache = new MemorySingleCache>>(1000 * 60 * 10); fastify.get(nodeinfo2_1path, async (request, reply) => { const base = await cache.fetch(() => nodeinfo2()); diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 6bae0bafd..da86b2c1d 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -34,6 +34,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; +import { ClientLoggerService } from './web/ClientLoggerService.js'; +import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; @Module({ imports: [ @@ -42,6 +44,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; ], providers: [ ClientServerService, + ClientLoggerService, FeedService, UrlPreviewService, ActivityPubServerService, @@ -67,6 +70,7 @@ import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; DriveChannelService, GlobalTimelineChannelService, HashtagChannelService, + RoleTimelineChannelService, HomeTimelineChannelService, HybridTimelineChannelService, LocalTimelineChannelService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 3f116845c..9257fee13 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -149,13 +149,12 @@ export class ServerService implements OnApplicationShutdown { host: (host == null) || (host === this.config.host) ? IsNull() : host, isSuspended: false, }, - relations: ['avatar'], }); reply.header('Cache-Control', 'public, max-age=86400'); if (user) { - reply.redirect(this.userEntityService.getAvatarUrlSync(user)); + reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); } else { reply.redirect('/static-assets/user-unknown.png'); } diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index e72256303..9bf8deb22 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -8,6 +8,7 @@ import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; import type { User } from '@/models/entities/User.js'; import * as Acct from '@/misc/acct.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { FindOptionsWhere } from 'typeorm'; import { bindThis } from '@/decorators.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @@ -23,6 +24,7 @@ export class WellKnownServerService { private usersRepository: UsersRepository, private nodeinfoServerService: NodeinfoServerService, + private userEntityService: UserEntityService, ) { //this.createServer = this.createServer.bind(this); } @@ -130,7 +132,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => { const self = { rel: 'self', type: 'application/activity+json', - href: `${this.config.url}/users/${user.id}`, + href: this.userEntityService.genLocalUserUri(user.id), }; const profilePage = { rel: 'http://webfinger.net/rel/profile-page', diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index bf5cb2091..e3483c82c 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -261,6 +261,17 @@ export class ApiCallService implements OnApplicationShutdown { } } + if (ep.meta.prohibitMoved) { + if (user?.movedToUri) { + throw new ApiError({ + message: 'You have moved your account.', + code: 'YOUR_ACCOUNT_MOVED', + id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', + httpStatusCode: 403, + }); + } + } + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) { const myRoles = await this.roleService.getUserRoles(user!.id); if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index b806ad5ca..d3b1c7786 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -89,7 +89,7 @@ export class ApiServerService { Params: { endpoint: string; }, Body: Record, Querystring: Record, - }>('/' + endpoint.name, { bodyLimit: 1024 * 32 }, async (request, reply) => { + }>('/' + endpoint.name, { bodyLimit: 1024 * 1024 }, async (request, reply) => { if (request.method === 'GET' && !endpoint.meta.allowGet) { reply.code(405); reply.send(); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index cab247741..6dc1313e5 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -29,6 +29,7 @@ import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; +import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; @@ -98,6 +99,7 @@ import * as ep___channels_update from './endpoints/channels/update.js'; import * as ep___channels_favorite from './endpoints/channels/favorite.js'; import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; +import * as ep___channels_search from './endpoints/channels/search.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -220,6 +222,7 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; +import * as ep___i_move from './endpoints/i/move.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -291,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js'; import * as ep___roles_list from './endpoints/roles/list.js'; import * as ep___roles_show from './endpoints/roles/show.js'; import * as ep___roles_users from './endpoints/roles/users.js'; +import * as ep___roles_notes from './endpoints/roles/notes.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; @@ -326,6 +330,7 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; +import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___retention from './endpoints/retention.js'; import { GetterService } from './GetterService.js'; @@ -360,6 +365,7 @@ const $admin_emoji_list: Provider = { provide: 'ep:admin/emoji/list', useClass: const $admin_emoji_removeAliasesBulk: Provider = { provide: 'ep:admin/emoji/remove-aliases-bulk', useClass: ep___admin_emoji_removeAliasesBulk.default }; const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-aliases-bulk', useClass: ep___admin_emoji_setAliasesBulk.default }; const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; +const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; @@ -429,6 +435,7 @@ const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; +const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default }; const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; @@ -551,6 +558,7 @@ const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: e const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; +const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default }; const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; @@ -622,6 +630,7 @@ const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_r const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default }; const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default }; const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default }; +const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default }; const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; @@ -657,6 +666,7 @@ const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___use const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; +const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; @@ -695,6 +705,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_emoji_removeAliasesBulk, $admin_emoji_setAliasesBulk, $admin_emoji_setCategoryBulk, + $admin_emoji_setLicenseBulk, $admin_emoji_update, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, @@ -764,6 +775,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_favorite, $channels_unfavorite, $channels_myFavorites, + $channels_search, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -886,6 +898,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_move, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -957,6 +970,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $roles_list, $roles_show, $roles_users, + $roles_notes, $requestResetPassword, $resetDb, $resetPassword, @@ -992,6 +1006,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_show, $users_stats, $users_achievements, + $users_updateMemo, $fetchRss, $retention, ], @@ -1024,6 +1039,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_emoji_removeAliasesBulk, $admin_emoji_setAliasesBulk, $admin_emoji_setCategoryBulk, + $admin_emoji_setLicenseBulk, $admin_emoji_update, $admin_federation_deleteAllFiles, $admin_federation_refreshRemoteInstanceMetadata, @@ -1093,6 +1109,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_favorite, $channels_unfavorite, $channels_myFavorites, + $channels_search, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -1215,6 +1232,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_move, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1286,6 +1304,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $roles_list, $roles_show, $roles_users, + $roles_notes, $requestResetPassword, $resetDb, $resetPassword, @@ -1319,6 +1338,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_show, $users_stats, $users_achievements, + $users_updateMemo, $fetchRss, $retention, ], diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts index 1f8915ecc..fe2db1d66 100644 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import Limiter from 'ratelimiter'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { LoggerService } from '@/core/LoggerService.js'; diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index fbabf47af..f44e71771 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; import bcrypt from 'bcryptjs'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -15,7 +16,6 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { bindThis } from '@/decorators.js'; import { SigninService } from './SigninService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; -import { IsNull } from 'typeorm'; @Injectable() export class SignupApiService { @@ -137,6 +137,11 @@ export class SignupApiService { throw new FastifyReplyError(400, 'USED_USERNAME'); } + const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + if (isPreserved) { + throw new FastifyReplyError(400, 'USED_USERNAME'); + } + const code = rndstr('a-z0-9', 16); // Generate hash of password diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 13526f277..258e8de03 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'events'; import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import * as websocket from 'websocket'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js'; @@ -9,6 +9,7 @@ import { NoteReadService } from '@/core/NoteReadService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; import { AuthenticateService } from './AuthenticateService.js'; import MainStreamConnection from './stream/index.js'; import { ChannelsService } from './stream/ChannelsService.js'; @@ -21,8 +22,8 @@ export class StreamingApiServerService { @Inject(DI.config) private config: Config, - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -45,7 +46,7 @@ export class StreamingApiServerService { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private globalEventService: GlobalEventService, + private cacheService: CacheService, private noteReadService: NoteReadService, private authenticateService: AuthenticateService, private channelsService: ChannelsService, @@ -73,8 +74,6 @@ export class StreamingApiServerService { return; } - const connection = request.accept(); - const ev = new EventEmitter(); async function onRedisMessage(_: string, data: string): Promise { @@ -82,22 +81,22 @@ export class StreamingApiServerService { ev.emit(parsed.channel, parsed.message); } - this.redisSubscriber.on('message', onRedisMessage); + this.redisForSub.on('message', onRedisMessage); const main = new MainStreamConnection( - this.followingsRepository, - this.mutingsRepository, - this.renoteMutingsRepository, - this.blockingsRepository, - this.channelFollowingsRepository, - this.userProfilesRepository, this.channelsService, - this.globalEventService, this.noteReadService, this.notificationService, - connection, ev, user, miapp, + this.cacheService, + ev, user, miapp, ); + await main.init(); + + const connection = request.accept(); + + main.init2(connection); + const intervalId = user ? setInterval(() => { this.usersRepository.update(user.id, { lastActiveDate: new Date(), @@ -112,7 +111,7 @@ export class StreamingApiServerService { connection.once('close', () => { ev.removeAllListeners(); main.dispose(); - this.redisSubscriber.off('message', onRedisMessage); + this.redisForSub.off('message', onRedisMessage); if (intervalId) clearInterval(intervalId); }); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index e33c2349c..acd7f7ec3 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -29,6 +29,7 @@ import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; +import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; @@ -98,6 +99,7 @@ import * as ep___channels_update from './endpoints/channels/update.js'; import * as ep___channels_favorite from './endpoints/channels/favorite.js'; import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; +import * as ep___channels_search from './endpoints/channels/search.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -220,6 +222,7 @@ import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; import * as ep___i_unpin from './endpoints/i/unpin.js'; import * as ep___i_updateEmail from './endpoints/i/update-email.js'; import * as ep___i_update from './endpoints/i/update.js'; +import * as ep___i_move from './endpoints/i/move.js'; import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; @@ -291,6 +294,7 @@ import * as ep___promo_read from './endpoints/promo/read.js'; import * as ep___roles_list from './endpoints/roles/list.js'; import * as ep___roles_show from './endpoints/roles/show.js'; import * as ep___roles_users from './endpoints/roles/users.js'; +import * as ep___roles_notes from './endpoints/roles/notes.js'; import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; import * as ep___resetDb from './endpoints/reset-db.js'; import * as ep___resetPassword from './endpoints/reset-password.js'; @@ -326,6 +330,7 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___users_achievements from './endpoints/users/achievements.js'; +import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; import * as ep___retention from './endpoints/retention.js'; @@ -358,6 +363,7 @@ const eps = [ ['admin/emoji/remove-aliases-bulk', ep___admin_emoji_removeAliasesBulk], ['admin/emoji/set-aliases-bulk', ep___admin_emoji_setAliasesBulk], ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], + ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], ['admin/emoji/update', ep___admin_emoji_update], ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], @@ -427,6 +433,7 @@ const eps = [ ['channels/favorite', ep___channels_favorite], ['channels/unfavorite', ep___channels_unfavorite], ['channels/my-favorites', ep___channels_myFavorites], + ['channels/search', ep___channels_search], ['charts/active-users', ep___charts_activeUsers], ['charts/ap-request', ep___charts_apRequest], ['charts/drive', ep___charts_drive], @@ -549,6 +556,7 @@ const eps = [ ['i/unpin', ep___i_unpin], ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], + ['i/move', ep___i_move], ['i/webhooks/create', ep___i_webhooks_create], ['i/webhooks/list', ep___i_webhooks_list], ['i/webhooks/show', ep___i_webhooks_show], @@ -620,6 +628,7 @@ const eps = [ ['roles/list', ep___roles_list], ['roles/show', ep___roles_show], ['roles/users', ep___roles_users], + ['roles/notes', ep___roles_notes], ['request-reset-password', ep___requestResetPassword], ['reset-db', ep___resetDb], ['reset-password', ep___resetPassword], @@ -655,6 +664,7 @@ const eps = [ ['users/show', ep___users_show], ['users/stats', ep___users_stats], ['users/achievements', ep___users_achievements], + ['users/update-memo', ep___users_updateMemo], ['fetch-rss', ep___fetchRss], ['retention', ep___retention], ]; @@ -692,6 +702,12 @@ export interface IEndpointMeta { readonly requireRolePolicy?: keyof RolePolicies; + /** + * 引っ越し済みのユーザーによるリクエストを禁止するか + * 省略した場合は false として解釈されます。 + */ + readonly prohibitMoved?: boolean; + /** * エンドポイントのリミテーションに関するやつ * 省略した場合はリミテーションは無いものとして解釈されます。 diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index bac8ae16e..8a3541dff 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -52,6 +52,7 @@ export default class extends Endpoint { const { account, secret } = await this.signupService.signup({ username: ps.username, password: ps.password, + ignorePreservedUsernames: true, }); const res = await this.userEntityService.pack(account, account, { diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index e9f72676f..16232813a 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -61,11 +61,6 @@ export default class extends Endpoint { await this.usersRepository.update(user.id, { isDeleted: true, }); - - if (this.userEntityService.isLocalUser(user)) { - // Terminate streaming - this.globalEventService.publishUserEvent(user.id, 'terminate', {}); - } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts index 4e4f845b0..6e604ed88 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -26,38 +22,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.findBy({ - id: In(ps.ids), - }); - - for (const emoji of emojis) { - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), - aliases: [...new Set(emoji.aliases.concat(ps.aliases))], - }); - } - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packDetailedMany(ps.ids), - }); + await this.customEmojiService.addAliasesBulk(ps.ids, ps.aliases); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index fea11a67d..82dca9cc7 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -90,8 +90,6 @@ export default class extends Endpoint { license: emoji.license, }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); - await this.db.queryResultCache?.remove(['meta_emojis']); - this.globalEventService.publishBroadcastStream('emojiAdded', { emoji: await this.emojiEntityService.packDetailed(copied.id), }); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts index 84aad020a..9f8263629 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -1,11 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -24,38 +19,14 @@ export const paramDef = { required: ['ids'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private moderationLogService: ModerationLogService, - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.findBy({ - id: In(ps.ids), - }); - - for (const emoji of emojis) { - await this.emojisRepository.delete(emoji.id); - await this.db.queryResultCache?.remove(['meta_emojis']); - this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { - emoji: emoji, - }); - } - - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: await this.emojiEntityService.packDetailedMany(emojis), - }); + await this.customEmojiService.deleteBulk(ps.ids); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index 90a5856a1..429c819fe 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -1,12 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { ApiError } from '../../../error.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -31,38 +25,14 @@ export const paramDef = { required: ['id'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private moderationLogService: ModerationLogService, - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); - - if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - - await this.emojisRepository.delete(emoji.id); - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: [await this.emojiEntityService.packDetailed(emoji)], - }); - - this.moderationLogService.insertModerationLog(me, 'deleteEmoji', { - emoji: emoji, - }); + await this.customEmojiService.delete(ps.id); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 814668294..4aa4ad82b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -87,12 +87,18 @@ export default class extends Endpoint { //const emojis = await q.take(ps.limit).getMany(); emojis = await q.getMany(); + const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g); - emojis = emojis.filter(emoji => - emoji.name.includes(ps.query!) || - emoji.aliases.some(a => a.includes(ps.query!)) || - emoji.category?.includes(ps.query!)); - + if (queryarry) { + emojis = emojis.filter(emoji => + queryarry.includes(`:${emoji.name}:`) + ); + } else { + emojis = emojis.filter(emoji => + emoji.name.includes(ps.query!) || + emoji.aliases.some(a => a.includes(ps.query!)) || + emoji.category?.includes(ps.query!)); + } emojis.splice(ps.limit + 1); } else { emojis = await q.take(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index 393518350..83f882cac 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -26,38 +22,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emojis = await this.emojisRepository.findBy({ - id: In(ps.ids), - }); - - for (const emoji of emojis) { - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), - aliases: emoji.aliases.filter(x => !ps.aliases.includes(x)), - }); - } - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packDetailedMany(ps.ids), - }); + await this.customEmojiService.removeAliasesBulk(ps.ids, ps.aliases); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index 6a875f9c8..1d3a432bb 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -26,34 +22,14 @@ export const paramDef = { required: ['ids', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - await this.emojisRepository.update({ - id: In(ps.ids), - }, { - updatedAt: new Date(), - aliases: ps.aliases, - }); - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packDetailedMany(ps.ids), - }); + await this.customEmojiService.setAliasesBulk(ps.ids, ps.aliases); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index d3b999c0e..453968c7a 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, In } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; export const meta = { tags: ['admin'], @@ -28,34 +24,14 @@ export const paramDef = { required: ['ids'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - await this.emojisRepository.update({ - id: In(ps.ids), - }, { - updatedAt: new Date(), - category: ps.category, - }); - - await this.db.queryResultCache?.remove(['meta_emojis']); - - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: await this.emojiEntityService.packDetailedMany(ps.ids), - }); + await this.customEmojiService.setCategoryBulk(ps.ids, ps.category ?? null); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts new file mode 100644 index 000000000..b90b9757b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireRolePolicy: 'canManageCustomEmojis', +} as const; + +export const paramDef = { + type: 'object', + properties: { + ids: { type: 'array', items: { + type: 'string', format: 'misskey:id', + } }, + license: { + type: 'string', + nullable: true, + description: 'Use `null` to reset the license.', + }, + }, + required: ['ids'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + private customEmojiService: CustomEmojiService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.customEmojiService.setLicenseBulk(ps.ids, ps.license ?? null); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index bc0475e05..f63348b60 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -1,10 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; -import { DataSource, IsNull } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { EmojisRepository } from '@/models/index.js'; -import { DI } from '@/di-symbols.js'; -import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -45,51 +41,19 @@ export const paramDef = { required: ['id', 'name', 'aliases'], } as const; -// TODO: ロジックをサービスに切り出す - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.db) - private db: DataSource, - - @Inject(DI.emojisRepository) - private emojisRepository: EmojisRepository, - - private emojiEntityService: EmojiEntityService, - private globalEventService: GlobalEventService, + private customEmojiService: CustomEmojiService, ) { super(meta, paramDef, async (ps, me) => { - const emoji = await this.emojisRepository.findOneBy({ id: ps.id }); - const sameNameEmoji = await this.emojisRepository.findOneBy({ name: ps.name, host: IsNull() }); - if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - if (sameNameEmoji != null && sameNameEmoji.id !== ps.id) throw new ApiError(meta.errors.sameNameEmojiExists); - await this.emojisRepository.update(emoji.id, { - updatedAt: new Date(), + await this.customEmojiService.update(ps.id, { name: ps.name, - category: ps.category, + category: ps.category ?? null, aliases: ps.aliases, - license: ps.license, + license: ps.license ?? null, }); - - await this.db.queryResultCache?.remove(['meta_emojis']); - - const updated = await this.emojiEntityService.packDetailed(emoji.id); - - if (emoji.name === ps.name) { - this.globalEventService.publishBroadcastStream('emojiUpdated', { - emojis: [updated], - }); - } else { - this.globalEventService.publishBroadcastStream('emojiDeleted', { - emojis: [await this.emojiEntityService.packDetailed(emoji)], - }); - - this.globalEventService.publishBroadcastStream('emojiAdded', { - emoji: updated, - }); - } }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts index b073209a5..83f729953 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -29,7 +29,7 @@ export default class extends Endpoint { @Inject(DI.notesRepository) private followingsRepository: FollowingsRepository, - private userFollowingService: UserFollowingService, + private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { const followings = await this.followingsRepository.findBy({ @@ -39,11 +39,9 @@ export default class extends Endpoint { const pairs = await Promise.all(followings.map(f => Promise.all([ this.usersRepository.findOneByOrFail({ id: f.followerId }), this.usersRepository.findOneByOrFail({ id: f.followeeId }), - ]))); + ]).then(([from, to]) => [{ id: from.id }, { id: to.id }]))); - for (const pair of pairs) { - this.userFollowingService.unfollow(pair[0], pair[1]); - } + this.queueService.createUnfollowJob(pairs.map(p => ({ from: p[0], to: p[1], silent: true }))); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts index 0a529ecb0..4fd74e591 100644 --- a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -3,6 +3,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { InstancesRepository } from '@/models/index.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; +import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; export const meta = { tags: ['admin'], @@ -28,6 +29,7 @@ export default class extends Endpoint { private instancesRepository: InstancesRepository, private utilityService: UtilityService, + private federatedInstanceService: FederatedInstanceService, ) { super(meta, paramDef, async (ps, me) => { const instance = await this.instancesRepository.findOneBy({ host: this.utilityService.toPuny(ps.host) }); @@ -36,7 +38,7 @@ export default class extends Endpoint { throw new Error('instance not found'); } - this.instancesRepository.update({ host: this.utilityService.toPuny(ps.host) }, { + this.federatedInstanceService.update(instance.id, { isSuspended: ps.isSuspended, }); }); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index fc318a621..87a2d22ac 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -118,6 +118,14 @@ export const meta = { optional: false, nullable: false, }, }, + preservedUsernames: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, hcaptchaSecretKey: { type: 'string', optional: true, nullable: true, @@ -311,6 +319,7 @@ export default class extends Endpoint { hiddenTags: instance.hiddenTags, blockedHosts: instance.blockedHosts, sensitiveWords: instance.sensitiveWords, + preservedUsernames: instance.preservedUsernames, hcaptchaSecretKey: instance.hcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, turnstileSecretKey: instance.turnstileSecretKey, diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts index 135989463..916172f54 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -25,6 +25,7 @@ export const paramDef = { isPublic: { type: 'boolean' }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, + isExplorable: { type: 'boolean', default: false }, // optional for backward compatibility asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, @@ -76,12 +77,13 @@ export default class extends Endpoint { isPublic: ps.isPublic, isAdministrator: ps.isAdministrator, isModerator: ps.isModerator, + isExplorable: ps.isExplorable, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, displayOrder: ps.displayOrder, policies: ps.policies, }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); - + this.globalEventService.publishInternalEvent('roleCreated', created); return await this.roleEntityService.pack(created, me); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index 37b68c4c4..467f157a6 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -33,6 +33,7 @@ export const paramDef = { isPublic: { type: 'boolean' }, isModerator: { type: 'boolean' }, isAdministrator: { type: 'boolean' }, + isExplorable: { type: 'boolean' }, asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, displayOrder: { type: 'number' }, @@ -85,6 +86,7 @@ export default class extends Endpoint { isPublic: ps.isPublic, isModerator: ps.isModerator, isAdministrator: ps.isAdministrator, + isExplorable: ps.isExplorable, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, displayOrder: ps.displayOrder, diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts index 9c576dffe..4ef4fdc66 100644 --- a/packages/backend/src/server/api/endpoints/admin/server-info.ts +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -2,7 +2,7 @@ import * as os from 'node:os'; import si from 'systeminformation'; import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index 770b61850..eabbceac0 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -1,15 +1,15 @@ +import { IsNull, Not } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { RelationshipJobData } from '@/queue/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { UserSuspendService } from '@/core/UserSuspendService.js'; -import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { QueueService } from '@/core/QueueService.js'; export const meta = { tags: ['admin'], @@ -36,12 +36,10 @@ export default class extends Endpoint { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - private userEntityService: UserEntityService, - private userFollowingService: UserFollowingService, private userSuspendService: UserSuspendService, private roleService: RoleService, private moderationLogService: ModerationLogService, - private globalEventService: GlobalEventService, + private queueService: QueueService, ) { super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneBy({ id: ps.userId }); @@ -62,11 +60,6 @@ export default class extends Endpoint { targetId: user.id, }); - // Terminate streaming - if (this.userEntityService.isLocalUser(user)) { - this.globalEventService.publishUserEvent(user.id, 'terminate', {}); - } - (async () => { await this.userSuspendService.doPostSuspend(user).catch(e => {}); await this.unFollowAll(user).catch(e => {}); @@ -76,20 +69,23 @@ export default class extends Endpoint { @bindThis private async unFollowAll(follower: User) { - const followings = await this.followingsRepository.findBy({ - followerId: follower.id, + const followings = await this.followingsRepository.find({ + where: { + followerId: follower.id, + followeeId: Not(IsNull()), + }, }); - + + const jobs: RelationshipJobData[] = []; for (const following of followings) { - const followee = await this.usersRepository.findOneBy({ - id: following.followeeId, - }); - - if (followee == null) { - throw `Cant find followee ${following.followeeId}`; + if (following.followeeId && following.followerId) { + jobs.push({ + from: { id: following.followerId }, + to: { id: following.followeeId }, + silent: true, + }); } - - await this.userFollowingService.unfollow(follower, followee, true); } + this.queueService.createUnfollowJob(jobs); } } diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 11de29bf8..0e94f56cf 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -94,6 +94,8 @@ export const paramDef = { enableActiveEmailValidation: { type: 'boolean' }, enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' }, + serverRules: { type: 'array', items: { type: 'string' } }, + preservedUsernames: { type: 'array', items: { type: 'string' } }, }, required: [], } as const; @@ -387,6 +389,14 @@ export default class extends Endpoint { set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; } + if (ps.serverRules !== undefined) { + set.serverRules = ps.serverRules; + } + + if (ps.preservedUsernames !== undefined) { + set.preservedUsernames = ps.preservedUsernames; + } + await this.metaService.update(set); this.moderationLogService.insertModerationLog(me, 'updateMeta'); }); diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index b7ce3363a..5754a9f12 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index 364f9d9c0..dca0f443b 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, AntennasRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; @@ -76,17 +76,18 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchAntenna); } + const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const noteIdsRes = await this.redisClient.xrevrange( `antennaTimeline:${antenna.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - '-', - 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', + 'COUNT', limit); if (noteIdsRes.length === 0) { return []; } - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); if (noteIds.length === 0) { return []; @@ -95,16 +96,10 @@ export default class extends Endpoint { const query = this.notesRepository.createQueryBuilder('note') .where('note.id IN (:...noteIds)', { noteIds: noteIds }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index 3f8544213..5f980bdbe 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { @@ -71,7 +73,7 @@ export default class extends Endpoint { @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - + private antennaEntityService: AntennaEntityService, private globalEventService: GlobalEventService, ) { diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index dff8a9d10..6294b08fa 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', limit: { diff --git a/packages/backend/src/server/api/endpoints/channels/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts index f52b45ccf..c8544273a 100644 --- a/packages/backend/src/server/api/endpoints/channels/favorite.ts +++ b/packages/backend/src/server/api/endpoints/channels/favorite.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 91693918f..f3ca66cfd 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { @@ -41,7 +43,6 @@ export default class extends Endpoint { private channelFollowingsRepository: ChannelFollowingsRepository, private idService: IdService, - private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -58,8 +59,6 @@ export default class extends Endpoint { followerId: me.id, followeeId: channel.id, }); - - this.globalEventService.publishUserEvent(me.id, 'followChannel', channel); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts new file mode 100644 index 000000000..a954ba224 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -0,0 +1,67 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; +import type { ChannelsRepository } from '@/models/index.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { type: 'string' }, + type: { type: 'string', enum: ['nameAndDescription', 'nameOnly'], default: 'nameAndDescription' }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 5 }, + }, + required: ['query'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + private channelEntityService: ChannelEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.channelsRepository.createQueryBuilder('channel'), ps.sinceId, ps.untilId); + + if (ps.type === 'nameAndDescription') { + query.andWhere(new Brackets(qb => { qb + .where('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) + .orWhere('channel.description ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + })); + } else { + query.andWhere('channel.name ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }); + } + + const channels = await query + .take(ps.limit) + .getMany(); + + return await Promise.all(channels.map(x => this.channelEntityService.pack(x, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index eef343d13..c881074ba 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository, NotesRepository } from '@/models/index.js'; +import type { ChannelsRepository, Note, NotesRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -73,48 +73,67 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - const noteIdsRes = await this.redisClient.xrevrange( - `channelTimeline:${channel.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - '-', - 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + let timeline: Note[] = []; - if (noteIdsRes.length === 0) { - return []; + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + let noteIdsRes: [string, string[]][] = []; + + if (!ps.sinceId && !ps.sinceDate) { + noteIdsRes = await this.redisClient.xrevrange( + `channelTimeline:${channel.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + '-', + 'COUNT', limit); } - const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + // redis から取得していないとき・取得数が足りないとき + if (noteIdsRes.length < limit) { + //#region Construct query + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('note.channelId = :channelId', { channelId: channel.id }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); - if (noteIds.length === 0) { - return []; + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + //#endregion + + timeline = await query.take(ps.limit).getMany(); + } else { + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + //#region Construct query + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser') + .leftJoinAndSelect('note.channel', 'channel'); + + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedNoteQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + } + //#endregion + + timeline = await query.getMany(); + timeline.sort((a, b) => a.id > b.id ? -1 : 1); } - //#region Construct query - const query = this.notesRepository.createQueryBuilder('note') - .where('note.id IN (:...noteIds)', { noteIds: noteIds }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') - .leftJoinAndSelect('note.channel', 'channel'); - - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); - } - //#endregion - - const timeline = await query.getMany(); - timeline.sort((a, b) => a.id > b.id ? -1 : 1); - if (me) this.activeUsersChart.read(me); return await this.noteEntityService.packMany(timeline, me); diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts index 0c3f6c485..67fb1ea03 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index ac2ef825b..f46ff9f28 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:channels', errors: { @@ -38,8 +40,6 @@ export default class extends Endpoint { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - - private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -54,8 +54,6 @@ export default class extends Endpoint { followerId: me.id, followeeId: channel.id, }); - - this.globalEventService.publishUserEvent(me.id, 'unfollowChannel', channel); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts index b9d8dce47..c3561e2a7 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', limit: { diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index a770dc986..5395a5c37 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', res: { @@ -57,7 +59,7 @@ export default class extends Endpoint { if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) { throw new ApiError(meta.errors.tooManyClips); } - + const clip = await this.clipsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts index 6addf743a..f08caaf8d 100644 --- a/packages/backend/src/server/api/endpoints/clips/favorite.ts +++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:clip-favorite', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 6818d31cc..dcb415b75 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -75,16 +75,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoin(this.clipNotesRepository.metadata.targetName, 'clipNote', 'clipNote.noteId = note.id') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .andWhere('clipNote.clipId = :clipId', { clipId: clip.id }); if (me) { diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts index 5d88870ed..50c5d758b 100644 --- a/packages/backend/src/server/api/endpoints/clips/remove-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts index 244843d50..3da252a22 100644 --- a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts +++ b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:clip-favorite', errors: { diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index a103c3f7d..70f195935 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', errors: { diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index b3bdef41d..a1c1f9325 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -15,6 +15,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + limit: { duration: ms('1hour'), max: 120, diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts index cfef79383..c835587c4 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -19,6 +19,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:drive', } as const; diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 0711fe4a5..13cc709d3 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -58,10 +58,6 @@ export default class extends Endpoint { category: 'ASC', name: 'ASC', }, - cache: { - id: 'meta_emojis', - milliseconds: 3600000, // 1 hour - }, }); return { diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts index f21d9d5c3..3172bdbfd 100644 --- a/packages/backend/src/server/api/endpoints/flash/create.ts +++ b/packages/backend/src/server/api/endpoints/flash/create.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash', limit: { diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts index 5581b8ec6..23de2f397 100644 --- a/packages/backend/src/server/api/endpoints/flash/like.ts +++ b/packages/backend/src/server/api/endpoints/flash/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/flash/unlike.ts b/packages/backend/src/server/api/endpoints/flash/unlike.ts index b994f5d34..696512b06 100644 --- a/packages/backend/src/server/api/endpoints/flash/unlike.ts +++ b/packages/backend/src/server/api/endpoints/flash/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts index cd4e413a4..78dfd4a06 100644 --- a/packages/backend/src/server/api/endpoints/flash/update.ts +++ b/packages/backend/src/server/api/endpoints/flash/update.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:flash', limit: { diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts index 411c39110..4ad16de91 100644 --- a/packages/backend/src/server/api/endpoints/following/create.ts +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -19,6 +19,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:following', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts index cb8b6a2e3..ca6bfa7e0 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery', limit: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts index 519e56ed6..6ac5fa860 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts index cfbedcc4d..513089217 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index f14d644a3..a2a10d840 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:gallery', limit: { diff --git a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts index 102dae4fb..4eef49638 100644 --- a/packages/backend/src/server/api/endpoints/i/claim-achievement.ts +++ b/packages/backend/src/server/api/endpoints/i/claim-achievement.ts @@ -4,6 +4,7 @@ import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService export const meta = { requireCredential: true, + prohibitMoved: true, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index 8c1c158ab..811971591 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), @@ -58,15 +60,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportBlockingJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index 383bdc02b..8af278c88 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), max: 1, @@ -57,15 +59,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportFollowingJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 345ad916c..eb0f9ba47 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), @@ -58,15 +60,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportMutingJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index 875af7ec2..4568e9390 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueueService } from '@/core/QueueService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; import type { DriveFilesRepository } from '@/models/index.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; @@ -9,6 +10,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, + prohibitMoved: true, limit: { duration: ms('1hour'), max: 1, @@ -57,15 +59,22 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private queueService: QueueService, + private accountMoveService: AccountMoveService, ) { super(meta, paramDef, async (ps, me) => { const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); if (file == null) throw new ApiError(meta.errors.noSuchFile); //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); - if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + const checkMoving = await this.accountMoveService.validateAlsoKnownAs( + me, + (old, src) => !!src.movedAt && src.movedAt.getTime() + 1000 * 60 * 60 * 2 > (new Date()).getTime(), + true + ); + if (checkMoving ? file.size > 32 * 1024 * 1024 : file.size > 64 * 1024) throw new ApiError(meta.errors.tooBigFile); + this.queueService.createImportUserListsJob(me, file.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts new file mode 100644 index 000000000..261dd527c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -0,0 +1,130 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; + +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; + +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +import { LocalUser, RemoteUser } from '@/models/entities/User.js'; + +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +import * as Acct from '@/misc/acct.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + prohibitMoved: true, + limit: { + duration: ms('1day'), + max: 5, + }, + + errors: { + destinationAccountForbids: { + message: + 'Destination account doesn\'t have proper \'Known As\' alias, or has already moved.', + code: 'DESTINATION_ACCOUNT_FORBIDS', + id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4', + }, + rootForbidden: { + message: 'The root can\'t migrate.', + code: 'NOT_ROOT_FORBIDDEN', + id: '4362e8dc-731f-4ad8-a694-be2a88922a24', + }, + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + localUriNull: { + message: 'Local User ActivityPup URI is null.', + code: 'URI_NULL', + id: '95ba11b9-90e8-43a5-ba16-7acc1ab32e71', + }, + alreadyMoved: { + message: 'Account was already moved to another account.', + code: 'ALREADY_MOVED', + id: 'b234a14e-9ebe-4581-8000-074b3c215962', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + moveToAccount: { type: 'string' }, + }, + required: ['moveToAccount'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + private getterService: GetterService, + private apPersonService: ApPersonService, + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + // check parameter + if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser); + // abort if user is the root + if (me.isRoot) throw new ApiError(meta.errors.rootForbidden); + // abort if user has already moved + if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved); + + // parse user's input into the destination account + const { username, host } = Acct.parse(ps.moveToAccount); + // retrieve the destination account + let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + const destination = await this.getterService.getUser(moveTo.id) as LocalUser | RemoteUser; + const newUri = this.userEntityService.getUserUri(destination); + + // update local db + await this.apPersonService.updatePerson(newUri); + // retrieve updated user + moveTo = await this.apPersonService.resolvePerson(newUri); + + // make sure that the user has indicated the old account as an alias + const fromUrl = this.userEntityService.genLocalUserUri(me.id); + let allowed = false; + if (moveTo.alsoKnownAs) { + for (const knownAs of moveTo.alsoKnownAs) { + if (knownAs.includes(fromUrl)) { + allowed = true; + break; + } + } + } + + // abort if unintended + if (!allowed || moveTo.movedToUri) throw new ApiError(meta.errors.destinationAccountForbids); + + return await this.accountMoveService.moveFromLocal(me, moveTo); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index f27b4e86d..e141be764 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -1,5 +1,5 @@ import { Brackets, In } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js'; import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; @@ -91,11 +91,12 @@ export default class extends Endpoint { const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + const limit = ps.limit + (ps.untilId ? 1 : 0); // untilIdに指定したものも含まれるため+1 const notificationsRes = await this.redisClient.xrevrange( `notificationTimeline:${me.id}`, ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', '-', - 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + 'COUNT', limit); if (notificationsRes.length === 0) { return []; diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts index d4af00027..229350094 100644 --- a/packages/backend/src/server/api/endpoints/i/pin.ts +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -8,6 +8,7 @@ export const meta = { tags: ['account', 'notes'], requireCredential: true, + prohibitMoved: true, kind: 'write:account', diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index 786e64374..23ff63f5e 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -54,11 +54,6 @@ export default class extends Endpoint { // Publish event this.globalEventService.publishInternalEvent('userTokenRegenerated', { id: me.id, oldToken, newToken }); this.globalEventService.publishMainStream(me.id, 'myTokenRegenerated'); - - // Terminate streaming - setTimeout(() => { - this.globalEventService.publishUserEvent(me.id, 'terminate', {}); - }, 5000); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts index 5e1dddb6b..93daeb0cd 100644 --- a/packages/backend/src/server/api/endpoints/i/revoke-token.ts +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -35,9 +35,6 @@ export default class extends Endpoint { id: ps.tokenId, userId: me.id, }); - - // Terminate streaming - this.globalEventService.publishUserEvent(me.id, 'terminate'); } }); } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 46b16e9dc..738edf397 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -3,6 +3,7 @@ import * as mfm from 'mfm-js'; import { Inject, Injectable } from '@nestjs/common'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; +import * as Acct from '@/misc/acct.js'; import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/entities/User.js'; @@ -19,6 +20,10 @@ import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { CacheService } from '@/core/CacheService.js'; +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { ApiLoggerService } from '../../ApiLoggerService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -70,6 +75,24 @@ export const meta = { code: 'TOO_MANY_MUTED_WORDS', id: '010665b1-a211-42d2-bc64-8f6609d79785', }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + + forbiddenToSetYourself: { + message: 'You can\'t set yourself as your own alias.', + code: 'FORBIDDEN_TO_SET_YOURSELF', + id: '25c90186-4ab0-49c8-9bba-a1fa6c202ba4', + }, }, res: { @@ -128,6 +151,12 @@ export const paramDef = { emailNotificationTypes: { type: 'array', items: { type: 'string', } }, + alsoKnownAs: { + type: 'array', + maxItems: 10, + uniqueItems: true, + items: { type: 'string' }, + }, }, } as const; @@ -148,9 +177,13 @@ export default class extends Endpoint { private pagesRepository: PagesRepository, private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, private globalEventService: GlobalEventService, private userFollowingService: UserFollowingService, private accountUpdateService: AccountUpdateService, + private accountMoveService: AccountMoveService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, private hashtagService: HashtagService, private roleService: RoleService, private cacheService: CacheService, @@ -170,8 +203,6 @@ export default class extends Endpoint { if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility; - if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; - if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; if (ps.mutedWords !== undefined) { // TODO: ちゃんと数える const length = JSON.stringify(ps.mutedWords).length; @@ -217,6 +248,14 @@ export default class extends Endpoint { if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); if (!avatar.type.startsWith('image/')) throw new ApiError(meta.errors.avatarNotAnImage); + + updates.avatarId = avatar.id; + updates.avatarUrl = this.driveFileEntityService.getPublicUrl(avatar, 'avatar'); + updates.avatarBlurhash = avatar.blurhash; + } else if (ps.avatarId === null) { + updates.avatarId = null; + updates.avatarUrl = null; + updates.avatarBlurhash = null; } if (ps.bannerId) { @@ -224,6 +263,14 @@ export default class extends Endpoint { if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); if (!banner.type.startsWith('image/')) throw new ApiError(meta.errors.bannerNotAnImage); + + updates.bannerId = banner.id; + updates.bannerUrl = this.driveFileEntityService.getPublicUrl(banner); + updates.bannerBlurhash = banner.blurhash; + } else if (ps.bannerId === null) { + updates.bannerId = null; + updates.bannerUrl = null; + updates.bannerBlurhash = null; } if (ps.pinnedPageId) { @@ -244,6 +291,38 @@ export default class extends Endpoint { }); } + if (ps.alsoKnownAs) { + if (_user.movedToUri) { + throw new ApiError({ + message: 'You have moved your account.', + code: 'YOUR_ACCOUNT_MOVED', + id: '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31', + httpStatusCode: 403, + }); + } + + // Parse user's input into the old account + const newAlsoKnownAs = new Set(); + for (const line of ps.alsoKnownAs) { + if (!line) throw new ApiError(meta.errors.noSuchUser); + const { username, host } = Acct.parse(line); + + // Retrieve the old account + const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself); + + const toUrl = this.userEntityService.getUserUri(knownAs); + if (!toUrl) throw new ApiError(meta.errors.uriNull); + + newAlsoKnownAs.add(toUrl); + } + + updates.alsoKnownAs = newAlsoKnownAs.size > 0 ? Array.from(newAlsoKnownAs) : null; + } + //#region emojis/tags let emojis = [] as string[]; @@ -271,6 +350,9 @@ export default class extends Endpoint { //#endregion if (Object.keys(updates).length > 0) await this.usersRepository.update(user.id, updates); + if (Object.keys(updates).includes('alsoKnownAs')) { + this.cacheService.uriPersonCache.set(this.userEntityService.genLocalUserUri(user.id), { ...user, ...updates }); + } if (Object.keys(profileUpdates).length > 0) await this.userProfilesRepository.update(user.id, profileUpdates); const iObj = await this.userEntityService.pack(user.id, user, { @@ -284,7 +366,6 @@ export default class extends Endpoint { // Publish meUpdated event this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj); - this.globalEventService.publishUserEvent(user.id, 'updateUserProfile', updatedProfile); // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 if (user.isLocked && ps.isLocked === false) { diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index 37974ce2a..a5cb3fa7e 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -310,6 +310,8 @@ export default class extends Endpoint { translatorAvailable: instance.deeplAuthKey != null, + serverRules: instance.serverRules, + policies: { ...DEFAULT_POLICIES, ...instance.policies }, mediaProxy: this.config.mediaProxy, diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index fd062e1ca..ee358d5c6 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -1,19 +1,17 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { IdService } from '@/core/IdService.js'; import type { MutingsRepository } from '@/models/index.js'; -import type { Muting } from '@/models/entities/Muting.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { CacheService } from '@/core/CacheService.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { tags: ['account'], requireCredential: true, + prohibitMoved: true, kind: 'write:mutes', @@ -63,10 +61,8 @@ export default class extends Endpoint { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, private getterService: GetterService, - private idService: IdService, - private cacheService: CacheService, + private userMutingService: UserMutingService, ) { super(meta, paramDef, async (ps, me) => { const muter = me; @@ -96,17 +92,7 @@ export default class extends Endpoint { return; } - // Create mute - await this.mutingsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, - muterId: muter.id, - muteeId: mutee.id, - } as Muting); - - this.cacheService.userMutingsCache.delete(muter.id); - this.globalEventService.publishUserEvent(me.id, 'mute', mutee); + await this.userMutingService.mute(muter, mutee, ps.expiresAt ? new Date(ps.expiresAt) : null); }); } } diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts index 612c4a4c0..90b74590b 100644 --- a/packages/backend/src/server/api/endpoints/mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -1,10 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MutingsRepository } from '@/models/index.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['account'], @@ -49,7 +49,7 @@ export default class extends Endpoint { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, + private userMutingService: UserMutingService, private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { @@ -76,12 +76,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.notMuting); } - // Delete mute - await this.mutingsRepository.delete({ - id: exist.id, - }); - - this.globalEventService.publishUserEvent(me.id, 'unmute', mutee); + await this.userMutingService.unmute([exist]); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts index 0a8f2292a..5fbc7aba5 100644 --- a/packages/backend/src/server/api/endpoints/notes.ts +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -49,16 +49,10 @@ export default class extends Endpoint { .andWhere('note.visibility = \'public\'') .andWhere('note.localOnly = FALSE') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.local) { query.andWhere('note.userHost IS NULL'); diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index ea7a825f9..26f2d6772 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -57,16 +57,10 @@ export default class extends Endpoint { })); })) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) { diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 69fafcb9c..fa2dc447d 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -18,6 +18,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + limit: { duration: ms('1hour'), max: 300, diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts index 0ce80a1a6..611ea1956 100644 --- a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -12,6 +12,7 @@ export const meta = { tags: ['notes', 'favorites'], requireCredential: true, + prohibitMoved: true, kind: 'write:favorites', diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index 6bf17b222..bdb06498b 100644 --- a/packages/backend/src/server/api/endpoints/notes/featured.ts +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -53,16 +53,10 @@ export default class extends Endpoint { .andWhere('note.createdAt > :date', { date: new Date(Date.now() - day) }) .andWhere('note.visibility = \'public\'') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId }); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 9118d3393..c11c1eac4 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -73,16 +73,10 @@ export default class extends Endpoint { .andWhere('note.visibility = \'public\'') .andWhere('note.channelId IS NULL') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateRepliesQuery(query, me); if (me) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 8a7ec65ab..89abd91c7 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -91,16 +91,10 @@ export default class extends Endpoint { .orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)'); })) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .setParameters(followingQuery.getParameters()); this.queryService.generateChannelQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index 8c1c07a9f..afdafc7c5 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -80,16 +80,10 @@ export default class extends Endpoint { .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateChannelQuery(query, me); this.queryService.generateRepliesQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index dcb0d0adc..4e9f604d8 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -60,16 +60,10 @@ export default class extends Endpoint { .orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`); })) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index 2a44dc537..3a33b037f 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -17,6 +17,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:votes', errors: { diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index f758bfe9b..4772c4f80 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -75,7 +75,7 @@ export default class extends Endpoint { order: { id: -1, }, - relations: ['user', 'user.avatar', 'user.banner', 'note'], + relations: ['user', 'note'], }); return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me))); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts index 04e374d1a..97cb02677 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:reactions', errors: { diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index 026a1baa3..d40685566 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -4,8 +4,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -62,16 +62,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere('note.renoteId = :renoteId', { renoteId: note.id }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 4df95962c..f2af71d55 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -46,16 +46,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere('note.replyId = :replyId', { replyId: ps.noteId }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index da1a4bcc4..2956bf1cb 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -71,16 +71,10 @@ export default class extends Endpoint { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts index 5db5b6267..fb5abd917 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -85,16 +85,10 @@ export default class extends Endpoint { query .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(ps.query) }%` }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) this.queryService.generateMutedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index d9e72d260..c6ee1e5c2 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -70,16 +70,10 @@ export default class extends Endpoint { ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); if (followees.length > 0) { const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 9b23103fd..afc9bc421 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -84,16 +84,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .innerJoin(this.userListJoiningsRepository.metadata.targetName, 'userListJoining', 'userListJoining.userId = note.userId') .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner') .andWhere('userListJoining.userListId = :userListId', { userListId: list.id }); this.queryService.generateVisibilityQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts index 9ba607918..e601bf9d5 100644 --- a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -24,7 +24,7 @@ export default class extends Endpoint { private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { - this.notificationService.readAllNotification(me.id); + this.notificationService.readAllNotification(me.id, true); }); } } diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index 4015bf1f2..e08ab399f 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:pages', limit: { diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts index d27990f7e..543c126d9 100644 --- a/packages/backend/src/server/api/endpoints/pages/like.ts +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -10,6 +10,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:page-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts index e397e2a23..f0c019846 100644 --- a/packages/backend/src/server/api/endpoints/pages/unlike.ts +++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts @@ -9,6 +9,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:page-likes', errors: { diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index 35b402ec5..751274067 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -11,6 +11,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:pages', limit: { diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts index b28526961..beb5850d7 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/create.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -13,6 +13,7 @@ export const meta = { tags: ['account'], requireCredential: true, + prohibitMoved: true, kind: 'write:mutes', diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts index 51a895fb7..70901a140 100644 --- a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -80,8 +80,6 @@ export default class extends Endpoint { await this.renoteMutingsRepository.delete({ id: exist.id, }); - - // publishUserEvent(user.id, 'unmute', mutee); }); } } diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts index 655dd7cd8..4ced6d3ff 100644 --- a/packages/backend/src/server/api/endpoints/reset-db.ts +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import Redis from 'ioredis'; +import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { resetDb } from '@/misc/reset-db.js'; diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts new file mode 100644 index 000000000..6202c740f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -0,0 +1,112 @@ +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { NotesRepository, RolesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { DI } from '@/di-symbols.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['role', 'notes'], + + requireCredential: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: 'eb70323a-df61-4dd4-ad90-89c83c7cf26e', + }, + }, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, + required: ['roleId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private idService: IdService, + private noteEntityService: NoteEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ + id: ps.roleId, + isPublic: true, + }); + + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + if (!role.isExplorable) { + return []; + } + const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const noteIdsRes = await this.redisClient.xrevrange( + `roleTimeline:${role.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-', + 'COUNT', limit); + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId); + + if (noteIds.length === 0) { + return []; + } + + const query = this.notesRepository.createQueryBuilder('note') + .where('note.id IN (:...noteIds)', { noteIds: noteIds }) + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + this.queryService.generateVisibilityQuery(query, me); + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + + const notes = await query.getMany(); + notes.sort((a, b) => a.id > b.id ? -1 : 1); + + return await this.noteEntityService.packMany(notes, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts index c80b6efdc..6293c5cb5 100644 --- a/packages/backend/src/server/api/endpoints/username/available.ts +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -4,6 +4,7 @@ import type { UsedUsernamesRepository, UsersRepository } from '@/models/index.js import { Endpoint } from '@/server/api/endpoint-base.js'; import { localUsernameSchema } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; +import { MetaService } from '@/core/MetaService.js'; export const meta = { tags: ['users'], @@ -39,9 +40,10 @@ export default class extends Endpoint { @Inject(DI.usedUsernamesRepository) private usedUsernamesRepository: UsedUsernamesRepository, + + private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { - // Get exist const exist = await this.usersRepository.countBy({ host: IsNull(), usernameLower: ps.username.toLowerCase(), @@ -49,8 +51,11 @@ export default class extends Endpoint { const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() }); + const meta = await this.metaService.fetch(); + const isPreserved = meta.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase()); + return { - available: exist === 0 && exist2 === 0, + available: exist === 0 && exist2 === 0 && !isPreserved, }; }); } diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index a840c1a04..751088952 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -13,6 +13,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Create a new list of users.', @@ -58,7 +60,7 @@ export default class extends Endpoint { if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } - + const userList = await this.userListsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts index d2dd5731e..d50b70efc 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/pull.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Remove a user from a list.', diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts index 1c1fdc23f..925037e48 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/push.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -12,6 +12,8 @@ export const meta = { requireCredential: true, + prohibitMoved: true, + kind: 'write:account', description: 'Add a user to an existing list.', diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index aab32cc58..aaf94734a 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -74,16 +74,10 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('note.userId = :userId', { userId: user.id }) .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('user.avatar', 'avatar') - .leftJoinAndSelect('user.banner', 'banner') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar') - .leftJoinAndSelect('replyUser.banner', 'replyUserBanner') - .leftJoinAndSelect('renote.user', 'renoteUser') - .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') - .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); if (me) { diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 6c340d8fb..b001159ee 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -41,8 +41,6 @@ export const paramDef = { ], } as const; -// TODO: avatar,bannerをJOINしたいけどエラーになる - // eslint-disable-next-line import/no-default-export @Injectable() export default class extends Endpoint { diff --git a/packages/backend/src/server/api/endpoints/users/update-memo.ts b/packages/backend/src/server/api/endpoints/users/update-memo.ts new file mode 100644 index 000000000..ca7756ef7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/update-memo.ts @@ -0,0 +1,85 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import type { UserMemoRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '6fef56f3-e765-4957-88e5-c6f65329b8a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + memo: { + type: 'string', + nullable: true, + description: 'A personal memo for the target user. If null or empty, delete the memo.', + }, + }, + required: ['userId', 'memo'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userMemosRepository) + private userMemosRepository: UserMemoRepository, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + // Get target + const target = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // 引数がnullか空文字であれば、パーソナルメモを削除する + if (ps.memo === '' || ps.memo == null) { + await this.userMemosRepository.delete({ + userId: me.id, + targetUserId: target.id, + }); + return; + } + + // 以前に作成されたパーソナルメモがあるかどうか確認 + const previousMemo = await this.userMemosRepository.findOneBy({ + userId: me.id, + targetUserId: target.id, + }); + + if (!previousMemo) { + await this.userMemosRepository.insert({ + id: this.idService.genId(), + userId: me.id, + targetUserId: target.id, + memo: ps.memo, + }); + } else { + await this.userMemosRepository.update(previousMemo.id, { + userId: me.id, + targetUserId: target.id, + memo: ps.memo, + }); + } + }); + } +} diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index f9ef8218c..c77ba6602 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -13,6 +13,7 @@ import { UserListChannelService } from './channels/user-list.js'; import { AntennaChannelService } from './channels/antenna.js'; import { DriveChannelService } from './channels/drive.js'; import { HashtagChannelService } from './channels/hashtag.js'; +import { RoleTimelineChannelService } from './channels/role-timeline.js'; @Injectable() export class ChannelsService { @@ -24,6 +25,7 @@ export class ChannelsService { private globalTimelineChannelService: GlobalTimelineChannelService, private userListChannelService: UserListChannelService, private hashtagChannelService: HashtagChannelService, + private roleTimelineChannelService: RoleTimelineChannelService, private antennaChannelService: AntennaChannelService, private channelChannelService: ChannelChannelService, private driveChannelService: DriveChannelService, @@ -43,6 +45,7 @@ export class ChannelsService { case 'globalTimeline': return this.globalTimelineChannelService; case 'userList': return this.userListChannelService; case 'hashtag': return this.hashtagChannelService; + case 'roleTimeline': return this.roleTimelineChannelService; case 'antenna': return this.antennaChannelService; case 'channel': return this.channelChannelService; case 'drive': return this.driveChannelService; diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 32935325a..e67aec9ec 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -23,16 +23,16 @@ export default abstract class Channel { return this.connection.following; } - protected get muting() { - return this.connection.muting; + protected get userIdsWhoMeMuting() { + return this.connection.userIdsWhoMeMuting; } - protected get renoteMuting() { - return this.connection.renoteMuting; + protected get userIdsWhoMeMutingRenotes() { + return this.connection.userIdsWhoMeMutingRenotes; } - protected get blocking() { - return this.connection.blocking; + protected get userIdsWhoBlockingMe() { + return this.connection.userIdsWhoBlockingMe; } protected get followingChannels() { diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index e2a42fbfe..d48dea725 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -35,11 +35,11 @@ class AntennaChannel extends Channel { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 12caa7f23..9e5b40997 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -47,11 +47,11 @@ class ChannelChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index d79247cd6..5454836fe 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -64,11 +64,11 @@ class GlobalTimelineChannel extends Channel { if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 98dc858de..0268fdedd 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -46,11 +46,11 @@ class HashtagChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index c623fef64..ee874ad81 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -24,7 +24,6 @@ class HomeTimelineChannel extends Channel { @bindThis public async init(params: any) { - // Subscribe events this.subscriber.on('notesStream', this.onNote); } @@ -38,7 +37,7 @@ class HomeTimelineChannel extends Channel { } // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; + if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances ?? []))) return; if (['followers', 'specified'].includes(note.visibility)) { note = await this.noteEntityService.pack(note.id, this.user!, { @@ -71,18 +70,18 @@ class HomeTimelineChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる - if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + if (await checkWordMute(note, this.user, this.userProfile!.mutedWords)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index f54767bc9..4f7b4e78b 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -72,7 +72,7 @@ class HybridTimelineChannel extends Channel { } // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; + if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances ?? []))) return; // 関係ない返信は除外 if (note.reply && !this.user!.showTimelineReplies) { @@ -82,11 +82,11 @@ class HybridTimelineChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index eb0642900..09b0005ac 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -54,18 +54,18 @@ class LocalTimelineChannel extends Channel { } // 関係ない返信は除外 - if (note.reply && !this.user!.showTimelineReplies) { + if (note.reply && this.user && !this.user.showTimelineReplies) { const reply = note.reply; // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; + if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 4dd16b530..139320ce3 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -26,7 +26,7 @@ class MainChannel extends Channel { case 'notification': { // Ignore notifications from instances the user has muted if (isUserFromMutedInstance(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; - if (data.body.userId && this.muting.has(data.body.userId)) return; + if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.note && data.body.note.isHidden) { const note = await this.noteEntityService.pack(data.body.note.id, this.user, { @@ -40,7 +40,7 @@ class MainChannel extends Channel { case 'mention': { if (isInstanceMuted(data.body, new Set(this.userProfile?.mutedInstances ?? []))) return; - if (this.muting.has(data.body.userId)) return; + if (this.userIdsWhoMeMuting.has(data.body.userId)) return; if (data.body.isHidden) { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true, diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts new file mode 100644 index 000000000..9d106c8b2 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { bindThis } from '@/decorators.js'; +import Channel from '../channel.js'; +import { StreamMessages } from '../types.js'; + +class RoleTimelineChannel extends Channel { + public readonly chName = 'roleTimeline'; + public static shouldShare = false; + public static requireCredential = false; + private roleId: string; + + constructor( + private noteEntityService: NoteEntityService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onNote = this.onNote.bind(this); + } + + @bindThis + public async init(params: any) { + this.roleId = params.roleId as string; + + this.subscriber.on(`roleTimelineStream:${this.roleId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: StreamMessages['roleTimeline']['payload']) { + if (data.type === 'note') { + const note = data.body; + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + + this.send('note', note); + } else { + this.send(data.type, data.body); + } + } + + @bindThis + public dispose() { + // Unsubscribe events + this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent); + } +} + +@Injectable() +export class RoleTimelineChannelService { + public readonly shouldShare = RoleTimelineChannel.shouldShare; + public readonly requireCredential = RoleTimelineChannel.requireCredential; + + constructor( + private noteEntityService: NoteEntityService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): RoleTimelineChannel { + return new RoleTimelineChannel( + this.noteEntityService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 8a42e99a5..8802fc5ab 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -89,11 +89,11 @@ class UserListChannel extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.userIdsWhoMeMuting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; this.send('note', note); } diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index f1f8bfd3a..a6f914595 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -1,13 +1,11 @@ import type { User } from '@/models/entities/User.js'; -import type { Channel as ChannelModel } from '@/models/entities/Channel.js'; -import type { FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; -import type { UserProfile } from '@/models/entities/UserProfile.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { GlobalEventService } from '@/core/GlobalEventService.js'; import type { NoteReadService } from '@/core/NoteReadService.js'; import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; +import { CacheService } from '@/core/CacheService.js'; +import { UserProfile } from '@/models/index.js'; import type { ChannelsService } from './ChannelsService.js'; import type * as websocket from 'websocket'; import type { EventEmitter } from 'events'; @@ -19,106 +17,71 @@ import type { StreamEventEmitter, StreamMessages } from './types.js'; */ export default class Connection { public user?: User; - public userProfile?: UserProfile | null; - public following: Set = new Set(); - public muting: Set = new Set(); - public renoteMuting: Set = new Set(); - public blocking: Set = new Set(); // "被"blocking - public followingChannels: Set = new Set(); public token?: AccessToken; private wsConnection: websocket.connection; public subscriber: StreamEventEmitter; private channels: Channel[] = []; private subscribingNotes: any = {}; private cachedNotes: Packed<'Note'>[] = []; + public userProfile: UserProfile | null = null; + public following: Set = new Set(); + public followingChannels: Set = new Set(); + public userIdsWhoMeMuting: Set = new Set(); + public userIdsWhoBlockingMe: Set = new Set(); + public userIdsWhoMeMutingRenotes: Set = new Set(); + private fetchIntervalId: NodeJS.Timer | null = null; constructor( - private followingsRepository: FollowingsRepository, - private mutingsRepository: MutingsRepository, - private renoteMutingsRepository: RenoteMutingsRepository, - private blockingsRepository: BlockingsRepository, - private channelFollowingsRepository: ChannelFollowingsRepository, - private userProfilesRepository: UserProfilesRepository, private channelsService: ChannelsService, - private globalEventService: GlobalEventService, private noteReadService: NoteReadService, private notificationService: NotificationService, + private cacheService: CacheService, - wsConnection: websocket.connection, subscriber: EventEmitter, user: User | null | undefined, token: AccessToken | null | undefined, ) { - this.wsConnection = wsConnection; this.subscriber = subscriber; if (user) this.user = user; if (token) this.token = token; + } - //this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this); - //this.onUserEvent = this.onUserEvent.bind(this); - //this.onNoteStreamMessage = this.onNoteStreamMessage.bind(this); - //this.onBroadcastMessage = this.onBroadcastMessage.bind(this); + @bindThis + public async fetch() { + if (this.user == null) return; + const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([ + this.cacheService.userProfileCache.fetch(this.user.id), + this.cacheService.userFollowingsCache.fetch(this.user.id), + this.cacheService.userFollowingChannelsCache.fetch(this.user.id), + this.cacheService.userMutingsCache.fetch(this.user.id), + this.cacheService.userBlockedCache.fetch(this.user.id), + this.cacheService.renoteMutingsCache.fetch(this.user.id), + ]); + this.userProfile = userProfile; + this.following = following; + this.followingChannels = followingChannels; + this.userIdsWhoMeMuting = userIdsWhoMeMuting; + this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; + this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; + } + @bindThis + public async init() { + if (this.user != null) { + await this.fetch(); + + this.fetchIntervalId = setInterval(this.fetch, 1000 * 10); + } + } + + @bindThis + public async init2(wsConnection: websocket.connection) { + this.wsConnection = wsConnection; this.wsConnection.on('message', this.onWsConnectionMessage); this.subscriber.on('broadcast', data => { this.onBroadcastMessage(data); }); - - if (this.user) { - this.updateFollowing(); - this.updateMuting(); - this.updateRenoteMuting(); - this.updateBlocking(); - this.updateFollowingChannels(); - this.updateUserProfile(); - - this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); - } - } - - @bindThis - private onUserEvent(data: StreamMessages['user']['payload']) { // { type, body }と展開するとそれぞれ型が分離してしまう - switch (data.type) { - case 'follow': - this.following.add(data.body.id); - break; - - case 'unfollow': - this.following.delete(data.body.id); - break; - - case 'mute': - this.muting.add(data.body.id); - break; - - case 'unmute': - this.muting.delete(data.body.id); - break; - - // TODO: renote mute events - // TODO: block events - - case 'followChannel': - this.followingChannels.add(data.body.id); - break; - - case 'unfollowChannel': - this.followingChannels.delete(data.body.id); - break; - - case 'updateUserProfile': - this.userProfile = data.body; - break; - - case 'terminate': - this.wsConnection.close(); - this.dispose(); - break; - - default: - break; - } } /** @@ -186,10 +149,7 @@ export default class Connection { if (note == null) return; if (this.user && (note.userId !== this.user.id)) { - this.noteReadService.read(this.user.id, [note], { - following: this.following, - followingChannels: this.followingChannels, - }); + this.noteReadService.read(this.user.id, [note]); } } @@ -321,78 +281,12 @@ export default class Connection { } } - @bindThis - private async updateFollowing() { - const followings = await this.followingsRepository.find({ - where: { - followerId: this.user!.id, - }, - select: ['followeeId'], - }); - - this.following = new Set(followings.map(x => x.followeeId)); - } - - @bindThis - private async updateMuting() { - const mutings = await this.mutingsRepository.find({ - where: { - muterId: this.user!.id, - }, - select: ['muteeId'], - }); - - this.muting = new Set(mutings.map(x => x.muteeId)); - } - - @bindThis - private async updateRenoteMuting() { - const renoteMutings = await this.renoteMutingsRepository.find({ - where: { - muterId: this.user!.id, - }, - select: ['muteeId'], - }); - - this.renoteMuting = new Set(renoteMutings.map(x => x.muteeId)); - } - - @bindThis - private async updateBlocking() { // ここでいうBlockingは被Blockingの意 - const blockings = await this.blockingsRepository.find({ - where: { - blockeeId: this.user!.id, - }, - select: ['blockerId'], - }); - - this.blocking = new Set(blockings.map(x => x.blockerId)); - } - - @bindThis - private async updateFollowingChannels() { - const followings = await this.channelFollowingsRepository.find({ - where: { - followerId: this.user!.id, - }, - select: ['followeeId'], - }); - - this.followingChannels = new Set(followings.map(x => x.followeeId)); - } - - @bindThis - private async updateUserProfile() { - this.userProfile = await this.userProfilesRepository.findOneBy({ - userId: this.user!.id, - }); - } - /** * ストリームが切れたとき */ @bindThis public dispose() { + if (this.fetchIntervalId) clearInterval(this.fetchIntervalId); for (const c of this.channels.filter(c => c.dispose)) { if (c.dispose) c.dispose(); } diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 1e6e51e76..d9dba682c 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -38,6 +38,11 @@ export interface InternalStreamTypes { antennaDeleted: Antenna; antennaUpdated: Antenna; metaUpdated: Meta; + followChannel: { userId: User['id']; channelId: Channel['id']; }; + unfollowChannel: { userId: User['id']; channelId: Channel['id']; }; + updateUserProfile: UserProfile; + mute: { muterId: User['id']; muteeId: User['id']; }; + unmute: { muterId: User['id']; muteeId: User['id']; }; } export interface BroadcastTypes { @@ -56,18 +61,6 @@ export interface BroadcastTypes { }; } -export interface UserStreamTypes { - terminate: Record; - followChannel: Channel; - unfollowChannel: Channel; - updateUserProfile: UserProfile; - mute: User; - unmute: User; - follow: Packed<'UserDetailedNotMe'>; - unfollow: Packed<'User'>; - userAdded: Packed<'User'>; -} - export interface MainStreamTypes { notification: Packed<'Notification'>; mention: Packed<'Note'>; @@ -97,8 +90,6 @@ export interface MainStreamTypes { readAllAntennas: undefined; unreadAntenna: Antenna; readAllAnnouncements: undefined; - readAllChannels: undefined; - unreadChannel: Note['id']; myTokenRegenerated: undefined; signin: Signin; registryUpdated: { @@ -157,6 +148,10 @@ export interface AntennaStreamTypes { note: Note; } +export interface RoleTimelineStreamTypes { + note: Packed<'Note'>; +} + export interface AdminStreamTypes { newAbuseUserReport: { id: AbuseUserReport['id']; @@ -177,7 +172,7 @@ type EventUnionFromDictionary< > = U[keyof U]; // redis通すとDateのインスタンスはstringに変換されるので -type Serialized = { +export type Serialized = { [K in keyof T]: T[K] extends Date ? string @@ -202,10 +197,6 @@ export type StreamMessages = { name: 'broadcast'; payload: EventUnionFromDictionary>; }; - user: { - name: `user:${User['id']}`; - payload: EventUnionFromDictionary>; - }; main: { name: `mainStream:${User['id']}`; payload: EventUnionFromDictionary>; @@ -222,6 +213,10 @@ export type StreamMessages = { name: `userListStream:${UserList['id']}`; payload: EventUnionFromDictionary>; }; + roleTimeline: { + name: `roleTimelineStream:${Role['id']}`; + payload: EventUnionFromDictionary>; + }; antenna: { name: `antennaStream:${Antenna['id']}`; payload: EventUnionFromDictionary>; diff --git a/packages/backend/src/server/web/ClientLoggerService.ts b/packages/backend/src/server/web/ClientLoggerService.ts new file mode 100644 index 000000000..6a882aa76 --- /dev/null +++ b/packages/backend/src/server/web/ClientLoggerService.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +@Injectable() +export class ClientLoggerService { + public logger: Logger; + + constructor( + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('client'); + } +} diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index fb76f07e4..50b23a068 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -1,6 +1,7 @@ import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; +import { v4 as uuid } from 'uuid'; import { createBullBoard } from '@bull-board/api'; import { BullAdapter } from '@bull-board/api/bullAdapter.js'; import { FastifyAdapter } from '@bull-board/fastify'; @@ -26,6 +27,7 @@ import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityServi import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, NotesRepository, PagesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type Logger from '@/logger.js'; import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; @@ -34,6 +36,7 @@ import manifest from './manifest.json' assert { type: 'json' }; import { FeedService } from './FeedService.js'; import { UrlPreviewService } from './UrlPreviewService.js'; import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; +import { ClientLoggerService } from './ClientLoggerService.js'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -46,6 +49,8 @@ const viteOut = `${_dirname}/../../../../../built/_vite_/`; @Injectable() export class ClientServerService { + private logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -85,6 +90,7 @@ export class ClientServerService { private urlPreviewService: UrlPreviewService, private feedService: FeedService, private roleService: RoleService, + private clientLoggerService: ClientLoggerService, @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @@ -419,7 +425,7 @@ export class ClientServerService { reply.header('Cache-Control', 'public, max-age=15'); return await reply.view('user', { user, profile, me, - avatarUrl: await this.userEntityService.getAvatarUrl(user), + avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), sub: request.params.sub, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, @@ -464,7 +470,7 @@ export class ClientServerService { return await reply.view('note', { note: _note, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: note.userId })), + avatarUrl: _note.user.avatarUrl, // TODO: Let locale changeable by instance setting summary: getNoteSummary(_note), instanceName: meta.name ?? 'Misskey', @@ -503,7 +509,7 @@ export class ClientServerService { return await reply.view('page', { page: _page, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: page.userId })), + avatarUrl: _page.user.avatarUrl, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -527,7 +533,7 @@ export class ClientServerService { return await reply.view('flash', { flash: _flash, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: flash.userId })), + avatarUrl: _flash.user.avatarUrl, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -551,7 +557,7 @@ export class ClientServerService { return await reply.view('clip', { clip: _clip, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: clip.userId })), + avatarUrl: _clip.user.avatarUrl, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -573,7 +579,7 @@ export class ClientServerService { return await reply.view('gallery-post', { post: _post, profile, - avatarUrl: await this.userEntityService.getAvatarUrl(await this.usersRepository.findOneByOrFail({ id: post.userId })), + avatarUrl: _post.user.avatarUrl, instanceName: meta.name ?? 'Misskey', icon: meta.iconUrl, themeColor: meta.themeColor, @@ -649,6 +655,24 @@ export class ClientServerService { return await renderBase(reply); }); + fastify.setErrorHandler(async (error, request, reply) => { + const errId = uuid(); + this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, { + path: request.routerPath, + params: request.params, + query: request.query, + code: error.name, + stack: error.stack, + id: errId, + }); + reply.code(500); + reply.header('Cache-Control', 'max-age=10, must-revalidate'); + return await reply.view('error', { + code: error.code, + id: errId, + }); + }); + done(); } } diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index a14609adf..0c0e92cc0 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -58,7 +58,7 @@ export class FeedService { generator: 'Misskey', description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, link: author.link, - image: await this.userEntityService.getAvatarUrl(user), + image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), feedLinks: { json: `${author.link}.json`, atom: `${author.link}.atom`, diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css new file mode 100644 index 000000000..ab913f7a9 --- /dev/null +++ b/packages/backend/src/server/web/error.css @@ -0,0 +1,110 @@ +* { + font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; +} + +#misskey_app, +#splash { + display: none !important; +} + +body, +html { + background-color: #222; + color: #dfddcc; + justify-content: center; + margin: auto; + padding: 10px; + text-align: center; +} + +button { + border-radius: 999px; + padding: 0px 12px 0px 12px; + border: none; + cursor: pointer; + margin-bottom: 12px; +} + +.button-big { + background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); + line-height: 50px; +} + +.button-big:hover { + background: rgb(153, 204, 0); +} + +.button-small { + background: #444; + line-height: 40px; +} + +.button-small:hover { + background: #555; +} + +.button-label-big { + color: #222; + font-weight: bold; + font-size: 20px; + padding: 12px; +} + +.button-label-small { + color: rgb(153, 204, 0); + font-size: 16px; + padding: 12px; +} + +a { + color: rgb(134, 179, 0); + text-decoration: none; +} + +p, +li { + font-size: 16px; +} + +.dont-worry, +#msg { + font-size: 18px; +} + +.icon-warning { + color: #dec340; + height: 4rem; + padding-top: 2rem; +} + +h1 { + font-size: 32px; +} + +code { + display: block; + font-family: Fira, FiraCode, monospace; + background: #333; + padding: 0.5rem 1rem; + max-width: 40rem; + border-radius: 10px; + justify-content: center; + margin: auto; + white-space: pre-wrap; + word-break: break-word; +} + +summary { + cursor: pointer; +} + +summary > * { + display: inline; + white-space: pre-wrap; +} + +@media screen and (max-width: 500px) { + details { + width: 50%; + } +} \ No newline at end of file diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug new file mode 100644 index 000000000..b177ae411 --- /dev/null +++ b/packages/backend/src/server/web/views/error.pug @@ -0,0 +1,65 @@ +doctype html + +// + - + _____ _ _ + | |_|___ ___| |_ ___ _ _ + | | | | |_ -|_ -| '_| -_| | | + |_|_|_|_|___|___|_,_|___|_ | + |___| + Thank you for using Misskey! + If you are reading this message... how about joining the development? + https://github.com/misskey-dev/misskey + + +html + + head + meta(charset='utf-8') + meta(name='viewport' content='width=device-width, initial-scale=1') + meta(name='application-name' content='Misskey') + meta(name='referrer' content='origin') + + title + block title + = 'An error has occurred... | Misskey' + + style + include ../error.css + +body + svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round") + path(stroke="none", d="M0 0h24v24H0z", fill="none") + path(d="M12 9v2m0 4v.01") + path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75") + + h1 An error has occurred! + + button.button-big(onclick="location.reload();") + span.button-label-big Refresh + + p.dont-worry Don't worry, it's (probably) not your fault. + + p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID. + + div#errors + code. + ERROR CODE: #{code} + ERROR ID: #{id} + + p You may also try the following options: + + p Update your os and browser. + p Disable an adblocker. + + a(href="/flush") + button.button-small + span.button-label-small Clear preferences and cache + br + a(href="/cli") + button.button-small + span.button-label-small Start the simple client + br + a(href="/bios") + button.button-small + span.button-label-small Start the repair tool diff --git a/packages/backend/test/docker-compose.yml b/packages/backend/test/docker-compose.yml index 5f95bec4c..da6c01dda 100644 --- a/packages/backend/test/docker-compose.yml +++ b/packages/backend/test/docker-compose.yml @@ -2,7 +2,7 @@ version: "3" services: redistest: - image: redis:6 + image: redis:7 ports: - "127.0.0.1:56312:6379" diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index afb72c84d..689843508 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -4,8 +4,9 @@ import * as assert from 'assert'; // node-fetch only supports it's own Blob yet // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; -import { startServer, signup, post, api, uploadFile, simpleGet } from '../utils.js'; +import { startServer, signup, post, api, uploadFile, simpleGet, initTestDb } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; +import { User } from '@/models/index.js'; describe('Endpoints', () => { let app: INestApplicationContext; @@ -289,6 +290,16 @@ describe('Endpoints', () => { }, bob); assert.strictEqual(res.status, 200); + + const connection = await initTestDb(true); + const Users = connection.getRepository(User); + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.followersCount, 0); + assert.strictEqual(newBob.followingCount, 1); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + assert.strictEqual(newAlice.followersCount, 1); + assert.strictEqual(newAlice.followingCount, 0); + connection.destroy(); }); test('既にフォローしている場合は怒る', async () => { @@ -341,6 +352,16 @@ describe('Endpoints', () => { }, bob); assert.strictEqual(res.status, 200); + + const connection = await initTestDb(true); + const Users = connection.getRepository(User); + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.followersCount, 0); + assert.strictEqual(newBob.followingCount, 0); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + assert.strictEqual(newAlice.followersCount, 0); + assert.strictEqual(newAlice.followingCount, 0); + connection.destroy(); }); test('フォローしていない場合は怒る', async () => { @@ -849,4 +870,85 @@ describe('Endpoints', () => { assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED'); }); }); + + describe('パーソナルメモ機能のテスト', () => { + test('他者に関するメモを更新できる', async () => { + const memo = '10月まで低浮上とのこと。'; + + const res1 = await api('/users/update-memo', { + memo, + userId: bob.id, + }, alice); + + const res2 = await api('/users/show', { + userId: bob.id, + }, alice); + assert.strictEqual(res1.status, 204); + assert.strictEqual(res2.body?.memo, memo); + }); + + test('自分に関するメモを更新できる', async () => { + const memo = 'チケットを月末までに買う。'; + + const res1 = await api('/users/update-memo', { + memo, + userId: alice.id, + }, alice); + + const res2 = await api('/users/show', { + userId: alice.id, + }, alice); + assert.strictEqual(res1.status, 204); + assert.strictEqual(res2.body?.memo, memo); + }); + + test('メモを削除できる', async () => { + const memo = '10月まで低浮上とのこと。'; + + await api('/users/update-memo', { + memo, + userId: bob.id, + }, alice); + + await api('/users/update-memo', { + memo: '', + userId: bob.id, + }, alice); + + const res = await api('/users/show', { + userId: bob.id, + }, alice); + + // memoには常に文字列かnullが入っている(5cac151) + assert.strictEqual(res.body.memo, null); + }); + + test('メモは個人ごとに独立して保存される', async () => { + const memoAliceToBob = '10月まで低浮上とのこと。'; + const memoCarolToBob = '例の件について今度問いただす。'; + + await Promise.all([ + api('/users/update-memo', { + memo: memoAliceToBob, + userId: bob.id, + }, alice), + api('/users/update-memo', { + memo: memoCarolToBob, + userId: bob.id, + }, carol), + ]); + + const [resAlice, resCarol] = await Promise.all([ + api('/users/show', { + userId: bob.id, + }, alice), + api('/users/show', { + userId: bob.id, + }, carol), + ]); + + assert.strictEqual(resAlice.body.memo, memoAliceToBob); + assert.strictEqual(resCarol.body.memo, memoCarolToBob); + }); + }); }); diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts new file mode 100644 index 000000000..4dd5cbb9d --- /dev/null +++ b/packages/backend/test/e2e/move.ts @@ -0,0 +1,455 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import rndstr from 'rndstr'; +import { loadConfig } from '@/config.js'; +import { User, UsersRepository } from '@/models/index.js'; +import { jobQueue } from '@/boot/common.js'; +import { uploadFile, signup, startServer, initTestDb, api, sleep, successfulApiCall } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Account Move', () => { + let app: INestApplicationContext; + let url: URL; + + let root: any; + let alice: any; + let bob: any; + let carol: any; + let dave: any; + let eve: any; + let frank: any; + + let Users: UsersRepository; + + beforeAll(async () => { + app = await startServer(); + await jobQueue(); + const config = loadConfig(); + url = new URL(config.url); + const connection = await initTestDb(false); + root = await signup({ username: 'root' }); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); + eve = await signup({ username: 'eve' }); + frank = await signup({ username: 'frank' }); + Users = connection.getRepository(User); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + describe('Create Alias', () => { + afterEach(async () => { + await Users.update(bob.id, { alsoKnownAs: null }); + }, 1000 * 10); + + test('Able to create an alias', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + assert.strictEqual(res.body.alsoKnownAs?.length, 1); + assert.strictEqual(res.body.alsoKnownAs[0], alice.id); + }); + + test('Able to create a local alias without hostname', async () => { + await api('/i/update', { + alsoKnownAs: ['@alice'], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + }); + + test('Able to create a local alias without @', async () => { + await api('/i/update', { + alsoKnownAs: ['alice'], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 1); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + }); + + test('Able to set remote user (but may fail)', async () => { + const res = await api('/i/update', { + alsoKnownAs: ['@syuilo@example.com'], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Unable to add duplicated aliases to alsoKnownAs', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`, `@alice@${url.hostname}`], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'INVALID_PARAM'); + assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); + }); + + test('Unable to add itself', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@bob@${url.hostname}`], + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF'); + assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); + }); + + test('Unable to add a nonexisting local account to alsoKnownAs', async () => { + const res1 = await api('/i/update', { + alsoKnownAs: [`@nonexist@${url.hostname}`], + }, bob); + + assert.strictEqual(res1.status, 400); + assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + + const res2 = await api('/i/update', { + alsoKnownAs: ['@alice', 'nonexist'], + }, bob); + + assert.strictEqual(res2.status, 400); + assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Able to add two existing local account to alsoKnownAs', async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`, `@carol@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 2); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${alice.id}`); + assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${carol.id}`); + }); + + test('Able to properly overwrite alsoKnownAs', async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + await api('/i/update', { + alsoKnownAs: [`@carol@${url.hostname}`, `@dave@${url.hostname}`], + }, bob); + + const newBob = await Users.findOneByOrFail({ id: bob.id }); + assert.strictEqual(newBob.alsoKnownAs?.length, 2); + assert.strictEqual(newBob.alsoKnownAs[0], `${url.origin}/users/${carol.id}`); + assert.strictEqual(newBob.alsoKnownAs[1], `${url.origin}/users/${dave.id}`); + }); + }); + + describe('Local to Local', () => { + let antennaId = ''; + + beforeAll(async () => { + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, root); + const listRoot = await api('/users/lists/create', { + name: rndstr('0-9a-z', 8), + }, root); + await api('/users/lists/push', { + listId: listRoot.body.id, + userId: alice.id, + }, root); + + await api('/following/create', { + userId: root.id, + }, alice); + await api('/following/create', { + userId: eve.id, + }, alice); + const antenna = await api('/antennas/create', { + name: rndstr('0-9a-z', 8), + src: 'home', + keywords: [rndstr('0-9a-z', 8)], + excludeKeywords: [], + users: [], + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + }, alice); + antennaId = antenna.body.id; + + await api('/i/update', { + alsoKnownAs: [`@alice@${url.hostname}`], + }, bob); + + await api('/following/create', { + userId: alice.id, + }, carol); + + await api('/mute/create', { + userId: alice.id, + }, dave); + await api('/blocking/create', { + userId: alice.id, + }, dave); + await api('/following/create', { + userId: eve.id, + }, dave); + + await api('/following/create', { + userId: dave.id, + }, eve); + const listEve = await api('/users/lists/create', { + name: rndstr('0-9a-z', 8), + }, eve); + await api('/users/lists/push', { + listId: listEve.body.id, + userId: bob.id, + }, eve); + + await api('/i/update', { + isLocked: true, + }, frank); + await api('/following/create', { + userId: frank.id, + }, alice); + await api('/following/requests/accept', { + userId: alice.id, + }, frank); + }, 1000 * 10); + + test('Prohibit the root account from moving', async () => { + const res = await api('/i/move', { + moveToAccount: `@bob@${url.hostname}`, + }, root); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN'); + assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); + }); + + test('Unable to move to a nonexisting local account', async () => { + const res = await api('/i/move', { + moveToAccount: `@nonexist@${url.hostname}`, + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); + assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + }); + + test('Unable to move if alsoKnownAs is invalid', async () => { + const res = await api('/i/move', { + moveToAccount: `@carol@${url.hostname}`, + }, alice); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + }); + + test('Relationships have been properly migrated', async () => { + const move = await api('/i/move', { + moveToAccount: `@bob@${url.hostname}`, + }, alice); + + assert.strictEqual(move.status, 200); + + await sleep(1000 * 3); // wait for jobs to finish + + // Unfollow delayed? + const aliceFollowings = await api('/users/following', { + userId: alice.id, + }, alice); + assert.strictEqual(aliceFollowings.status, 200); + assert.strictEqual(aliceFollowings.body.length, 3); + + const carolFollowings = await api('/users/following', { + userId: carol.id, + }, carol); + assert.strictEqual(carolFollowings.status, 200); + assert.strictEqual(carolFollowings.body.length, 2); + assert.strictEqual(carolFollowings.body[0].followeeId, bob.id); + assert.strictEqual(carolFollowings.body[1].followeeId, alice.id); + + const blockings = await api('/blocking/list', {}, dave); + assert.strictEqual(blockings.status, 200); + assert.strictEqual(blockings.body.length, 2); + assert.strictEqual(blockings.body[0].blockeeId, bob.id); + assert.strictEqual(blockings.body[1].blockeeId, alice.id); + + const mutings = await api('/mute/list', {}, dave); + assert.strictEqual(mutings.status, 200); + assert.strictEqual(mutings.body.length, 2); + assert.strictEqual(mutings.body[0].muteeId, bob.id); + assert.strictEqual(mutings.body[1].muteeId, alice.id); + + const rootLists = await api('/users/lists/list', {}, root); + assert.strictEqual(rootLists.status, 200); + assert.strictEqual(rootLists.body[0].userIds.length, 2); + assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id)); + assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id)); + + const eveLists = await api('/users/lists/list', {}, eve); + assert.strictEqual(eveLists.status, 200); + assert.strictEqual(eveLists.body[0].userIds.length, 1); + assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id)); + }); + + test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => { + await successfulApiCall({ + endpoint: '/following/create', + parameters: { + userId: frank.id, + }, + user: bob, + }); + const followers = await api('/users/followers', { + userId: frank.id, + }, frank); + + assert.strictEqual(followers.status, 200); + assert.strictEqual(followers.body.length, 2); + assert.strictEqual(followers.body[0].followerId, bob.id); + }); + + test('Unfollowed after 10 sec (24 hours in production).', async () => { + await sleep(1000 * 8); + + const following = await api('/users/following', { + userId: alice.id, + }, alice); + + assert.strictEqual(following.status, 200); + assert.strictEqual(following.body.length, 0); + }); + + test('Unable to move if the destination account has already moved.', async () => { + const res = await api('/i/move', { + moveToAccount: `@alice@${url.hostname}`, + }, bob); + + assert.strictEqual(res.status, 400); + assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + }); + + test('Follow and follower counts are properly adjusted', async () => { + await api('/following/create', { + userId: alice.id, + }, eve); + const newAlice = await Users.findOneByOrFail({ id: alice.id }); + const newCarol = await Users.findOneByOrFail({ id: carol.id }); + let newEve = await Users.findOneByOrFail({ id: eve.id }); + assert.strictEqual(newAlice.movedToUri, `${url.origin}/users/${bob.id}`); + assert.strictEqual(newAlice.followingCount, 0); + assert.strictEqual(newAlice.followersCount, 0); + assert.strictEqual(newCarol.followingCount, 1); + assert.strictEqual(newEve.followingCount, 1); + assert.strictEqual(newEve.followersCount, 1); + + await api('/following/delete', { + userId: alice.id, + }, eve); + newEve = await Users.findOneByOrFail({ id: eve.id }); + assert.strictEqual(newEve.followingCount, 1); + assert.strictEqual(newEve.followersCount, 1); + }); + + test.each([ + '/antennas/create', + '/channels/create', + '/channels/favorite', + '/channels/follow', + '/channels/unfavorite', + '/channels/unfollow', + '/clips/add-note', + '/clips/create', + '/clips/favorite', + '/clips/remove-note', + '/clips/unfavorite', + '/clips/update', + '/drive/files/upload-from-url', + '/flash/create', + '/flash/like', + '/flash/unlike', + '/flash/update', + '/following/create', + '/gallery/posts/create', + '/gallery/posts/like', + '/gallery/posts/unlike', + '/gallery/posts/update', + '/i/claim-achievement', + '/i/move', + '/i/import-blocking', + '/i/import-following', + '/i/import-muting', + '/i/import-user-lists', + '/i/pin', + '/mute/create', + '/notes/create', + '/notes/favorites/create', + '/notes/polls/vote', + '/notes/reactions/create', + '/pages/create', + '/pages/like', + '/pages/unlike', + '/pages/update', + '/renote-mute/create', + '/users/lists/create', + '/users/lists/pull', + '/users/lists/push', + ])('Prohibit access after moving: %s', async (endpoint) => { + const res = await api(endpoint, {}, alice); + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit access after moving: /antennas/update', async () => { + const res = await api('/antennas/update', { + antennaId, + name: rndstr('0-9a-z', 8), + src: 'users', + keywords: [rndstr('0-9a-z', 8)], + excludeKeywords: [], + users: [eve.id], + caseSensitive: false, + withReplies: false, + withFile: false, + notify: false, + }, alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit access after moving: /drive/files/create', async () => { + const res = await uploadFile(alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + + test('Prohibit updating alsoKnownAs after moving', async () => { + const res = await api('/i/update', { + alsoKnownAs: [`@eve@${url.hostname}`], + }, alice); + + assert.strictEqual(res.status, 403); + assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + }); + }); +}); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index e1b690c30..d1394ef7a 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -172,6 +172,7 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + /* TODO test('リモートユーザーの投稿は流れない', async () => { const fired = await waitFire( ayano, 'localTimeline', // ayano:Local @@ -191,6 +192,7 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + */ test('ホーム指定の投稿は流れない', async () => { const fired = await waitFire( @@ -244,6 +246,7 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + /* TODO test('フォローしているリモートユーザーの投稿が流れる', async () => { const fired = await waitFire( ayano, 'hybridTimeline', // ayano:Hybrid @@ -263,6 +266,7 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + */ test('フォローしているユーザーのダイレクト投稿が流れる', async () => { const fired = await waitFire( @@ -316,6 +320,7 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + /* TODO test('フォローしていないリモートユーザーの投稿が流れる', async () => { const fired = await waitFire( ayano, 'globalTimeline', // ayano:Global @@ -325,6 +330,7 @@ describe('Streaming', () => { assert.strictEqual(fired, true); }); + */ test('ホーム投稿は流れない', async () => { const fired = await waitFire( @@ -385,6 +391,8 @@ describe('Streaming', () => { }); }); + // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" + /* describe('Hashtag Timeline', () => { test('指定したハッシュタグの投稿が流れる', () => new Promise(async done => { const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { @@ -404,45 +412,43 @@ describe('Streaming', () => { }); })); - // XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac" + test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { + let fooCount = 0; + let barCount = 0; + let fooBarCount = 0; - // test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { - // let fooCount = 0; - // let barCount = 0; - // let fooBarCount = 0; + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type === 'note') { + if (body.text === '#foo') fooCount++; + if (body.text === '#bar') barCount++; + if (body.text === '#foo #bar') fooBarCount++; + } + }, { + q: [ + ['foo', 'bar'], + ], + }); - // const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { - // if (type === 'note') { - // if (body.text === '#foo') fooCount++; - // if (body.text === '#bar') barCount++; - // if (body.text === '#foo #bar') fooBarCount++; - // } - // }, { - // q: [ - // ['foo', 'bar'], - // ], - // }); + post(chitose, { + text: '#foo', + }); - // post(chitose, { - // text: '#foo', - // }); + post(chitose, { + text: '#bar', + }); - // post(chitose, { - // text: '#bar', - // }); + post(chitose, { + text: '#foo #bar', + }); - // post(chitose, { - // text: '#foo #bar', - // }); - - // setTimeout(() => { - // assert.strictEqual(fooCount, 0); - // assert.strictEqual(barCount, 0); - // assert.strictEqual(fooBarCount, 1); - // ws.close(); - // done(); - // }, 3000); - // })); + setTimeout(() => { + assert.strictEqual(fooCount, 0); + assert.strictEqual(barCount, 0); + assert.strictEqual(fooBarCount, 1); + ws.close(); + done(); + }, 3000); + })); test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise(async done => { let fooCount = 0; @@ -543,5 +549,6 @@ describe('Streaming', () => { }, 3000); })); }); + */ }); }); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts new file mode 100644 index 000000000..c1a57f102 --- /dev/null +++ b/packages/backend/test/e2e/users.ts @@ -0,0 +1,887 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { inspect } from 'node:util'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { + signup, + post, + page, + role, + startServer, + api, + successfulApiCall, + failedApiCall, + uploadFile, +} from '../utils.js'; +import type * as misskey from 'misskey-js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('ユーザー', () => { + // エンティティとしてのユーザーを主眼においたテストを記述する + // (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする) + + const stripUndefined = (orig: T): Partial => { + return Object.entries({ ...orig }) + .filter(([, value]) => value !== undefined) + .reduce((obj: Partial, [key, value]) => { + obj[key as keyof T] = value; + return obj; + }, {}); + }; + + // BUG misskey-jsとjson-schemaと実際に返ってくるデータが全部違う + type UserLite = misskey.entities.UserLite & { + badgeRoles: any[], + }; + + type UserDetailedNotMe = UserLite & + misskey.entities.UserDetailed & { + roles: any[], + }; + + type MeDetailed = UserDetailedNotMe & + misskey.entities.MeDetailed & { + showTimelineReplies: boolean, + achievements: object[], + loggedInDays: number, + policies: object, + }; + + type User = MeDetailed & { token: string }; + + const show = async (id: string, me = alice): Promise => { + return successfulApiCall({ endpoint: 'users/show', parameters: { userId: id }, user: me }) as any; + }; + + // UserLiteのキーが過不足なく入っている? + const userLite = (user: User): Partial => { + return stripUndefined({ + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: user.avatarUrl, + avatarBlurhash: user.avatarBlurhash, + isBot: user.isBot, + isCat: user.isCat, + instance: user.instance, + emojis: user.emojis, + onlineStatus: user.onlineStatus, + badgeRoles: user.badgeRoles, + + // BUG isAdmin/isModeratorはUserLiteではなくMeDetailedOnlyに含まれる。 + isAdmin: undefined, + isModerator: undefined, + }); + }; + + // UserDetailedNotMeのキーが過不足なく入っている? + const userDetailedNotMe = (user: User): Partial => { + return stripUndefined({ + ...userLite(user), + url: user.url, + uri: user.uri, + movedTo: user.movedTo, + alsoKnownAs: user.alsoKnownAs, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + lastFetchedAt: user.lastFetchedAt, + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, + isLocked: user.isLocked, + isSilenced: user.isSilenced, + isSuspended: user.isSuspended, + description: user.description, + location: user.location, + birthday: user.birthday, + lang: user.lang, + fields: user.fields, + followersCount: user.followersCount, + followingCount: user.followingCount, + notesCount: user.notesCount, + pinnedNoteIds: user.pinnedNoteIds, + pinnedNotes: user.pinnedNotes, + pinnedPageId: user.pinnedPageId, + pinnedPage: user.pinnedPage, + publicReactions: user.publicReactions, + ffVisibility: user.ffVisibility, + twoFactorEnabled: user.twoFactorEnabled, + usePasswordLessLogin: user.usePasswordLessLogin, + securityKeys: user.securityKeys, + roles: user.roles, + memo: user.memo, + }); + }; + + // Relations関連のキーが過不足なく入っている? + const userDetailedNotMeWithRelations = (user: User): Partial => { + return stripUndefined({ + ...userDetailedNotMe(user), + isFollowing: user.isFollowing ?? false, + isFollowed: user.isFollowed ?? false, + hasPendingFollowRequestFromYou: user.hasPendingFollowRequestFromYou ?? false, + hasPendingFollowRequestToYou: user.hasPendingFollowRequestToYou ?? false, + isBlocking: user.isBlocking ?? false, + isBlocked: user.isBlocked ?? false, + isMuted: user.isMuted ?? false, + isRenoteMuted: user.isRenoteMuted ?? false, + }); + }; + + // MeDetailedのキーが過不足なく入っている? + const meDetailed = (user: User, security = false): Partial => { + return stripUndefined({ + ...userDetailedNotMe(user), + avatarId: user.avatarId, + bannerId: user.bannerId, + isModerator: user.isModerator, + isAdmin: user.isAdmin, + injectFeaturedNote: user.injectFeaturedNote, + receiveAnnouncementEmail: user.receiveAnnouncementEmail, + alwaysMarkNsfw: user.alwaysMarkNsfw, + autoSensitive: user.autoSensitive, + carefulBot: user.carefulBot, + autoAcceptFollowed: user.autoAcceptFollowed, + noCrawle: user.noCrawle, + isExplorable: user.isExplorable, + isDeleted: user.isDeleted, + hideOnlineStatus: user.hideOnlineStatus, + hasUnreadSpecifiedNotes: user.hasUnreadSpecifiedNotes, + hasUnreadMentions: user.hasUnreadMentions, + hasUnreadAnnouncement: user.hasUnreadAnnouncement, + hasUnreadAntenna: user.hasUnreadAntenna, + hasUnreadChannel: user.hasUnreadChannel, + hasUnreadNotification: user.hasUnreadNotification, + hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, + mutedWords: user.mutedWords, + mutedInstances: user.mutedInstances, + mutingNotificationTypes: user.mutingNotificationTypes, + emailNotificationTypes: user.emailNotificationTypes, + showTimelineReplies: user.showTimelineReplies, + achievements: user.achievements, + loggedInDays: user.loggedInDays, + policies: user.policies, + ...(security ? { + email: user.email, + emailVerified: user.emailVerified, + securityKeysList: user.securityKeysList, + } : {}), + }); + }; + + let app: INestApplicationContext; + + let root: User; + let alice: User; + let aliceNote: misskey.entities.Note; + let alicePage: misskey.entities.Page; + let aliceList: misskey.entities.UserList; + + let bob: User; + let bobNote: misskey.entities.Note; + + let carol: User; + let dave: User; + let ellen: User; + let frank: User; + + let usersReplying: User[]; + + let userNoNote: User; + let userNotExplorable: User; + let userLocking: User; + let userAdmin: User; + let roleAdmin: any; + let userModerator: User; + let roleModerator: any; + let userRolePublic: User; + let rolePublic: any; + let userRoleBadge: User; + let roleBadge: any; + let userSilenced: User; + let roleSilenced: any; + let userSuspended: User; + let userDeletedBySelf: User; + let userDeletedByAdmin: User; + let userFollowingAlice: User; + let userFollowedByAlice: User; + let userBlockingAlice: User; + let userBlockedByAlice: User; + let userMutingAlice: User; + let userMutedByAlice: User; + let userRnMutingAlice: User; + let userRnMutedByAlice: User; + let userFollowRequesting: User; + let userFollowRequested: User; + + beforeAll(async () => { + app = await startServer(); + }, 1000 * 60 * 2); + + beforeAll(async () => { + root = await signup({ username: 'alice' }); + alice = root; + aliceNote = await post(alice, { text: 'test' }) as any; + alicePage = await page(alice); + aliceList = (await api('users/list/create', { name: 'aliceList' }, alice)).body; + bob = await signup({ username: 'bob' }); + bobNote = await post(bob, { text: 'test' }) as any; + carol = await signup({ username: 'carol' }); + dave = await signup({ username: 'dave' }); + ellen = await signup({ username: 'ellen' }); + frank = await signup({ username: 'frank' }); + + // @alice -> @replyingへのリプライ。Promise.allで一気に作るとtimeoutしてしまうのでreduceで一つ一つawaitする + usersReplying = await [...Array(10)].map((_, i) => i).reduce(async (acc, i) => { + const u = await signup({ username: `replying${i}` }); + for (let j = 0; j < 10 - i; j++) { + const p = await post(u, { text: `test${j}` }); + await post(alice, { text: `@${u.username} test${j}`, replyId: p.id }); + } + + return (await acc).concat(u); + }, Promise.resolve([] as User[])); + + userNoNote = await signup({ username: 'userNoNote' }); + userNotExplorable = await signup({ username: 'userNotExplorable' }); + await post(userNotExplorable, { text: 'test' }); + await api('i/update', { isExplorable: false }, userNotExplorable); + userLocking = await signup({ username: 'userLocking' }); + await post(userLocking, { text: 'test' }); + await api('i/update', { isLocked: true }, userLocking); + userAdmin = await signup({ username: 'userAdmin' }); + roleAdmin = await role(root, { isAdministrator: true, name: 'Admin Role' }); + await api('admin/roles/assign', { userId: userAdmin.id, roleId: roleAdmin.id }, root); + userModerator = await signup({ username: 'userModerator' }); + roleModerator = await role(root, { isModerator: true, name: 'Moderator Role' }); + await api('admin/roles/assign', { userId: userModerator.id, roleId: roleModerator.id }, root); + userRolePublic = await signup({ username: 'userRolePublic' }); + rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); + await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); + userRoleBadge = await signup({ username: 'userRoleBadge' }); + roleBadge = await role(root, { asBadge: true, name: 'Badge Role' }); + await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root); + userSilenced = await signup({ username: 'userSilenced' }); + await post(userSilenced, { text: 'test' }); + roleSilenced = await role(root, {}, { canPublicNote: { priority: 0, useDefault: false, value: false } }); + await api('admin/roles/assign', { userId: userSilenced.id, roleId: roleSilenced.id }, root); + userSuspended = await signup({ username: 'userSuspended' }); + await post(userSuspended, { text: 'test' }); + await successfulApiCall({ endpoint: 'i/update', parameters: { description: '#user_testuserSuspended' }, user: userSuspended }); + await api('admin/suspend-user', { userId: userSuspended.id }, root); + userDeletedBySelf = await signup({ username: 'userDeletedBySelf', password: 'userDeletedBySelf' }); + await post(userDeletedBySelf, { text: 'test' }); + await api('i/delete-account', { password: 'userDeletedBySelf' }, userDeletedBySelf); + userDeletedByAdmin = await signup({ username: 'userDeletedByAdmin' }); + await post(userDeletedByAdmin, { text: 'test' }); + await api('admin/delete-account', { userId: userDeletedByAdmin.id }, root); + userFollowingAlice = await signup({ username: 'userFollowingAlice' }); + await post(userFollowingAlice, { text: 'test' }); + await api('following/create', { userId: alice.id }, userFollowingAlice); + userFollowedByAlice = await signup({ username: 'userFollowedByAlice' }); + await post(userFollowedByAlice, { text: 'test' }); + await api('following/create', { userId: userFollowedByAlice.id }, alice); + userBlockingAlice = await signup({ username: 'userBlockingAlice' }); + await post(userBlockingAlice, { text: 'test' }); + await api('blocking/create', { userId: alice.id }, userBlockingAlice); + userBlockedByAlice = await signup({ username: 'userBlockedByAlice' }); + await post(userBlockedByAlice, { text: 'test' }); + await api('blocking/create', { userId: userBlockedByAlice.id }, alice); + userMutingAlice = await signup({ username: 'userMutingAlice' }); + await post(userMutingAlice, { text: 'test' }); + await api('mute/create', { userId: alice.id }, userMutingAlice); + userMutedByAlice = await signup({ username: 'userMutedByAlice' }); + await post(userMutedByAlice, { text: 'test' }); + await api('mute/create', { userId: userMutedByAlice.id }, alice); + userRnMutingAlice = await signup({ username: 'userRnMutingAlice' }); + await post(userRnMutingAlice, { text: 'test' }); + await api('renote-mute/create', { userId: alice.id }, userRnMutingAlice); + userRnMutedByAlice = await signup({ username: 'userRnMutedByAlice' }); + await post(userRnMutedByAlice, { text: 'test' }); + await api('renote-mute/create', { userId: userRnMutedByAlice.id }, alice); + userFollowRequesting = await signup({ username: 'userFollowRequesting' }); + await post(userFollowRequesting, { text: 'test' }); + userFollowRequested = userLocking; + await api('following/create', { userId: userFollowRequested.id }, userFollowRequesting); + }, 1000 * 60 * 10); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = { + ...alice, + ...await successfulApiCall({ endpoint: 'i', parameters: {}, user: alice }) as any, + }; + aliceNote = await successfulApiCall({ endpoint: 'notes/show', parameters: { noteId: aliceNote.id }, user: alice }); + }); + + //#region サインアップ(signup) + + test('が作れる。(作りたての状態で自分のユーザー情報が取れる)', async () => { + // SignupApiService.ts + const response = await successfulApiCall({ + endpoint: 'signup', + parameters: { username: 'zoe', password: 'password' }, + user: undefined, + }) as unknown as User; // BUG MeDetailedに足りないキーがある + + // signupの時はtokenが含まれる特別なMeDetailedが返ってくる + assert.match(response.token, /[a-zA-Z0-9]{16}/); + + // UserLite + assert.match(response.id, /[0-9a-z]{10}/); + assert.strictEqual(response.name, null); + assert.strictEqual(response.username, 'zoe'); + assert.strictEqual(response.host, null); + assert.match(response.avatarUrl, /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.strictEqual(response.avatarBlurhash, null); + assert.strictEqual(response.isBot, false); + assert.strictEqual(response.isCat, false); + assert.strictEqual(response.instance, undefined); + assert.deepStrictEqual(response.emojis, {}); + assert.strictEqual(response.onlineStatus, 'unknown'); + assert.deepStrictEqual(response.badgeRoles, []); + // UserDetailedNotMeOnly + assert.strictEqual(response.url, null); + assert.strictEqual(response.uri, null); + assert.strictEqual(response.movedTo, null); + assert.strictEqual(response.alsoKnownAs, null); + assert.strictEqual(response.createdAt, new Date(response.createdAt).toISOString()); + assert.strictEqual(response.updatedAt, null); + assert.strictEqual(response.lastFetchedAt, null); + assert.strictEqual(response.bannerUrl, null); + assert.strictEqual(response.bannerBlurhash, null); + assert.strictEqual(response.isLocked, false); + assert.strictEqual(response.isSilenced, false); + assert.strictEqual(response.isSuspended, false); + assert.strictEqual(response.description, null); + assert.strictEqual(response.location, null); + assert.strictEqual(response.birthday, null); + assert.strictEqual(response.lang, null); + assert.deepStrictEqual(response.fields, []); + assert.strictEqual(response.followersCount, 0); + assert.strictEqual(response.followingCount, 0); + assert.strictEqual(response.notesCount, 0); + assert.deepStrictEqual(response.pinnedNoteIds, []); + assert.deepStrictEqual(response.pinnedNotes, []); + assert.strictEqual(response.pinnedPageId, null); + assert.strictEqual(response.pinnedPage, null); + assert.strictEqual(response.publicReactions, false); + assert.strictEqual(response.ffVisibility, 'public'); + assert.strictEqual(response.twoFactorEnabled, false); + assert.strictEqual(response.usePasswordLessLogin, false); + assert.strictEqual(response.securityKeys, false); + assert.deepStrictEqual(response.roles, []); + assert.strictEqual(response.memo, null); + + // MeDetailedOnly + assert.strictEqual(response.avatarId, null); + assert.strictEqual(response.bannerId, null); + assert.strictEqual(response.isModerator, false); + assert.strictEqual(response.isAdmin, false); + assert.strictEqual(response.injectFeaturedNote, true); + assert.strictEqual(response.receiveAnnouncementEmail, true); + assert.strictEqual(response.alwaysMarkNsfw, false); + assert.strictEqual(response.autoSensitive, false); + assert.strictEqual(response.carefulBot, false); + assert.strictEqual(response.autoAcceptFollowed, true); + assert.strictEqual(response.noCrawle, false); + assert.strictEqual(response.isExplorable, true); + assert.strictEqual(response.isDeleted, false); + assert.strictEqual(response.hideOnlineStatus, false); + assert.strictEqual(response.hasUnreadSpecifiedNotes, false); + assert.strictEqual(response.hasUnreadMentions, false); + assert.strictEqual(response.hasUnreadAnnouncement, false); + assert.strictEqual(response.hasUnreadAntenna, false); + assert.strictEqual(response.hasUnreadChannel, false); + assert.strictEqual(response.hasUnreadNotification, false); + assert.strictEqual(response.hasPendingReceivedFollowRequest, false); + assert.deepStrictEqual(response.mutedWords, []); + assert.deepStrictEqual(response.mutedInstances, []); + assert.deepStrictEqual(response.mutingNotificationTypes, []); + assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); + assert.strictEqual(response.showTimelineReplies, false); + assert.deepStrictEqual(response.achievements, []); + assert.deepStrictEqual(response.loggedInDays, 0); + assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); + assert.notStrictEqual(response.email, undefined); + assert.strictEqual(response.emailVerified, false); + assert.deepStrictEqual(response.securityKeysList, []); + }); + + //#endregion + //#region 自分の情報(i) + + test('を読み取ることができること(自分)、キーが過不足なく入っていること。', async () => { + const response = await successfulApiCall({ + endpoint: 'i', + parameters: {}, + user: userNoNote, + }); + const expected = meDetailed(userNoNote, true); + expected.loggedInDays = 1; // iはloggedInDaysを更新する + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region 自分の情報の更新(i/update) + + test.each([ + { parameters: (): object => ({ name: null }) }, + { parameters: (): object => ({ name: 'x'.repeat(50) }) }, + { parameters: (): object => ({ name: 'x' }) }, + { parameters: (): object => ({ name: 'My name' }) }, + { parameters: (): object => ({ description: null }) }, + { parameters: (): object => ({ description: 'x'.repeat(1500) }) }, + { parameters: (): object => ({ description: 'x' }) }, + { parameters: (): object => ({ description: 'My description' }) }, + { parameters: (): object => ({ location: null }) }, + { parameters: (): object => ({ location: 'x'.repeat(50) }) }, + { parameters: (): object => ({ location: 'x' }) }, + { parameters: (): object => ({ location: 'My location' }) }, + { parameters: (): object => ({ birthday: '0000-00-00' }) }, + { parameters: (): object => ({ birthday: '9999-99-99' }) }, + { parameters: (): object => ({ lang: 'en-US' }) }, + { parameters: (): object => ({ fields: [] }) }, + { parameters: (): object => ({ fields: [{ name: 'x', value: 'x' }] }) }, + { parameters: (): object => ({ fields: [{ name: 'x'.repeat(3000), value: 'x'.repeat(3000) }] }) }, // BUG? fieldには制限がない + { parameters: (): object => ({ fields: Array(16).fill({ name: 'x', value: 'y' }) }) }, + { parameters: (): object => ({ isLocked: true }) }, + { parameters: (): object => ({ isLocked: false }) }, + { parameters: (): object => ({ isExplorable: false }) }, + { parameters: (): object => ({ isExplorable: true }) }, + { parameters: (): object => ({ hideOnlineStatus: true }) }, + { parameters: (): object => ({ hideOnlineStatus: false }) }, + { parameters: (): object => ({ publicReactions: false }) }, + { parameters: (): object => ({ publicReactions: true }) }, + { parameters: (): object => ({ autoAcceptFollowed: true }) }, + { parameters: (): object => ({ autoAcceptFollowed: false }) }, + { parameters: (): object => ({ noCrawle: true }) }, + { parameters: (): object => ({ noCrawle: false }) }, + { parameters: (): object => ({ isBot: true }) }, + { parameters: (): object => ({ isBot: false }) }, + { parameters: (): object => ({ isCat: true }) }, + { parameters: (): object => ({ isCat: false }) }, + { parameters: (): object => ({ showTimelineReplies: true }) }, + { parameters: (): object => ({ showTimelineReplies: false }) }, + { parameters: (): object => ({ injectFeaturedNote: true }) }, + { parameters: (): object => ({ injectFeaturedNote: false }) }, + { parameters: (): object => ({ receiveAnnouncementEmail: true }) }, + { parameters: (): object => ({ receiveAnnouncementEmail: false }) }, + { parameters: (): object => ({ alwaysMarkNsfw: true }) }, + { parameters: (): object => ({ alwaysMarkNsfw: false }) }, + { parameters: (): object => ({ autoSensitive: true }) }, + { parameters: (): object => ({ autoSensitive: false }) }, + { parameters: (): object => ({ ffVisibility: 'private' }) }, + { parameters: (): object => ({ ffVisibility: 'followers' }) }, + { parameters: (): object => ({ ffVisibility: 'public' }) }, + { parameters: (): object => ({ mutedWords: Array(19).fill(['xxxxx']) }) }, + { parameters: (): object => ({ mutedWords: [['x'.repeat(194)]] }) }, + { parameters: (): object => ({ mutedWords: [] }) }, + { parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) }, + { parameters: (): object => ({ mutedInstances: [] }) }, + { parameters: (): object => ({ mutingNotificationTypes: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) }, + { parameters: (): object => ({ mutingNotificationTypes: [] }) }, + { parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) }, + { parameters: (): object => ({ emailNotificationTypes: [] }) }, + ] as const)('を書き換えることができる($#)', async ({ parameters }) => { + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters(), user: alice }); + const expected = { ...meDetailed(alice, true), ...parameters() }; + assert.deepStrictEqual(response, expected, inspect(parameters())); + }); + + test('を書き換えることができる(Avatar)', async () => { + const aliceFile = (await uploadFile(alice)).body; + const parameters = { avatarId: aliceFile.id }; + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); + assert.match(response.avatarUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.match(response.avatarBlurhash ?? '.', /[ -~]{54}/); + const expected = { + ...meDetailed(alice, true), + avatarId: aliceFile.id, + avatarBlurhash: response.avatarBlurhash, + avatarUrl: response.avatarUrl, + }; + assert.deepStrictEqual(response, expected, inspect(parameters)); + + const parameters2 = { avatarId: null }; + const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); + const expected2 = { + ...meDetailed(alice, true), + avatarId: null, + avatarBlurhash: null, + avatarUrl: alice.avatarUrl, // 解除した場合、identiconになる + }; + assert.deepStrictEqual(response2, expected2, inspect(parameters)); + }); + + test('を書き換えることができる(Banner)', async () => { + const aliceFile = (await uploadFile(alice)).body; + const parameters = { bannerId: aliceFile.id }; + const response = await successfulApiCall({ endpoint: 'i/update', parameters: parameters, user: alice }); + assert.match(response.bannerUrl ?? '.', /^[-a-zA-Z0-9@:%._\+~#&?=\/]+$/); + assert.match(response.bannerBlurhash ?? '.', /[ -~]{54}/); + const expected = { + ...meDetailed(alice, true), + bannerId: aliceFile.id, + bannerBlurhash: response.bannerBlurhash, + bannerUrl: response.bannerUrl, + }; + assert.deepStrictEqual(response, expected, inspect(parameters)); + + const parameters2 = { bannerId: null }; + const response2 = await successfulApiCall({ endpoint: 'i/update', parameters: parameters2, user: alice }); + const expected2 = { + ...meDetailed(alice, true), + bannerId: null, + bannerBlurhash: null, + bannerUrl: null, + }; + assert.deepStrictEqual(response2, expected2, inspect(parameters)); + }); + + //#endregion + //#region 自分の情報の更新(i/pin, i/unpin) + + test('を書き換えることができる(ピン止めノート)', async () => { + const parameters = { noteId: aliceNote.id }; + const response = await successfulApiCall({ endpoint: 'i/pin', parameters, user: alice }); + const expected = { ...meDetailed(alice, false), pinnedNoteIds: [aliceNote.id], pinnedNotes: [aliceNote] }; + assert.deepStrictEqual(response, expected); + + const response2 = await successfulApiCall({ endpoint: 'i/unpin', parameters, user: alice }); + const expected2 = meDetailed(alice, false); + assert.deepStrictEqual(response2, expected2); + }); + + //#endregion + //#region メモの更新(users/update-memo) + + test.each([ + { label: '最大長', memo: 'x'.repeat(2048) }, + { label: '空文字', memo: '', expects: null }, + { label: 'null', memo: null }, + ])('を書き換えることができる(メモを$labelに)', async ({ memo, expects }) => { + const expected = { ...await show(bob.id), memo: expects === undefined ? memo : expects }; + const parameters = { userId: bob.id, memo }; + await successfulApiCall({ endpoint: 'users/update-memo', parameters, user: alice }); + const response = await show(bob.id); + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region ユーザー(users) + + test.each([ + { label: 'ID昇順', parameters: { limit: 5 }, selector: (u: UserLite): string => u.id }, + { label: 'フォロワー昇順', parameters: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: 'フォロワー降順', parameters: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: '登録日時昇順', parameters: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '登録日時降順', parameters: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '投稿日時昇順', parameters: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + { label: '投稿日時降順', parameters: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + ] as const)('をリスト形式で取得することができる($label)', async ({ parameters, selector }) => { + const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); + + // 結果の並びを事前にアサートするのは困難なので返ってきたidに対応するユーザーが返っており、ソート順が正しいことだけを検証する + const users = await Promise.all(response.map(u => show(u.id))); + const expected = users.sort((x, y) => { + const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; + return index * (parameters.sort?.startsWith('+') ? -1 : 1); + }); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれない', user: (): User => userNotExplorable, excluded: true }, + { label: 'ミュートユーザーが含まれない', user: (): User => userMutedByAlice, excluded: true }, + { label: 'ブロックされているユーザーが含まれない', user: (): User => userBlockedByAlice, excluded: true }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice, excluded: true }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をリスト形式で取得することができ、結果に$label', async ({ user, excluded }) => { + const parameters = { limit: 100 }; + const response = await successfulApiCall({ endpoint: 'users', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id)]; + assert.deepStrictEqual(response.filter((u) => u.id === user().id), expected); + }); + test.todo('をリスト形式で取得することができる(リモート, hostname指定)'); + test.todo('をリスト形式で取得することができる(pagenation)'); + + //#endregion + //#region ユーザー情報(users/show) + + test.each([ + { label: 'ID指定で自分自身を', parameters: (): object => ({ userId: alice.id }), user: (): User => alice, type: meDetailed }, + { label: 'ID指定で他人を', parameters: (): object => ({ userId: alice.id }), user: (): User => bob, type: userDetailedNotMeWithRelations }, + { label: 'ID指定かつ未認証', parameters: (): object => ({ userId: alice.id }), user: undefined, type: userDetailedNotMe }, + { label: '@指定で自分自身を', parameters: (): object => ({ username: alice.username }), user: (): User => alice, type: meDetailed }, + { label: '@指定で他人を', parameters: (): object => ({ username: alice.username }), user: (): User => bob, type: userDetailedNotMeWithRelations }, + { label: '@指定かつ未認証', parameters: (): object => ({ username: alice.username }), user: undefined, type: userDetailedNotMe }, + ] as const)('を取得することができる($label)', async ({ parameters, user, type }) => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: parameters(), user: user?.() }); + const expected = type(alice); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: 'Administratorになっている', user: (): User => userAdmin, me: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin }, + { label: '自分以外から見たときはAdministratorか判定できない', user: (): User => userAdmin, selector: (user: User): unknown => user.isAdmin, expected: (): undefined => undefined }, + { label: 'Moderatorになっている', user: (): User => userModerator, me: (): User => userModerator, selector: (user: User): unknown => user.isModerator }, + { label: '自分以外から見たときはModeratorか判定できない', user: (): User => userModerator, selector: (user: User): unknown => user.isModerator, expected: (): undefined => undefined }, + { label: 'サイレンスになっている', user: (): User => userSilenced, selector: (user: User): unknown => user.isSilenced }, + { label: 'サスペンドになっている', user: (): User => userSuspended, selector: (user: User): unknown => user.isSuspended }, + { label: '削除済みになっている', user: (): User => userDeletedBySelf, me: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted }, + { label: '自分以外から見たときは削除済みか判定できない', user: (): User => userDeletedBySelf, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, + { label: '削除済み(byAdmin)になっている', user: (): User => userDeletedByAdmin, me: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted }, + { label: '自分以外から見たときは削除済み(byAdmin)か判定できない', user: (): User => userDeletedByAdmin, selector: (user: User): unknown => user.isDeleted, expected: (): undefined => undefined }, + { label: 'フォロー中になっている', user: (): User => userFollowedByAlice, selector: (user: User): unknown => user.isFollowing }, + { label: 'フォローされている', user: (): User => userFollowingAlice, selector: (user: User): unknown => user.isFollowed }, + { label: 'ブロック中になっている', user: (): User => userBlockedByAlice, selector: (user: User): unknown => user.isBlocking }, + { label: 'ブロックされている', user: (): User => userBlockingAlice, selector: (user: User): unknown => user.isBlocked }, + { label: 'ミュート中になっている', user: (): User => userMutedByAlice, selector: (user: User): unknown => user.isMuted }, + { label: 'リノートミュート中になっている', user: (): User => userRnMutedByAlice, selector: (user: User): unknown => user.isRenoteMuted }, + { label: 'フォローリクエスト中になっている', user: (): User => userFollowRequested, me: (): User => userFollowRequesting, selector: (user: User): unknown => user.hasPendingFollowRequestFromYou }, + { label: 'フォローリクエストされている', user: (): User => userFollowRequesting, me: (): User => userFollowRequested, selector: (user: User): unknown => user.hasPendingFollowRequestToYou }, + ] as const)('を取得することができ、$labelこと', async ({ user, me, selector, expected }) => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: user().id }, user: me?.() ?? alice }); + assert.strictEqual(selector(response), (expected ?? ((): true => true))()); + }); + test('を取得することができ、Publicなロールがセットされていること', async () => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRolePublic.id }, user: alice }); + assert.deepStrictEqual(response.badgeRoles, []); + assert.deepStrictEqual(response.roles, [{ + id: rolePublic.id, + name: rolePublic.name, + color: rolePublic.color, + iconUrl: rolePublic.iconUrl, + description: rolePublic.description, + isModerator: rolePublic.isModerator, + isAdministrator: rolePublic.isAdministrator, + displayOrder: rolePublic.displayOrder, + }]); + }); + test('を取得することができ、バッヂロールがセットされていること', async () => { + const response = await successfulApiCall({ endpoint: 'users/show', parameters: { userId: userRoleBadge.id }, user: alice }); + assert.deepStrictEqual(response.badgeRoles, [{ + name: roleBadge.name, + iconUrl: roleBadge.iconUrl, + displayOrder: roleBadge.displayOrder, + }]); + assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない + }); + test('をID指定のリスト形式で取得することができる(空)', async () => { + const parameters = { userIds: [] }; + const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); + const expected: [] = []; + assert.deepStrictEqual(response, expected); + }); + test('をID指定のリスト形式で取得することができる', async() => { + const parameters = { userIds: [bob.id, alice.id, carol.id] }; + const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: alice }); + const expected = [ + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: bob.id }, user: alice }), + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: alice.id }, user: alice }), + await successfulApiCall({ endpoint: 'users/show', parameters: { userId: carol.id }, user: alice }), + ]; + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが(モデレーターが見るときは)含まれる', user: (): User => userSuspended, me: (): User => root }, + // BUG サスペンドユーザーを一般ユーザーから見るとrootユーザーが返ってくる + //{ label: 'サスペンドユーザーが(一般ユーザーが見るときは)含まれない', user: (): User => userSuspended, me: (): User => bob, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をID指定のリスト形式で取得することができ、結果に$label', async ({ user, me, excluded }) => { + const parameters = { userIds: [user().id] }; + const response = await successfulApiCall({ endpoint: 'users/show', parameters, user: me?.() ?? alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id, me?.() ?? alice)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('をID指定のリスト形式で取得することができる(リモート)'); + + //#endregion + //#region 検索(users/search) + + test('を検索することができる', async () => { + const parameters = { query: 'carol', limit: 10 }; + const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); + const expected = [await show(carol.id)]; + assert.deepStrictEqual(response, expected); + }); + test('を検索することができる(UserLite)', async () => { + const parameters = { query: 'carol', detail: false, limit: 10 }; + const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); + const expected = [userLite(await show(carol.id))]; + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('を検索することができ、結果に$labelが含まれる', async ({ user, excluded }) => { + const parameters = { query: user().username, limit: 1 }; + const response = await successfulApiCall({ endpoint: 'users/search', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('を検索することができる(リモート)'); + test.todo('を検索することができる(pagenation)'); + + //#endregion + //#region ID指定検索(users/search-by-username-and-host) + + test.each([ + { label: '自分', parameters: { username: 'alice' }, user: (): User[] => [alice] }, + { label: '自分かつusernameが大文字', parameters: { username: 'ALICE' }, user: (): User[] => [alice] }, + { label: 'ローカルのフォロイーでノートなし', parameters: { username: 'userFollowedByAlice' }, user: (): User[] => [userFollowedByAlice] }, + { label: 'ローカルでノートなしは検索に載らない', parameters: { username: 'userNoNote' }, user: (): User[] => [] }, + { label: 'ローカルの他人1', parameters: { username: 'bob' }, user: (): User[] => [bob] }, + { label: 'ローカルの他人2', parameters: { username: 'bob', host: null }, user: (): User[] => [bob] }, + { label: 'ローカルの他人3', parameters: { username: 'bob', host: '.' }, user: (): User[] => [bob] }, + { label: 'ローカル', parameters: { host: null, limit: 1 }, user: (): User[] => [userFollowedByAlice] }, + { label: 'ローカル', parameters: { host: '.', limit: 1 }, user: (): User[] => [userFollowedByAlice] }, + ])('をID&ホスト指定で検索できる($label)', async ({ parameters, user }) => { + const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); + const expected = await Promise.all(user().map(u => show(u.id))); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended, excluded: true }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をID&ホスト指定で検索でき、結果に$label', async ({ user, excluded }) => { + const parameters = { username: user().username }; + const response = await successfulApiCall({ endpoint: 'users/search-by-username-and-host', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('をID&ホスト指定で検索できる(リモート)'); + + //#endregion + //#region ID指定検索(users/get-frequently-replied-users) + + test('がよくリプライをするユーザーのリストを取得できる', async () => { + const parameters = { userId: alice.id, limit: 5 }; + const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); + const expected = await Promise.all(usersReplying.slice(0, parameters.limit).map(async (s, i) => ({ + user: await show(s.id), + weight: (usersReplying.length - i) / usersReplying.length, + }))); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれない', user: (): User => userBlockingAlice, excluded: true }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれない', user: (): User => userSuspended }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('がよくリプライをするユーザーのリストを取得でき、結果に$label', async ({ user, excluded }) => { + const replyTo = (await successfulApiCall({ endpoint: 'users/notes', parameters: { userId: user().id }, user: undefined }))[0]; + await post(alice, { text: `@${user().username} test`, replyId: replyTo.id }); + const parameters = { userId: alice.id, limit: 100 }; + const response = await successfulApiCall({ endpoint: 'users/get-frequently-replied-users', parameters, user: alice }); + const expected = (excluded ?? false) ? [] : [await show(user().id)]; + assert.deepStrictEqual(response.map(s => s.user).filter((u) => u.id === user().id), expected); + }); + + //#endregion + //#region ハッシュタグ(hashtags/users) + + test.each([ + { label: 'フォロワー昇順', sort: { sort: '+follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: 'フォロワー降順', sort: { sort: '-follower' }, selector: (u: UserDetailedNotMe): string => String(u.followersCount) }, + { label: '登録日時昇順', sort: { sort: '+createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '登録日時降順', sort: { sort: '-createdAt' }, selector: (u: UserDetailedNotMe): string => u.createdAt }, + { label: '投稿日時昇順', sort: { sort: '+updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + { label: '投稿日時降順', sort: { sort: '-updatedAt' }, selector: (u: UserDetailedNotMe): string => String(u.updatedAt) }, + ] as const)('をハッシュタグ指定で取得することができる($label)', async ({ sort, selector }) => { + const hashtag = 'test_hashtag'; + await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: alice }); + const parameters = { tag: hashtag, limit: 5, ...sort }; + const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); + const users = await Promise.all(response.map(u => show(u.id))); + const expected = users.sort((x, y) => { + const index = (selector(x) < selector(y)) ? -1 : (selector(x) > selector(y)) ? 1 : 0; + return index * (parameters.sort.startsWith('+') ? -1 : 1); + }); + assert.deepStrictEqual(response, expected); + }); + test.each([ + { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: (): User => userNotExplorable }, + { label: 'ミュートユーザーが含まれる', user: (): User => userMutedByAlice }, + { label: 'ブロックされているユーザーが含まれる', user: (): User => userBlockedByAlice }, + { label: 'ブロックしてきているユーザーが含まれる', user: (): User => userBlockingAlice }, + { label: '承認制ユーザーが含まれる', user: (): User => userLocking }, + { label: 'サイレンスユーザーが含まれる', user: (): User => userSilenced }, + { label: 'サスペンドユーザーが含まれる', user: (): User => userSuspended }, + { label: '削除済ユーザーが含まれる', user: (): User => userDeletedBySelf }, + { label: '削除済(byAdmin)ユーザーが含まれる', user: (): User => userDeletedByAdmin }, + ] as const)('をハッシュタグ指定で取得することができ、結果に$label', async ({ user }) => { + const hashtag = `user_test${user().username}`; + if (user() !== userSuspended) { + // サスペンドユーザーはupdateできない。 + await successfulApiCall({ endpoint: 'i/update', parameters: { description: `#${hashtag}` }, user: user() }); + } + const parameters = { tag: hashtag, limit: 100, sort: '-follower' } as const; + const response = await successfulApiCall({ endpoint: 'hashtags/users', parameters, user: alice }); + const expected = [await show(user().id)]; + assert.deepStrictEqual(response, expected); + }); + test.todo('をハッシュタグ指定で取得することができる(リモート)'); + + //#endregion + //#region オススメユーザー(users/recommendation) + + // BUG users/recommendationは壊れている? > QueryFailedError: missing FROM-clause entry for table "note" + test.skip('のオススメを取得することができる', async () => { + const parameters = {}; + const response = await successfulApiCall({ endpoint: 'users/recommendation', parameters, user: alice }); + const expected = await Promise.all(response.map(u => show(u.id))); + assert.deepStrictEqual(response, expected); + }); + + //#endregion + //#region ピン止めユーザー(pinned-users) + + test('のピン止めユーザーを取得することができる', async () => { + await successfulApiCall({ endpoint: 'admin/update-meta', parameters: { pinnedUsers: [bob.username, `@${carol.username}`] }, user: root }); + const parameters = {} as const; + const response = await successfulApiCall({ endpoint: 'pinned-users', parameters, user: alice }); + const expected = await Promise.all([bob, carol].map(u => show(u.id))); + assert.deepStrictEqual(response, expected); + }); + + //#endregion + + test.todo('を管理人として確認することができる(admin/show-user)'); + test.todo('を管理人として確認することができる(admin/show-users)'); + test.todo('をサーバー向けに取得することができる(federation/users)'); +}); diff --git a/packages/backend/test/resources/kick_gaba7.aac b/packages/backend/test/resources/kick_gaba7.aac new file mode 100644 index 000000000..4644542f9 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.aac differ diff --git a/packages/backend/test/resources/kick_gaba7.flac b/packages/backend/test/resources/kick_gaba7.flac new file mode 100644 index 000000000..751281201 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.flac differ diff --git a/packages/backend/test/resources/kick_gaba7.mp3 b/packages/backend/test/resources/kick_gaba7.mp3 new file mode 100644 index 000000000..6ba317deb Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.mp3 differ diff --git a/packages/backend/test/resources/kick_gaba7.wav b/packages/backend/test/resources/kick_gaba7.wav new file mode 100644 index 000000000..2cd280148 Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.wav differ diff --git a/packages/backend/test/resources/kick_gaba7.webm b/packages/backend/test/resources/kick_gaba7.webm new file mode 100644 index 000000000..82c5349cd Binary files /dev/null and b/packages/backend/test/resources/kick_gaba7.webm differ diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index d05833560..f378184c7 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -7,10 +7,10 @@ import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { FileInfoService } from '@/core/FileInfoService.js'; -import { DI } from '@/di-symbols.js'; +//import { DI } from '@/di-symbols.js'; import { AiService } from '@/core/AiService.js'; import type { TestingModule } from '@nestjs/testing'; -import type { jest } from '@jest/globals'; +import { describe, beforeAll, afterAll, test } from '@jest/globals'; import type { MockFunctionMetadata } from 'jest-mock'; const _filename = fileURLToPath(import.meta.url); @@ -74,164 +74,271 @@ describe('FileInfoService', () => { }); }); - test('Generic JPEG', async () => { - const path = `${resources}/Lenna.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 25360, - md5: '091b3f259662aa31e2ffef4519951168', - type: { - mime: 'image/jpeg', - ext: 'jpg', - }, - width: 512, - height: 512, - orientation: undefined, + describe('IMAGE', () => { + test('Generic JPEG', async () => { + const path = `${resources}/Lenna.jpg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 25360, + md5: '091b3f259662aa31e2ffef4519951168', + type: { + mime: 'image/jpeg', + ext: 'jpg', + }, + width: 512, + height: 512, + orientation: undefined, + }); + }); + + test('Generic APNG', async () => { + const path = `${resources}/anime.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 1868, + md5: '08189c607bea3b952704676bb3c979e0', + type: { + mime: 'image/apng', + ext: 'apng', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('Generic AGIF', async () => { + const path = `${resources}/anime.gif`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 2248, + md5: '32c47a11555675d9267aee1a86571e7e', + type: { + mime: 'image/gif', + ext: 'gif', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('PNG with alpha', async () => { + const path = `${resources}/with-alpha.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 3772, + md5: 'f73535c3e1e27508885b69b10cf6e991', + type: { + mime: 'image/png', + ext: 'png', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('Generic SVG', async () => { + const path = `${resources}/image.svg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 505, + md5: 'b6f52b4b021e7b92cdd04509c7267965', + type: { + mime: 'image/svg+xml', + ext: 'svg', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('SVG with XML definition', async () => { + // https://github.com/misskey-dev/misskey/issues/4413 + const path = `${resources}/with-xml-def.svg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 544, + md5: '4b7a346cde9ccbeb267e812567e33397', + type: { + mime: 'image/svg+xml', + ext: 'svg', + }, + width: 256, + height: 256, + orientation: undefined, + }); + }); + + test('Dimension limit', async () => { + const path = `${resources}/25000x25000.png`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 75933, + md5: '268c5dde99e17cf8fe09f1ab3f97df56', + type: { + mime: 'application/octet-stream', // do not treat as image + ext: null, + }, + width: 25000, + height: 25000, + orientation: undefined, + }); + }); + + test('Rotate JPEG', async () => { + const path = `${resources}/rotate.jpg`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + assert.deepStrictEqual(info, { + size: 12624, + md5: '68d5b2d8d1d1acbbce99203e3ec3857e', + type: { + mime: 'image/jpeg', + ext: 'jpg', + }, + width: 512, + height: 256, + orientation: 8, + }); }); }); - test('Generic APNG', async () => { - const path = `${resources}/anime.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 1868, - md5: '08189c607bea3b952704676bb3c979e0', - type: { - mime: 'image/apng', - ext: 'apng', - }, - width: 256, - height: 256, - orientation: undefined, + describe('AUDIO', () => { + test('MP3', async () => { + const path = `${resources}/kick_gaba7.mp3`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 19853, + md5: '4f557df8548bc3cecc794c652f690446', + type: { + mime: 'audio/mpeg', + ext: 'mp3', + }, + }); }); - }); - - test('Generic AGIF', async () => { - const path = `${resources}/anime.gif`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 2248, - md5: '32c47a11555675d9267aee1a86571e7e', - type: { - mime: 'image/gif', - ext: 'gif', - }, - width: 256, - height: 256, - orientation: undefined, + + test('WAV', async () => { + const path = `${resources}/kick_gaba7.wav`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 87630, + md5: '8bc9bb4fe5e77bb1871448209be635c1', + type: { + mime: 'audio/wav', + ext: 'wav', + }, + }); }); - }); - - test('PNG with alpha', async () => { - const path = `${resources}/with-alpha.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 3772, - md5: 'f73535c3e1e27508885b69b10cf6e991', - type: { - mime: 'image/png', - ext: 'png', - }, - width: 256, - height: 256, - orientation: undefined, + + test('AAC', async () => { + const path = `${resources}/kick_gaba7.aac`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 7291, + md5: '2789323f05e3392b648066f50be6a2a6', + type: { + mime: 'audio/aac', + ext: 'aac', + }, + }); }); - }); - - test('Generic SVG', async () => { - const path = `${resources}/image.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 505, - md5: 'b6f52b4b021e7b92cdd04509c7267965', - type: { - mime: 'image/svg+xml', - ext: 'svg', - }, - width: 256, - height: 256, - orientation: undefined, + + test('FLAC', async () => { + const path = `${resources}/kick_gaba7.flac`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 108793, + md5: 'bc0f3adfe0e1ca99ae6c7528c46b3173', + type: { + mime: 'audio/flac', + ext: 'flac', + }, + }); }); - }); - - test('SVG with XML definition', async () => { - // https://github.com/misskey-dev/misskey/issues/4413 - const path = `${resources}/with-xml-def.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 544, - md5: '4b7a346cde9ccbeb267e812567e33397', - type: { - mime: 'image/svg+xml', - ext: 'svg', - }, - width: 256, - height: 256, - orientation: undefined, - }); - }); - - test('Dimension limit', async () => { - const path = `${resources}/25000x25000.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 75933, - md5: '268c5dde99e17cf8fe09f1ab3f97df56', - type: { - mime: 'application/octet-stream', // do not treat as image - ext: null, - }, - width: 25000, - height: 25000, - orientation: undefined, - }); - }); - - test('Rotate JPEG', async () => { - const path = `${resources}/rotate.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; - assert.deepStrictEqual(info, { - size: 12624, - md5: '68d5b2d8d1d1acbbce99203e3ec3857e', - type: { - mime: 'image/jpeg', - ext: 'jpg', - }, - width: 512, - height: 256, - orientation: 8, + + /* + * video/webmとして検出されてしまう + test('WEBM AUDIO', async () => { + const path = `${resources}/kick_gaba7.webm`; + const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; + delete info.warnings; + delete info.blurhash; + delete info.sensitive; + delete info.porn; + delete info.width; + delete info.height; + delete info.orientation; + assert.deepStrictEqual(info, { + size: 8879, + md5: '3350083dec312419cfdc06c16413aca7', + type: { + mime: 'audio/webm', + ext: 'webm', + }, + }); }); + */ }); }); diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index 529e923b2..c2280142a 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -7,6 +7,7 @@ import { GlobalModule } from '@/GlobalModule.js'; import { RelayService } from '@/core/RelayService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; import { IdService } from '@/core/IdService.js'; import type { RelaysRepository } from '@/models/index.js'; @@ -21,6 +22,7 @@ describe('RelayService', () => { let relayService: RelayService; let queueService: jest.Mocked; let relaysRepository: RelaysRepository; + let userEntityService: UserEntityService; beforeAll(async () => { app = await Test.createTestingModule({ @@ -32,6 +34,7 @@ describe('RelayService', () => { CreateSystemUserService, ApRendererService, RelayService, + UserEntityService, ], }) .useMocker((token) => { @@ -51,6 +54,7 @@ describe('RelayService', () => { relayService = app.get(RelayService); queueService = app.get(QueueService) as jest.Mocked; relaysRepository = app.get(DI.relaysRepository); + userEntityService = app.get(UserEntityService); }); afterAll(async () => { @@ -63,7 +67,7 @@ describe('RelayService', () => { expect(result.inbox).toBe('https://example.com'); expect(result.status).toBe('requesting'); expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1].type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Follow'); expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); }); @@ -80,8 +84,8 @@ describe('RelayService', () => { await relayService.removeRelay('https://example.com'); expect(queueService.deliver).toHaveBeenCalled(); - expect(queueService.deliver.mock.lastCall![1].type).toBe('Undo'); - expect(queueService.deliver.mock.lastCall![1].object.type).toBe('Follow'); + expect(queueService.deliver.mock.lastCall![1]?.type).toBe('Undo'); + expect(queueService.deliver.mock.lastCall![1]?.object.type).toBe('Follow'); expect(queueService.deliver.mock.lastCall![2]).toBe('https://example.com'); //expect(queueService.deliver.mock.lastCall![0].username).toBe('relay.actor'); diff --git a/packages/backend/test/unit/misc/id.ts b/packages/backend/test/unit/misc/id.ts new file mode 100644 index 000000000..ecd0e60a3 --- /dev/null +++ b/packages/backend/test/unit/misc/id.ts @@ -0,0 +1,44 @@ +import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js'; +import { genMeid, meidRegExp, parseMeid } from '@/misc/id/meid.js'; +import { genMeidg, meidgRegExp, parseMeidg } from '@/misc/id/meidg.js'; +import { genObjectId, objectIdRegExp, parseObjectId } from '@/misc/id/object-id.js'; +import { ulidRegExp, parseUlid } from '@/misc/id/ulid.js'; +import { ulid } from 'ulid'; +import { describe, test, expect } from '@jest/globals'; + +describe('misc:id', () => { + test('aid', () => { + const date = new Date(); + const gotAid = genAid(date); + expect(gotAid).toMatch(aidRegExp); + expect(parseAid(gotAid).date.getTime()).toBe(date.getTime()); + }); + + test('meid', () => { + const date = new Date(); + const gotMeid = genMeid(date); + expect(gotMeid).toMatch(meidRegExp); + expect(parseMeid(gotMeid).date.getTime()).toBe(date.getTime()); + }); + + test('meidg', () => { + const date = new Date(); + const gotMeidg = genMeidg(date); + expect(gotMeidg).toMatch(meidgRegExp); + expect(parseMeidg(gotMeidg).date.getTime()).toBe(date.getTime()); + }); + + test('objectid', () => { + const date = new Date(); + const gotObjectId = genObjectId(date); + expect(gotObjectId).toMatch(objectIdRegExp); + expect(Math.floor(parseObjectId(gotObjectId).date.getTime() / 1000)).toBe(Math.floor(date.getTime() / 1000)); + }); + + test('ulid', () => { + const date = new Date(); + const gotUlid = ulid(date.getTime()); + expect(gotUlid).toMatch(ulidRegExp); + expect(parseUlid(gotUlid).date.getTime()).toBe(date.getTime()); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 4f501a872..809ed2c66 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -6,6 +6,7 @@ import WebSocket from 'ws'; import fetch, { Blob, File, RequestInit } from 'node-fetch'; import { DataSource } from 'typeorm'; import { JSDOM } from 'jsdom'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; @@ -31,12 +32,12 @@ export type ApiRequest = { }; export const successfulApiCall = async (request: ApiRequest, assertion: { - status: number, -} = { status: 200 }): Promise => { + status?: number, +} = {}): Promise => { const { endpoint, parameters, user } = request; - const { status } = assertion; const res = await api(endpoint, parameters, user); - assert.strictEqual(res.status, status, inspect(res.body)); + const status = assertion.status ?? (res.body == null ? 204 : 200); + assert.strictEqual(res.status, status, inspect(res.body, { depth: 5, colors: true })); return res.body; }; @@ -188,6 +189,36 @@ export const channel = async (user: any, channel: any = {}): Promise => { return res.body; }; +export const role = async (user: any, role: any = {}, policies: any = {}): Promise => { + const res = await api('admin/roles/create', { + asBadge: false, + canEditMembersByModerator: false, + color: null, + condFormula: { + id: 'ebef1684-672d-49b6-ad82-1b3ec3784f85', + type: 'isRemote', + }, + description: '', + displayOrder: 0, + iconUrl: null, + isAdministrator: false, + isModerator: false, + isPublic: false, + name: 'New Role', + target: 'manual', + policies: { + ...Object.entries(DEFAULT_POLICIES).map(([k, v]) => [k, { + priority: 0, + useDefault: true, + value: v, + }]), + ...policies, + }, + ...role, + }, user); + return res.body; +}; + interface UploadOptions { /** Optional, absolute path or relative from ./resources/ */ path?: string | URL; diff --git a/packages/frontend/.storybook/.gitignore b/packages/frontend/.storybook/.gitignore index 649b36b84..e421532a5 100644 --- a/packages/frontend/.storybook/.gitignore +++ b/packages/frontend/.storybook/.gitignore @@ -1,9 +1,7 @@ -# (cd path/to/frontend; pnpm tsc -p .storybook) -# (cd path/to/frontend; node .storybook/generate.js) +/changes.js /generate.js -# (cd path/to/frontend; node .storybook/preload-locale.js) /preload-locale.js /locale.ts -# (cd path/to/frontend; node .storybook/preload-theme.js) +/main.js /preload-theme.js /themes.ts diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts new file mode 100644 index 000000000..755bec686 --- /dev/null +++ b/packages/frontend/.storybook/changes.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import micromatch from 'micromatch'; +import main from './main'; + +interface Stats { + readonly modules: readonly { + readonly id: string; + readonly name: string; + readonly reasons: readonly { + readonly moduleName: string; + }[]; + }[]; +} + +fs.readFile( + path.resolve(__dirname, '../storybook-static/preview-stats.json') +).then((buffer) => { + const stats: Stats = JSON.parse(buffer.toString()); + const keys = new Set(stats.modules.map((stat) => stat.id)); + const map = new Map( + Array.from(keys, (key) => [ + key, + new Set( + stats.modules + .filter((stat) => stat.id === key) + .flatMap((stat) => stat.reasons) + .map((reason) => reason.moduleName) + ), + ]) + ); + const modules = new Set( + process.argv + .slice(2) + .map((arg) => + path.relative( + path.resolve(__dirname, '..'), + path.resolve(__dirname, '../../..', arg) + ) + ) + .map((path) => path.replace(/(?:(?<=\.stories)\.(?:impl|meta)|\.msw)(?=\.ts$)/g, '')) + .map((path) => (path.startsWith('.') ? path : `./${path}`)) + ); + if ( + micromatch(Array.from(modules), [ + '../../assets/**', + '../../fluent-emojis/**', + '../../locales/**', + '../../misskey-assets/**', + 'assets/**', + 'public/**', + '../../pnpm-lock.yaml', + ]).length + ) { + return; + } + for (;;) { + const oldSize = modules.size; + for (const module of Array.from(modules)) { + if (map.has(module)) { + for (const dependency of Array.from(map.get(module)!)) { + modules.add(dependency); + } + } + } + if (modules.size === oldSize) { + break; + } + } + const stories = micromatch( + Array.from(modules), + main.stories.map((story) => `./${path.relative('..', story)}`) + ); + if (stories.length) { + for (const story of stories) { + process.stdout.write(` --only-story-files ${story}`); + } + } else { + process.stdout.write(` --skip`); + } +}); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index b620cf68a..5fd21cdf0 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -1,54 +1,117 @@ import type { entities } from 'misskey-js' -export const userDetailed = { - id: 'someuserid', - username: 'miskist', - host: 'misskey-hub.net', - name: 'Misskey User', - onlineStatus: 'unknown', - avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', - avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', - emojis: [], - bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog', - bannerColor: '#000000', - bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', - birthday: '2014-06-20', - createdAt: '2016-12-28T22:49:51.000Z', - description: 'I am a cool user!', - ffVisibility: 'public', - fields: [ - { - name: 'Website', - value: 'https://misskey-hub.net', +export function abuseUserReport() { + return { + id: 'someabusereportid', + createdAt: '2016-12-28T22:49:51.000Z', + comment: 'This user is a spammer!', + resolved: false, + reporterId: 'reporterid', + targetUserId: 'targetuserid', + assigneeId: 'assigneeid', + reporter: userDetailed('reporterid', 'reporter', 'misskey-hub.net', 'Reporter'), + targetUser: userDetailed('targetuserid', 'target', 'misskey-hub.net', 'Target'), + assignee: userDetailed('assigneeid', 'assignee', 'misskey-hub.net', 'Assignee'), + me: null, + forwarded: false, + }; +} + +export function galleryPost(isSensitive = false) { + return { + id: 'somepostid', + createdAt: '2016-12-28T22:49:51.000Z', + updatedAt: '2016-12-28T22:49:51.000Z', + userid: 'someuserid', + user: userDetailed(), + title: 'Some post title', + description: 'Some post description', + fileIds: ['somefileid'], + files: [ + file(isSensitive), + ], + isSensitive, + likedCount: 0, + isLiked: false, + } +} + +export function file(isSensitive = false) { + return { + id: 'somefileid', + createdAt: '2016-12-28T22:49:51.000Z', + name: 'somefile.jpg', + type: 'image/jpeg', + md5: 'f6fc51c73dc21b1fb85ead2cdf57530a', + size: 77752, + isSensitive, + blurhash: 'eQAmoa^-MH8w9ZIvNLSvo^$*MwRPbwtSxutRozjEiwR.RjWBoeozog', + properties: { + width: 1024, + height: 270 }, - ], - followersCount: 1024, - followingCount: 16, - hasPendingFollowRequestFromYou: false, - hasPendingFollowRequestToYou: false, - isAdmin: false, - isBlocked: false, - isBlocking: false, - isBot: false, - isCat: false, - isFollowed: false, - isFollowing: false, - isLocked: false, - isModerator: false, - isMuted: false, - isSilenced: false, - isSuspended: false, - lang: 'en', - location: 'Fediverse', - notesCount: 65536, - pinnedNoteIds: [], - pinnedNotes: [], - pinnedPage: null, - pinnedPageId: null, - publicReactions: false, - securityKeys: false, - twoFactorEnabled: false, - updatedAt: null, - uri: null, - url: null, -} satisfies entities.UserDetailed + url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + thumbnailUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + comment: null, + folderId: null, + folder: null, + userId: null, + user: null, + }; +} + +export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed { + return { + id, + username, + host, + name, + onlineStatus: 'unknown', + avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', + avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay', + emojis: [], + bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog', + bannerColor: '#000000', + bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true', + birthday: '2014-06-20', + createdAt: '2016-12-28T22:49:51.000Z', + description: 'I am a cool user!', + ffVisibility: 'public', + roles: [], + fields: [ + { + name: 'Website', + value: 'https://misskey-hub.net', + }, + ], + followersCount: 1024, + followingCount: 16, + hasPendingFollowRequestFromYou: false, + hasPendingFollowRequestToYou: false, + isAdmin: false, + isBlocked: false, + isBlocking: false, + isBot: false, + isCat: false, + isFollowed: false, + isFollowing: false, + isLocked: false, + isModerator: false, + isMuted: false, + isSilenced: false, + isSuspended: false, + lang: 'en', + location: 'Fediverse', + notesCount: 65536, + pinnedNoteIds: [], + pinnedNotes: [], + pinnedPage: null, + pinnedPageId: null, + publicReactions: false, + securityKeys: false, + twoFactorEnabled: false, + updatedAt: null, + uri: null, + url: null, + }; +} diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index f0865fcc2..dbe972917 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -118,7 +118,7 @@ function toStories(component: string): string { .replace(/[-.]|^(?=\d)/g, '_') .replace(/(?<=^[^A-Z_]*$)/, '_')} /> as estree.Identifier; - const parameters = ( + const parameters = - ) as estree.ObjectExpression; - const program = ( + /> as estree.ObjectExpression; + const program = ) as estree.Identifier} /> as estree.ExportDefaultDeclaration, ]} - /> - ) as estree.Program; + /> as estree.Program; return format( '/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' + '/* eslint-disable import/no-default-export */\n' + + '/* eslint-disable import/no-duplicates */\n' + generate(program, { generator }) + (hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''), { @@ -394,13 +393,16 @@ function toStories(component: string): string { ); } -// glob('src/{components,pages,ui,widgets}/**/*.vue').then( -glob('src/components/global/**/*.vue').then( - (components) => - Promise.all( - components.map((component) => { - const stories = component.replace(/\.vue$/, '.stories.ts'); - return writeFile(stories, toStories(component)); - }) - ) -); +// glob('src/{components,pages,ui,widgets}/**/*.vue') +Promise.all([ + glob('src/components/global/*.vue'), + glob('src/components/Mk{A,B}*.vue'), + glob('src/components/MkGalleryPostPreview.vue'), + glob('src/components/MkSignupServerRules.vue'), + glob('src/pages/user/home.vue'), +]) + .then((globs) => globs.flat()) + .then((components) => Promise.all(components.map((component) => { + const stories = component.replace(/\.vue$/, '.stories.ts'); + return writeFile(stories, toStories(component)); + }))); diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts index 1e57c97b6..1d0ce5ab6 100644 --- a/packages/frontend/.storybook/main.ts +++ b/packages/frontend/.storybook/main.ts @@ -1,6 +1,7 @@ import { resolve } from 'node:path'; import type { StorybookConfig } from '@storybook/vue3-vite'; -import { mergeConfig } from 'vite'; +import { type Plugin, mergeConfig } from 'vite'; +import turbosnap from 'vite-plugin-turbosnap'; const config = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], addons: [ @@ -20,8 +21,17 @@ const config = { core: { disableTelemetry: true, }, - async viteFinal(config, options) { + async viteFinal(config) { + const replacePluginForIsChromatic = config.plugins?.findIndex((plugin) => plugin && (plugin as Partial)?.name === 'replace') ?? -1; + if (~replacePluginForIsChromatic) { + config.plugins?.splice(replacePluginForIsChromatic, 1); + } return mergeConfig(config, { + plugins: [ + turbosnap({ + rootDir: config.root ?? process.cwd(), + }), + ], build: { target: [ 'chrome108', diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts index 41c3c5c4d..4091e3968 100644 --- a/packages/frontend/.storybook/mocks.ts +++ b/packages/frontend/.storybook/mocks.ts @@ -8,6 +8,16 @@ export const onUnhandledRequest = ((req, print) => { }) satisfies SharedOptions['onUnhandledRequest']; export const commonHandlers = [ + rest.get('/fluent-emoji/:codepoints.png', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); + }), + rest.get('/fluent-emojis/:codepoints.png', async (req, res, ctx) => { + const { codepoints } = req.params; + const value = await fetch(`https://raw.githubusercontent.com/misskey-dev/emojis/main/dist/${codepoints}.png`).then((response) => response.blob()); + return res(ctx.set('Content-Type', 'image/png'), ctx.body(value)); + }), rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => { const { codepoints } = req.params; const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob()); diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html index 01912da28..ab694f64f 100644 --- a/packages/frontend/.storybook/preview-head.html +++ b/packages/frontend/.storybook/preview-head.html @@ -1,4 +1,12 @@ + + + + diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index b2974276a..06aa7b668 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -19,7 +19,7 @@ function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme const theme = themes[document.documentElement.dataset.misskeyTheme]; if (theme) { applyTheme(themes[document.documentElement.dataset.misskeyTheme]); - } else if (isChromatic()) { + } else { applyTheme(themes['l-light']); } const observer = new MutationObserver((entries) => { diff --git a/packages/frontend/.storybook/tsconfig.json b/packages/frontend/.storybook/tsconfig.json index 01aa9db6e..2db2f1eab 100644 --- a/packages/frontend/.storybook/tsconfig.json +++ b/packages/frontend/.storybook/tsconfig.json @@ -18,5 +18,10 @@ "jsx": "react", "jsxFactory": "h" }, - "files": ["./generate.tsx", "./preload-locale.ts", "./preload-theme.ts"] + "files": [ + "./changes.ts", + "./generate.tsx", + "./preload-locale.ts", + "./preload-theme.ts" + ] } diff --git a/packages/frontend/.vscode/storybook.code-snippets b/packages/frontend/.vscode/storybook.code-snippets new file mode 100644 index 000000000..785d0a160 --- /dev/null +++ b/packages/frontend/.vscode/storybook.code-snippets @@ -0,0 +1,84 @@ +{ + "Storybook Story Impl File": { + "scope": "typescript", + "prefix": "storyimpl", + "body": [ + "/* eslint-disable @typescript-eslint/explicit-function-return-type */", + "import { StoryObj } from '@storybook/vue3';", + "import $1 from './$1.vue';", + "export const Default = {", + "\trender(args) {", + "\t\treturn {", + "\t\t\tcomponents: {", + "\t\t\t\t$1,", + "\t\t\t},", + "\t\t\tsetup() {", + "\t\t\t\treturn {", + "\t\t\t\t\targs,", + "\t\t\t\t};", + "\t\t\t},", + "\t\t\tcomputed: {", + "\t\t\t\tprops() {", + "\t\t\t\t\treturn {", + "\t\t\t\t\t\t...this.args,", + "\t\t\t\t\t};", + "\t\t\t\t},", + "\t\t\t},", + "\t\t\ttemplate: '<$1 v-bind=\"props\" />',", + "\t\t};", + "\t},", + "\targs: {", + "\t\t$2", + "\t},", + "\tparameters: {", + "\t\tlayout: 'centered',", + "\t},", + "} satisfies StoryObj;", + "" + ] + }, + "Storybook Story Impl File (w/ events)": { + "scope": "typescript", + "prefix": "storyimplevent", + "body": [ + "/* eslint-disable @typescript-eslint/explicit-function-return-type */", + "import { action } from '@storybook/addon-actions';", + "import { StoryObj } from '@storybook/vue3';", + "import $1 from './$1.vue';", + "export const Default = {", + "\trender(args) {", + "\t\treturn {", + "\t\t\tcomponents: {", + "\t\t\t\t$1,", + "\t\t\t},", + "\t\t\tsetup() {", + "\t\t\t\treturn {", + "\t\t\t\t\targs,", + "\t\t\t\t};", + "\t\t\t},", + "\t\t\tcomputed: {", + "\t\t\t\tprops() {", + "\t\t\t\t\treturn {", + "\t\t\t\t\t\t...this.args,", + "\t\t\t\t\t};", + "\t\t\t\t},", + "\t\t\t\tevents() {", + "\t\t\t\t\treturn {", + "\t\t\t\t\t\t$3", + "\t\t\t\t\t};", + "\t\t\t\t},", + "\t\t\t},", + "\t\t\ttemplate: '<$1 v-bind=\"props\" v-on=\"events\" />',", + "\t\t};", + "\t},", + "\targs: {", + "\t\t$2", + "\t},", + "\tparameters: {", + "\t\tlayout: 'centered',", + "\t},", + "} satisfies StoryObj;", + "" + ] + } +} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 2d96d5514..5b1318fb1 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -15,12 +15,14 @@ }, "dependencies": { "@discordapp/twemoji": "14.1.2", - "@rollup/plugin-alias": "4.0.3", + "@rollup/plugin-alias": "5.0.0", "@rollup/plugin-json": "6.0.0", + "@rollup/plugin-replace": "^5.0.2", "@rollup/pluginutils": "5.0.2", "@syuilo/aiscript": "0.13.1", - "@tabler/icons-webfont": "2.12.0", + "@tabler/icons-webfont": "2.16.0", "@vitejs/plugin-vue": "4.1.0", + "@vue-macros/reactivity-transform": "^0.3.5", "@vue/compiler-sfc": "3.2.47", "autosize": "5.0.2", "blurhash": "2.0.5", @@ -32,6 +34,7 @@ "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", + "chromatic": "6.17.3", "compare-versions": "5.0.1", "cropperjs": "2.0.0-beta.2", "date-fns": "2.29.3", @@ -50,55 +53,57 @@ "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.20.2", + "rollup": "3.20.6", "s-age": "1.1.2", "sanitize-html": "2.10.0", - "sass": "1.60.0", + "sass": "1.62.0", "seedrandom": "3.0.5", "strict-event-emitter-types": "2.0.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.150.1", + "three": "0.151.3", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", "tsc-alias": "1.8.5", "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typescript": "5.0.2", + "typescript": "5.0.4", "uuid": "9.0.0", "vanilla-tilt": "1.8.0", - "vite": "4.2.1", + "vite": "4.2.2", "vue": "3.2.47", "vue-plyr": "7.0.0", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "next" }, "devDependencies": { - "@storybook/addon-essentials": "7.0.2", - "@storybook/addon-interactions": "7.0.2", - "@storybook/addon-links": "7.0.2", - "@storybook/addon-storysource": "7.0.2", - "@storybook/addons": "7.0.2", - "@storybook/blocks": "7.0.2", - "@storybook/core-events": "7.0.2", + "@storybook/addon-actions": "7.0.6", + "@storybook/addon-essentials": "7.0.6", + "@storybook/addon-interactions": "7.0.6", + "@storybook/addon-links": "7.0.6", + "@storybook/addon-storysource": "7.0.6", + "@storybook/addons": "7.0.6", + "@storybook/blocks": "7.0.6", + "@storybook/core-events": "7.0.6", "@storybook/jest": "0.1.0", - "@storybook/manager-api": "7.0.2", - "@storybook/preview-api": "7.0.2", - "@storybook/react": "7.0.2", - "@storybook/react-vite": "7.0.2", - "@storybook/testing-library": "0.0.14-next.1", - "@storybook/theming": "7.0.2", + "@storybook/manager-api": "7.0.6", + "@storybook/preview-api": "7.0.6", + "@storybook/react": "7.0.6", + "@storybook/react-vite": "7.0.6", + "@storybook/testing-library": "0.1.0", + "@storybook/theming": "7.0.6", "@storybook/types": "7.0.2", - "@storybook/vue3": "7.0.2", - "@storybook/vue3-vite": "7.0.2", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/vue": "^6.6.1", + "@storybook/vue3": "7.0.6", + "@storybook/vue3-vite": "7.0.6", + "@testing-library/jest-dom": "5.16.5", + "@testing-library/vue": "7.0.0", "@types/escape-regexp": "0.0.1", - "@types/estree": "^1.0.0", + "@types/estree": "1.0.1", "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", "@types/matter-js": "0.18.2", - "@types/node": "18.15.11", + "@types/micromatch": "4.0.2", + "@types/node": "18.15.12", "@types/punycode": "2.1.0", "@types/sanitize-html": "2.9.0", "@types/seedrandom": "3.0.5", @@ -108,32 +113,33 @@ "@types/uuid": "9.0.1", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.57.0", - "@typescript-eslint/parser": "5.57.0", - "@vitest/coverage-c8": "^0.29.8", + "@typescript-eslint/eslint-plugin": "5.59.0", + "@typescript-eslint/parser": "5.59.0", + "@vitest/coverage-c8": "0.30.1", "@vue/runtime-core": "3.2.47", - "astring": "^1.8.4", - "chokidar-cli": "^3.0.0", - "chromatic": "^6.17.2", + "astring": "1.8.4", + "chokidar-cli": "3.0.0", "cross-env": "7.0.3", - "cypress": "12.9.0", - "eslint": "8.37.0", + "cypress": "12.10.0", + "eslint": "8.38.0", "eslint-plugin-import": "2.27.5", - "eslint-plugin-vue": "9.10.0", - "fast-glob": "^3.2.12", - "happy-dom": "8.9.0", - "msw": "^1.1.0", - "msw-storybook-addon": "^1.8.0", - "prettier": "^2.8.4", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "eslint-plugin-vue": "9.11.0", + "fast-glob": "3.2.12", + "happy-dom": "9.8.4", + "micromatch": "3.1.10", + "msw": "1.2.1", + "msw-storybook-addon": "1.8.0", + "prettier": "2.8.7", + "react": "18.2.0", + "react-dom": "18.2.0", "start-server-and-test": "2.0.0", - "storybook": "7.0.2", + "storybook": "7.0.6", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "summaly": "github:misskey-dev/summaly", - "vitest": "^0.29.8", - "vitest-fetch-mock": "^0.2.2", - "vue-eslint-parser": "9.1.0", - "vue-tsc": "1.2.0" + "vite-plugin-turbosnap": "^1.0.1", + "vitest": "0.30.1", + "vitest-fetch-mock": "0.2.2", + "vue-eslint-parser": "9.1.1", + "vue-tsc": "1.4.0" } } diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts new file mode 100644 index 000000000..7d27adeb0 --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { abuseUserReport } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAbuseReport from './MkAbuseReport.vue'; +export const Default = { + render(args) { + return { + components: { + MkAbuseReport, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + resolved: action('resolved'), + }; + }, + }, + template: '', + }; + }, + args: { + report: abuseUserReport(), + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/admin/resolve-abuse-user-report', async (req, res, ctx) => { + action('POST /api/admin/resolve-abuse-user-report')(await req.json()); + return res(ctx.json({})); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts new file mode 100644 index 000000000..d0877ffd3 --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAbuseReportWindow from './MkAbuseReportWindow.vue'; +export const Default = { + render(args) { + return { + components: { + MkAbuseReportWindow, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + 'closed': action('closed'), + }; + }, + }, + template: '', + }; + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/report-abuse', async (req, res, ctx) => { + action('POST /api/users/report-abuse')(await req.json()); + return res(ctx.json({})); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts new file mode 100644 index 000000000..bed9d9431 --- /dev/null +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { userDetailed } from '../../.storybook/fakes'; +import MkAccountMoved from './MkAccountMoved.vue'; +export const Default = { + render(args) { + return { + components: { + MkAccountMoved, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + username: userDetailed().username, + host: userDetailed().host, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue new file mode 100644 index 000000000..b02bfdc2b --- /dev/null +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts new file mode 100644 index 000000000..477152a47 --- /dev/null +++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAchievements from './MkAchievements.vue'; +import { ACHIEVEMENT_TYPES } from '@/scripts/achievements'; +export const Empty = { + render(args) { + return { + components: { + MkAchievements, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/achievements', (req, res, ctx) => { + return res(ctx.json([])); + }), + ], + }, + }, +} satisfies StoryObj; +export const All = { + ...Empty, + parameters: { + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/achievements', (req, res, ctx) => { + return res(ctx.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 })))); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts index 05190aa26..e7fbb4728 100644 --- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; import MkAnalogClock from './MkAnalogClock.vue'; +import isChromatic from 'chromatic'; export const Default = { render(args) { return { @@ -22,6 +23,14 @@ export const Default = { template: '', }; }, + args: { + now: isChromatic() ? () => new Date('2023-01-01T10:10:30') : undefined, + }, + decorators: [ + () => ({ + template: '
', + }), + ], parameters: { layout: 'fullscreen', }, diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index 121820261..f12020f81 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -99,6 +99,7 @@ const props = withDefaults(defineProps<{ graduations?: 'none' | 'dots' | 'numbers'; fadeGraduations?: boolean; sAnimation?: 'none' | 'elastic' | 'easeOut'; + now?: () => Date; }>(), { numbers: false, thickness: 0.1, @@ -107,6 +108,7 @@ const props = withDefaults(defineProps<{ graduations: 'dots', fadeGraduations: true, sAnimation: 'elastic', + now: () => new Date(), }); const graduationsMajor = computed(() => { @@ -145,11 +147,17 @@ let disableSAnimate = $ref(false); let sOneRound = false; function tick() { - const now = new Date(); - now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset)); + const now = props.now(); + now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset); + const previousS = s; + const previousM = m; + const previousH = h; s = now.getSeconds(); m = now.getMinutes(); h = now.getHours(); + if (previousS === s && previousM === m && previousH === h) { + return; + } hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6); mAngle = Math.PI * (m + s / 60) / 30; if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない) diff --git a/packages/frontend/src/components/MkAsUi.stories.impl.ts b/packages/frontend/src/components/MkAsUi.stories.impl.ts new file mode 100644 index 000000000..b67c0e679 --- /dev/null +++ b/packages/frontend/src/components/MkAsUi.stories.impl.ts @@ -0,0 +1,2 @@ +import MkAsUi from './MkAsUi.vue'; +void MkAsUi; diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts new file mode 100644 index 000000000..075904d6a --- /dev/null +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { expect } from '@storybook/jest'; +import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAutocomplete from './MkAutocomplete.vue'; +import MkInput from './MkInput.vue'; +import { tick } from '@/scripts/test-utils'; +const common = { + render(args) { + return { + components: { + MkAutocomplete, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + open: action('open'), + closed: action('closed'), + }; + }, + }, + template: '', + }; + }, + args: { + close: action('close'), + x: 0, + y: 0, + }, + decorators: [ + (_, context) => ({ + components: { + MkInput, + }, + data() { + return { + q: context.args.q, + textarea: null, + }; + }, + methods: { + inputMounted() { + this.textarea = this.$refs.input.$refs.inputEl; + }, + }, + template: '', + }), + ], + parameters: { + controls: { + exclude: ['textarea'], + }, + layout: 'centered', + chromatic: { + // FIXME: flaky + disableSnapshot: true, + }, + }, +} satisfies StoryObj; +export const User = { + ...common, + args: { + ...common.args, + type: 'user', + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(() => userEvent.type(input, 'm')); + await waitFor(async () => { + await userEvent.type(input, ' ', { delay: 256 }); + await tick(); + return await expect(canvas.getByRole('list')).toBeInTheDocument(); + }, { timeout: 16384 }); + }, + parameters: { + ...common.parameters, + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/search-by-username-and-host', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44', 'mizuki', 'misskey-hub.net', 'Mizuki'), + userDetailed('49', 'momoko', 'misskey-hub.net', 'Momoko'), + ])); + }), + ], + }, + }, +}; +export const Hashtag = { + ...common, + args: { + ...common.args, + type: 'hashtag', + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(() => userEvent.type(input, '気象')); + await waitFor(async () => { + await userEvent.type(input, ' ', { delay: 256 }); + await tick(); + return await expect(canvas.getByRole('list')).toBeInTheDocument(); + }, { interval: 256, timeout: 16384 }); + }, + parameters: { + ...common.parameters, + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/hashtags/search', (req, res, ctx) => { + return res(ctx.json([ + '気象警報注意報', + '気象警報', + '気象情報', + ])); + }), + ], + }, + }, +}; +export const Emoji = { + ...common, + args: { + ...common.args, + type: 'emoji', + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(() => userEvent.type(input, 'smile')); + await waitFor(async () => { + await userEvent.type(input, ' ', { delay: 256 }); + await tick(); + return await expect(canvas.getByRole('list')).toBeInTheDocument(); + }, { interval: 256, timeout: 16384 }); + }, +} satisfies StoryObj; +export const MfmTag = { + ...common, + args: { + ...common.args, + type: 'mfmTag', + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(async () => { + await tick(); + return await expect(canvas.getByRole('list')).toBeInTheDocument(); + }, { interval: 256, timeout: 16384 }); + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkAvatars.stories.impl.ts b/packages/frontend/src/components/MkAvatars.stories.impl.ts new file mode 100644 index 000000000..14052c734 --- /dev/null +++ b/packages/frontend/src/components/MkAvatars.stories.impl.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAvatars from './MkAvatars.vue'; +export const Default = { + render(args) { + return { + components: { + MkAvatars, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + userIds: ['17', '20', '18'], + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/show', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('17'), + userDetailed('20'), + userDetailed('18'), + ])); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index e1c1c54d1..982a8b3be 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -/* eslint-disable import/no-duplicates */ +import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; import MkButton from './MkButton.vue'; export const Default = { @@ -20,11 +20,60 @@ export const Default = { ...this.args, }; }, + events() { + return { + click: action('click'), + }; + }, }, - template: 'Text', + template: 'Text', }; }, + args: { + }, parameters: { layout: 'centered', }, } satisfies StoryObj; +export const Primary = { + ...Default, + args: { + ...Default.args, + primary: true, + }, +} satisfies StoryObj; +export const Gradate = { + ...Default, + args: { + ...Default.args, + gradate: true, + }, +} satisfies StoryObj; +export const Rounded = { + ...Default, + args: { + ...Default.args, + rounded: true, + }, +} satisfies StoryObj; +export const Danger = { + ...Default, + args: { + ...Default.args, + danger: true, + }, +} satisfies StoryObj; +export const Small = { + ...Default, + args: { + ...Default.args, + small: true, + }, +} satisfies StoryObj; +export const Large = { + ...Default, + args: { + ...Default.args, + large: true, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkChannelList.vue b/packages/frontend/src/components/MkChannelList.vue new file mode 100644 index 000000000..408eab739 --- /dev/null +++ b/packages/frontend/src/components/MkChannelList.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 1834224b8..d03331a6e 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -1,6 +1,6 @@ - diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 863ea702c..155f7f6d2 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -9,7 +9,7 @@ - +
@@ -17,8 +17,8 @@ @@ -32,11 +32,11 @@
- {{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} - {{ cancelText ?? i18n.ts.cancel }} + {{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }} + {{ cancelText ?? i18n.ts.cancel }}
- {{ action.text }} + {{ action.text }}
@@ -84,6 +84,7 @@ const props = withDefaults(defineProps<{ actions?: { text: string; primary?: boolean, + danger?: boolean, callback: (...args: any[]) => void; }[]; showOkButton?: boolean; diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index a5a39108d..9eaf16374 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -497,6 +497,10 @@ defineExpose({ background: transparent; color: var(--fg); + &:not(:focus):not(.filled) { + margin-bottom: env(safe-area-inset-bottom, 0px); + } + &:not(.filled) { order: 1; z-index: 2; diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 58cc0de5c..10eee6aab 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -1,8 +1,8 @@