diff --git a/.config/docker_example.yml b/.config/docker_example.yml index e51d7e8f5..a9871a110 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 ea5d1094a..53d465c97 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 └───────────────────────────── diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fde7ec0f2..6accd4347 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,5 +7,18 @@ "ghcr.io/devcontainers-contrib/features/pnpm:2": {} }, "forwardPorts": [3000], - "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh" + "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", + "customizations": { + "vscode": { + "extensions": [ + "editorconfig.editorconfig", + "dbaeumer.vscode-eslint", + "Vue.volar", + "Vue.vscode-typescript-vue-plugin", + "Orta.vscode-jest", + "dbaeumer.vscode-eslint", + "mrmlnc.vscode-json5" + ] + } + } } 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/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 6ec3c86a4..8f8c5a13a 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -16,7 +16,7 @@ services: - external_network redis: - restart: always + restart: unless-stopped image: redis:7-alpine networks: - internal_network diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index 450c3920c..bcad3e6d8 100755 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -4,6 +4,7 @@ set -xe sudo chown -R node /workspace git submodule update --init +pnpm config set store-dir /home/node/.local/share/pnpm/store pnpm install --frozen-lockfile cp .devcontainer/devcontainer.yml .config/default.yml pnpm build diff --git a/.dockerignore b/.dockerignore index 8f984831e..151ede038 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,6 +25,8 @@ fluent-emojis/ !.yarn/sdks !.yarn/versions +.pnpm-store + .idea/ packages/*/.vscode/ packages/backend/test/docker-compose.yml diff --git a/.github/PULL_REQUEST_TEMPLATE/01_bug.md b/.github/PULL_REQUEST_TEMPLATE/01_bug.md index 79ca97dfa..0739fee70 100644 --- a/.github/PULL_REQUEST_TEMPLATE/01_bug.md +++ b/.github/PULL_REQUEST_TEMPLATE/01_bug.md @@ -4,14 +4,20 @@ Thank you for your PR! Before creating a PR, please check the contribution guide https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md --> -# What +## What -# Why +## Why -# Additional info (optional) +## Additional info (optional) + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/PULL_REQUEST_TEMPLATE/02_enhance.md b/.github/PULL_REQUEST_TEMPLATE/02_enhance.md index 79ca97dfa..0739fee70 100644 --- a/.github/PULL_REQUEST_TEMPLATE/02_enhance.md +++ b/.github/PULL_REQUEST_TEMPLATE/02_enhance.md @@ -4,14 +4,20 @@ Thank you for your PR! Before creating a PR, please check the contribution guide https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md --> -# What +## What -# Why +## Why -# Additional info (optional) +## Additional info (optional) + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (If needed) Update CHANGELOG.md +- [ ] (If possible) Add tests diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..0739fee70 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ + + +## What + + + +## Why + + + +## Additional info (optional) + + + +## Checklist +- [ ] Read the [contribution guide](https://github.com/misskey-dev/misskey/blob/develop/CONTRIBUTING.md) +- [ ] Test working in a local environment +- [ ] (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 new file mode 100644 index 000000000..6411d63bd --- /dev/null +++ b/.github/workflows/api-misskey-js.yml @@ -0,0 +1,36 @@ +name: API report (misskey.js) + +on: [push, pull_request] + +jobs: + report: + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3.3.0 + + - run: corepack enable + + - name: Setup Node.js + uses: actions/setup-node@v3.6.0 + with: + node-version: 18.x + cache: 'pnpm' + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Build + run: pnpm --filter misskey-js build + + - name: Check files + run: ls packages/misskey-js/built + + - name: API report + run: pnpm --filter misskey-js api-prod + + - name: Show report + if: always() + run: cat packages/misskey-js/temp/misskey-js.api.md diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d65076ebb..1c6615e17 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,6 +36,7 @@ jobs: - backend - frontend - sw + - misskey-js steps: - uses: actions/checkout@v3.3.0 with: @@ -61,6 +62,7 @@ jobs: matrix: workspace: - backend + - misskey-js steps: - uses: actions/checkout@v3.3.0 with: diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml new file mode 100644 index 000000000..6792674d9 --- /dev/null +++ b/.github/workflows/storybook.yml @@ -0,0 +1,74 @@ +name: Storybook + +on: + push: + 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: + fetch-depth: 0 + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 7 + run_install: false + - name: Use Node.js 18.x + uses: actions/setup-node@v3.6.0 + with: + node-version: 18.x + cache: 'pnpm' + - run: corepack enable + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Build misskey-js + run: pnpm --filter misskey-js build + - name: Build storybook + run: pnpm --filter frontend build-storybook + - name: Publish to Chromatic + 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: + name: storybook + path: packages/frontend/storybook-static diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml new file mode 100644 index 000000000..f1e414dbb --- /dev/null +++ b/.github/workflows/test-backend.yml @@ -0,0 +1,59 @@ +name: Test (backend) + +on: + push: + branches: + - master + - develop + pull_request: + +jobs: + jest: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + + services: + postgres: + image: postgres:13 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:7 + ports: + - 56312:6379 + + steps: + - uses: actions/checkout@v3.3.0 + with: + submodules: true + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 7 + 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 + - name: Build + run: pnpm build + - name: Test + run: pnpm jest-and-coverage + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/backend/coverage/coverage-final.json diff --git a/.github/workflows/test.yml b/.github/workflows/test-frontend.yml similarity index 88% rename from .github/workflows/test.yml rename to .github/workflows/test-frontend.yml index 9135b4f60..a5505d30d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test-frontend.yml @@ -1,4 +1,4 @@ -name: Test +name: Test (frontend) on: push: @@ -8,26 +8,13 @@ on: pull_request: jobs: - jest: + vitest: runs-on: ubuntu-latest strategy: matrix: node-version: [18.x] - services: - postgres: - image: postgres:13 - ports: - - 54312:5432 - env: - POSTGRES_DB: test-misskey - POSTGRES_HOST_AUTH_METHOD: trust - redis: - image: redis:6 - ports: - - 56312:6379 - steps: - uses: actions/checkout@v3.3.0 with: @@ -51,12 +38,12 @@ jobs: - name: Build run: pnpm build - name: Test - run: pnpm jest-and-coverage + run: pnpm --filter frontend test-and-coverage - name: Upload Coverage uses: codecov/codecov-action@v3 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ./packages/backend/coverage/coverage-final.json + files: ./packages/frontend/coverage/coverage-final.json e2e: runs-on: ubuntu-latest @@ -76,7 +63,7 @@ jobs: POSTGRES_DB: test-misskey POSTGRES_HOST_AUTH_METHOD: trust redis: - image: redis:6 + image: redis:7 ports: - 56312:6379 diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml new file mode 100644 index 000000000..b15e704c7 --- /dev/null +++ b/.github/workflows/test-misskey-js.yml @@ -0,0 +1,52 @@ +# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node +# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions + +name: Test (misskey.js) + +on: + push: + branches: [ develop ] + pull_request: + branches: [ develop ] + +jobs: + test: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [18.x] + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + - name: Checkout + uses: actions/checkout@v3.3.0 + + - run: corepack enable + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3.6.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + + - name: Install dependencies + run: pnpm i --frozen-lockfile + + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + + - name: Build + run: pnpm --filter misskey-js build + + - name: Test + run: pnpm --filter misskey-js test + env: + CI: true + + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/misskey-js/coverage/coverage-final.json diff --git a/.gitignore b/.gitignore index 62b818c62..fbe224550 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ packages/frontend/.yarn/cache packages/backend/.yarn/cache packages/sw/.yarn/cache +# pnpm +.pnpm-store + # Cypress cypress/screenshots cypress/videos @@ -52,6 +55,8 @@ api-docs.json .DS_Store /files ormconfig.json +temp +/packages/frontend/src/**/*.stories.ts # blender backups *.blend1 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 42264548e..baca8db24 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,9 +1,11 @@ { "recommendations": [ "editorconfig.editorconfig", - "eg2.vscode-npm-script", "dbaeumer.vscode-eslint", "Vue.volar", - "Vue.vscode-typescript-vue-plugin" + "Vue.vscode-typescript-vue-plugin", + "Orta.vscode-jest", + "dbaeumer.vscode-eslint", + "mrmlnc.vscode-json5" ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 6a0497946..baffbe18e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,5 +5,6 @@ "typescript.tsdk": "node_modules/typescript/lib", "files.associations": { "*.test.ts": "typescript" - } + }, + "jest.autoRun": "off" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 37a5ab3e1..adc989174 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,158 @@ +## 13.11.0 + +### NOTE +- このバージョンからRedis 7.xが必要です。 +- アップデートを行うと全ての通知およびアンテナのノートはリセットされます。 + +### General +- チャンネルをお気に入りに登録できるように +- チャンネルにノートをピン留めできるように + +### Client +- 投稿フォームのデザインを改善 +- 検索ページでURLを入力した際に照会したときと同等の挙動をするように +- ノートのリアクションを大きく表示するオプションを追加 +- ギャラリー一覧にメディア表示と同じように NSFW 設定を反映するように(ホバーで表示) +- オブジェクトストレージの設定画面を分かりやすく +- 広告・お知らせが新規登録時に増殖しないように +- 「にゃああああああああああああああ!!!!!!!!!!!!」 (`isCat`) 有効時にアバターに表示される猫耳について挙動を変更 + - 「UIにぼかし効果を使用」 (`useBlurEffect`) で次の挙動が有効になります + - 猫耳のアバター内部部分をぼかしでマスク表示してより猫耳っぽく見えるように + - 「UIのアニメーションを減らす」 (`reduceAnimation`) で猫耳を撫でられなくなります +- Add Minimizing ("folding") of windows + +### Server +- PostgreSQLのレプリケーション対応 + - 設定ファイルの `dbReplications` および `dbSlaves` にて設定できます +- イベント用Redisを別サーバーに分離できるように +- ジョブキュー用Redisを別サーバーに分離できるように +- サーバーの全体的なパフォーマンスを向上 +- ノート作成時のパフォーマンスを向上 +- アンテナのタイムライン取得時のパフォーマンスを向上 +- チャンネルのタイムライン取得時のパフォーマンスを向上 +- 通知に関する全体的なパフォーマンスを向上 +- webhookがcontent-type text/plain;charset=UTF-8 で飛んでくる問題を修正 + +## 13.10.3 + +### Changes +- オブジェクトストレージのリージョン指定が必須になりました + - リージョンの指定の無いサービスは us-east-1 を設定してください + - 値が空の場合は設定ファイルまたは環境変数の使用を試みます + - e.g. ~/aws/config, AWS_REGION + +### General +- コンディショナルロールの条件に「投稿数が~以下」「投稿数が~以上」を追加 +- リアクション非対応AP実装からのLikeアクティビティの解釈を👍から♥に + +### Client +- クリップボタンをノートアクションに追加できるように +- センシティブワードの一覧にピン留めユーザーのIDが表示される問題を修正 + +### Server +- リモートユーザーのチャート生成を無効にするオプションを追加 +- リモートサーバーのチャート生成を無効にするオプションを追加 +- ドライブのチャートはローカルユーザーのみ生成するように +- 空のアンテナが作成できるのを修正 + +## 13.10.2 + +### Server +- 絵文字を編集すると保存できないことがある問題を修正 + +### Client +- ドライブファイルのメニューが正常に動作しない問題を修正 + +## 13.10.1 + +### Client +- Misskey PlayのPlayボタンを押した時にエラーが発生する問題を修正 + +## 13.10.0 + +### General +- ユーザーごとにRenoteをミュートできるように +- ノートごとに絵文字リアクションを受け取るか設定できるように +- クリップをお気に入りに登録できるように +- ノート検索の利用可否をロールで制御可能に(デフォルトでオフ) +- ロールの並び順を設定可能に +- カスタム絵文字にライセンス情報を付与できるように +- 指定した文字列を含む投稿の公開範囲をホームにできるように +- 使われてないアンテナは自動停止されるように + +### Client +- 設定から自分のロールを確認できるように +- 広告一覧ページを追加 +- ドライブクリーナーを追加 +- DM作成時にメンションも含むように +- フォロー申請のボタンのデザインを改善 +- 付箋ウィジェットの高さを設定可能に +- APオブジェクトを入力してフェッチする機能とユーザーやノートの検索機能を分離 +- ナビゲーションバーの項目に「プロフィール」を追加できるように +- ナビゲーションバーのカスタマイズをドラッグ&ドロップで行えるように +- ジョブキューの再試行をワンクリックでできるように +- AiScriptを0.13.1に更新 +- oEmbedをサポートしているウェブサイトのプレビューができるように + - YouTubeをoEmbedでロードし、プレビューで共有ボタンを押すとOSの共有画面がでるように + - ([FirefoxでSpotifyのプレビューを開けるとフルサイズじゃなくプレビューサイズだけ再生できる問題](https://bugzilla.mozilla.org/show_bug.cgi?id=1792395)があります) + - (すでにブラウザーでキャッシュされたリンクに対しては以前のプレビュー行動が行われてます。その場合、ブラウザーのキャッシュをクリアしてまた試してください。) +- プロフィールで設定した情報が削除できない問題を修正 +- ロールで広告を無効にするとadmin/adsでプレビューがでてこない問題を修正 +- /api-consoleページにアクセスすると404が出る問題を修正 +- Safariでプラグインが複数ある場合に正常に読み込まれない問題を修正 +- Bookwyrmのユーザーのプロフィールページで「リモートで表示」をタップしても反応がない問題を修正 +- 非ログイン時の「Misskeyについて」の表示を修正 +- PC版にて「設定」「コントロールパネル」のリンクを2度以上続けてクリックした際に空白のページが表示される問題を修正 + +### Server +- OpenAPIエンドポイントを復旧 +- WebP/AVIF/JPEGのweb公開用画像は、サーバーサイドではJPEGではなくWebPに変換するように +- アニメーション画像のサムネイルを生成するように +- アクティブユーザー数チャートの記録上限値を拡張 +- Playのソースコード上限文字数を2倍に拡張 +- 配送先サーバーが410 Goneで応答してきた場合は自動で配送停止をするように +- avatarBlurHash/bannerBlurHashの型をstringに限定 +- タイムライン取得時のパフォーマンスを改善 +- SMTP Login id length is too short +- API上で`visibility`を`followers`に設定してrenoteすると連合や削除で不具合が発生する問題を修正 +- AWS S3からのファイル削除でNoSuchKeyエラーが出ると進めらない状態になる問題を修正 +- `disableCache: true`を設定している場合に絵文字管理操作でエラーが出る問題を修正 +- リテンション分析が上手く機能しないことがあるのを修正 +- 空のアンテナが作成できないように修正 +- 特定の条件で通報が見れない問題を修正 +- 絵文字の名前に任意の文字が使用できる問題を修正 + +## 13.9.2 (2023/03/06) + +### Improvements +- クリップ、チャンネルページに共有ボタンを追加 +- チャンネルでタイムライン上部に投稿フォームを表示するかどうかのオプションを追加 +- ブラウザでメディアプロキシ(/proxy)からファイルを保存した際に、なるべくオリジナルのファイル名を継承するように +- ドライブの「URLからアップロード」で、content-dispositionのfilenameがあればそれをファイル名に +- Identiconがローカルとリモートで同じになるように + - これまでのIdenticonは異なる画像になります +- サーバーのパフォーマンスを改善 + +### Bugfixes +- ロールの権限で「一般ユーザー」のロールがいきなり設定できない問題を修正 +- ユーザーページのバッジ表示を適切に折り返すように @arrow2nd +- fix(client): みつけるのロール一覧でコンディショナルロールが含まれるのを修正 +- macOSでDev Containerが動作しない問題を修正 @RyotaK + ## 13.9.1 (2023/03/03) ### Bugfixes @@ -229,8 +372,8 @@ You should also include the user name that made the change. ## 13.3.2 (2023/02/04) ### Improvements -- 外部メディアプロキシへの対応を強化しました - 外部メディアプロキシのFastify実装を作りました +- 外部メディアプロキシへの対応を強化しました + 外部メディアプロキシのFastify実装を作りました https://github.com/misskey-dev/media-proxy - Server: improve performance @@ -393,7 +536,7 @@ You should also include the user name that made the change. - ユーザーごとのドライブ容量設定はロールに統合されました。 - インスタンスデフォルトのドライブ容量設定はロールに統合されました。アップデート後、ベースロールもしくはコンディショナルロールでドライブ容量を編集してください。 - LTL/GTLの解放状態はロールに統合されました。 -- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。 +- Dockerの実行をrootで行わないようにしました。Dockerかつオブジェクトストレージを使用していない場合は`chown -hR 991.991 ./files`を実行してください。 https://github.com/misskey-dev/misskey/pull/9560 #### For users @@ -621,7 +764,7 @@ You should also include the user name that made the change. ## 12.112.2 (2022/07/08) ### Bugfixes -- Fix Docker doesn't work @mei23 +- Fix Docker doesn't work @mei23 Still not working on arm64 environment. (See 12.112.0) ## 12.112.1 (2022/07/07) @@ -663,7 +806,7 @@ same as 12.112.0 - Improve player detection in URL preview @mei23 - Add Badge Image to Push Notification #8012 @tamaina - Server: Improve performance -- Server: Supports IPv6 on Redis transport. @mei23 +- Server: Supports IPv6 on Redis transport. @mei23 IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`. - Server: Add possibility to log IP addresses of users @syuilo - Add additional drive capacity change support @CyberRex0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d93cd9f..fece05d7a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Before creating an issue, please check the following: - To avoid duplication, please search for similar issues before creating a new issue. - Do not use Issues to ask questions or troubleshooting. - Issues should only be used to feature requests, suggestions, and bug tracking. - - Please ask questions or troubleshooting in the [Misskey Forum](https://forum.misskey.io/) or [Discord](https://discord.gg/Wp8gVStHW3). + - Please ask questions or troubleshooting in ~~the [Misskey Forum](https://forum.misskey.io/)~~ [GitHub Discussions](https://github.com/misskey-dev/misskey/discussions) or [Discord](https://discord.gg/Wp8gVStHW3). > **Warning** > Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. @@ -203,6 +203,116 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。 これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。 +## Storybook + +Misskey uses [Storybook](https://storybook.js.org/) for UI development. + +### Setup & Run + +#### Universal + +##### Setup + +```bash +pnpm --filter misskey-js build +pnpm --filter frontend tsc -p .storybook && (node packages/frontend/.storybook/preload-locale.js & node packages/frontend/.storybook/preload-theme.js) +``` + +##### Run + +```bash +node packages/frontend/.storybook/generate.js && pnpm --filter frontend storybook dev +``` + +#### macOS & Linux + +##### Setup + +```bash +pnpm --filter misskey-js build +``` + +##### Run + +```bash +pnpm --filter frontend storybook-dev +``` + +### Usage + +When you create a new component (in this example, `MyComponent.vue`), the story file (`MyComponent.stories.ts`) will be automatically generated by the `.storybook/generate.js` script. +You can override the default story by creating a impl story file (`MyComponent.stories.impl.ts`). + +```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 = { + render(args) { + return { + components: { + MyComponent, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + foo: 'bar', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; +``` + +If you want to opt-out from the automatic generation, create a `MyComponent.stories.impl.ts` file and add the following line to the file. + +```ts +import MyComponent from './MyComponent.vue'; +void MyComponent; +``` + +You can override the component meta by creating a meta story file (`MyComponent.stories.meta.ts`). + +```ts +export const argTypes = { + scale: { + control: { + type: 'range', + min: 1, + max: 4, + }, +}; +``` + +Also, you can use msw to mock API requests in the storybook. Creating a `MyComponent.stories.msw.ts` file to define the mock handlers. + +```ts +import { rest } from 'msw'; +export const handlers = [ + rest.post('/api/notes/timeline', (req, res, ctx) => { + return res( + ctx.json([]), + ); + }), +]; +``` + +Don't forget to re-run the `.storybook/generate.js` script after adding, editing, or removing the above files. + ## Notes ### How to resolve conflictions occurred at pnpm-lock.yaml? diff --git a/Dockerfile b/Dockerfile index b439716be..8db7400c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,9 @@ ARG NODE_VERSION=18.13.0-bullseye -FROM node:${NODE_VERSION} AS builder +# build assets & compile TypeScript + +FROM --platform=$BUILDPLATFORM node:${NODE_VERSION} AS native-builder RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ @@ -21,6 +23,7 @@ COPY --link ["scripts", "./scripts"] COPY --link ["packages/backend/package.json", "./packages/backend/"] COPY --link ["packages/frontend/package.json", "./packages/frontend/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] +COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output @@ -33,33 +36,49 @@ RUN git submodule update --init RUN pnpm build RUN rm -rf .git/ -FROM node:${NODE_VERSION}-slim AS runner +# build native dependencies for target platform + +FROM --platform=$TARGETPLATFORM node:${NODE_VERSION} AS target-builder + +RUN apt-get update \ + && apt-get install -yqq --no-install-recommends \ + build-essential + +RUN corepack enable + +WORKDIR /misskey + +COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] +COPY --link ["scripts", "./scripts"] +COPY --link ["packages/backend/package.json", "./packages/backend/"] + +RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ + pnpm i --frozen-lockfile --aggregate-output + +FROM --platform=$TARGETPLATFORM node:${NODE_VERSION}-slim AS runner ARG UID="991" ARG GID="991" -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - rm -f /etc/apt/apt.conf.d/docker-clean \ - ; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache \ - && apt-get update \ +RUN apt-get update \ && apt-get install -y --no-install-recommends \ ffmpeg tini curl \ && corepack enable \ && groupadd -g "${GID}" misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ - && find / -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ - && find / -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; + && find / -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ + && find / -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists USER misskey WORKDIR /misskey -COPY --chown=misskey:misskey --from=builder /misskey/node_modules ./node_modules -COPY --chown=misskey:misskey --from=builder /misskey/built ./built -COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/node_modules ./packages/backend/node_modules -COPY --chown=misskey:misskey --from=builder /misskey/packages/backend/built ./packages/backend/built -COPY --chown=misskey:misskey --from=builder /misskey/packages/frontend/node_modules ./packages/frontend/node_modules -COPY --chown=misskey:misskey --from=builder /misskey/fluent-emojis /misskey/fluent-emojis +COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules +COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules +COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built +COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey . ./ ENV NODE_ENV=production 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/chart/templates/Deployment.yml b/chart/templates/Deployment.yml index d16aece91..d5dd14f59 100644 --- a/chart/templates/Deployment.yml +++ b/chart/templates/Deployment.yml @@ -3,16 +3,16 @@ kind: Deployment metadata: name: {{ include "misskey.fullname" . }} labels: - {{- include "misskey.labels" . | nindent 4 }} + {{- include "misskey.labels" . | nindent 4 }} spec: selector: matchLabels: - {{- include "misskey.selectorLabels" . | nindent 6 }} + {{- include "misskey.selectorLabels" . | nindent 6 }} replicas: 1 template: metadata: labels: - {{- include "misskey.selectorLabels" . | nindent 8 }} + {{- include "misskey.selectorLabels" . | nindent 8 }} spec: containers: - name: misskey diff --git a/chart/templates/Service.yml b/chart/templates/Service.yml index 320958129..afd851a9f 100644 --- a/chart/templates/Service.yml +++ b/chart/templates/Service.yml @@ -11,4 +11,4 @@ spec: protocol: TCP name: http selector: - {{- include "misskey.selectorLabels" . | nindent 4 }} + {{- include "misskey.selectorLabels" . | nindent 4 }} diff --git a/cypress/e2e/basic.cy.js b/cypress/e2e/basic.cy.js index b1b856119..8dc07c180 100644 --- a/cypress/e2e/basic.cy.js +++ b/cypress/e2e/basic.cy.js @@ -52,13 +52,30 @@ describe('After setup instance', () => { cy.intercept('POST', '/api/signup').as('signup'); cy.get('[data-cy-signup]').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'); cy.get('[data-cy-signup-password] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-password-retype] input').type('alice1234'); + cy.get('[data-cy-signup-submit]').should('not.be.disabled'); cy.get('[data-cy-signup-submit]').click(); cy.wait('@signup'); }); + + it('signup with duplicated username', () => { + cy.registerUser('alice', 'alice1234'); + + cy.visitHome(); + + // ユーザー名が重複している場合の挙動確認 + cy.get('[data-cy-signup]').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'); + cy.get('[data-cy-signup-submit]').should('be.disabled'); + }); }); describe('After user signup', () => { diff --git a/cypress/e2e/widgets.cy.js b/cypress/e2e/widgets.cy.js index 7d2039ff9..a39ea85e1 100644 --- a/cypress/e2e/widgets.cy.js +++ b/cypress/e2e/widgets.cy.js @@ -29,17 +29,17 @@ describe('After user signed in', () => { it('first widget should be removed', () => { cy.get('.mk-widget-edit').click(); - cy.get('.data-cy-customize-container:first-child .data-cy-customize-container-remove._button').click(); - cy.get('.data-cy-customize-container').should('have.length', 2); + cy.get('[data-cy-customize-container]:first-child [data-cy-customize-container-remove]._button').click(); + cy.get('[data-cy-customize-container]').should('have.length', 2); }); function buildWidgetTest(widgetName) { it(`${widgetName} widget should get added`, () => { cy.get('.mk-widget-edit').click(); cy.get('.mk-widget-select select').select(widgetName, { force: true }); - cy.get('.data-cy-bg._modalBg.data-cy-transparent').click({ multiple: true, force: true }); + cy.get('[data-cy-bg]._modalBg[data-cy-transparent]').click({ multiple: true, force: true }); cy.get('.mk-widget-add').click({ force: true }); - cy.get(`.data-cy-mkw-${widgetName}`).should('exist'); + cy.get(`[data-cy-mkw-${widgetName}]`).should('exist'); }); } diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 5254b20ef..4b9062051 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -545,7 +545,6 @@ tokenRequested: "منح حق الوصول إلى الحساب" pluginTokenRequestedDescription: "ستتمكن الإضافة من استخدام هذه الأذونات." notificationType: "أنواع الإشعارات" edit: "التعديل" -useStarForReactionFallback: "استخدم ★ كبديل إذا كان التفاعل مجهولًا" emailServer: "خادم البريد الإلكتروني" emailConfigInfo: "يستخدم لتأكيد عنوان بريدك الإلكتروني ولإعادة تعيين كلمة المرور إن نسيتها." email: "البريد الإلكتروني " @@ -1275,3 +1274,6 @@ _deck: channel: "القنوات" mentions: "الإشارات" direct: "مباشرة" +_webhookSettings: + name: "الإسم" + active: "مفعّل" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 49b76b8ab..734943960 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -562,7 +562,6 @@ tokenRequested: "অ্যাকাউন্টে অ্যাক্সেস pluginTokenRequestedDescription: "এই প্লাগইনটি এখানে দেওয়া অনুমুতিসমূহ ব্যাবহার করবে" notificationType: "বিজ্ঞপ্তির ধরন" edit: "সম্পাদনা" -useStarForReactionFallback: "রিঅ্যাকশনের ইমোজি না জানলে ★ ব্যবহার করুন" emailServer: "ইমেইল সার্ভার" enableEmail: "ইমেইল বিতরণ চালু করুন" emailConfigInfo: "আপনার ইমেল ঠিকানা নিশ্চিত করতে এবং আপনার পাসওয়ার্ড পুনরায় সেট করতে ব্যবহৃত হয়" @@ -1354,3 +1353,6 @@ _deck: channel: "চ্যানেলগুলি" mentions: "উল্লেখসমূহ" direct: "ডাইরেক্ট নোটগুলি" +_webhookSettings: + name: "নাম" + active: "চালু" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 7f665895b..19815c6f3 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -776,3 +776,6 @@ _deck: list: "Seznamy" channel: "Kanály" mentions: "Zmínění" +_webhookSettings: + name: "Jméno" + active: "Zapnuto" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index c5ddf334c..aa5649393 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -67,7 +67,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." @@ -122,6 +122,8 @@ unmarkAsSensitive: "Als nicht NSFW markieren" enterFileName: "Dateinamen eingeben" mute: "Stummschalten" unmute: "Stummschaltung aufheben" +renoteMute: "Renotes stummschalten" +renoteUnmute: "Renote-Stummschaltung aufheben" block: "Blockieren" unblock: "Blockierung aufheben" suspend: "Sperren" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "Antworten in der Chronik anzeigen" flagShowTimelineRepliesDescription: "Ist diese Option aktiviert, so werden Antworten von Benutzern auf die Notizen anderer Benutzer in der Chronik angezeigt." autoAcceptFollowed: "Follow-Anfragen von Benutzern, denen du folgst, automatisch akzeptieren" addAccount: "Benutzerkonto hinzufügen" +reloadAccountsList: "Benutzerkontoliste aktualisieren" loginFailed: "Anmeldung fehlgeschlagen" showOnRemote: "Auf Ursprungsinstanz ansehen" general: "Allgemein" @@ -345,7 +348,7 @@ basicInfo: "Grundlegende Informationen" pinnedUsers: "Angeheftete Benutzer" pinnedUsersDescription: "Gib durch Leerzeichen getrennte Benutzer an, die an die \"Erkunden\"-Seite angeheftet werden sollen." pinnedPages: "Angeheftete Seiten" -pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfäde zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen.\n" +pinnedPagesDescription: "Gib durch Leerzeilen getrennte Pfade zu Seiten an, die an die Startseite dieser Instanz angeheftet werden sollen." pinnedClipId: "ID des anzuheftenden Clips" pinnedNotes: "Angeheftete Notizen" hcaptcha: "hCaptcha" @@ -404,7 +407,7 @@ securityKey: "Sicherheitsschlüssel" lastUsed: "Zuletzt benutzt" lastUsedAt: "Zuletzt verwendet: {t}" unregister: "Deaktivieren" -passwordLessLogin: "Passwortloses Anmelden einrichten" +passwordLessLogin: "Passwortloses Anmelden" passwordLessLoginDescription: "Ermöglicht passwortfreies Einloggen, nur via Security-Token oder Passkey" resetPassword: "Passwort zurücksetzen" newPasswordIs: "Das neue Passwort ist „{password}“" @@ -457,7 +460,7 @@ aboutX: "Über {x}" emojiStyle: "Emoji-Stil" native: "Nativ" disableDrawer: "Keine ausfahrbaren Menüs verwenden" -showNoteActionsOnlyHover: "Aktionen für Notizen nur bei Mouseover anzeigen" +showNoteActionsOnlyHover: "Notizmenü nur bei Mouseover anzeigen" noHistory: "Kein Verlauf gefunden" signinHistory: "Anmeldungsverlauf" enableAdvancedMfm: "Erweitertes MFM aktivieren" @@ -497,15 +500,17 @@ 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" +showFixedPostFormInChannel: "Bereich zum Schreiben neuer Notizen am Anfang der Chronik anzeigen (Kanäle)" newNoteRecived: "Es gibt neue Notizen" sounds: "Töne" sound: "Töne" @@ -543,6 +548,10 @@ userSuspended: "Dieser Benutzer wurde gesperrt." userSilenced: "Dieser Benutzer wurde instanzweit stummgeschaltet." yourAccountSuspendedTitle: "Dieses Benutzerkonto ist gesperrt" yourAccountSuspendedDescription: "Dieses Benutzerkonto wurde gesperrt, da es gegen die Nutzungsbedingungen dieses Servers verstoßen hat. Trete mit dem Betreiber in Kontakt, falls du weitere Details erfahren möchtest. Bitte erstelle kein neues Benutzerkonto." +tokenRevoked: "Ungültiger Token" +tokenRevokedDescription: "Der Token ist abgelaufen. Bitte melde dich erneut an." +accountDeleted: "Benutzerkonto wurde gelöscht" +accountDeletedDescription: "Dieses Konto wurde gelöscht." menu: "Menü" divider: "Trenner" addItem: "Element hinzufügen" @@ -586,7 +595,6 @@ tokenRequested: "Zugriff zum Benutzerkonto gewähren" pluginTokenRequestedDescription: "Dieses Plugin wird die hier konfigurierten Berechtigungen verwenden können." notificationType: "Art der Benachrichtigung" edit: "Bearbeiten" -useStarForReactionFallback: "Verwende ★ falls das Reaktions-Emoji unbekannt ist" emailServer: "Email-Server" enableEmail: "Email-Versand aktivieren" emailConfigInfo: "Zur Email-Bestätigung bei Registrierung oder zum Zurücksetzen des Passworts verwendet" @@ -912,6 +920,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" @@ -953,8 +962,43 @@ 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" +cannotBeChangedLater: "Kann später nicht mehr geändert werden." +reactionAcceptance: "Reaktionsannahme" +likeOnly: "Nur \"Gefällt mir\"" +likeOnlyForRemote: "Nur \"Gefällt mir\" für fremde Instanzen" +rolesAssignedToMe: "Mir zugewiesene Rollen" +resetPasswordConfirm: "Wirklich Passwort zurücksetzen?" +sensitiveWords: "Sensible Wörter" +sensitiveWordsDescription: "Die Notizsichtbarkeit aller Notizen, die diese Wörter enthalten, wird automatisch auf \"Startseite\" gesetzt. Durch Zeilenumbrüche können mehrere konfiguriert werden." +notesSearchNotAvailable: "Die Notizsuche ist nicht verfügbar." +license: "Lizenz" +unfavoriteConfirm: "Wirklich aus Favoriten entfernen?" +myClips: "Meine Clips" +drivecleaner: "Drive-Reiniger" +retryAllQueuesNow: "Sofort Warteschlangen erneut ausführen" +retryAllQueuesConfirmTitle: "Wirklich erneut versuchen?" +retryAllQueuesConfirmText: "Dies wird zu einer temporären Erhöhung der Serverlast führen." +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:" +_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: @@ -1102,7 +1146,7 @@ _achievements: title: "Beliebt" description: "Die Anzahl deiner Follower hat 100 überschritten" _followers300: - title: "Stellt euch bitte in einer Reihe auf" + title: "Eine geordnete Reihe, bitte!" description: "Die Anzahl deiner Follower hat 300 überschritten" _followers500: title: "Funkmast" @@ -1163,7 +1207,7 @@ _achievements: description: "Du hast hier geklickt" _justPlainLucky: title: "Pures Glück" - description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.01% erhalten werden" + description: "Kann alle 10 Sekunden mit einer Warscheinlichkeit von 0.005% erhalten werden" _setNameToSyuilo: title: "Gottkomplex" description: "Setze deinen Namen auf \"syuilo\"" @@ -1214,6 +1258,8 @@ _role: iconUrl: "Icon-URL" asBadge: "Als Abzeichen anzeigen" descriptionOfAsBadge: "Ist dies aktiviert, so wird das Icon dieser Rolle an der Seite der Namen von Benutzern mit dieser Rolle angezeigt." + displayOrder: "Position" + descriptionOfDisplayOrder: "Je höher die Nummer, desto höher die UI-Position." canEditMembersByModerator: "Moderatoren können Benutzern diese Rolle zuweisen" descriptionOfCanEditMembersByModerator: "Wenn aktiviert, so können Moderatoren und Adminstratoren anderen Benutzern diese Rolle zuweisen bzw. diese Zuweisung aufheben. Wenn deaktiviert, so ist es nur Administratoren möglich, Zuweisungen dieser Rolle zu verwalten." priority: "Priorität" @@ -1225,7 +1271,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" @@ -1239,6 +1285,7 @@ _role: rateLimitFactor: "Versuchsanzahl" descriptionOfRateLimitFactor: "Je niedriger desto weniger restriktiv, je höher destro restriktiver." canHideAds: "Kann Werbung ausblenden" + canSearchNotes: "Nutzung der Notizsuchfunktion" _condition: isLocal: "Lokaler Benutzer" isRemote: "Benutzer fremder Instanz" @@ -1248,6 +1295,8 @@ _role: followersMoreThanOrEq: "Hat X oder mehr Follower" followingLessThanOrEq: "Folgt X oder weniger Benutzern" followingMoreThanOrEq: "Folgt X oder mehr Benutzern" + notesLessThanOrEq: "Beitragszahl ist kleiner-gleich" + notesMoreThanOrEq: "Beitragszahl ist größer-gleich" and: "UND-Bedingung" or: "ODER-Bedingung" not: "NICHT-Bedingung" @@ -1375,7 +1424,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" @@ -1510,7 +1559,7 @@ _2fa: step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:" step3Title: "Authentifizierungsscode eingeben" step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird." - step4: "Alle folgenden Anmeldungsversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." + step4: "Alle folgenden Anmeldeversuche werden ab sofort die Eingabe eines solchen Tokens benötigen." securityKeyNotSupported: "Dein Browser unterstützt keine Security-Tokens." registerTOTPBeforeKey: "Um einen Security-Token oder einen Passkey zu registrieren, musst du zuerst eine Authentifizierungs-App registrieren." securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels einrichten." @@ -1840,3 +1889,23 @@ _deck: _dialog: charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" +_disabledTimeline: + title: "Chronik deaktiviert" + description: "Mit deinen jetzigen Rollen ist diese Chronik nicht verfügbar." +_drivecleaner: + orderBySizeDesc: "Absteigende Dateigrößen" + orderByCreatedAtAsc: "Aufsteigendes Erstelldatum" +_webhookSettings: + createWebhook: "Webhook erstellen" + name: "Name" + secret: "Secret" + events: "Webhook-Ereignisse" + active: "Aktiviert" + _events: + follow: "Wenn du jemandem folgst" + followed: "Wenn dir jemand folgt" + note: "Wenn du eine Notiz schickst" + reply: "Wenn du eine Antwort erhältst" + 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 0721ba6e9..32964e998 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -392,3 +392,5 @@ _deck: antenna: "Αντένες" list: "Λίστα" mentions: "Επισημάνσεις" +_webhookSettings: + name: "Όνομα" diff --git a/locales/en-US.yml b/locales/en-US.yml index 638c47091..4b8ec8622 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -67,7 +67,7 @@ import: "Import" export: "Export" files: "Files" download: "Download" -driveFileDeleteConfirm: "Are you sure you want to delete the file \"{name}\"? Notes with this file attached will also be deleted." +driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted." 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." @@ -122,6 +122,8 @@ unmarkAsSensitive: "Unmark as NSFW" enterFileName: "Enter filename" mute: "Mute" unmute: "Unmute" +renoteMute: "Mute Renotes" +renoteUnmute: "Unmute Renotes" block: "Block" unblock: "Unblock" suspend: "Suspend" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "Show replies in timeline" flagShowTimelineRepliesDescription: "Shows replies of users to notes of other users in the timeline if turned on." autoAcceptFollowed: "Automatically approve follow requests from users you're following" addAccount: "Add account" +reloadAccountsList: "Reload account list" loginFailed: "Failed to sign in" showOnRemote: "View on remote instance" general: "General" @@ -197,7 +200,7 @@ clearQueueConfirmText: "Any undelivered notes remaining in the queue will not be clearCachedFiles: "Clear cache" clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote files?" blockedInstances: "Blocked Instances" -blockedInstancesDescription: "List the hostnames of the instances that you want to block. Listed instances will no longer be able to communicate with this instance." +blockedInstancesDescription: "List the hostnames of the instances that you want to block separated by linebreaks. Listed instances will no longer be able to communicate with this instance." muteAndBlock: "Mutes and Blocks" mutedUsers: "Muted users" blockedUsers: "Blocked users" @@ -503,9 +506,11 @@ objectStorageUseSSLDesc: "Turn this off if you are not going to use HTTPS for AP 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" +showFixedPostFormInChannel: "Display the posting form at the top of the timeline (Channels)" newNoteRecived: "There are new notes" sounds: "Sounds" sound: "Sounds" @@ -526,7 +531,7 @@ nothing: "There's nothing to see here" installedDate: "Authorized at" lastUsedDate: "Last used at" state: "State" -sort: "Sort" +sort: "Sorting order" ascendingOrder: "Ascending" descendingOrder: "Descending" scratchpad: "Scratchpad" @@ -543,6 +548,10 @@ userSuspended: "This user has been suspended." userSilenced: "This user is being silenced." yourAccountSuspendedTitle: "This account is suspended" yourAccountSuspendedDescription: "This account has been suspended due to breaking the server's terms of services or similar. Contact the administrator if you would like to know a more detailed reason. Please do not create a new account." +tokenRevoked: "Invalid token" +tokenRevokedDescription: "This token has expired. Please log in again." +accountDeleted: "Account deleted" +accountDeletedDescription: "This account has been deleted." menu: "Menu" divider: "Divider" addItem: "Add Item" @@ -586,7 +595,6 @@ tokenRequested: "Grant access to account" pluginTokenRequestedDescription: "This plugin will be able to use the permissions set here." notificationType: "Notification type" edit: "Edit" -useStarForReactionFallback: "Use ★ as fallback if the reaction emoji is unknown" emailServer: "Email server" enableEmail: "Enable email distribution" emailConfigInfo: "Used to confirm your email during sign-up or if you forget your password" @@ -912,6 +920,7 @@ pushNotificationNotSupported: "Your browser or instance does not support push no 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." windowMaximize: "Maximize" +windowMinimize: "Minimize" windowRestore: "Restore" caption: "Caption" loggedInAsBot: "Currently logged in as bot" @@ -953,8 +962,44 @@ 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" +cannotBeChangedLater: "This cannot be changed later." +reactionAcceptance: "Reaction Acceptance" +likeOnly: "Only likes" +likeOnlyForRemote: "Only likes for remote instances" +rolesAssignedToMe: "Roles assigned to me" +resetPasswordConfirm: "Really reset your password?" +sensitiveWords: "Sensitive words" +sensitiveWordsDescription: "The visibility of all notes containing any of the configured words will be set to \"Home\" automatically. You can list multiple by separating them via line breaks." +notesSearchNotAvailable: "Note search is unavailable." +license: "License" +unfavoriteConfirm: "Really remove from favorites?" +myClips: "My clips" +drivecleaner: "Drive Cleaner" +retryAllQueuesNow: "Retry running all queues" +retryAllQueuesConfirmTitle: "Really retry all?" +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:" +_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: @@ -1182,7 +1227,7 @@ _achievements: _loggedInOnNewYearsDay: title: "Happy New Year!" description: "Logged in on the first day of the year" - flavor: "To another great year!" + flavor: "To another great year on this instance" _cookieClicked: title: "A game in which you click cookies" description: "Clicked the cookie" @@ -1214,6 +1259,8 @@ _role: iconUrl: "Icon URL" asBadge: "Show as badge" descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on." + displayOrder: "Position" + descriptionOfDisplayOrder: "The higher the number, the higher its UI position." canEditMembersByModerator: "Allow moderators to edit the list of members for this role" descriptionOfCanEditMembersByModerator: "When turned on, moderators as well as administrators will be able to assign and unassign users to this role. When turned off, only administrators will be able to assign users." priority: "Priority" @@ -1239,6 +1286,7 @@ _role: rateLimitFactor: "Rate limit" descriptionOfRateLimitFactor: "Lower rate limits are less restrictive, higher ones more restrictive. " canHideAds: "Can hide ads" + canSearchNotes: "Usage of note search" _condition: isLocal: "Local user" isRemote: "Remote user" @@ -1248,6 +1296,8 @@ _role: followersMoreThanOrEq: "Has X or more followers" followingLessThanOrEq: "Follows X or fewer accounts" followingMoreThanOrEq: "Follows X or more accounts" + notesLessThanOrEq: "Post count is less than/equal to" + notesMoreThanOrEq: "Post count is greater than/equal to" and: "AND-Condition" or: "OR-Condition" not: "NOT-Condition" @@ -1840,3 +1890,23 @@ _deck: _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}." +_disabledTimeline: + title: "Timeline disabled" + description: "You cannot use this timeline under your current roles." +_drivecleaner: + orderBySizeDesc: "Descending Filesizes" + orderByCreatedAtAsc: "Ascending Dates" +_webhookSettings: + createWebhook: "Create Webhook" + name: "Name" + secret: "Secret" + events: "Webhook Events" + active: "Enabled" + _events: + follow: "When following a user" + followed: "When being followed" + note: "When posting a note" + reply: "When receiving a reply" + 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 3ad2c21ff..29cf9b3ec 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -122,6 +122,8 @@ unmarkAsSensitive: "Desmarcar como sensible" enterFileName: "Ingrese el nombre del archivo" mute: "Silenciar" unmute: "Dejar de silenciar" +renoteMute: "Silenciar renota" +renoteUnmute: "Desilenciar renota" block: "Bloquear" unblock: "Dejar de bloquear" suspend: "Suspender" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "Mostrar respuestas a las notas en la biografía" flagShowTimelineRepliesDescription: "Cuando se marca, la línea de tiempo muestra respuestas a otras notas además de las notas del usuario" autoAcceptFollowed: "Aceptar automáticamente las solicitudes de seguimiento de los usuarios que sigues" addAccount: "Agregar Cuenta" +reloadAccountsList: "Recargar lista de cuentas" loginFailed: "Error al iniciar sesión." showOnRemote: "Ver en una instancia remota" general: "General" @@ -503,9 +506,11 @@ 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" +showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)" newNoteRecived: "Tienes una nota nueva" sounds: "Sonidos" sound: "Sonidos" @@ -543,6 +548,10 @@ userSuspended: "Este usuario ha sido suspendido." userSilenced: "Este usuario ha sido silenciado." yourAccountSuspendedTitle: "Esta cuenta ha sido suspendida" yourAccountSuspendedDescription: "Esta cuenta ha sido suspendida debido a violaciones de los términos de servicio del servidor y otras razones. Para más información, póngase en contacto con el administrador. Por favor, no cree una nueva cuenta." +tokenRevoked: "Token inválido" +tokenRevokedDescription: "Este token expiró, vuelve a iniciar sesión." +accountDeleted: "Cuenta borrada" +accountDeletedDescription: "Esta cuenta ha sido borrada." menu: "Menú" divider: "Divisor" addItem: "Agregar elemento" @@ -586,7 +595,6 @@ tokenRequested: "Permiso de acceso a la cuenta" pluginTokenRequestedDescription: "Este plugin podrá usar los permisos descritos aquí" notificationType: "Tipo de notificación" edit: "Editar" -useStarForReactionFallback: "En caso de que los emojis de reacciones no sean claros, usar en su lugar una estrella" emailServer: "Servidor de correo" enableEmail: "Activar el envío de correos electrónicos" emailConfigInfo: "Usar en caso de validación de correo electrónico y pedido de contraseña" @@ -912,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." @@ -953,8 +962,43 @@ 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" +cannotBeChangedLater: "Esto no podrá ser cambiado después." +reactionAcceptance: "Aceptación de reacciones" +likeOnly: "Sólo 'me gusta'" +likeOnlyForRemote: "Sólo reacciones de instancias remotas" +rolesAssignedToMe: "Roles asignados a mí" +resetPasswordConfirm: "¿Realmente quieres cambiar la contraseña?" +sensitiveWords: "Palabras sensibles" +sensitiveWordsDescription: "La visibilidad de todas las notas que contienen cualquiera de las palabras configuradas serán puestas en \"Inicio\" automáticamente. Puedes enumerás varias separándolas con saltos de línea" +notesSearchNotAvailable: "No se puede buscar una nota" +license: "Licencia" +unfavoriteConfirm: "¿Desea quitar de favoritos?" +myClips: "Mis clips" +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: @@ -1214,6 +1258,8 @@ _role: iconUrl: "URL del ícono" asBadge: "Mostrar como emblema" descriptionOfAsBadge: "Este ícono de rol se mostrará a lado del nombre de usuario cuando este rol se encuentre activo." + displayOrder: "Posición" + descriptionOfDisplayOrder: "Entre más alto el número, mayor es la posición en la interfaz." canEditMembersByModerator: "Permitir a los moderadores editar los miembros" descriptionOfCanEditMembersByModerator: "Si se activa, los moderadores, al igual que los administradores, serán capaces de asignar/quitar usuarios a éste rol. Si se desactiva, sólo los administradores podrán hacerlo." priority: "Prioridad" @@ -1239,6 +1285,7 @@ _role: rateLimitFactor: "Limitador" descriptionOfRateLimitFactor: "Límites más bajos son menos restrictivos, más altos menos restrictivos" canHideAds: "Puede ocultar anuncios" + canSearchNotes: "Uso de la búsqueda de notas" _condition: isLocal: "Usuario local" isRemote: "Usuario remoto" @@ -1248,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" @@ -1840,3 +1889,23 @@ _deck: _dialog: charactersExceeded: "¡Has excedido el límite de caracteres! Actualmente {current} de {max}." charactersBelow: "¡Estás por debajo del límite de caracteres! Actualmente {current} de {min}." +_disabledTimeline: + title: "Línea de tiempo deshabilitada" + description: "No puedes usar esta línea de tiempo con tus roles actuales." +_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 f9b8939e8..e0767fdc0 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -575,7 +575,6 @@ tokenRequested: "Autoriser l'accès au compte" pluginTokenRequestedDescription: "Ce plugin pourra utiliser les autorisations définies ici." notificationType: "Type de notifications" edit: "Editer" -useStarForReactionFallback: "Utiliser ★ comme alternative si l’émoji de réaction est inconnu" emailServer: "Serveur mail" enableEmail: "Activer la distribution de courriel" emailConfigInfo: "Utilisé pour confirmer votre adresse de courriel et la réinitialisation de votre mot de passe en cas d’oubli." @@ -1468,3 +1467,6 @@ _deck: channel: "Canaux" mentions: "Mentions" direct: "Direct" +_webhookSettings: + name: "Nom" + active: "Activé" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 5d74cf538..1272da9b3 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -579,7 +579,6 @@ tokenRequested: "Berikan ijin akses ke akun" pluginTokenRequestedDescription: "Plugin ini dapat menggunakan setelan ijin disini." notificationType: "Jenis pemberitahuan" edit: "Sunting" -useStarForReactionFallback: "Gunakan ★ sebagai fallback jika reaksi emoji tidak diketahui" emailServer: "Peladen surel" enableEmail: "Nyalakan distribusi surel" emailConfigInfo: "Digunakan untuk mengonfirmasi surel kamu disaat mendaftar dan lupa kata sandi" @@ -1804,3 +1803,6 @@ _deck: channel: "Kanal" mentions: "Sebutan" direct: "Langsung" +_webhookSettings: + name: "Nama" + active: "Aktif" diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 89d456d1f..01209b6fd 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -54,8 +54,8 @@ copyUsername: "Copia nome utente" searchUser: "Cerca utente" reply: "Rispondi" loadMore: "Mostra di più" -showMore: "Mostra di più" -showLess: "Chiudi" +showMore: "Espandi" +showLess: "Comprimi" youGotNewFollower: "Ha iniziato a seguirti" receiveFollowRequest: "Hai ricevuto una richiesta di follow" followRequestAccepted: "Richiesta di follow accettata" @@ -76,7 +76,7 @@ noLists: "Nessuna lista" note: "Nota" notes: "Note" following: "Follow" -followers: "Followers" +followers: "Follower" followsYou: "Ti segue" createList: "Aggiungi una nuova lista" manageLists: "Gestisci liste" @@ -122,6 +122,8 @@ unmarkAsSensitive: "Segna come non sensibile" enterFileName: "Nome del file" mute: "Silenzia" unmute: "Riattiva l'audio" +renoteMute: "Silenzia i Rinota" +renoteUnmute: "Non silenziare i Rinota" block: "Blocca" unblock: "Sblocca" suspend: "Sospendi" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "Mostra le risposte alle note sulla timeline." flagShowTimelineRepliesDescription: "Se è attiva, la timeline mostra le risposte alle altre note dell'utente oltre a quelle dell'utente stesso." autoAcceptFollowed: "Accetta automaticamente le richieste di follow da utenti che già segui" addAccount: "Aggiungi profilo" +reloadAccountsList: "Ricarica l'elenco dei profili" loginFailed: "Accesso non riuscito" showOnRemote: "Leggi sull'istanza remota" general: "Generali" @@ -167,7 +170,7 @@ proxyAccountDescription: "Un profilo proxy funziona come follower per i profili host: "Server remoto" selectUser: "Seleziona profilo" recipient: "Destinatario" -annotation: "Descrizione" +annotation: "Annotazione" federation: "Federazione" instances: "Istanza" registeredAt: "Registrato presso" @@ -209,7 +212,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" @@ -234,14 +237,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" @@ -406,7 +409,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 +509,7 @@ objectStorageSetPublicRead: "Imposta \"visibilità pubblica\" al momento di cari serverLogs: "Log del server" deleteAll: "Cancella cronologia" showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" +showFixedPostFormInChannel: "Per i canali, mostra il modulo di pubblicazione in cima alla timeline" newNoteRecived: "Vedi le nuove note" sounds: "Impostazioni suoni" sound: "Impostazioni suoni" @@ -543,6 +547,10 @@ userSuspended: "L'utente è in sospensione" userSilenced: "L'utente è silenziat@." yourAccountSuspendedTitle: "Questo profilo è sospeso" yourAccountSuspendedDescription: "Questo profilo è stato sospeso a causa di una violazione del regolamento. Per informazioni, contattare l'amministrazione. Si prega di non creare un nuovo account." +tokenRevoked: "Il token non è valido" +tokenRevokedDescription: "Il token di accesso è scaduto. Per favore, accedi nuovamente." +accountDeleted: "Profilo eliminato" +accountDeletedDescription: "Questo profilo è stato eliminato." menu: "Menù" divider: "Linea di separazione" addItem: "Aggiungi elemento" @@ -557,8 +565,8 @@ enableInfiniteScroll: "Abilita scorrimento infinito" visibility: "Visibilità" poll: "Sondaggio" useCw: "Nascondere media" -enablePlayer: "Apri in lettore video" -disablePlayer: "Chiudi lettore video" +enablePlayer: "Visualizza" +disablePlayer: "Chiudi" expandTweet: "Espandi tweet" themeEditor: "Editor di temi" description: "Descrizione" @@ -586,10 +594,9 @@ tokenRequested: "Autorizza accesso al profilo" pluginTokenRequestedDescription: "Il plugin potrà utilizzare le autorizzazioni impostate qui." notificationType: "Tipo di notifiche" edit: "Modifica" -useStarForReactionFallback: "Se è sconosciuto l'emoji di reazione, usare la ★ come alternativa." 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" @@ -953,8 +960,30 @@ 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." +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 nel canale" +cannotBeChangedLater: "Non sarà più modificabile" +reactionAcceptance: "Accettazione reazioni" +likeOnly: "Solo i Like" +likeOnlyForRemote: "Solo Like remoti" +rolesAssignedToMe: "I miei ruoli" +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." +license: "Licenza" +unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?" +myClips: "Le mie Clip" +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" _achievements: earnedAt: "Data di conseguimento" _types: @@ -1214,6 +1243,8 @@ _role: iconUrl: "URL dell'icona" asBadge: "Mostra come badge" descriptionOfAsBadge: "Se indicato, accanto al nome utente viene visualizzata l'icona del ruolo." + displayOrder: "Ordine di visualizzazione" + descriptionOfDisplayOrder: "I valori più alti vengono visualizzati per primi" canEditMembersByModerator: "Anche i Moderatori assegnano profili a questo ruolo" descriptionOfCanEditMembersByModerator: "Se disattivo, potranno farlo solamente gli Amministratori." priority: "Priorità" @@ -1239,6 +1270,7 @@ _role: rateLimitFactor: "Limite del rapporto" descriptionOfRateLimitFactor: "I rapporti più bassi sono meno restrittivi, quelli più alti lo sono di più." canHideAds: "Può nascondere i banner" + canSearchNotes: "Ricercare nelle Note" _condition: isLocal: "Profilo locale" isRemote: "Profilo remoto" @@ -1248,6 +1280,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" @@ -1286,8 +1320,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" @@ -1613,7 +1647,7 @@ _widgets: clicker: "Cliccaggio" _cw: hide: "Nascondere" - show: "Mostra di più" + show: "Apri..." chars: "{count} caratteri" files: "{count} file" _poll: @@ -1840,3 +1874,23 @@ _deck: _dialog: charactersExceeded: "Hai superato il limite di {max} caratteri! ({corrente})" charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({corrente})" +_disabledTimeline: + title: "Timeline disabilitata" + 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 8fee2726e..32bf47c20 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -67,7 +67,7 @@ import: "インポート" export: "エクスポート" files: "ファイル" download: "ダウンロード" -driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを添付したノートも消えます。" +driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを使用した全てのコンテンツからも削除されます。" unfollowConfirm: "{name}のフォローを解除しますか?" exportRequested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、「ドライブ」に追加されます。" importRequested: "インポートをリクエストしました。これには時間がかかる場合があります。" @@ -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: "全般" @@ -457,7 +460,7 @@ aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" disableDrawer: "メニューをドロワーで表示しない" -showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示する" +showNoteActionsOnlyHover: "ノートのアクションをホバー時のみ表示する" noHistory: "履歴はありません" signinHistory: "ログイン履歴" enableAdvancedMfm: "高度なMFMを有効にする" @@ -497,15 +500,17 @@ objectStoragePrefixDesc: "このprefixのディレクトリ下に格納されま objectStorageEndpoint: "Endpoint" objectStorageEndpointDesc: "S3の場合は空、それ以外の場合は各サービスのendpointを指定してください。''または':'のように指定します。" objectStorageRegion: "Region" -objectStorageRegionDesc: "'xx-east-1'のようなregionを指定してください。使用サービスにregionの概念がない場合は、空または'us-east-1'にしてください。" +objectStorageRegionDesc: "'xx-east-1'のようなregionを指定してください。使用サービスにregionの概念がない場合は'us-east-1'にしてください。AWS設定ファイルまたは環境変数を参照する場合は空にしてください。" objectStorageUseSSL: "SSLを使用する" objectStorageUseSSLDesc: "API接続にhttpsを使用しない場合はオフにしてください" objectStorageUseProxy: "Proxyを利用する" objectStorageUseProxyDesc: "API接続にproxyを利用しない場合はオフにしてください" objectStorageSetPublicRead: "アップロード時に'public-read'を設定する" +s3ForcePathStyleDesc: "s3ForcePathStyleを有効にすると、バケット名をURLのホスト名ではなくパスの一部として指定することを強制します。セルフホストされたMinioなどの使用時に有効にする必要がある場合があります。" serverLogs: "サーバーログ" deleteAll: "全て削除" showFixedPostForm: "タイムライン上部に投稿フォームを表示する" +showFixedPostFormInChannel: "タイムライン上部に投稿フォームを表示する(チャンネル)" newNoteRecived: "新しいノートがあります" sounds: "サウンド" sound: "サウンド" @@ -543,6 +548,10 @@ userSuspended: "このユーザーは凍結されています。" userSilenced: "このユーザーはサイレンスされています。" yourAccountSuspendedTitle: "アカウントが凍結されています" yourAccountSuspendedDescription: "このアカウントは、サーバーの利用規約に違反したなどの理由により、凍結されています。詳細については管理者までお問い合わせください。新しいアカウントを作らないでください。" +tokenRevoked: "トークンが無効です" +tokenRevokedDescription: "ログイントークンが失効しています。ログインし直してください。" +accountDeleted: "アカウントは削除されています" +accountDeletedDescription: "このアカウントは削除されています。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" @@ -586,7 +595,6 @@ tokenRequested: "アカウントへのアクセス許可" pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。" notificationType: "通知の種類" edit: "編集" -useStarForReactionFallback: "リアクション絵文字が不明な場合、代わりに★を使う" emailServer: "メールサーバー" enableEmail: "メール配信機能を有効化する" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" @@ -912,6 +920,7 @@ pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知 sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "元に戻す" caption: "キャプション" loggedInAsBot: "Botアカウントでログイン中" @@ -953,8 +962,44 @@ copyErrorInfo: "エラー情報をコピー" joinThisServer: "このサーバーに登録する" exploreOtherServers: "他のサーバーを探す" letsLookAtTimeline: "タイムラインを見てみる" -disableFederationWarn: "連合が無効になっています。無効にしても投稿が非公開にはなりません。ほとんどの場合、このオプションを有効にする必要はありません。" +disableFederationConfirm: "連合なしにしますか?" +disableFederationConfirmWarn: "連合なしにしても投稿は非公開になりません。ほとんどの場合、連合なしにする必要はありません。" +disableFederationOk: "連合なしにする" invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。" +emailNotSupported: "このサーバーではメール配信はサポートされていません" +postToTheChannel: "チャンネルに投稿" +cannotBeChangedLater: "後から変更できません。" +reactionAcceptance: "リアクションの受け入れ" +likeOnly: "いいねのみ" +likeOnlyForRemote: "リモートからはいいねのみ" +rolesAssignedToMe: "自分に割り当てられたロール" +resetPasswordConfirm: "パスワードリセットしますか?" +sensitiveWords: "センシティブワード" +sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" +notesSearchNotAvailable: "ノート検索は利用できません。" +license: "ライセンス" +unfavoriteConfirm: "お気に入り解除しますか?" +myClips: "自分のクリップ" +drivecleaner: "ドライブクリーナー" +retryAllQueuesNow: "すべてのキューを今すぐ再試行" +retryAllQueuesConfirmTitle: "今すぐ再試行しますか?" +retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。" +enableChartsForRemoteUser: "リモートユーザーのチャートを生成" +enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" +showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" +largeNoteReactions: "ノートのリアクションを大きく表示" +noteIdOrUrl: "ノートIDまたはURL" +accountMigration: "アカウントの引っ越し" +accountMoved: "このユーザーは新しいアカウントに引っ越しました:" + +_accountMigration: + moveTo: "このアカウントを新しいアカウントに引っ越す" + moveToLabel: "引っ越し先のアカウント:" + moveAccountDescription: "この操作は取り消せません。まずは引っ越し先のアカウントでこのアカウントに対しエイリアスを作成したことを確認してください。エイリアス作成後、引っ越し先のアカウントをこのように入力してください:@person@instance.com" + moveFrom: "別のアカウントからこのアカウントに引っ越す" + moveFromLabel: "引っ越し元のアカウント:" + moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com" + migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用できなくなります。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。" _achievements: earnedAt: "獲得日時" @@ -1216,6 +1261,8 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして表示" descriptionOfAsBadge: "オンにすると、ユーザー名の横にロールのアイコンが表示されます。" + displayOrder: "表示順" + descriptionOfDisplayOrder: "数値が大きいほどUI上で先頭に表示されます。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" priority: "優先度" @@ -1241,6 +1288,7 @@ _role: rateLimitFactor: "レートリミット" descriptionOfRateLimitFactor: "小さいほど制限が緩和され、大きいほど制限が強化されます。" canHideAds: "広告の非表示" + canSearchNotes: "ノート検索の利用可否" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -1250,6 +1298,8 @@ _role: followersMoreThanOrEq: "フォロワー数が~以上" followingLessThanOrEq: "フォロー数が~以下" followingMoreThanOrEq: "フォロー数が~以上" + notesLessThanOrEq: "投稿数が~以下" + notesMoreThanOrEq: "投稿数が~以上" and: "~かつ~" or: "~または~" not: "~ではない" @@ -1893,3 +1943,26 @@ _deck: _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}" + +_disabledTimeline: + title: "無効化されたタイムライン" + description: "現在のロールでは、このタイムラインを使用することはできません。" + +_drivecleaner: + orderBySizeDesc: "サイズが大きい順" + orderByCreatedAtAsc: "追加日が古い順" + +_webhookSettings: + createWebhook: "Webhookを作成" + name: "名前" + secret: "シークレット" + events: "Webhookを実行するタイミング" + active: "有効" + _events: + follow: "フォローしたとき" + followed: "フォローされたとき" + note: "ノートを投稿したとき" + reply: "返信されたとき" + renote: "Renoteされたとき" + reaction: "リアクションがあったとき" + mention: "メンションされたとき" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 862453dd5..398dbcd5c 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -2,7 +2,7 @@ _lang_: "日本語 (関西弁)" headlineMisskey: "ノートでつながるネットワーク" introMisskey: "ようお越し!Misskeyは、オープンソースの分散型マイクロブログサービスやねん。\n「ノート」を作って、いま起こっとることを共有したり、あんたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加したりもできるで✌\nほな新しい世界を探検しよか🚀" -poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyを使ったサービス(Misskeyインスタンスと呼ばれるやつや)のひとつやで。" +poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyのサーバーのひとつなんやで。" monthAndDay: "{month}月 {day}日" search: "探す" notifications: "通知" @@ -15,23 +15,23 @@ gotIt: "ほい" cancel: "やめとく" noThankYou: "やめとく" enterUsername: "ユーザー名を入れてや" -renotedBy: "{user}がRenote" +renotedBy: "{user}がRenoteしたで" noNotes: "ノートはあらへん" noNotifications: "通知はあらへん" -instance: "インスタンス" +instance: "サーバー" settings: "設定" basicSettings: "基本設定" -otherSettings: "その他の設定" +otherSettings: "ほかの設定" openInWindow: "ウィンドウで開くで" profile: "プロフィール" timeline: "タイムライン" -noAccountDescription: "自己紹介食ってもた" +noAccountDescription: "自己紹介はあらへん" login: "ログイン" loggingIn: "ログインしよるで" logout: "ログアウト" signup: "新規登録" uploading: "アップロードしとるで" -save: "保存" +save: "とっとく" users: "ユーザー" addUser: "ユーザーを追加や" favorite: "お気に入り" @@ -55,7 +55,7 @@ searchUser: "ユーザーを検索" reply: "返事" loadMore: "まだまだあるで!" showMore: "まだまだあるで!" -showLess: "閉じる" +showLess: "さいなら" youGotNewFollower: "フォローされたで" receiveFollowRequest: "フォローリクエストされたで" followRequestAccepted: "フォローが承認されたで" @@ -81,15 +81,15 @@ followsYou: "フォローされとるで" createList: "リスト作る" manageLists: "リストの管理" error: "エラー" -somethingHappened: "なんかアカンことが起こったで" +somethingHappened: "なんかあかんわ" retry: "もっぺんやる?" -pageLoadError: "ページの読み込みに失敗してもうたわ…" -pageLoadErrorDescription: "これは普通、ネットワークかブラウザキャッシュが原因やからね。キャッシュをクリアするか、もうちっとだけ待ってくれへんか?" +pageLoadError: "ページが読み込めんかったわ。" +pageLoadErrorDescription: "これは普通ならネットワークかブラウザキャッシュが悪さしてるんよ。キャッシュをほかすか、もうちょっとだけ待ってくれへん?" serverIsDead: "サーバーからの応答がないで。もうちょい待ってから試してみてな。" youShouldUpgradeClient: "このページを表示するには、リロードして新しいバージョンのクライアントを使ってなー。" enterListName: "リスト名を入れてや" privacy: "プライバシー" -makeFollowManuallyApprove: "他人のフォローは許可してからや!" +makeFollowManuallyApprove: "ええって言わなフォローできへんようにする" defaultNoteVisibility: "もとからの公開範囲" follow: "フォロー" followRequest: "フォローを頼む" @@ -108,12 +108,12 @@ inChannelQuote: "チャンネル内引用" pinnedNote: "ピン留めされとるノート" pinned: "ピン留めしとく" you: "あんた" -clickToShow: "押したら見えるで" -sensitive: "ちょっとアカンやつやで" +clickToShow: "押したら出ら" +sensitive: "気いつけて見いや" add: "増やす" reaction: "リアクション" reactions: "リアクション" -reactionSetting: "Reaction that will be displayed in Picker. " +reactionSetting: "ピッカーに出しとくリアクション" reactionSettingDescription2: "ドラッグで並び替え、クリックで削除、+を押して追加やで。" rememberNoteVisibility: "公開範囲覚えといて" attachCancel: "のっけるのやめる" @@ -122,6 +122,8 @@ unmarkAsSensitive: "そこまでアカンことないやろ" enterFileName: "ファイル名を入れてや" mute: "ミュート" unmute: "ミュートやめたる" +renoteMute: "Renoteは見いひん" +renoteUnmute: "Renoteもやっぱ見るわ" block: "ブロック" unblock: "ブロックやめたる" suspend: "凍結" @@ -139,26 +141,27 @@ editWidgetsExit: "編集終ったで" customEmojis: "カスタム絵文字" emoji: "絵文字" emojis: "絵文字" -emojiName: "絵文字名" +emojiName: "絵文字はんの名前" emojiUrl: "絵文字画像URL" addEmoji: "絵文字を追加" settingGuide: "ええ感じの設定" cacheRemoteFiles: "リモートのファイルをキャッシュする" -cacheRemoteFilesDescription: "この設定を切っとくと、リモートファイルをキャッシュせず直リンクするようになるで。サーバーの容量は節約できるけど、サムネイルが作られんくなるから通信量が増えるで。" -flagAsBot: "Botやで" -flagAsBotDescription: "もしこのアカウントがプログラムによって運用されるんやったら、このフラグをオンにしてたのむで。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったもんになるんやで。" +cacheRemoteFilesDescription: "この設定を切っとったら、リモートファイルをキャッシュせんと直リンクするようになるで。サーバーの容量は節約できるけど、サムネイルを作らんなるから通信量が増えるで。" +flagAsBot: "Botにするで" +flagAsBotDescription: "もしこのアカウントをプログラム使うて運用するんやったら、このフラグをオンにしてや。オンにすれば、反応がバーッて連鎖せんように開発者が使うたり、Misskeyのシステム上での扱いがBotに合ったもんになるからな。" flagAsCat: "Catやで" flagAsCatDescription: "ワレ、猫ちゃんならこのフラグをつけてみ?" flagShowTimelineReplies: "タイムラインにノートへの返信を表示するで" flagShowTimelineRepliesDescription: "オンにしたら、タイムラインにユーザーのノートの他にもそのユーザーの他のノートへの返信を表示するで。" autoAcceptFollowed: "フォローしとるユーザーからのフォローリクエストを勝手に許可しとく" addAccount: "アカウントを追加" +reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗してもうた…" showOnRemote: "リモートで見る" general: "全般" wallpaper: "壁紙" setWallpaper: "壁紙を設定" -removeWallpaper: "壁紙を削除" +removeWallpaper: "壁紙ほかす" searchWith: "検索: {q}" youHaveNoLists: "リストがあらへんで?" followConfirm: "{name}をフォローしてええか?" @@ -169,7 +172,7 @@ selectUser: "ユーザーを選ぶ" recipient: "宛先" annotation: "注釈" federation: "連合" -instances: "インスタンス" +instances: "サーバー" registeredAt: "初観測" latestRequestReceivedAt: "ちょっと前のリクエスト受信" latestStatus: "ちょっと前のステータス" @@ -178,7 +181,7 @@ charts: "チャート" perHour: "1時間ごと" perDay: "1日ごと" stopActivityDelivery: "アクティビティの配送をやめる" -blockThisInstance: "このインスタンスをブロック" +blockThisInstance: "このサーバーをブロックすんで" operations: "操作" software: "ソフトウェア" version: "バージョン" @@ -189,28 +192,28 @@ jobQueue: "ジョブキュー" cpuAndMemory: "CPUとメモリ" network: "ネットワーク" disk: "ディスク" -instanceInfo: "インスタンス情報" +instanceInfo: "サーバー情報" statistics: "統計" -clearQueue: "キューにさいなら" -clearQueueConfirmTitle: "キューをクリアしまっか?" -clearQueueConfirmText: "未配達の投稿は配送されなくなるで。通常この操作を行う必要はあらへんや。" -clearCachedFiles: "キャッシュにさいなら" +clearQueue: "キューをほかす" +clearQueueConfirmTitle: "キューをほかしとこか?" +clearQueueConfirmText: "未配達の投稿は配送されんなるで。ふつうこの操作を行う必要は無いんやけどな。" +clearCachedFiles: "キャッシュをほかす" clearCachedFilesConfirm: "キャッシュされとるリモートファイルをみんなほかしてええか?" -blockedInstances: "インスタンスブロック" -blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定してな。ブロックされてもうたインスタンスとはもう金輪際やり取りできひんくなるで。" +blockedInstances: "ブロックしたサーバー" +blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定してな。ブロックされてもうたサーバーとはもう金輪際やり取りできひんくなるで。ついでにそのサブドメインもブロックするで。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" -noUsers: "ユーザーはおらへん" +noUsers: "ユーザーはおらん" editProfile: "プロフィールをいじる" -noteDeleteConfirm: "このノートを削除しまっか?" +noteDeleteConfirm: "このノートをほかしてええか?" pinLimitExceeded: "これ以上ピン留めできひん" -intro: "Misskeyのインストールが完了してん!管理者アカウントを作ってや。" +intro: "Misskeyのインストールが完了したで!管理者アカウントを作ってや。" done: "でけた" processing: "処理しとる" preview: "プレビュー" default: "デフォルト" -defaultValueIs: "デフォルト" +defaultValueIs: "デフォルト: {value}" noCustomEmojis: "絵文字はあらへん" noJobs: "ジョブはあらへん" federating: "連合しとる" @@ -220,17 +223,17 @@ all: "みんな" subscribing: "購読しとる" publishing: "配信しとる" notResponding: "応答してへんで" -instanceFollowing: "インスタンスのフォロー" -instanceFollowers: "インスタンスのフォロワー\n" -instanceUsers: "インスタンスのユーザー" -changePassword: "パスワード変える" +instanceFollowing: "サーバーのフォロー" +instanceFollowers: "サーバーのフォロワー\n" +instanceUsers: "サーバーのユーザー" +changePassword: "パスワードをいじる" security: "セキュリティ" -retypedNotMatch: "そやないねん。" +retypedNotMatch: "入れたやつ合うてへんわ。" currentPassword: "今のパスワード" -newPassword: "今度のパスワード" +newPassword: "次のパスワード" newPasswordRetype: "今度のパスワード(もっぺん入れて)" attachFile: "ファイルのっける" -more: "他のやつ!" +more: "他のん" featured: "ハイライト" usernameOrUserId: "ユーザー名かユーザーID" noSuchUser: "ユーザーが見つからへんで" @@ -238,15 +241,15 @@ lookup: "見てきて" announcements: "お知らせ" imageUrl: "画像URL" remove: "ほかす" -removed: "削除したで!" +removed: "ほかしたで!" removeAreYouSure: "「{x}」はほかしてええか?" deleteAreYouSure: "「{x}」はほかしてええか?" resetAreYouSure: "リセットしてええん?" saved: "保存したで!" messaging: "チャット" upload: "アップロード" -keepOriginalUploading: "オリジナル画像を保持するわ" -keepOriginalUploadingDescription: "画像を上げるときにオリジナル版を保持するで。オフにしたら上げたときにブラウザでWeb公開用の画像を生成するで。 " +keepOriginalUploading: "オリジナル画像のまんま" +keepOriginalUploadingDescription: "画像を上げるときにオリジナル版のまんまにするで。オフにしたら、上げたときにブラウザでWeb公開用の画像を生成するで。 " fromDrive: "ドライブから" fromUrl: "URLから" uploadFromUrl: "URLアップロード" @@ -255,7 +258,7 @@ uploadFromUrlRequested: "アップロードしたい言うといたで" uploadFromUrlMayTakeTime: "アップロード終わるんにちょい時間かかるかもしれへんわ。" explore: "みつける" messageRead: "もう読んだ" -noMoreHistory: "これより過去の履歴はあらへんで" +noMoreHistory: "これより昔のんはあらへんで" startMessaging: "チャットやるで" nUsersRead: "{n}人が読んでもうた" agreeTo: "{0}に同意したで" @@ -272,8 +275,8 @@ yearsOld: "{age}歳" registeredDate: "始めた日" location: "場所" theme: "テーマ" -themeForLightMode: "ライトモードではこのテーマつこて" -themeForDarkMode: "ダークモードではこのテーマつこて" +themeForLightMode: "ライトモードではこのテーマ使うて" +themeForDarkMode: "ダークモードではこのテーマ使うて" light: "ライト" dark: "ダーク" lightThemes: "デイゲーム" @@ -289,22 +292,22 @@ renameFile: "ファイル名をいらう" folderName: "フォルダー名" createFolder: "フォルダー作る" renameFolder: "フォルダー名を変える" -deleteFolder: "フォルダーを消してまう" +deleteFolder: "フォルダーをほかす" addFile: "ファイルを追加" -emptyDrive: "ドライブにはなんも残っとらん" -emptyFolder: "ふぉろだーにはなんも残っとらん" -unableToDelete: "消そうおもってんけどな、あかんかったわ" +emptyDrive: "ドライブは空っぽや" +emptyFolder: "このフォルダーは空や" +unableToDelete: "消せんかったわ" inputNewFileName: "今度のファイル名は何にするん?" -inputNewDescription: "新しいキャプションを入力しましょ" +inputNewDescription: "新しいキャプションを入れてや" inputNewFolderName: "今度のフォルダ名は何にするん?" circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーや。" -hasChildFilesOrFolders: "このフォルダ、まだなんか入っとるから消されへん" +hasChildFilesOrFolders: "このフォルダは空っぽちゃうから消されへん" copyUrl: "URLをコピー" rename: "名前を変えるで" avatar: "アイコン" banner: "バナー" -nsfw: "閲覧注意" -whenServerDisconnected: "サーバーとの接続が切れたとき" +nsfw: "見るんは気いつけてな" +whenServerDisconnected: "サーバーとの接続が失くなってしもうたとき" disconnectedFromServer: "サーバーが機嫌悪いねん" reload: "リロード" doNothing: "何もせんとく" @@ -314,10 +317,10 @@ unwatch: "ウォッチやめる" accept: "ええで" reject: "あかん" normal: "ええ感じ" -instanceName: "インスタンス名" -instanceDescription: "インスタンスの紹介" -maintainerName: "管理者の名前" -maintainerEmail: "管理者のメールアドレス" +instanceName: "サーバー名" +instanceDescription: "サーバーの紹介" +maintainerName: "管理者はんの名前" +maintainerEmail: "管理者はんのメールアドレス" tosUrl: "利用規約のURL" thisYear: "今年" thisMonth: "今月" @@ -329,23 +332,23 @@ pages: "ページ" integration: "連携" connectService: "つなげるで" disconnectService: "切るで" -enableLocalTimeline: "ローカルタイムラインを使えるようにする" -enableGlobalTimeline: "グローバルタイムラインを使えるようにする" +enableLocalTimeline: "ローカルタイムラインを使えるようにするわ" +enableGlobalTimeline: "グローバルタイムラインを使えるようにするわ" disablingTimelinesInfo: "ここらへんのタイムラインを使えんようにしてしもても、管理者とモデレーターは使えるままになってるで、そうやなかったら不便やからな。" registration: "登録" enableRegistration: "一見さんでも誰でもいらっしゃ~い" invite: "来てや" -driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量" -driveCapacityPerRemoteAccount: "リモートユーザーひとりあたりのドライブ容量" +driveCapacityPerLocalAccount: "ローカルユーザーはんひとりあたりのドライブ容量" +driveCapacityPerRemoteAccount: "リモートユーザーはんひとりあたりのドライブ容量" inMb: "メガバイト単位" iconUrl: "アイコン画像のURL" bannerUrl: "バナー画像のURL" backgroundImageUrl: "背景画像のURL" basicInfo: "基本情報" pinnedUsers: "ピン留めしたユーザー" -pinnedUsersDescription: "「みつける」ページとかにピン留めしたいユーザーをここに書けばええんやで。他ん人との名前は改行で区切ればええんやで。" +pinnedUsersDescription: "「みつける」ページとかにピン留めしたいユーザーをここに書けばええんやで。ユーザー毎に改行してや。" pinnedPages: "ピン留めページ" -pinnedPagesDescription: "インスタンスのいっちゃん上にピン留めしたいページのパスを改行で区切って記述してな" +pinnedPagesDescription: "サーバーのいっちゃん上にピン留めしたいページのパスを改行で区切って記述してな" pinnedClipId: "ピン留めするクリップのID" pinnedNotes: "ピン留めされとるノート" hcaptcha: "hCaptcha(キャプチャ)" @@ -370,7 +373,7 @@ antennaExcludeKeywords: "除外キーワード" antennaKeywordsDescription: "スペースで区切ったるとAND指定で、改行で区切ったるとOR指定や" notifyAntenna: "新しいノートを通知すんで" withFileAntenna: "なんか添付されたノートだけ" -enableServiceworker: "ServiceWorkerをつこて" +enableServiceworker: "ブラウザにプッシュ通知が行くようにする" antennaUsersDescription: "ユーザー名を改行で区切ったってな" caseSensitive: "大文字と小文字は別もんや" withReplies: "返信も入れたって" @@ -395,23 +398,23 @@ administrator: "管理者" token: "トークン" 2fa: "二要素認証" totp: "認証アプリ" -totpDescription: "認証アプリ使てワンタイムパスワードを入れる" +totpDescription: "認証アプリ使うてワンタイムパスワードを入れる" moderator: "モデレーター" moderation: "モデレーション" nUsersMentioned: "{n}人が投稿" securityKeyAndPasskey: "セキュリティキー・パスキー" securityKey: "セキュリティキー" lastUsed: "最後につこうた日" -lastUsedAt: "最後に使たん: {t}" +lastUsedAt: "最後に使うたんは: {t}" unregister: "登録やめる" passwordLessLogin: "パスワード無くてもログインできるようにする" -passwordLessLoginDescription: "パスワードやなくて、セキュリティキーとかパスキーだけでログインするわ" +passwordLessLoginDescription: "パスワードなんかいらん、セキュリティキーとかパスキーだけでログインするわ" resetPassword: "パスワードをリセット" newPasswordIs: "今度のパスワードは「{password}」や" -reduceUiAnimation: "UIの動きやアニメーションを減らす" +reduceUiAnimation: "UIの動きやアニメーションを少なする" share: "わけわけ" notFound: "見つからへんね" -notFoundDescription: "指定されたURLに該当するページはあらへんやった。" +notFoundDescription: "言われたURLにはまるページはなかったで。" uploadFolder: "とりあえずアップロードしたやつ置いとく所" cacheClear: "キャッシュをほかす" markAsReadAllNotifications: "通知はもう全て読んだわっ" @@ -419,37 +422,37 @@ markAsReadAllUnreadNotes: "投稿は全て読んだわっ" markAsReadAllTalkMessages: "チャットはもうぜんぶ読んだわっ" help: "ヘルプ" inputMessageHere: "ここにメッセージ書いてや" -close: "閉じる" +close: "さいなら" invites: "来てや" -members: "メンバー" +members: "メンバーはん" transfer: "譲渡" title: "タイトル" text: "テキスト" enable: "有効にするで" next: "次" retype: "もっかい入力" -noteOf: "{user}のノート" +noteOf: "{user}はんのノート" quoteAttached: "引用付いとるで" quoteQuestion: "引用として添付してもええか?" noMessagesYet: "まだチャットはあらへんで" newMessageExists: "新しいメッセージがきたで" -onlyOneFileCanBeAttached: "すまん、メッセージに添付できるファイルはひとつだけなんや。" +onlyOneFileCanBeAttached: "ごめんな、メッセージに添付できるファイルはひとつだけなんよ。" signinRequired: "ログインしてくれへん?" invitations: "来てや" invitationCode: "招待コード" checking: "確認しとるで" -available: "利用できる\n" +available: "使えるで" unavailable: "利用できん" usernameInvalidFormat: "a~z、A~Z、0~9、_が使えるで" tooShort: "短すぎやろ!" tooLong: "長すぎやろ!" weakPassword: "へぼいパスワード" -normalPassword: "普通のパスワード" +normalPassword: "ぼちぼちのパスワード" strongPassword: "ええ感じのパスワード" passwordMatched: "よし!一致や!" passwordNotMatched: "一致しとらんで?" signinWith: "{x}でログイン" -signinFailed: "ログインできんかったで。もっかいユーザー名とパスワードを確認してみてな。" +signinFailed: "ログインできんかったで。もっかいユーザー名とパスワードを確認してみてや。" or: "それか" language: "言語" uiLanguage: "UIの表示言語" @@ -458,7 +461,7 @@ emojiStyle: "絵文字のスタイル" native: "ネイティブ" disableDrawer: "メニューをドロワーで表示せぇへん" showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで" -noHistory: "履歴はあらへんねぇ。" +noHistory: "履歴はないわ。" signinHistory: "ログイン履歴" enableAdvancedMfm: "ややこしいMFMもありにする" enableAnimatedMfm: "動きがやかましいMFMも許したる" @@ -466,12 +469,12 @@ doing: "やっとるがな" category: "カテゴリ" tags: "タグ" docSource: "このドキュメントのソース" -createAccount: "アカウントを作成" -existingAccount: "既存のアカウント" -regenerate: "再生成" -fontSize: "フォントサイズ" +createAccount: "アカウントを作るで" +existingAccount: "前に作ったアカウント" +regenerate: "もっぺん生成するで" +fontSize: "字の大きさ" noFollowRequests: "フォロー申請はあらへんで" -openImageInNewTab: "画像を新しいタブで開く" +openImageInNewTab: "画像を新しいタブで開くで" dashboard: "ダッシュボード" local: "ローカル" remote: "リモート" @@ -503,9 +506,11 @@ objectStorageUseSSLDesc: "API接続にhttpsを使わん場合はオフにする objectStorageUseProxy: "Proxyを使う" objectStorageUseProxyDesc: "API接続にproxy使わんのやったら切ってくれへん?" objectStorageSetPublicRead: "アップロードした時に'public-read'を設定してや" +s3ForcePathStyleDesc: "s3ForcePathStyleを使たらバケット名をURLのホスト名やなくてパスの一部として必ず指定させるようになるで。セルフホストされたMinioとかを使うてるんやったら有効にせなあかん場合があるで。" serverLogs: "サーバーログ" -deleteAll: "全て削除してや" +deleteAll: "全部ほかす" showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?" +showFixedPostFormInChannel: "タイムラインの上の方で投稿できるようにするわ(チャンネル)" newNoteRecived: "新しいノートがあるで" sounds: "サウンド" sound: "サウンド" @@ -513,11 +518,11 @@ listen: "聴く" none: "なし" showInPage: "ページで表示" popout: "ポップアウト" -volume: "音量" -masterVolume: "全体の音量" +volume: "やかましさ" +masterVolume: "全体のやかましさ" details: "もっと" chooseEmoji: "絵文字を選ぶ" -unableToProcess: "なんか作業が止まってしまったようやね" +unableToProcess: "なんか奥の方で詰まってもうた" recentUsed: "最近使ったやつ" install: "インストール" uninstall: "アンインストール" @@ -535,14 +540,18 @@ output: "出力" script: "スクリプト" disablePagesScript: "Pagesのスクリプトを無効にしてや" updateRemoteUser: "リモートユーザー情報の更新してくれん?" -deleteAllFiles: "すべてのファイルを削除" -deleteAllFilesConfirm: "ホンマにすべてのファイルを削除するん?消したもんはもう戻ってこんのやで?" +deleteAllFiles: "ファイルを全部ほかす" +deleteAllFilesConfirm: "ホンマにファイル全部ほかすんか?消したもんはもう戻ってこんのやで?" removeAllFollowing: "フォローを全解除" removeAllFollowingDescription: "{host}からのフォローをすべて解除するで。そのインスタンスが消えて無くなった時とかには便利な機能やで。" userSuspended: "このユーザーは...凍結されとる。" userSilenced: "このユーザーは...サイレンスされとる。" yourAccountSuspendedTitle: "あんたのアカウント凍結されとるで" yourAccountSuspendedDescription: "あんたのアカウントは、サーバーの利用規約に違反したとかの理由で、凍結されとるで。細かいことは管理者までお問い合わせたってなー。絶対に新しいアカウント作ったらあかんで。絶対やで。" +tokenRevoked: "トークンが無効やで" +tokenRevokedDescription: "ログイントークンが失効しとるで。もっかいログインしてもろてもええか?" +accountDeleted: "アカウントは削除されとるで" +accountDeletedDescription: "このアカウントは削除されとるで。" menu: "メニュー" divider: "分割線" addItem: "項目を追加" @@ -565,7 +574,7 @@ description: "説明" describeFile: "キャプションを付ける" enterFileDescription: "キャプションを入力" author: "作者" -leaveConfirm: "未保存の変更があるで!ほかしてええか?" +leaveConfirm: "あんた、いじったのにまだ保存してないで!ほかしてええか?" manage: "管理" plugins: "プラグイン" preferencesBackups: "設定のバックアップ" @@ -586,7 +595,6 @@ tokenRequested: "アカウントへのアクセス許してやったらどうや pluginTokenRequestedDescription: "このプラグインはここで設定した権限を使えるようになるで。" notificationType: "通知の種類" edit: "編集" -useStarForReactionFallback: "リアクションがようわからん場合、★を使う" emailServer: "メールサーバー" enableEmail: "メール配信を受け取る" emailConfigInfo: "メールアドレスの確認とかパスワードリセットの時に使うで" @@ -599,12 +607,12 @@ smtpUser: "ユーザー名" smtpPass: "パスワード" emptyToDisableSmtpAuth: "ユーザー名とパスワードになんも入れんかったら、SMTP認証を無効化するで" smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" -smtpSecureInfo: "STARTTLS使っとる時はオフにするで。" +smtpSecureInfo: "STARTTLS使っとる時はオフにしてや。" testEmail: "配信テスト" wordMute: "ワードミュート" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが出てきたで:" -instanceMute: "インスタンスミュート" +instanceMute: "サーバーミュート" userSaysSomething: "{name}が何か言うとるわ" makeActive: "使うで" display: "表示" @@ -623,7 +631,7 @@ useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使 other: "その他" regenerateLoginToken: "ログイントークンを再生成" regenerateLoginTokenDescription: "ログインに使われる内部トークンをもっかい作るで。いつもならこれをやる必要はないで。もっかい作ると、全部のデバイスでログアウトされるで気ぃつけてなー。" -setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できるで。" +setMultipleBySeparatingWithSpace: "スペースで区切って何個でも設定できるで。" fileIdOrUrl: "ファイルIDかURL" behavior: "動作" sample: "サンプル" @@ -635,15 +643,15 @@ abuseReported: "無事内容が送信されたみたいやで。おおきに〜 reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートインスタンスに通報を転送するで" -forwardReportIsAnonymous: "リモートインスタンスからはあんたの情報は見れへんくって、匿名のシステムアカウントとして表示されるで。" +forwardReport: "リモートサーバーに通報を転送するで" +forwardReportIsAnonymous: "リモートサーバーからはあんたの情報は見えんなって、匿名のシステムアカウントとして表示されるで。" send: "送信" abuseMarkAsResolved: "対応したで" openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" editTheseSettingsMayBreakAccount: "このへんの設定をようわからんままイジるとアカウントが壊れて使えんくなるかも知れへんで?" -instanceTicker: "ノートのインスタンス情報" +instanceTicker: "ノートのサーバー情報" waitingFor: "{x}を待っとるで" random: "ランダム" system: "システム" @@ -654,7 +662,7 @@ createNew: "新しく作るで" optional: "任意" createNewClip: "新しいクリップを作るで" unclip: "クリップ解除するで" -confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれとるで。ノートをこのクリップから除外したる?" +confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれとるで。ノートをこのクリップから除外しよか?" public: "パブリック" i18nInfo: "Misskeyは有志によっていろんな言語に翻訳されとるで。{link}で翻訳に協力したってやー。" manageAccessTokens: "アクセストークンの管理" @@ -671,15 +679,15 @@ receivedReactionsCount: "リアクションされた数" pollVotesCount: "アンケートに投票した数" pollVotedCount: "アンケートに投票された数" yes: "ええで" -no: "あかんで" +no: "あかん" driveFilesCount: "ドライブのファイル数" driveUsage: "ドライブ使用量やで" noCrawle: "クローラーによるインデックスを拒否するで" -noCrawleDescription: "検索エンジンにあんたのユーザーページ、ノート、Pagesとかのコンテンツを登録(インデックス)せぇへんように頼むで。" +noCrawleDescription: "検索エンジンにあんたのユーザーページ、ノート、Pagesとかのコンテンツを登録(インデックス)せんように頼むで。邪魔すんねんやったら帰って〜。" lockedAccountInfo: "フォローを承認制にしとっても、ノートの公開範囲を「フォロワー」にせぇへん限り、誰でもあんたのノートを見れるで。" alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にするで" loadRawImages: "添付画像のサムネイルをオリジナル画質にするで" -disableShowingAnimatedImages: "アニメーション画像を再生しやへんで" +disableShowingAnimatedImages: "アニメーション画像を再生せんとくで" verificationEmailSent: "無事確認のメールを送れたで。メールに書いてあるリンクにアクセスして、設定を完了してなー。" notSet: "未設定" emailVerified: "メールアドレスは確認されたで" @@ -689,14 +697,14 @@ pageLikedCount: "Pageにええやんと思ってくれた数" contact: "連絡先" useSystemFont: "システムのデフォルトのフォントを使うで" clips: "クリップ" -experimentalFeatures: "実験的機能やで" +experimentalFeatures: "おためし機能やで" developer: "開発者やで" makeExplorable: "アカウントを見つけやすくするで" makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らんくなるで。" showGapBetweenNotesInTimeline: "タイムラインのノートを離して表示するで" duplicate: "複製" left: "左" -center: "中央" +center: "真ん中" wide: "広い" narrow: "狭い" reloadToApplySetting: "設定はページリロード後に反映されるで。今リロードしとくか?" @@ -707,7 +715,7 @@ onlineUsersCount: "{n}人が起きとるで" nUsers: "{n}ユーザー" nNotes: "{n}ノート" sendErrorReports: "エラーリポートを送る" -sendErrorReportsDescription: "オンにしたら、なんか変なことが起きたときにエラーの詳細がMisskeyに共有されて、ソフトウェアの品質向上に役立てられるんや。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴などが含まれるで。" +sendErrorReportsDescription: "オンにしたら、変なことが起きたときにエラーの詳細がMisskeyに送られて、ソフトウェアの品質向上に使えるようになるで。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴なんかが含まれるで。" myTheme: "マイテーマ" backgroundColor: "背景" accentColor: "アクセント" @@ -732,7 +740,7 @@ capacity: "容量" inUse: "使用中" editCode: "コードを編集" apply: "適用" -receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" +receiveAnnouncementFromInstance: "サーバーからのお知らせを受け取る" emailNotification: "メール通知" publish: "公開" inChannelSearch: "チャンネル内検索" @@ -760,7 +768,7 @@ active: "アクティブ" offline: "オフライン" notRecommended: "あんま推奨しやんで" botProtection: "Botプロテクション" -instanceBlocking: "インスタンスブロック" +instanceBlocking: "サーバーブロック" selectAccount: "アカウントを選んでなー" switchAccount: "アカウントを変えるで" enabled: "有効" @@ -844,8 +852,8 @@ themeColor: "テーマカラー" size: "大きさ" numberOfColumn: "列の数" searchByGoogle: "探す" -instanceDefaultLightTheme: "インスタンスの最初の明るいテーマ" -instanceDefaultDarkTheme: "インスタンスの最初の暗いテーマ" +instanceDefaultLightTheme: "サーバーおすすめの明るいテーマ" +instanceDefaultDarkTheme: "サーバーおすすめのの暗いテーマ" instanceDefaultThemeDescription: "オブジェクト形式のテーマコードを記入するで。" mutePeriod: "ミュートする期間" period: "期限" @@ -859,7 +867,7 @@ reflectMayTakeTime: "反映されるまで時間がかかることがあるで" failedToFetchAccountInformation: "アカウントの取得に失敗したみたいや…" rateLimitExceeded: "レート制限が超えたみたいやで" cropImage: "画像のクロップ" -cropImageAsk: "画像をクロップしたってええか?" +cropImageAsk: "画像をクロップしてもええか?" cropYes: "切り抜いたる" cropNo: "切り抜かへん" file: "ファイル" @@ -876,7 +884,7 @@ isSystemAccount: "システムが自動で作成・管理しとるアカウン typeToConfirm: "この操作をやるんなら {x} と入力してなー" deleteAccount: "アカウント削除するで" document: "ドキュメント" -numberOfPageCache: "ページキャッシュ数やで" +numberOfPageCache: "ページ、どんだけキャッシュすんの?" numberOfPageCacheDescription: "増やすと使いやすくなる、負荷とメモリ使用量が増えてくで。一長一短やな。" logoutConfirm: "ログアウトしまっか?" lastActiveDate: "最後に使った日時" @@ -894,11 +902,11 @@ sensitiveMediaDetection: "センシティブなメディアの検出" localOnly: "ローカルのみ" remoteOnly: "リモートのみ" failedToUpload: "アップロードに失敗してもうたわ…" -cannotUploadBecauseInappropriate: "不適切な内容を含むかもしれへんって判定されたでアップロードできまへん。" -cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いでアップロードできまへん。" +cannotUploadBecauseInappropriate: "不適切な内容を含むかもしれへんって判定されたからアップロードできへんわ。" +cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いからアップロードできへんわ。" beta: "ベータ" enableAutoSensitive: "自動NSFW判定" -enableAutoSensitiveDescription: "使える時は、機械学習を使って自動でメディアにNSFWフラグを設定するで。この機能をオフにしても、インスタンスによっては自動で設定されることがあるで。" +enableAutoSensitiveDescription: "使える時は、機械学習を使って自動でメディアにNSFWフラグを設定するで。この機能をオフにしても、サーバーによっては自動で設定されることがあるで。" activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかとかを判定して積極的に行うで。オフにすると単に文字列として正しいかどうかだけチェックするで。" navbar: "ナビゲーションバー" shuffle: "シャッフルするで" @@ -908,10 +916,11 @@ pushNotification: "プッシュ通知" subscribePushNotification: "プッシュ通知をオンにするで" unsubscribePushNotification: "プッシュ通知を止めるで" pushNotificationAlreadySubscribed: "プッシュ通知はオンになってるで" -pushNotificationNotSupported: "ブラウザかインスタンスがプッシュ通知に対応してないみたいやで。" -sendPushNotificationReadMessage: "通知やメッセージが既読担ったらプッシュ通知を消すで" +pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に対応してないみたいやで。" +sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を消すで" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」っていう表示が一瞬表示されるようになるで。端末の電池使用量が増える可能性があるで。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "元に戻す" caption: "キャプション" loggedInAsBot: "Botアカウントでログイン中やで" @@ -924,7 +933,7 @@ numberOfLikes: "いいね数" show: "表示" neverShow: "今後表示しない" remindMeLater: "また後で" -didYouLikeMisskey: "Misskeyを気に入っとっただけましたん?" +didYouLikeMisskey: "Misskey気に入ってくれた?" pleaseDonate: "Misskeyは{host}が使用している無料のソフトウェアやで。これからも開発を続けれるように、寄付したってな~。" roles: "ロール" role: "ロール" @@ -934,7 +943,7 @@ assign: "アサイン" unassign: "アサインを解除" color: "色" manageCustomEmojis: "カスタム絵文字の管理" -youCannotCreateAnymore: "これ以上作れなさそうや" +youCannotCreateAnymore: "これ以上作れなさそうやわ" cannotPerformTemporary: "一時的に利用できへんで" cannotPerformTemporaryDescription: "操作回数が制限を超えたから一時的に利用できへんくなったで。ちょっと時間置いてからもう一回やってやー。" preset: "プリセット" @@ -946,15 +955,41 @@ thisPostMayBeAnnoying: "この投稿は迷惑かもしらんで。" thisPostMayBeAnnoyingHome: "ホームに投稿" thisPostMayBeAnnoyingCancel: "やめとく" thisPostMayBeAnnoyingIgnore: "このまま投稿" -collapseRenotes: "見たことあるRenoteは省略やで" +collapseRenotes: "見たことあるRenoteは飛ばして表示するで" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバー内部でよう分からんエラーやわ" copyErrorInfo: "エラー情報をコピー" joinThisServer: "このサーバーに登録するわ" exploreOtherServers: "他のサーバー見てみる" letsLookAtTimeline: "タイムライン見てみーや" -disableFederationWarn: "連合が無効になっとるで。無効にしても投稿は非公開ってわけちゃうねん。大体の場合はこのオプションを有効にする必要は別にないで。" +disableFederationConfirm: "連合なしにしとくか?" +disableFederationConfirmWarn: "連合なしにしても投稿は非公開にはならへんで。大体の場合は連合なしにする必要はないで。" +disableFederationOk: "連合なしにしとく" invitationRequiredToRegister: "今このサーバー招待制になってもうてんねん。招待コードを持っとるんやったら登録できるで。" +emailNotSupported: "このサーバーはメール配信がサポートされてへんみたいやわ" +postToTheChannel: "チャンネルに投稿" +cannotBeChangedLater: "後からは変えられへんで。" +reactionAcceptance: "リアクションの受け入れ" +likeOnly: "いいねだけ" +likeOnlyForRemote: "リモートからはいいねだけな" +rolesAssignedToMe: "自分に割り当てられたロール" +resetPasswordConfirm: "パスワード作り直すんでええな?" +sensitiveWords: "けったいな単語" +sensitiveWordsDescription: "設定した単語が入っとるノートの公開範囲をホームにしたるわ。改行で区切ったら複数設定できるで。" +notesSearchNotAvailable: "ノート検索は使われへんで。" +license: "ライセンス" +unfavoriteConfirm: "ほんまに気に入らんの?" +myClips: "自分のクリップ" +drivecleaner: "ドライブキレイキレイ" +retryAllQueuesNow: "キューを全部もっかいやり直す" +retryAllQueuesConfirmTitle: "もっかいやってみるか?" +retryAllQueuesConfirmText: "一時的にサーバー重なるかもしれへんで。" +enableChartsForRemoteUser: "リモートユーザーのチャートを作る" +enableChartsForFederatedInstances: "リモートサーバーのチャートを作る" +showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" +largeNoteReactions: "ノートのリアクションを大きする" +noteIdOrUrl: "ノートIDかURL" +accountMoved: "このユーザーはさらのアカウントに引っ越したで:" _achievements: earnedAt: "貰った日ぃ" _types: @@ -972,7 +1007,7 @@ _achievements: title: "ノートの生駒山" description: "ノートを500回投稿した" _notes1000: - title: "ノートの山" + title: "ノートの六甲山" description: "ノートを1,000回投稿した" _notes5000: title: "箕面の滝からノート" @@ -1015,7 +1050,7 @@ _achievements: _login7: title: "ビギナーⅡ" description: "通算7日ログインした" - flavor: "慣れてきたんちゃう?" + flavor: "慣れてきたんとちゃう?" _login15: title: "ビギナーⅢ" description: "通算15日ログインした" @@ -1072,7 +1107,7 @@ _achievements: description: "プロフィールを設定した" _markedAsCat: title: "吾輩は猫やねん" - description: "アカウントがCatになってもうた" + description: "アカウントをCatにしたった" flavor: "名前はまだないねん。" _following1: title: "はじめてのフォロー" @@ -1119,7 +1154,7 @@ _achievements: _iLoveMisskey: title: "Misskey好きやねん" description: "\"I ❤ #Misskey\"を投稿した" - flavor: "Misskeyを使ってくれてありがとうな~ by 開発チーム" + flavor: "Misskeyを使ってくれておおきにな~ by 開発チーム" _foundTreasure: title: "なんでも鑑定団" description: "隠されたお宝を発見した" @@ -1145,7 +1180,7 @@ _achievements: description: "ホームタイムラインの流速が20npmを超す" _viewInstanceChart: title: "アナリスト" - description: "インスタンスのチャートを表示した" + description: "サーバーのチャートを表示した" _outputHelloWorldOnScratchpad: title: "Hello, world!" description: "スクラッチパッドで hello worldを出力した" @@ -1182,7 +1217,7 @@ _achievements: _loggedInOnNewYearsDay: title: "あけましておめでとうございます!" description: "元旦にログインした" - flavor: "今年も弊インスタンスをよろしくお願いします" + flavor: "今年も弊サーバーをよろしゅう頼みますわ" _cookieClicked: title: "クッキー叩くやつ" description: "クッキー叩いてもうた" @@ -1197,8 +1232,8 @@ _role: name: "ロール名" description: "ロールの説明" permission: "ロールの権限" - descriptionOfPermission: "モデレーターは基本的なモデレーションに関わる操作を行えるで。\n管理者はインスタンスの全ての設定を変更できるで。" - assignTarget: "アサインターゲット" + descriptionOfPermission: "モデレーターは基本的なモデレーションに関わる操作を行えるで。\n管理者はサーバーの全ての設定を変更できるで。" + assignTarget: "アサイン" descriptionOfAssignTarget: "マニュアルは誰がこのロールに含まれてるかを手動で管理するで。\nコンディショナルは条件を設定して、それに合うユーザーが自動で含まれるようになるで。" manual: "マニュアル" conditional: "コンディショナル" @@ -1214,6 +1249,8 @@ _role: iconUrl: "アイコン画像のURL" asBadge: "バッジとして見せる" descriptionOfAsBadge: "オンにすると、ユーザー名の横んとこにロールのアイコンが表示されるで。" + displayOrder: "表示順" + descriptionOfDisplayOrder: "数がでかいほど、UI上で先に表示されるで。" canEditMembersByModerator: "モデレーターのメンバー編集を許可" descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになるで。オフにすると管理者のみが行えるで。" priority: "優先度" @@ -1225,7 +1262,7 @@ _role: gtlAvailable: "グローバルタイムラインの閲覧" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" - canInvite: "インスタンス招待コードの発行" + canInvite: "サーバー招待コードの発行" canManageCustomEmojis: "カスタム絵文字の管理" driveCapacity: "ドライブ容量" pinMax: "ノートのピン留めの最大数" @@ -1237,8 +1274,9 @@ _role: userListMax: "ユーザーリストの作成可能数" userEachUserListsMax: "ユーザーリスト内のユーザーの最大数" rateLimitFactor: "レートリミット" - descriptionOfRateLimitFactor: "ちっちゃいほど制限が緩くなって、大きいほど制限されるで。" + descriptionOfRateLimitFactor: "ちっちゃいほど制限が緩なって、大きいほど制限されるで。" canHideAds: "広告を表示させへん" + canSearchNotes: "ノート検索を使わすかどうか" _condition: isLocal: "ローカルユーザー" isRemote: "リモートユーザー" @@ -1248,6 +1286,8 @@ _role: followersMoreThanOrEq: "フォロワー数が~以上" followingLessThanOrEq: "フォロー数が~以下" followingMoreThanOrEq: "フォロー数が~以上" + notesLessThanOrEq: "投稿数が~以下しかない" + notesMoreThanOrEq: "投稿を~以上しとる" and: "~かつ~" or: "~または~" not: "~ではない" @@ -1287,7 +1327,7 @@ _ad: _forgotPassword: enterEmail: "アカウントに登録したメールアドレスをここに入力してや。そのアドレス宛に、パスワードリセット用のリンクが送られるから待っててな~。" ifNoEmail: "メールアドレスを登録してへんのやったら、管理者まで教えてな~。" - contactAdmin: "このインスタンスはメールに対応してへんから、パスワードリセットをしたいときは管理者まで教えてな~。" + contactAdmin: "このサーバーはメールに対応してへんから、パスワードリセットをしたいときは管理者まで教えてな~。" _gallery: my: "あんたの投稿" liked: "いいねした投稿" @@ -1372,10 +1412,10 @@ _wordMute: hard: "ハード" mutedNotes: "ミュートされたノート" _instanceMute: - instanceMuteDescription: "ミュートしたインスタンスのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートにするで。" - instanceMuteDescription2: "改行で区切って設定するで" - title: "設定したインスタンスのノートを隠すで。" - heading: "ミュートするインスタンス" + instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したインスタンスの全てのノートとRenoteをミュートにするで。" + instanceMuteDescription2: "改行で区切って設定するんやで" + title: "設定したサーバーのノートを隠すで。" + heading: "ミュートするサーバー" _theme: explore: "テーマを探す" install: "テーマのインストール" @@ -1460,7 +1500,7 @@ _sfx: channel: "チャンネル通知" _ago: future: "未来" - justNow: "たった今" + justNow: "ついさっき" secondsAgo: "{n}秒前" minutesAgo: "{n}分前" hoursAgo: "{n}時間前" @@ -1583,7 +1623,7 @@ _weekday: saturday: "土曜日" _widgets: profile: "プロフィール" - instanceInfo: "インスタンス情報" + instanceInfo: "サーバー情報" memo: "付箋" notifications: "通知" timeline: "タイムライン" @@ -1597,7 +1637,7 @@ _widgets: digitalClock: "デジタル時計" unixClock: "UNIX時計" federation: "連合" - instanceCloud: "インスタンスクラウド" + instanceCloud: "サーバークラウド" postForm: "投稿フォーム" slideshow: "スライドショー" button: "ボタン" @@ -1648,7 +1688,7 @@ _visibility: specified: "ダイレクト" specifiedDescription: "選んだユーザーのみに公開するで" disableFederation: "連合なし" - disableFederationDescription: "他インスタンスへは送らんとくわ" + disableFederationDescription: "他サーバーへは送らんとくわ" _postForm: replyPlaceholder: "このノートに返信..." quotePlaceholder: "このノートを引用..." @@ -1686,7 +1726,7 @@ _charts: apRequest: "リクエスト" usersIncDec: "ユーザーの増減" usersTotal: "ユーザーの合計" - activeUsers: "アクティブユーザー数" + activeUsers: "いまおるユーザー数" notesIncDec: "ノートの増減" localNotesIncDec: "ローカルのノートの増減" remoteNotesIncDec: "リモートのノートの増減" @@ -1840,3 +1880,23 @@ _deck: _dialog: charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}" charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}" +_disabledTimeline: + title: "使われへんタイムライン" + description: "あんたの今のロールやったら、このタイムラインは使われへんで。" +_drivecleaner: + orderBySizeDesc: "サイズのでかい順" + orderByCreatedAtAsc: "追加日の古い順" +_webhookSettings: + createWebhook: "Webhookをつくる" + name: "名前" + secret: "シークレット" + events: "Webhookを投げるタイミング" + active: "有効" + _events: + follow: "フォローしたとき~!" + followed: "フォローもらったとき~!" + note: "ノートを投稿したとき~!" + reply: "返信があるとき~!" + renote: "Renoteされるとき~!" + reaction: "リアクションがあるとき~!" + mention: "メンションがあるとき~!" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 9115afe5a..3ff00482d 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.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: "일반" @@ -506,6 +509,7 @@ objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" serverLogs: "서버 로그" deleteAll: "모두 삭제" showFixedPostForm: "타임라인 상단에 글 작성란을 표시" +showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란을 표시" newNoteRecived: "새 노트가 있습니다" sounds: "소리" sound: "소리" @@ -543,6 +547,8 @@ userSuspended: "이 계정은 정지된 상태입니다." userSilenced: "이 계정은 사일런스된 상태입니다." yourAccountSuspendedTitle: "계정이 정지되었습니다" yourAccountSuspendedDescription: "이 계정은 서버의 이용 약관을 위반하거나, 기타 다른 이유로 인해 정지되었습니다. 자세한 사항은 관리자에게 문의해 주십시오. 계정을 새로 생성하지 마십시오." +accountDeleted: "계정이 정지되었습니다" +accountDeletedDescription: "이 계정이 삭제되었습니다." menu: "메뉴" divider: "구분선" addItem: "항목 추가" @@ -586,7 +592,6 @@ tokenRequested: "계정 접근 허용" pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 됩니다." notificationType: "알림 유형" edit: "편집" -useStarForReactionFallback: "알 수 없는 리액션 이모지 대신 ★ 사용" emailServer: "메일 서버" enableEmail: "이메일 송신 기능 활성화" emailConfigInfo: "가입 시 메일 주소 확인이나 비밀번호 초기화 시에 사용합니다." @@ -953,8 +958,10 @@ copyErrorInfo: "오류 정보 복사" joinThisServer: "이 서버에 가입" exploreOtherServers: "다른 서버 둘러보기" letsLookAtTimeline: "타임라인 구경하기" -disableFederationWarn: "연합이 비활성화됩니다. 비활성화해도 게시물이 비공개가 되지는 않습니다. 대부분의 경우 이 옵션을 활성화할 필요가 없습니다." invitationRequiredToRegister: "현재 이 서버는 비공개입니다. 회원가입을 하시려면 초대 코드가 필요합니다." +emailNotSupported: "이 서버에서는 메일 전송을 지원하지 않습니다" +postToTheChannel: "채널에 게시하기" +cannotBeChangedLater: "나중에 변경할 수 없습니다." _achievements: earnedAt: "달성 일시" _types: @@ -1840,3 +1847,6 @@ _deck: _dialog: charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {min}" charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" +_webhookSettings: + name: "이름" + active: "활성화" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index ce596d038..9c1a48c67 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -168,7 +168,9 @@ done: "ສຳເລັດ" processing: "ກຳລັງປະມວນຜົນ" preview: "ສະແດງເປັນຕົວຢ່າງ" default: "ຄ່າເລີ່ມຕົ້ນ" +federating: "ສະຫະພັນ" blocked: "ບລັອກແລ້ວ " +suspended: "ໂຈະ" all: "ທັງໝົດ" subscribing: "ສະໝັກສະມາຊິກແລັວ" publishing: "ການ​ພິມ​ເຜີຍ​ແຜ່" @@ -177,15 +179,35 @@ instanceFollowing: "ກຳລັງຕິດຕາມສຸດຕົວຢ່າ instanceFollowers: "ຜູ້ຕິດຕາມຕົວຢ່າງ" instanceUsers: "ຜູ້​ຊົມ​ໃຊ້​ຂອງ​ຕົວ​ຢ່າງ​ນີ້​" changePassword: "ປ່ຽນ​ລະ​ຫັດ​ຜ່ານ" +security: "ຄວາມປອດໄພ" +retypedNotMatch: "ວັດສະດຸປ້ອນບໍ່ກົງກັນ" +currentPassword: "ລະຫັດຜ່ານປະຈຸບັນ" +more: "ເພີ່ມເຕີມ!" featured: "ໄຮໄລທ໌" +usernameOrUserId: "ຊື່ຜູ້ໃຊ້ ຫຼື id ຜູ້ໃຊ້" +noSuchUser: "ບໍ່ພົບຜູ້ໃຊ້" +lookup: "ຄົ້ນ​ຫາ" announcements: "ປະກາດ" +imageUrl: "URL ຮູບພາບ" remove: "ລຶບ" +removed: "ລຶບແລ້ວ" +resetAreYouSure: "ຣີ​ເຊັດບໍ?" +saved: "ບັນທຶກແລ້ວ" messaging: "ແຊ໋ດ" +upload: "ອັບໂຫຼດ" +keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" +fromUrl: "ຈາກ URL" +uploadFromUrl: "ອັບໂຫຼດຈາກ URL" +uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ອງການອັບໂຫລດ" +messageRead: "ອ່ານແລ້ວ" +startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" +nUsersRead: "ອ່ານໂດຍ {n}" tos: "ເງື່ອນໄຂການໃຫ້ບໍລິການ" start: "ເລີ່ມຕົ້ນນຳໃຊ້ເລີຍ" home: "ໜ້າຫຼັກ" images: "ຮູບພາບ" birthday: "ວັນເກີດ" +yearsOld: "{age} ປີ" registeredDate: "ວັນທີ່ເປັນສະມາຊິກ" location: "ທີ່ຕັ້ງ" theme: "ແທ໋ມ" @@ -193,17 +215,96 @@ light: "ສະຫວ່າງ" dark: "ມືດ" lightThemes: "ຊຸດຮູບແບບສະຫວ່າງ" darkThemes: "ຮູບແບບສີສັນມືດ" +drive: "ຂັບ" fileName: "ຊື່ໄຟລ໌" selectFile: "ເລືອກໄຟລ໌" selectFiles: "ເລືອກໄຟລ໌" +selectFolder: "ເລືອກໂຟລເດີ" +selectFolders: "ເລືອກໂຟລເດີ" +renameFile: "ປ່ຽນຊື່ໄຟລ໌" +folderName: "ຊື່ໂຟນເດີ" +createFolder: "​ສ້າງ​ໂຟ​ລ​ເດີ" +renameFolder: "ປ່ຽນຊື່ໂຟນເດີນີ້" +deleteFolder: "ລົບໂຟ​ລ​ເດີ​" +addFile: "ເພີ່ມໄຟລ໌" +emptyDrive: "Drive ຂອງທ່ານຫວ່າງເປົ່າ" +emptyFolder: "ໂຟນເດີນີ້ເປົ່າຫວ່າງ" +unableToDelete: "ບໍ່​ສາ​ມາດລົບໄດ້" +inputNewFileName: "ໃສ່ຊື່ໄຟລ໌ໃໝ່" +inputNewDescription: "ໃສ່ຄຳບັນຍາຍໃໝ່" +inputNewFolderName: "ໃສ່ຊື່ໂຟນເດີໃໝ່" +circularReferenceFolder: "ໂຟນເດີປາຍທາງແມ່ນໂຟນເດີຍ່ອຍຂອງໂຟນເດີທີ່ທ່ານຕ້ອງການຍ້າຍ" +rename: "ປ່ຽນຊື່" nsfw: "NSFW" +watch: "ເບິ່ງ" +unwatch: "ຢຸດເບິ່ງ" accept: "ອະນຸຍາດ" +reject: "ປະຕິເສດ" +normal: "ປົກກະຕິ" +instanceName: "ຊື່ເຊີເວີ້" +instanceDescription: "ຄໍາອະທິບາຍຕົວຢ່າງ" +maintainerName: "ຜູ້ດູແລ" +maintainerEmail: "ອີເມວ admin" +tosUrl: "ເງື່ອນໄຂການໃຫ້ບໍລິການ URL" +thisYear: "ປີນີ້" +thisMonth: "ເດືອນນີ້" +today: "ມື້ນີ້" +dayX: "ວັນ {day}" +monthX: "ເດືອນ {month}" +yearX: "ປີ {year}" +pages: "ໜ້າ" +integration: "ຄວາມສຳພັນຂອງ" +connectService: "ເຊື່ອມຕໍ່" +disconnectService: "ຕັດການເຊື່ອມຕໍ່" +enableLocalTimeline: "ເປີດໃຊ້ທາມລາຍທ້ອງຖິ່ນ" +enableGlobalTimeline: "ເປີດໃຊ້ທາມລາຍທົ່ວໂລກ" +disablingTimelinesInfo: "ຜູ້ເບິ່ງແຍງລະບົບ ແລະຜູ້ຄວບຄຸມຈະມີການເຂົ້າເຖິງທຸກກຳນົດເວລາ, ເຖິງແມ່ນວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍຕາມ" +registration: "ລົງທະບຽນ" +enableRegistration: "ເປີດໃຊ້ການລົງທະບຽນຜູ້ໃຊ້ໃໝ່" +invite: "ເຊີນ" +driveCapacityPerLocalAccount: "ຄວາມອາດສາມາດຂັບຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" +driveCapacityPerRemoteAccount: "ໄດຣຟ໌ຄວາມອາດສາມາດຕໍ່ຜູ້ໃຊ້ທາງໄກ" pinnedNotes: "ບັນທຶກທີ່ປັກໝຸດໄວ້" userList: "ລາຍການ" +about: "ກ່ຽວກັບ" +aboutMisskey: "ກ່ຽວກັບ Misskey" +administrator: "ຜູ້ບໍລິຫານ" +share: "ແບ່ງປັນ" +notFound: "ບໍ່ພົບ" +cacheClear: "ລຶບລ້າງແຄສ" +invites: "ເຊີນ" +title: "ຫົວຂໍ້" +text: "ຂໍ້ຄວາມ" +enable: "ເປີດໃຊ້" +next: "ຕໍ່ໄປ" +invitations: "ເຊີນ" +language: "ພາສາ" +native: "ພາ​ສາ​ແມ່" +category: "ຫມວດຫມູ່" +tags: "ແທ໋ກ" +createAccount: "ສ້າງບັນຊີ" +existingAccount: "ທີ່ມີຢູ່" +dashboard: "ໜ້າປັດ" +local: "ທ້ອງຖິ່ນ" +objectStorageRegion: "ພາກ​ພື້ນ" +sounds: "ສຽງ" +sound: "ສຽງ" +none: "ບໍ່ມີ" +volume: "ລະດັບສຽງ" +details: "ລາຍລະອຽດ" +install: "ຕິດຕັ້ງ" +uninstall: "ຖອນການຕິດຕັ້ງ" +state: "ສະຖານະ" +sort: "ຈັດຮຽງໂດຍ" +ascendingOrder: "ນ້ອຍໄປຫາໃຫຍ່" +descendingOrder: "ໃຫຍ່ຫານ້ອຍ" +output: "ຜົນຜະລິດ" +script: "ບົດ​ຄວາມ" smtpHost: "ໂຮດສ" smtpUser: "ຊື່ຜູ້ໃຊ້" smtpPass: "ລະຫັດຜ່ານ" clearCache: "ລຶບລ້າງແຄສ" +info: "ກ່ຽວກັບ" user: "ຜູ້ໃຊ້ຕ່າງໆ" searchByGoogle: "ຄົ້ນຫາ" file: "ໄຟລ໌" @@ -244,6 +345,8 @@ _charts: federation: "ສະຫະພັນ" _timelines: home: "ໜ້າຫຼັກ" +_play: + script: "ບົດ​ຄວາມ" _pages: blocks: image: "ຮູບພາບ" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index 3d33b5227..31c28a66e 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -483,3 +483,5 @@ _deck: antenna: "Antennes" list: "Lijsten" mentions: "Vermeldingen" +_webhookSettings: + name: "Naam" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 1dc818d45..517e8431f 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -129,6 +129,7 @@ unblockConfirm: "Czy na pewno chcesz odblokować to konto?" suspendConfirm: "Czy na pewno chcesz zawiesić to konto?" unsuspendConfirm: "Czy na pewno chcesz cofnąć zawieszenie tego konta?" selectList: "Wybierz listę" +selectChannel: "Wybierz kanał" selectAntenna: "Wybierz Antennę" selectWidget: "Wybierz widżet" editWidgets: "Edytuj widżety" @@ -149,6 +150,7 @@ flagAsCatDescription: "Przełącz tę opcję, aby konto było oznaczone jako kot flagShowTimelineReplies: "Pokazuj odpowiedzi na osi czasu" autoAcceptFollowed: "Automatycznie przyjmuj prośby o możliwość obserwacji od użytkowników, których obserwujesz" addAccount: "Dodaj konto" +reloadAccountsList: "Odśwież listę kont" loginFailed: "Nie udało się zalogować" showOnRemote: "Zobacz na zdalnej instancji" general: "Ogólne" @@ -159,6 +161,7 @@ searchWith: "Szukaj: {q}" youHaveNoLists: "Nie masz żadnej listy" followConfirm: "Czy na pewno chcesz zaobserwować {name}?" proxyAccount: "Konto proxy" +proxyAccountDescription: "Opis konta pełnomocniczego" host: "Host" selectUser: "Wybierz użytkownika" recipient: "Odbiorca" @@ -253,6 +256,7 @@ noMoreHistory: "Nie ma dalszej historii" startMessaging: "Rozpocznij czat" nUsersRead: "przeczytano przez {n}" agreeTo: "Wyrażam zgodę na {0}" +agreeBelow: "Zaakceptuj poniżej" tos: "Regulamin" start: "Rozpocznij" home: "Strona główna" @@ -385,13 +389,19 @@ about: "Informacje" aboutMisskey: "O Misskey" administrator: "Admin" token: "Token" +2fa: "Klucz 2FA " +totp: "Klucz aplikacji uwierzytelniającej (totp)" +totpDescription: "Opis klucza czasowego" moderator: "Moderator" moderation: "Moderacja" nUsersMentioned: "{n} wspomnianych użytkowników" +securityKeyAndPasskey: "Klucz bezpieczeństwa i klucze Passkey" securityKey: "Klucz bezpieczeństwa" lastUsed: "Ostatnio używane" +lastUsedAt: "Ostatnio używane w" unregister: "Cofnij rejestrację" passwordLessLogin: "Skonfiguruj logowanie bez użycia hasła" +passwordLessLoginDescription: "Opis logowania bez użycia hasła" resetPassword: "Zresetuj hasło" newPasswordIs: "Nowe hasło to „{password}”" reduceUiAnimation: "Ogranicz animacje w UI" @@ -518,11 +528,16 @@ disablePagesScript: "Wyłącz AiScript na Stronach" updateRemoteUser: "Aktualizuj zdalne dane o użytkowniku" deleteAllFiles: "Usuń wszystkie pliki" deleteAllFilesConfirm: "Czy na pewno chcesz usunąć wszystkie pliki?" +removeAllFollowing: "Przestań obserwować" removeAllFollowingDescription: "Przestań obserwować wszystkie konta z {host}. Wykonaj to, jeżeli instancja już nie istnieje." userSuspended: "To konto zostało zawieszone." userSilenced: "Ten użytkownik został wyciszony." yourAccountSuspendedTitle: "To konto jest zawieszone" yourAccountSuspendedDescription: "To konto zostało zawieszone z powodu złamania regulaminu serwera lub innych podobnych. Skontaktuj się z administratorem, jeśli chciałbyś poznać bardziej szczegółowy powód. Proszę nie zakładać nowego konta." +tokenRevoked: "Token odrzucony" +tokenRevokedDescription: "Opis odrzuconego tokena" +accountDeleted: "Konto usunięte" +accountDeletedDescription: "Opis konta usuniętego" menu: "Menu" divider: "Rozdzielacz" addItem: "Dodaj element" @@ -548,7 +563,9 @@ author: "Autor" leaveConfirm: "Są niezapisane zmiany. Czy chcesz je odrzucić?" manage: "Zarządzanie" plugins: "Wtyczki" +preferencesBackups: "Kopia zapasowa ustawień" deck: "Tablica" +undeck: "oddkouj" useBlurEffectForModal: "Używaj efektu rozmycia w modalach" useFullReactionPicker: "Używaj pełnowymiarowego wybornika reakcji" width: "Szerokość" @@ -564,7 +581,6 @@ tokenRequested: "Przydziel dostęp do konta" pluginTokenRequestedDescription: "Ta wtyczka będzie mogła korzystać z ustawionych tu uprawnień." notificationType: "Rodzaj powiadomień" edit: "Edytuj" -useStarForReactionFallback: "Użyj ★ jako zapasowego emoji, gdy emoji reakcji jest nieznane" emailServer: "Serwer poczty e-mail" enableEmail: "Włącz dostarczanie wiadomości e-mail" emailConfigInfo: "Wykorzystywany do potwierdzenia adresu e-mail w trakcie rejestracji, lub gdy zapomnisz hasła" @@ -816,6 +832,8 @@ tenMinutes: "10 minut" oneHour: "1 godzina" oneDay: "1 dzień" oneWeek: "1 tydzień" +oneMonth: "jeden miesiąc" +failedToFetchAccountInformation: "Nie udało się uzyskać informacji o koncie" file: "Pliki" recommended: "Zalecane" check: "Zweryfikuj" @@ -1358,3 +1376,6 @@ _deck: channel: "Kanały" mentions: "Wspomnienia" direct: "Bezpośredni" +_webhookSettings: + name: "Nazwa" + active: "Właczono" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 40b4aee7e..8a7232fa2 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -555,3 +555,5 @@ _deck: list: "Listas" mentions: "Menções" direct: "Notas diretas" +_webhookSettings: + name: "Nome" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 10cb085f3..bbf0160de 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -561,7 +561,6 @@ tokenRequested: "Acordă acces la cont" pluginTokenRequestedDescription: "Acest plugin va putea să folosească permisiunile setate aici." notificationType: "Tipul notificării" edit: "Editează" -useStarForReactionFallback: "Folosește ★ ca fallback dacă emoji-ul este necunoscut" emailServer: "Server email" enableEmail: "Activează distribuția de emailuri" emailConfigInfo: "Folosit pentru a confirma emailul tău în timpul logări dacă îți uiți parola" @@ -702,3 +701,5 @@ _deck: list: "Liste" channel: "Canale" mentions: "Mențiuni" +_webhookSettings: + name: "Nume" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 81ea01179..8a09d3030 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -585,7 +585,6 @@ tokenRequested: "Открыть доступ к учётной записи" pluginTokenRequestedDescription: "Это расширение сможет пользоваться разрешениями, установленными здесь." notificationType: "Тип уведомления" edit: "Изменить" -useStarForReactionFallback: "Ставить ★ в качестве реакции вместо неизвестного эмодзи" emailServer: "Сервер электронной почты" enableEmail: "Включить обмен электронной почтой" emailConfigInfo: "Используется для подтверждения адреса электронной почты и сброса пароля." @@ -951,7 +950,6 @@ copyErrorInfo: "Скопировать код ошибки" joinThisServer: "Присоединяйтесь к этому серверу" exploreOtherServers: "Искать другие сервера" letsLookAtTimeline: "Давайте посмотрим на ленту" -disableFederationWarn: "Объединение отключено. Если вы отключите это, сообщение не будет приватным. В большинстве случаев вам не нужно включать эту опцию." _achievements: earnedAt: "Разблокировано в" _types: @@ -1837,3 +1835,6 @@ _deck: _dialog: charactersExceeded: "Превышено максимальное количество символов! У вас {current} / из {max}" charactersBelow: "Это ниже минимального количества символов! У вас {current} / из {min}" +_webhookSettings: + name: "Название" + active: "Вкл." diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index d4be5540b..7c7406015 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -586,7 +586,6 @@ tokenRequested: "Povoliť prístup k účtu" pluginTokenRequestedDescription: "Tento plugin bude môcť používať oprávnenia nastavené tu." notificationType: "Typ oznámenia" edit: "Upraviť" -useStarForReactionFallback: "Použiť ★ keď emoji reakcie nie je známe" emailServer: "Email server" enableEmail: "Zapnúť email" emailConfigInfo: "Používa sa na overenie emaily pri registrácii alebo pri zabudnutí hesla" @@ -1475,3 +1474,6 @@ _deck: channel: "Kanály" mentions: "Zmienky" direct: "Priame poznámky" +_webhookSettings: + name: "Názov" + active: "Zapnuté" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 5e66df207..c5653d32e 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -343,9 +343,15 @@ 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." 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 +360,13 @@ 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" 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 +376,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 +401,7 @@ _sfx: chat: "Chatt" antenna: "Antenner" _2fa: + passwordToTOTP: "Skriv in ditt lösenord" renewTOTPCancel: "Nej tack" _antennaSources: all: "Alla noter" @@ -442,3 +460,5 @@ _deck: antenna: "Antenner" list: "Listor" mentions: "Omnämningar" +_webhookSettings: + active: "Aktiverad" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 7dcc15824..8d4a41bf3 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -268,7 +268,7 @@ remoteUserCaution: "เนื่องจากผู้ใช้งานรา activity: "กิจกรรม" images: "รูปภาพ" birthday: "วันเกิด" -yearsOld: "{อายุ} ปี" +yearsOld: "{age} ปี" registeredDate: "วันที่สมัครสมาชิก" location: "ตำแหน่งที่ตั้ง" theme: "ธีม" @@ -506,6 +506,7 @@ objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในกา serverLogs: "บันทึกของเซิร์ฟเวอร์" deleteAll: "ลบทั้งหมด" showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" +showFixedPostFormInChannel: "แสดงแบบฟอร์มกำลังโพสต์ที่ด้านบนของไทม์ไลน์ (แชนแนล)" newNoteRecived: "มีโน้ตใหม่" sounds: "เสียง" sound: "เสียง" @@ -543,6 +544,8 @@ userSuspended: "ผู้ใช้รายนี้ถูกระงับก userSilenced: "ผู้ใช้รายนี้กำลังถูกปิดกั้น" yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" +tokenRevoked: "โทเค็นไม่ถูกต้อง" +accountDeleted: "ลบบัญชีแล้ว" menu: "เมนู" divider: "ตัวแบ่ง" addItem: "เพิ่มรายการ" @@ -586,7 +589,6 @@ tokenRequested: "ให้สิทธิ์การเข้าถึงบั pluginTokenRequestedDescription: "ปลั๊กอินนี้จะสามารถใช้การอนุญาตที่ตั้งค่าไว้ที่นี่นะ" notificationType: "ประเภทการแจ้งเตือน" edit: "แก้ไข" -useStarForReactionFallback: "ใช้ ★ เป็นทางเลือกแทนถ้าหากไม่ทราบอิโมจิ" emailServer: "อีเมล์เซิร์ฟเวอร์" enableEmail: "เปิดใช้งานการกระจายอีเมล" emailConfigInfo: "ใช้เพื่อยืนยันอีเมลของคุณระหว่างการสมัครหรือถ้าหากคุณลืมรหัสผ่าน" @@ -953,8 +955,22 @@ copyErrorInfo: "คัดลอกรายละเอียดข้อผิ joinThisServer: "ลงชื่อสมัครใช้ในอินสแตนซ์นี้" exploreOtherServers: "มองหาอินสแตนซ์อื่น" letsLookAtTimeline: "ลองดูที่ไทม์ไลน์" -disableFederationWarn: "การดำเนินการนี้ถ้าหากจะปิดใช้งานการรวมศูนย์ แต่โพสต์ดังกล่าวนั้นจะยังคงเป็นสาธารณะต่อไป ยกเว้นแต่ว่าจะตั้งค่าเป็นอย่างอื่น โดยปกติคุณไม่จำเป็นต้องใช้การตั้งค่านี้นะ" invitationRequiredToRegister: "อินสแตนซ์นี้เป็นแบบรับเชิญเท่านั้น คุณต้องป้อนรหัสเชิญที่ถูกต้องถึงจะลงทะเบียนได้นะค่ะ" +emailNotSupported: "อินสแตนซ์นี้ไม่รองรับการส่งอีเมลนะค่ะ" +postToTheChannel: "โพสต์ลงช่อง" +cannotBeChangedLater: "สิ่งนี้ไม่สามารถเปลี่ยนแปลงได้ในภายหลังนะ" +likeOnly: "ที่ชอบเท่านั้น" +resetPasswordConfirm: "รีเซ็ตรหัสผ่านของคุณจริงๆหรอ?" +sensitiveWords: "คำที่ละเอียดอ่อน" +sensitiveWordsDescription: "การเปิดเผยโน้ตทั้งหมดที่มีคำที่กำหนดค่าไว้จะถูกตั้งค่าเป็น \"หน้าแรก\" โดยอัตโนมัติ คุณยังสามารถแสดงหลายรายการได้โดยแยกรายการโดยใช้ตัวแบ่งบรรทัดได้นะ" +notesSearchNotAvailable: "การค้นหาโน้ตไม่พร้อมใช้งานนะค่ะ" +license: "ใบอนุญาต" +unfavoriteConfirm: "ลบออกจากรายการโปรดแน่ใจหรอ?" +myClips: "คลิปของฉัน" +drivecleaner: "ทำความสะอาดไดรฟ์" +retryAllQueuesNow: "ลองเรียกใช้คิวทั้งหมดอีกครั้ง" +retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริงๆหรอแน่ใจนะ?" +retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ" _achievements: earnedAt: "ได้รับเมื่อ" _types: @@ -1214,6 +1230,8 @@ _role: iconUrl: "ไอคอน URL" asBadge: "แสดงเป็นตรา" descriptionOfAsBadge: "ไอคอนของบทบาทนี้จะปรากฏถัดจากชื่อผู้ใช้ของผู้ใช้งานด้วยบทบาทนี้ถ้าหากเปิดใช้งาน" + displayOrder: "ตำแหน่ง" + descriptionOfDisplayOrder: "ยิ่งตัวเลขสูง ตำแหน่ง UI ก็ยิ่งสูงขึ้นนะ" canEditMembersByModerator: "อนุญาตให้ผู้ดูแลแก้ไขสมาชิก" descriptionOfCanEditMembersByModerator: "เมื่อเปิดใช้ ผู้ดูแลนอกเหนือจากผู้ดูแลระบบแล้ว จะสามารถกำหนดและยกเลิกการมอบหมายบทบาทนี้ให้กับผู้ใช้ได้ เมื่อปิด เฉพาะผู้ดูแลระบบเท่านั้นที่จะสามารถกำหนดผู้ใช้ได้นะ" priority: "ลำดับความสำคัญ" @@ -1239,6 +1257,7 @@ _role: rateLimitFactor: "ขีดจำกัดอัตรา" descriptionOfRateLimitFactor: "ขีดจํากัดอัตราที่ต่ำกว่ามีข้อจํากัดน้อยกว่าข้อจํากัดที่สูงกว่า" canHideAds: "ซ่อนโฆษณา" + canSearchNotes: "การใช้การค้นหาโน้ต" _condition: isLocal: "ผู้ใช้ภายใน" isRemote: "ผู้ใช้ระยะไกล" @@ -1840,3 +1859,12 @@ _deck: _dialog: charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}" charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}" +_disabledTimeline: + title: "ปิดใช้งานไทม์ไลน์" + description: "คุณไม่สามารถใช้ไทม์ไลน์นี้ภายใต้บทบาทปัจจุบันของคุณได้" +_drivecleaner: + orderBySizeDesc: "ขนาดไฟล์จากมากไปหาน้อย" + orderByCreatedAtAsc: "วันที่จากน้อยไปหามาก" +_webhookSettings: + name: "ชื่อ" + active: "เปิดใช้งาน" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 56e3f024a..895d2e1f1 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -576,7 +576,6 @@ tokenRequested: "Надати доступ до акаунту" pluginTokenRequestedDescription: "Цей плагін зможе використовувати дозволи які тут вказані." notificationType: "Тип сповіщення" edit: "Редагувати" -useStarForReactionFallback: "Використовувати ★ як запасний варіант, якщо емодзі реакції невідомий" emailServer: "Email сервер" enableEmail: "Увімкнути функцію доставки пошти" emailConfigInfo: "Використовується для підтвердження електронної пошти підчас реєстрації, а також для відновлення паролю." @@ -1639,3 +1638,6 @@ _deck: channel: "Канали" mentions: "Згадки" direct: "Особисте" +_webhookSettings: + name: "Ім'я" + active: "Увімкнено" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index ce36de03d..ebd046b6d 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -585,7 +585,6 @@ tokenRequested: "Cấp quyền truy cập vào tài khoản" pluginTokenRequestedDescription: "Plugin này sẽ có thể sử dụng các quyền được đặt ở đây." notificationType: "Loại thông báo" edit: "Sửa" -useStarForReactionFallback: "Dùng ★ nếu emoji biểu cảm không có" emailServer: "Email máy chủ" enableEmail: "Bật phân phối email" emailConfigInfo: "Được dùng để xác minh email của bạn lúc đăng ký hoặc nếu bạn quên mật khẩu của mình" @@ -1705,3 +1704,6 @@ _deck: _dialog: charactersExceeded: "Bạn nhắn quá giới hạn ký tự!! Hiện nay {current} / giới hạn {max}" charactersBelow: "Bạn nhắn quá ít tối thiểu ký tự!! Hiện nay {current} / Tối thiểu {min}" +_webhookSettings: + name: "Tên" + active: "Đã bật" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 517f5a9ef..563609d50 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -2,7 +2,7 @@ _lang_: "中文(简体)" headlineMisskey: "通过帖子连接在一起的网络" introMisskey: "欢迎!Misskey是一个开源的、去中心化的“微博客”服务。\n通过编写「帖文」来和大家分享你的以及你周围的事情吧!📡\n通过「回应」功能,可以让你快速地对大家的帖文表达反馈👍\n来探索新的世界吧!🚀" -poweredByMisskeyDescription: "{name} 由开源平台 Misskey 驱动(也被称为 Misskey 实例)" +poweredByMisskeyDescription: "{name} 是开源平台 Misskey 的服务器之一。" monthAndDay: "{month}月 {day}日" search: "搜索" notifications: "通知" @@ -16,16 +16,16 @@ cancel: "取消" noThankYou: "不用,谢谢" enterUsername: "输入用户名" renotedBy: "由 {user} 转贴" -noNotes: "没有帖子" +noNotes: "没有帖文" noNotifications: "无通知" -instance: "实例" +instance: "服务器" settings: "设置" basicSettings: "基本设置" otherSettings: "其他设置" openInWindow: "在新窗口中打开" profile: "个人资料" timeline: "时间线" -noAccountDescription: "这个人很懒,没有写自我介绍" +noAccountDescription: "此用户尚无自我介绍" login: "登录" loggingIn: "正在登录..." logout: "登出" @@ -60,7 +60,7 @@ youGotNewFollower: "你有新的关注者" receiveFollowRequest: "您收到了关注请求" followRequestAccepted: "您的关注请求被通过了" mention: "提及" -mentions: "提及" +mentions: "提到我的" directNotes: "私信" importAndExport: "导入和导出" import: "导入" @@ -85,7 +85,7 @@ somethingHappened: "出现了一些问题!" retry: "重试" pageLoadError: "页面加载失败。" pageLoadErrorDescription: "这通常是由于网络或浏览器缓存的原因。请清除缓存或等待片刻后重试。" -serverIsDead: "服务器没有响应。 请稍等片刻,然后重试。" +serverIsDead: "没有服务器响应。 请稍后再试。" youShouldUpgradeClient: "请重新加载并使用新版本的客户端查看此页面。" enterListName: "输入列表名称" privacy: "隐私" @@ -95,7 +95,7 @@ follow: "关注" followRequest: "关注申请" followRequests: "关注申请" unfollow: "取消关注" -followRequestPending: "发送关注请求" +followRequestPending: "关注请求批准中" enterEmoji: "输入表情符号" renote: "转发" unrenote: "取消转发" @@ -119,9 +119,11 @@ rememberNoteVisibility: "保存上次设置的可见性" attachCancel: "删除附件" markAsSensitive: "标记为敏感内容" unmarkAsSensitive: "取消标记为敏感内容" -enterFileName: "请输入文件名" +enterFileName: "输入文件名" mute: "屏蔽" unmute: "解除屏蔽" +renoteMute: "屏蔽转帖" +renoteUnmute: "解除屏蔽转帖" block: "拉黑" unblock: "取消拉黑" suspend: "冻结" @@ -140,21 +142,22 @@ customEmojis: "自定义表情符号" emoji: "表情符号" emojis: "表情符号" emojiName: "表情符号名称" -emojiUrl: "表情符号地址" +emojiUrl: "emoji 地址" addEmoji: "添加表情符号" settingGuide: "推荐配置" -cacheRemoteFiles: "远程文件缓存" -cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程实例载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" +cacheRemoteFiles: "缓存远程文件" +cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" flagAsBot: "这是一个机器人账号" flagAsBotDescription: "如果此帐户由程序控制,请启用此项。启用后,此标志可以帮助其他开发人员防止机器人之间产生无限互动的行为,并让Misskey的内部系统将此帐户识别为机器人。" flagAsCat: "将这个账户设定为一只猫" flagAsCatDescription: "如果您想表明此帐户是一只猫,请打开此标志。\n开启后,会在您的头像上出现猫耳朵,并将你的帖子中的「na」替换为「nya」,日文同理。" flagShowTimelineReplies: "在时间线上显示帖子的回复" flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的帖子外,还会显示其他用户对帖子的回复。" -autoAcceptFollowed: "自动允许关注者的关注" +autoAcceptFollowed: "自动允许来自我关注的用户对我的关注请求" addAccount: "添加账户" +reloadAccountsList: "更新账户列表" loginFailed: "登录失败" -showOnRemote: "转到所在实例显示" +showOnRemote: "转到所在服务器显示" general: "常规设置" wallpaper: "壁纸" setWallpaper: "设置壁纸" @@ -169,7 +172,7 @@ selectUser: "选择用户" recipient: "收件人" annotation: "注解" federation: "联合" -instances: "实例" +instances: "服务器" registeredAt: "初次观测" latestRequestReceivedAt: "上次收到的请求" latestStatus: "最后状态" @@ -178,7 +181,7 @@ charts: "图表" perHour: "每小时" perDay: "每天" stopActivityDelivery: "停止发送活动" -blockThisInstance: "阻止此实例向本实例推流" +blockThisInstance: "阻止此服务器向本服务器推流" operations: "操作" software: "软件" version: "版本" @@ -189,18 +192,18 @@ jobQueue: "作业队列" cpuAndMemory: "CPU和内存" network: "网络" disk: "存储" -instanceInfo: "实例信息" +instanceInfo: "服务器信息" statistics: "统计" clearQueue: "清除队列" clearQueueConfirmTitle: "确定清除队列?" clearQueueConfirmText: "未送达的帖子将不会送达。 通常,您不需要这样做。" clearCachedFiles: "清除缓存" clearCachedFilesConfirm: "确定要清除缓存文件?" -blockedInstances: "被阻拦的实例" -blockedInstancesDescription: "设定要阻拦的实例,以换行来进行分割。被阻拦的实例将无法与本实例进行交换通讯。" +blockedInstances: "被阻拦的服务器" +blockedInstancesDescription: "设定要阻拦的服务器,以换行来进行分割。被阻拦的服务器将无法与本服务器进行交换通讯。" muteAndBlock: "屏蔽/拉黑" mutedUsers: "已屏蔽用户" -blockedUsers: "被拉黑的用户" +blockedUsers: "已拉黑的用户" noUsers: "无用户" editProfile: "编辑资料" noteDeleteConfirm: "要删除该帖子吗?" @@ -220,9 +223,9 @@ all: "全部" subscribing: "已订阅" publishing: "投递中" notResponding: "没有响应" -instanceFollowing: "关注实例" -instanceFollowers: "关注实例" -instanceUsers: "实例用户" +instanceFollowing: "关注服务器" +instanceFollowers: "关注的服务器" +instanceUsers: "服务器用户" changePassword: "修改密码" security: "安全" retypedNotMatch: "两次输入不一致!" @@ -264,7 +267,7 @@ basicNotesBeforeCreateAccount: "基本注意事项" tos: "服务条款" start: "开始" home: "首页" -remoteUserCaution: "由于此用户来自其它实例,显示的信息可能不完整。" +remoteUserCaution: "由于此用户来自其它服务器,显示的信息可能不完整。" activity: "活动" images: "图片" birthday: "生日" @@ -314,8 +317,8 @@ unwatch: "取消关注" accept: "允许" reject: "拒绝" normal: "正常" -instanceName: "实例名称" -instanceDescription: "实例介绍" +instanceName: "服务器名称" +instanceDescription: "服务器简介" maintainerName: "管理员名称" maintainerEmail: "管理员电子邮箱" tosUrl: "服务条款URL" @@ -333,7 +336,7 @@ enableLocalTimeline: "启用本地时间线功能" enableGlobalTimeline: "启用全局时间线" disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和数据图表也可以继续使用。" registration: "注册" -enableRegistration: "允许新用户注册" +enableRegistration: "允许任何人注册" invite: "邀请" driveCapacityPerLocalAccount: "每个用户的网盘空间" driveCapacityPerRemoteAccount: "每个远程用户的网盘容量" @@ -345,17 +348,17 @@ basicInfo: "基本信息" pinnedUsers: "置顶用户" pinnedUsersDescription: "在「发现」页面中使用换行标记想要置顶的用户。" pinnedPages: "固定页面" -pinnedPagesDescription: "输入您要固定到实例首页的页面路径,以换行符分隔。" +pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,以换行符分隔。" pinnedClipId: "置顶的便签ID" pinnedNotes: "已置顶的帖子" hcaptcha: "hCaptcha" enableHcaptcha: "启用 hCaptcha" hcaptchaSiteKey: "网站密钥" -hcaptchaSecretKey: "hCaptcha 密钥(SecretKey)" +hcaptchaSecretKey: "密钥" recaptcha: "reCAPTCHA" enableRecaptcha: "启用 reCAPTCHA\n(请注意, 此功能在中国大陆不可用. 如果启用, 可能导致无法正常使用登录或注册等功能)" recaptchaSiteKey: "网站密钥" -recaptchaSecretKey: "reCAPTCHA 密钥" +recaptchaSecretKey: "reCAPTCHA 密钥(SecretKey)" turnstile: "Turnstile" enableTurnstile: "启用Turnstile" turnstileSiteKey: "网站密钥" @@ -489,7 +492,7 @@ showFeaturedNotesInTimeline: "在时间线上显示热门推荐" objectStorage: "对象存储" useObjectStorage: "使用对象存储" objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "用于引用的URL。如果您正在使用CDN或反向代理,请指定其URL,例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”" +objectStorageBaseUrlDesc: "这里是用于参考的URL,如果您正在使用CDN或反向代理,请指定其URL,例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”" objectStorageBucket: "存储桶" objectStorageBucketDesc: "请指定使用的对象存储服务的存储桶名称。" objectStoragePrefix: "前缀" @@ -503,9 +506,11 @@ objectStorageUseSSLDesc: "如果不使用https进行API连接,请关闭。" objectStorageUseProxy: "使用代理" objectStorageUseProxyDesc: "如果您不使用代理进行API连接,请将其关闭。" objectStorageSetPublicRead: "上传时设置为public-read" +s3ForcePathStyleDesc: "启用 s3ForcePathStyle 会强制将存储桶名称指定为 URL 中路径的一部分,而不是主机名。使用自托管 Minio 等时可能需要启用。" serverLogs: "服务器日志" deleteAll: "全部删除" showFixedPostForm: "在时间线顶部显示发帖框" +showFixedPostFormInChannel: "在时间线顶部显示发帖对话框(频道)" newNoteRecived: "有新的帖子" sounds: "提示音" sound: "提示音" @@ -538,11 +543,15 @@ updateRemoteUser: "更新远程用户信息" deleteAllFiles: "删除所有文件" deleteAllFilesConfirm: "要删除所有文件吗?" removeAllFollowing: "取消所有关注" -removeAllFollowingDescription: "取消{host}的所有关注者。当实例不存在时执行。" +removeAllFollowingDescription: "取消{host}的所有关注者。当服务器不再存在时执行。" userSuspended: "该用户已被冻结。" userSilenced: "该用户已被禁言。" yourAccountSuspendedTitle: "账户已被冻结" yourAccountSuspendedDescription: "由于违反了服务器的服务条款或其他原因,该账户已被冻结。 您可以与管理员联系以了解更多信息。 请不要创建一个新的账户。" +tokenRevoked: "令牌无效" +tokenRevokedDescription: "登录令牌已经失效。请重新登录。" +accountDeleted: "帐户已删除" +accountDeletedDescription: "此帐户已经被删除。" menu: "菜单" divider: "分割线" addItem: "添加项目" @@ -586,7 +595,6 @@ tokenRequested: "允许访问账户" pluginTokenRequestedDescription: "此插件将能够拥有此处设置的权限" notificationType: "通知类型" edit: "编辑" -useStarForReactionFallback: "如果回应的是未知表情符号,则使用★作为代替" emailServer: "邮件服务器" enableEmail: "启用发送邮件功能" emailConfigInfo: "用于确认电子邮件和密码重置" @@ -635,15 +643,15 @@ abuseReported: "内容已发送。感谢您提交信息。" reporter: "举报者" reporteeOrigin: "举报来源" reporterOrigin: "举报者来源" -forwardReport: "将该举报信息转发给远程实例" -forwardReportIsAnonymous: "勾选则在远程实例上显示的举报者是匿名的系统账号,而不是您的账号。" +forwardReport: "将该举报信息转发给远程服务器" +forwardReportIsAnonymous: "勾选则在远程服务器上显示的举报者是匿名的系统账号,而不是您的账号。" send: "发送" abuseMarkAsResolved: "处理完毕" openInNewTab: "在新标签页中打开" openInSideView: "在侧边栏中打开" defaultNavigationBehaviour: "默认导航" editTheseSettingsMayBreakAccount: "编辑这些设置可以会损坏您的账号" -instanceTicker: "帖子的实例信息" +instanceTicker: "帖子的服务器来源" waitingFor: "等待{x}" random: "随机" system: "系统" @@ -732,7 +740,7 @@ capacity: "容量" inUse: "已使用" editCode: "编辑代码" apply: "应用" -receiveAnnouncementFromInstance: "从实例接收通知" +receiveAnnouncementFromInstance: "从服务器接收通知" emailNotification: "邮件通知" publish: "发布" inChannelSearch: "频道内搜索" @@ -760,7 +768,7 @@ active: "活动" offline: "离线" notRecommended: "不推荐" botProtection: "Bot防御" -instanceBlocking: "被阻拦的实例" +instanceBlocking: "被阻拦的服务器" selectAccount: "选择账户" switchAccount: "切换账户" enabled: "已启用" @@ -844,8 +852,8 @@ themeColor: "主题颜色" size: "大小" numberOfColumn: "列数" searchByGoogle: "Google" -instanceDefaultLightTheme: "实例默认浅色主题" -instanceDefaultDarkTheme: "实例默认深色主题" +instanceDefaultLightTheme: "服务器默认浅色主题" +instanceDefaultDarkTheme: "服务器默认深色主题" instanceDefaultThemeDescription: "以对象格式键入主题代码" mutePeriod: "屏蔽期限" period: "截止时间" @@ -898,7 +906,7 @@ cannotUploadBecauseInappropriate: "因为可能含有不适宜的内容,无法 cannotUploadBecauseNoFreeSpace: "因为已无可用空间,无法上传。" beta: "测试" enableAutoSensitive: "自动 NSFW 识别" -enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据实例自动设置。" +enableAutoSensitiveDescription: "如果可用,请使用机器学习在媒体上自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。" activeEmailValidationDescription: "开启用户的电子邮件地址验证,判断它是一次性的电子邮件地址,还是可以实际通信的地址。关闭时,则只检查字符串是否正确。" navbar: "导航栏" shuffle: "随机" @@ -908,10 +916,11 @@ pushNotification: "推送通知" subscribePushNotification: "启用推送通知消息" unsubscribePushNotification: "停用推送通知消息" pushNotificationAlreadySubscribed: "推送通知消息已启用" -pushNotificationNotSupported: "浏览器或实例不支持推送通知消息" +pushNotificationNotSupported: "浏览器或服务器不支持推送通知消息" sendPushNotificationReadMessage: "删除已读推送通知消息" sendPushNotificationReadMessageCaption: "“{emptyPushNotificationMessage}”的通知消息将会显示。您终端设备的电池消耗可能会增加。" windowMaximize: "最大化" +windowMinimize: "最小化" windowRestore: "还原" caption: "标题" loggedInAsBot: "以Bot账户登录" @@ -950,11 +959,36 @@ collapseRenotes: "省略显示已经看过的转发内容" internalServerError: "内部服务器错误" internalServerErrorDescription: "内部服务器发生了预期外的错误" copyErrorInfo: "复制错误信息" -joinThisServer: "在本实例上注册" -exploreOtherServers: "探索其他实例" +joinThisServer: "在本服务器上注册" +exploreOtherServers: "探索其他服务器" letsLookAtTimeline: "时间线" -disableFederationWarn: "联合被禁用。 禁用它并不能使帖子变成私人的。 在大多数情况下,这个选项不需要被启用。" -invitationRequiredToRegister: "此实例目前只允许拥有邀请码的人注册。" +disableFederationConfirm: "确定要禁用联合?" +disableFederationConfirmWarn: "禁用联合不会将帖子设为私有。在大多数情况下,不需要禁用联合。" +disableFederationOk: "联合禁用" +invitationRequiredToRegister: "此服务器目前只允许拥有邀请码的人注册。" +emailNotSupported: "此服务器不支持发送邮件" +postToTheChannel: "发布到频道" +cannotBeChangedLater: "之后不能再更改。" +reactionAcceptance: "接受表情回应" +likeOnly: "仅点赞" +likeOnlyForRemote: "远程仅点赞" +rolesAssignedToMe: "指派给自己的角色" +resetPasswordConfirm: "确定重置密码?" +sensitiveWords: "敏感词" +sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。" +notesSearchNotAvailable: "帖子检索不可用" +license: "许可信息" +unfavoriteConfirm: "确定要取消收藏吗?" +myClips: "我的便签" +drivecleaner: "网盘整理" +retryAllQueuesNow: "立刻重试所有队列" +retryAllQueuesConfirmTitle: "要再尝试一次吗?" +retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加" +enableChartsForRemoteUser: "生成远程用户的图表" +enableChartsForFederatedInstances: "生成远程服务器的图表" +showClipButtonInNoteFooter: "在贴文下方显示便签按钮" +largeNoteReactions: "使用大图标来显示回应" +noteIdOrUrl: "帖子ID或URL" _achievements: earnedAt: "达成时间" _types: @@ -1145,7 +1179,7 @@ _achievements: description: "在首页时间线的流速超过20npm" _viewInstanceChart: title: "分析师" - description: "查看了实例信息中的图表" + description: "查看了服务器信息中的图表" _outputHelloWorldOnScratchpad: title: "Hello, world!" description: "在AiScript控制台中输出 hello world" @@ -1182,7 +1216,7 @@ _achievements: _loggedInOnNewYearsDay: title: "恭贺新禧" description: "在元旦登入" - flavor: "今年也请对本实例多多指教!" + flavor: "今年也请对本服务器多多指教!" _cookieClicked: title: "点击饼干小游戏" description: "点击了可疑的饼干" @@ -1197,7 +1231,7 @@ _role: name: "角色名称" description: "角色描述" permission: "角色权限" - descriptionOfPermission: "监察员可以执行基本的审核操作。\n管理员可以更改实例的所有设置。" + descriptionOfPermission: "监察员可以执行基本地审核操作。\n管理员可以更改服务器的所有设置。" assignTarget: "授权对象" descriptionOfAssignTarget: "手动指手动选择谁被包括在这个角色中。\n符合条件指设置条件以自动包括符合条件的用户。" manual: "手动" @@ -1214,6 +1248,8 @@ _role: iconUrl: "图标URL" asBadge: "作为徽章显示" descriptionOfAsBadge: "开启后,用户名旁边将会出现角色图标。" + displayOrder: "显示顺序" + descriptionOfDisplayOrder: "数字越大,显示位置越靠前。" canEditMembersByModerator: "允许监察者编辑成员" descriptionOfCanEditMembersByModerator: "如果选中,监察者和管理员都能够为用户分配/取消分配角色。如果未选中,则只有管理员可以执行此操作。" priority: "优先级" @@ -1225,7 +1261,7 @@ _role: gtlAvailable: "查看全局时间线" ltlAvailable: "查看本地时间线" canPublicNote: "允许公开发帖" - canInvite: "发放实例邀请码" + canInvite: "发放服务器邀请码" canManageCustomEmojis: "管理自定义表情符号" driveCapacity: "网盘容量" pinMax: "帖子置顶数量限制" @@ -1239,6 +1275,7 @@ _role: rateLimitFactor: "速率限制" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" canHideAds: "可以隐藏广告" + canSearchNotes: "是否可以搜索帖子" _condition: isLocal: "是本地用户" isRemote: "是远程用户" @@ -1248,6 +1285,8 @@ _role: followersMoreThanOrEq: "关注者不少于" followingLessThanOrEq: "关注中不多于" followingMoreThanOrEq: "关注中不少于" + notesLessThanOrEq: "帖子数在~以下" + notesMoreThanOrEq: "帖子数在~以上" and: "符合以下全部条件" or: "符合以下任一条件" not: "不符合以下任何条件" @@ -1287,7 +1326,7 @@ _ad: _forgotPassword: enterEmail: "请输入您验证账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。" ifNoEmail: "如果您没有使用电子邮件地址进行验证,请联系管理员。" - contactAdmin: "该实例不支持发送电子邮件。如果您想重设密码,请联系管理员。" + contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。" _gallery: my: "我的图库" liked: "喜欢的图片" @@ -1372,10 +1411,10 @@ _wordMute: hard: "硬屏蔽" mutedNotes: "被屏蔽的帖子" _instanceMute: - instanceMuteDescription: "屏蔽配置实例中的所有帖子和转帖,包括实例的用户回复。" + instanceMuteDescription: "屏蔽配置服务器中的所有帖子和转帖,包括这些服务器上的用户回复。" instanceMuteDescription2: "设置时用换行符来分隔" - title: "隐藏实例已设置的帖子。" - heading: "屏蔽实例" + title: "隐藏服务器已设置的帖子。" + heading: "屏蔽服务器" _theme: explore: "寻找主题" install: "安装主题" @@ -1513,7 +1552,7 @@ _2fa: step4: "从现在开始,任何登录操作都将要求您提供动态口令。" securityKeyNotSupported: "您的浏览器不支持安全密钥。" registerTOTPBeforeKey: "要注册安全密钥或Passkey,请先设置验证器应用程序。" - securityKeyInfo: "您可以设置使用支持FIDO2的硬件安全密钥、设备上的指纹或PIN来保护您的登录过程。" + securityKeyInfo: "注册兼容 WebAuthn 的密钥,例如支持 FIDO2 的硬件安全密钥、设备上的生物识别功能、PIN 码以及 Passkey 等。" chromePasskeyNotSupported: "目前不支持 Chrome 的Passkey。" registerSecurityKey: "注册安全密钥或Passkey" securityKeyName: "输入密钥名称" @@ -1583,7 +1622,7 @@ _weekday: saturday: "星期六" _widgets: profile: "个人资料" - instanceInfo: "实例信息" + instanceInfo: "服务器信息" memo: "便签" notifications: "通知" timeline: "时间线" @@ -1596,8 +1635,8 @@ _widgets: photos: "照片" digitalClock: "数字时钟" unixClock: "UNIX时钟" - federation: "联邦宇宙" - instanceCloud: "实例云" + federation: "联合" + instanceCloud: "服务器云" postForm: "投稿窗口" slideshow: "幻灯片展示" button: "按钮" @@ -1648,7 +1687,7 @@ _visibility: specified: "指定用户" specifiedDescription: "仅发送至指定用户" disableFederation: "不参与联合" - disableFederationDescription: "不发送到其他实例" + disableFederationDescription: "不发送到其他服务器" _postForm: replyPlaceholder: "回复这个帖子..." quotePlaceholder: "引用这个帖子..." @@ -1840,3 +1879,23 @@ _deck: _dialog: charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" +_disabledTimeline: + title: "时间线已禁用" + description: "您不能在当前角色使用时间线。" +_drivecleaner: + orderBySizeDesc: "按大小降序排列" + orderByCreatedAtAsc: "按添加日期降序排列" +_webhookSettings: + createWebhook: "创建 Webhook" + name: "名称" + secret: "密钥" + events: "何时运行Webhook" + active: "已启用" + _events: + follow: "关注时" + followed: "被关注时" + note: "发布贴文时" + reply: "收到回复时" + renote: "被转发时" + reaction: "被回应时" + mention: "被提及时" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 43ab334be..69f3d2f52 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -15,7 +15,7 @@ gotIt: "知道了" cancel: "取消" noThankYou: "現在不要" enterUsername: "輸入使用者名稱" -renotedBy: "{user} 轉傳了" +renotedBy: "{user} 轉發了" noNotes: "無貼文。" noNotifications: "沒有通知" instance: "實例" @@ -99,9 +99,9 @@ followRequestPending: "追隨許可批准中" enterEmoji: "輸入表情符號" renote: "轉發" unrenote: "取消轉發" -renoted: "轉傳成功" +renoted: "轉發成功" cantRenote: "無法轉發此貼文。" -cantReRenote: "無法轉傳之前已經轉傳過的內容。" +cantReRenote: "無法轉發之前已經轉發過的內容。" quote: "引用" inChannelRenote: "在頻道內轉發" inChannelQuote: "在頻道內引用" @@ -122,14 +122,16 @@ unmarkAsSensitive: "取消標記為敏感內容" enterFileName: "請輸入檔案名稱" mute: "靜音" unmute: "解除靜音" +renoteMute: "將轉發貼文靜音" +renoteUnmute: "解除轉發貼文的靜音" block: "封鎖" unblock: "解除封鎖" suspend: "凍結" unsuspend: "解除凍結" blockConfirm: "確定要封鎖此用戶?" unblockConfirm: "確定解除封鎖此用戶?" -suspendConfirm: "確定凍結此帳號?" -unsuspendConfirm: "確定解凍此帳號?" +suspendConfirm: "確定凍結此帳戶?" +unsuspendConfirm: "確定解凍此帳戶?" selectList: "選擇清單" selectChannel: "選擇頻道" selectAntenna: "選擇天線" @@ -153,6 +155,7 @@ flagShowTimelineReplies: "在時間軸上顯示貼文的回覆" flagShowTimelineRepliesDescription: "啟用時,時間線除了顯示用戶的貼文以外,還會顯示用戶對其他貼文的回覆。" autoAcceptFollowed: "自動追隨中使用者的追隨請求" addAccount: "添加帳戶" +reloadAccountsList: "更新帳戶清單的資訊" loginFailed: "登入失敗" showOnRemote: "轉到所在實例顯示" general: "一般" @@ -169,7 +172,7 @@ selectUser: "選取使用者" recipient: "收件人" annotation: "註解" federation: "站台聯邦" -instances: "實例" +instances: "伺服器" registeredAt: "初次觀測" latestRequestReceivedAt: "上次收到的請求" latestStatus: "最後狀態" @@ -403,7 +406,7 @@ securityKeyAndPasskey: "安全金鑰・Passkey" securityKey: "安全金鑰" lastUsed: "上次使用" lastUsedAt: "最後使用:{t}" -unregister: "註銷帳號" +unregister: "註銷帳戶" passwordLessLogin: "設置無密碼登入" passwordLessLoginDescription: "不使用密碼,以安全金鑰或 Passkey 登入" resetPassword: "重置密碼" @@ -506,6 +509,7 @@ objectStorageSetPublicRead: "上傳時設定為\"public-read\"" serverLogs: "伺服器日誌" deleteAll: "刪除所有記錄" showFixedPostForm: "於時間軸頁頂顯示「發送貼文」方框" +showFixedPostFormInChannel: "於時間軸頁頂顯示「發送貼文」方框(頻道)" newNoteRecived: "發現新的貼文" sounds: "音效" sound: "音效" @@ -527,8 +531,8 @@ installedDate: "安裝時間" lastUsedDate: "最後上線日期" state: "狀態" sort: "排序" -ascendingOrder: "遞增" -descendingOrder: "遞減" +ascendingOrder: "昇冪" +descendingOrder: "降冪" scratchpad: "暫存記憶體" scratchpadDescription: "AiScript控制台為AiScript提供了實驗環境。您可以在此編寫、執行和確認代碼與Misskey互動的结果。" output: "輸出" @@ -543,6 +547,10 @@ userSuspended: "該使用者已被停用" userSilenced: "該用戶已被禁言。" yourAccountSuspendedTitle: "帳戶已被凍結" yourAccountSuspendedDescription: "由於違反了伺服器的服務條款或其他原因,該帳戶已被凍結。 您可以與管理員連繫以了解更多訊息。 請不要創建一個新的帳戶。" +tokenRevoked: "權杖無效" +tokenRevokedDescription: "登入權杖失效,請重新登入。" +accountDeleted: "帳戶已被刪除" +accountDeletedDescription: "這個帳戶已被刪除。" menu: "選單" divider: "分割線" addItem: "新增項目" @@ -586,7 +594,6 @@ tokenRequested: "允許存取帳戶" pluginTokenRequestedDescription: "此外掛將擁有在此設定的權限。" notificationType: "通知形式" edit: "編輯" -useStarForReactionFallback: "以★代替未知的表情符號" emailServer: "電郵伺服器" enableEmail: "啟用發送電郵功能" emailConfigInfo: "用於確認電郵地址及密碼重置" @@ -670,8 +677,8 @@ sentReactionsCount: "反應發送次數" receivedReactionsCount: "收到反應次數" pollVotesCount: "已統計的投票數" pollVotedCount: "已投票數" -yes: "確定" -no: "取消" +yes: "是" +no: "否" driveFilesCount: "雲端硬碟檔案數量" driveUsage: "雲端硬碟使用量" noCrawle: "拒絕搜尋引擎索引" @@ -871,10 +878,10 @@ recommended: "推薦" check: "檢查" driveCapOverrideLabel: "更改這個使用者的雲端硬碟容量上限" driveCapOverrideCaption: "如果指定0以下的值,就會被取消。" -requireAdminForView: "必須以管理員帳號登入才可以檢視。" -isSystemAccount: "由系統自動建立與管理的帳號。" +requireAdminForView: "必須以管理員帳戶登入才可以檢視。" +isSystemAccount: "由系統自動建立與管理的帳戶。" typeToConfirm: "要執行這項操作,請輸入 {x} " -deleteAccount: "刪除帳號" +deleteAccount: "刪除帳戶" document: "文件" numberOfPageCache: "快取頁面數" numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。" @@ -914,7 +921,7 @@ sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通 windowMaximize: "最大化" windowRestore: "復原" caption: "標題" -loggedInAsBot: "以機器人帳號登入中" +loggedInAsBot: "以機器人帳戶登入中" tools: "工具" cannotLoad: "無法載入" numberOfProfileView: "個人檔案檢視次數" @@ -953,8 +960,30 @@ copyErrorInfo: "複製錯誤資訊" joinThisServer: "在此伺服器上註冊" exploreOtherServers: "探索其他伺服器" letsLookAtTimeline: "看看時間軸" -disableFederationWarn: "聯邦被停用了。即使停用也不會讓您的貼文不公開,在大多數情況下,不需要啟用這個選項。" invitationRequiredToRegister: "目前這個伺服器為邀請制,必須擁有邀請碼才能註冊。" +emailNotSupported: "這個伺服器不支援寄送郵件" +postToTheChannel: "發布到頻道" +cannotBeChangedLater: "之後不能變更。" +reactionAcceptance: "接受表情反應" +likeOnly: "僅限讚" +likeOnlyForRemote: "遠端僅限讚" +rolesAssignedToMe: "指派給自己的角色" +resetPasswordConfirm: "重設密碼?" +sensitiveWords: "敏感詞" +sensitiveWordsDescription: "將含有設定詞彙的貼文可見性設為發送至首頁。可以用換行來進行複數的設定。" +notesSearchNotAvailable: "無法使用搜尋貼文功能。" +license: "授權" +unfavoriteConfirm: "要取消收錄我的最愛嗎?" +myClips: "我的摘錄" +drivecleaner: "雲端硬碟清掃器" +retryAllQueuesNow: "立刻重試所有佇列" +retryAllQueuesConfirmTitle: "要現在重試嗎?" +retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" +enableChartsForRemoteUser: "生成遠端用戶的圖表" +enableChartsForFederatedInstances: "生成遠端伺服器的圖表" +showClipButtonInNoteFooter: "將摘錄添加至貼文" +largeNoteReactions: "將貼文的反應放大顯示" +noteIdOrUrl: "貼文ID或URL" _achievements: earnedAt: "獲得日期" _types: @@ -1071,7 +1100,7 @@ _achievements: title: "有備而來" description: "設定了個人檔案" _markedAsCat: - title: "我是貓" + title: "吾輩乃貓是也" description: "已將帳戶設定為貓" flavor: "還沒有名字。" _following1: @@ -1214,6 +1243,8 @@ _role: iconUrl: "圖示的URL" asBadge: "顯示為徽章" descriptionOfAsBadge: "開啟的話,角色圖示會顯示在用戶名旁邊。" + displayOrder: "顯示順序" + descriptionOfDisplayOrder: "數字越大,顯示在UI上的越上面。" canEditMembersByModerator: "允許編輯審查員的成員" descriptionOfCanEditMembersByModerator: "如果開啟,管理員與審查員都可以為使用者指派/解除指派該角色。如果關閉,則只有管理員可以執行。" priority: "優先級" @@ -1239,6 +1270,7 @@ _role: rateLimitFactor: "速率限制" descriptionOfRateLimitFactor: "值越小限制越少,值越大限制越多。" canHideAds: "不顯示廣告" + canSearchNotes: "可否搜尋貼文" _condition: isLocal: "本地使用者" isRemote: "遠端使用者" @@ -1248,6 +1280,8 @@ _role: followersMoreThanOrEq: "追隨者人數在~以上" followingLessThanOrEq: "追隨人數在~以下" followingMoreThanOrEq: "追隨人數在~以上" + notesLessThanOrEq: "發布數在~以下" + notesMoreThanOrEq: "發布數在~以上" and: "~和~" or: "~或~" not: "~否" @@ -1477,7 +1511,7 @@ _time: _tutorial: title: "Misskey使用方法" step1_1: "歡迎!" - step1_2: "此為「時間軸」頁面,它會按照時間順序顯示你「追隨」的人發出的「貼文」" + step1_2: "此為「時間軸」頁面,它會按照時間順序顯示你「追隨」的人發出的「貼文」。" step1_3: "由於你沒有發佈任何貼文,也沒有追隨任何人,所以你的時間軸目前是空的。" step2_1: "在發文或追隨其他人之前先讓我們設定一下個人資料吧。" step2_2: "提供一些關於自己的資訊來讓其他人更有追隨你的意願。" @@ -1710,7 +1744,7 @@ _instanceCharts: _timelines: home: "首頁" local: "本地" - social: "社群" + social: "社交" global: "公開" _play: new: "新增Play" @@ -1840,3 +1874,23 @@ _deck: _dialog: charactersExceeded: "已超過最大字數!現在 {current} / 限制 {max}" charactersBelow: "低於最少字數!現在 {current} / 限制 {max}" +_disabledTimeline: + title: "停用的時間軸" + description: "目前的角色無法使用這個時間軸。" +_drivecleaner: + orderBySizeDesc: "檔案由大到小" + orderByCreatedAtAsc: "依照加入的日期順序" +_webhookSettings: + createWebhook: "建立 Webhook" + name: "名稱" + secret: "秘密" + events: "什麼時候運行Webhook" + active: "已啟用" + _events: + follow: "當你追隨時" + followed: "當被追隨時" + note: "當發布貼文時" + reply: "當收到回覆時" + renote: "當被轉發時" + reaction: "當獲得反應時" + mention: "當被提到時" diff --git a/package.json b/package.json index a2d04030f..c590388f3 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "13.9.1", + "version": "13.11.0", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@7.27.0", + "packageManager": "pnpm@8.1.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", @@ -31,8 +32,8 @@ "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", "jest": "cd packages/backend && pnpm jest", "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", - "test": "pnpm jest", - "test-and-coverage": "pnpm jest-and-coverage", + "test": "pnpm -r test", + "test-and-coverage": "pnpm -r test-and-coverage", "format": "pnpm exec gulp format", "clean": "node ./scripts/clean.js", "clean-all": "node ./scripts/clean-all.js", @@ -50,17 +51,17 @@ "gulp-replace": "1.1.4", "gulp-terser": "2.1.0", "js-yaml": "4.1.0", - "typescript": "4.9.5" + "typescript": "5.0.3" }, "devDependencies": { "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", - "@typescript-eslint/eslint-plugin": "5.53.0", - "@typescript-eslint/parser": "5.53.0", + "@typescript-eslint/eslint-plugin": "5.57.1", + "@typescript-eslint/parser": "5.57.1", "cross-env": "7.0.3", - "cypress": "12.7.0", - "eslint": "8.35.0", - "start-server-and-test": "1.15.4" + "cypress": "12.9.0", + "eslint": "8.37.0", + "start-server-and-test": "2.0.0" }, "optionalDependencies": { "@tensorflow/tfjs-core": "4.2.0" diff --git a/packages/backend/assets/redoc.html b/packages/backend/assets/redoc.html index 9ee5a95c0..a9ebf662f 100644 --- a/packages/backend/assets/redoc.html +++ b/packages/backend/assets/redoc.html @@ -19,6 +19,6 @@ - + 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/1665091090561-add-renote-muting.js b/packages/backend/migration/1665091090561-add-renote-muting.js new file mode 100644 index 000000000..d2ed2bd2e --- /dev/null +++ b/packages/backend/migration/1665091090561-add-renote-muting.js @@ -0,0 +1,16 @@ + +export class addRenoteMuting1665091090561 { + constructor() { + this.name = 'addRenoteMuting1665091090561'; + } + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/migration/1675053125067-fixforeignkeyreports.js b/packages/backend/migration/1675053125067-fixforeignkeyreports.js new file mode 100644 index 000000000..ca5c10b11 --- /dev/null +++ b/packages/backend/migration/1675053125067-fixforeignkeyreports.js @@ -0,0 +1,15 @@ +export class fixforeignkeyreports1675053125067 { + name = 'fixforeignkeyreports1675053125067' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId")`); + await queryRunner.query(`DELETE FROM "abuse_user_report" WHERE "targetUserId" NOT IN (SELECT "id" FROM "user")`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT IF EXISTS "FK_a9021cc2e1feb5f72d3db6e9f5f"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_a9021cc2e1feb5f72d3db6e9f5"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`); + } +} diff --git a/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js new file mode 100644 index 000000000..f1765dd14 --- /dev/null +++ b/packages/backend/migration/1678164627293-per-note-reaction-acceptance.js @@ -0,0 +1,11 @@ +export class perNoteReactionAcceptance1678164627293 { + name = 'perNoteReactionAcceptance1678164627293' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "reactionAcceptance" character varying(64)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "reactionAcceptance"`); + } +} diff --git a/packages/backend/migration/1678426061773-tweak-varchar-length.js b/packages/backend/migration/1678426061773-tweak-varchar-length.js new file mode 100644 index 000000000..984c41dba --- /dev/null +++ b/packages/backend/migration/1678426061773-tweak-varchar-length.js @@ -0,0 +1,68 @@ +export class tweakVarcharLength1678426061773 { + name = 'tweakVarcharLength1678426061773' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "name" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "maintainerName" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "maintainerEmail" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "langs" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "pinnedUsers" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hiddenTags" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "blockedHosts" TYPE character varying(1024) array`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "themeColor" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "mascotImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "bannerUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "backgroundImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "logoImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "errorImageUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "iconUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hcaptchaSiteKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "hcaptchaSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "recaptchaSiteKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "recaptchaSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "turnstileSiteKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "turnstileSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "summalyProxy" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "email" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpHost" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpUser" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "smtpPass" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPublicKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPrivateKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "deeplAuthKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" RENAME COLUMN "ToSUrl" TO "termsOfServiceUrl"`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "termsOfServiceUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "feedbackUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageBucket" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStoragePrefix" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageBaseUrl" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageEndpoint" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageRegion" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageAccessKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "objectStorageSecretKey" TYPE character varying(1024)`, undefined); + await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "script" TYPE character varying(65536)`, undefined); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___readWrite" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___read" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___write" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredWithinYear" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart__active_users" ALTER COLUMN "___registeredOutsideYear" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___readWrite" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___read" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___write" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredWithinYear" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideWeek" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideMonth" TYPE integer`); + await queryRunner.query(`ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___registeredOutsideYear" TYPE integer`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" RENAME COLUMN "termsOfServiceUrl" TO "ToSUrl"`); + } +} diff --git a/packages/backend/migration/1678427401214-remove-unused.js b/packages/backend/migration/1678427401214-remove-unused.js new file mode 100644 index 000000000..ee643e777 --- /dev/null +++ b/packages/backend/migration/1678427401214-remove-unused.js @@ -0,0 +1,13 @@ +export class removeUnused1678427401214 { + name = 'removeUnused1678427401214' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedPages"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedClipId"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedClipId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{/featured,/channels,/explore,/pages,/about-misskey}'`); + } +} diff --git a/packages/backend/migration/1678602320354-role-display-order.js b/packages/backend/migration/1678602320354-role-display-order.js new file mode 100644 index 000000000..de8f6f103 --- /dev/null +++ b/packages/backend/migration/1678602320354-role-display-order.js @@ -0,0 +1,11 @@ +export class roleDisplayOrder1678602320354 { + name = 'roleDisplayOrder1678602320354' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "displayOrder" integer NOT NULL DEFAULT '0'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "displayOrder"`); + } +} diff --git a/packages/backend/migration/1678694614599-sensitive-words.js b/packages/backend/migration/1678694614599-sensitive-words.js new file mode 100644 index 000000000..6d4c5730c --- /dev/null +++ b/packages/backend/migration/1678694614599-sensitive-words.js @@ -0,0 +1,11 @@ +export class sensitiveWords1678694614599 { + name = 'sensitiveWords1678694614599' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveWords" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveWords"`); + } +} diff --git a/packages/backend/migration/1678869617549-retention-date-key.js b/packages/backend/migration/1678869617549-retention-date-key.js new file mode 100644 index 000000000..1a31b9a75 --- /dev/null +++ b/packages/backend/migration/1678869617549-retention-date-key.js @@ -0,0 +1,14 @@ +export class retentionDateKey1678869617549 { + name = 'retentionDateKey1678869617549' + + async up(queryRunner) { + await queryRunner.query(`TRUNCATE TABLE "retention_aggregation"`, undefined); + await queryRunner.query(`ALTER TABLE "retention_aggregation" ADD "dateKey" character varying(512) NOT NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_f7c3576b37bd2eec966ae24477" ON "retention_aggregation" ("dateKey") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_f7c3576b37bd2eec966ae24477"`); + await queryRunner.query(`ALTER TABLE "retention_aggregation" DROP COLUMN "dateKey"`); + } +} diff --git a/packages/backend/migration/1678945242650-add-props-for-custom-emoji.js b/packages/backend/migration/1678945242650-add-props-for-custom-emoji.js new file mode 100644 index 000000000..656a92177 --- /dev/null +++ b/packages/backend/migration/1678945242650-add-props-for-custom-emoji.js @@ -0,0 +1,11 @@ +export class addPropsForCustomEmoji1678945242650 { + name = 'addPropsForCustomEmoji1678945242650' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" ADD "license" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "license"`); + } +} diff --git a/packages/backend/migration/1678953978856-clip-favorite.js b/packages/backend/migration/1678953978856-clip-favorite.js new file mode 100644 index 000000000..aa5dc93a6 --- /dev/null +++ b/packages/backend/migration/1678953978856-clip-favorite.js @@ -0,0 +1,23 @@ +export class clipFavorite1678953978856 { + name = 'clipFavorite1678953978856' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "clip_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "clipId" character varying(32) NOT NULL, CONSTRAINT "PK_1b539f43906f05ebcabe752a977" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_25a31662b0b0cc9af6549a9d71" ON "clip_favorite" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_b1754a39d0b281e07ed7c078ec" ON "clip_favorite" ("userId", "clipId") `); + await queryRunner.query(`ALTER TABLE "clip" ADD "lastClippedAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`CREATE INDEX "IDX_a3eac04ae2aa9e221e7596114a" ON "clip" ("lastClippedAt") `); + await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "clip_favorite" ADD CONSTRAINT "FK_fce61c7986cee54393e79f1d849" FOREIGN KEY ("clipId") REFERENCES "clip"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_fce61c7986cee54393e79f1d849"`); + await queryRunner.query(`ALTER TABLE "clip_favorite" DROP CONSTRAINT "FK_25a31662b0b0cc9af6549a9d711"`); + await queryRunner.query(`DROP INDEX "public"."IDX_a3eac04ae2aa9e221e7596114a"`); + await queryRunner.query(`ALTER TABLE "clip" DROP COLUMN "lastClippedAt"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1754a39d0b281e07ed7c078ec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_25a31662b0b0cc9af6549a9d71"`); + await queryRunner.query(`DROP TABLE "clip_favorite"`); + } +} diff --git a/packages/backend/migration/1679309757174-antenna-active.js b/packages/backend/migration/1679309757174-antenna-active.js new file mode 100644 index 000000000..69e845c14 --- /dev/null +++ b/packages/backend/migration/1679309757174-antenna-active.js @@ -0,0 +1,17 @@ +export class antennaActive1679309757174 { + name = 'antennaActive1679309757174' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT 'now'`); + await queryRunner.query(`ALTER TABLE "antenna" ADD "isActive" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`CREATE INDEX "IDX_084c2abb8948ef59a37dce6ac1" ON "antenna" ("lastUsedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_36ef5192a1ce55ed0e40aa4db5" ON "antenna" ("isActive") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_36ef5192a1ce55ed0e40aa4db5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_084c2abb8948ef59a37dce6ac1"`); + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "isActive"`); + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "lastUsedAt"`); + } +} diff --git a/packages/backend/migration/1679639483253-enableChartsForRemoteUser.js b/packages/backend/migration/1679639483253-enableChartsForRemoteUser.js new file mode 100644 index 000000000..42faab746 --- /dev/null +++ b/packages/backend/migration/1679639483253-enableChartsForRemoteUser.js @@ -0,0 +1,11 @@ +export class enableChartsForRemoteUser1679639483253 { + name = 'enableChartsForRemoteUser1679639483253' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForRemoteUser" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForRemoteUser"`); + } +} diff --git a/packages/backend/migration/1679651580149-cleanup.js b/packages/backend/migration/1679651580149-cleanup.js new file mode 100644 index 000000000..1f00f3cc1 --- /dev/null +++ b/packages/backend/migration/1679651580149-cleanup.js @@ -0,0 +1,11 @@ +export class cleanup1679651580149 { + name = 'cleanup1679651580149' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "useStarForReactionFallback"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "useStarForReactionFallback" boolean NOT NULL DEFAULT false`); + } +} diff --git a/packages/backend/migration/1679652081809-enableChartsForFederatedInstances.js b/packages/backend/migration/1679652081809-enableChartsForFederatedInstances.js new file mode 100644 index 000000000..073333984 --- /dev/null +++ b/packages/backend/migration/1679652081809-enableChartsForFederatedInstances.js @@ -0,0 +1,11 @@ +export class enableChartsForFederatedInstances1679652081809 { + name = 'enableChartsForFederatedInstances1679652081809' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableChartsForFederatedInstances" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableChartsForFederatedInstances"`); + } +} diff --git a/packages/backend/migration/1680228513388-channelFavorite.js b/packages/backend/migration/1680228513388-channelFavorite.js new file mode 100644 index 000000000..afc676959 --- /dev/null +++ b/packages/backend/migration/1680228513388-channelFavorite.js @@ -0,0 +1,21 @@ +export class channelFavorite1680228513388 { + name = 'channelFavorite1680228513388' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "channel_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "channelId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_59bddfd54d48689a298d41af00c" PRIMARY KEY ("id")); COMMENT ON COLUMN "channel_favorite"."createdAt" IS 'The created date of the ChannelFavorite.'`); + await queryRunner.query(`CREATE INDEX "IDX_735a5544f9249d412255f47f95" ON "channel_favorite" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_d3ca0db011b75ac2a940a2337d" ON "channel_favorite" ("channelId") `); + await queryRunner.query(`CREATE INDEX "IDX_8302bd27226605ece14842fb25" ON "channel_favorite" ("userId") `); + await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "channel_favorite" ADD CONSTRAINT "FK_8302bd27226605ece14842fb25a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_8302bd27226605ece14842fb25a"`); + await queryRunner.query(`ALTER TABLE "channel_favorite" DROP CONSTRAINT "FK_d3ca0db011b75ac2a940a2337d2"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8302bd27226605ece14842fb25"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d3ca0db011b75ac2a940a2337d"`); + await queryRunner.query(`DROP INDEX "public"."IDX_735a5544f9249d412255f47f95"`); + await queryRunner.query(`DROP TABLE "channel_favorite"`); + } +} diff --git a/packages/backend/migration/1680238118084-channelNotePining.js b/packages/backend/migration/1680238118084-channelNotePining.js new file mode 100644 index 000000000..126eae87e --- /dev/null +++ b/packages/backend/migration/1680238118084-channelNotePining.js @@ -0,0 +1,11 @@ +export class channelNotePining1680238118084 { + name = 'channelNotePining1680238118084' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" ADD "pinnedNoteIds" character varying(128) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel" DROP COLUMN "pinnedNoteIds"`); + } +} diff --git a/packages/backend/migration/1680491187535-cleanup.js b/packages/backend/migration/1680491187535-cleanup.js new file mode 100644 index 000000000..1e609ca06 --- /dev/null +++ b/packages/backend/migration/1680491187535-cleanup.js @@ -0,0 +1,10 @@ +export class cleanup1680491187535 { + name = 'cleanup1680491187535' + + async up(queryRunner) { + await queryRunner.query(`DROP TABLE "antenna_note" `); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/migration/1680582195041-cleanup.js b/packages/backend/migration/1680582195041-cleanup.js new file mode 100644 index 000000000..c587e456a --- /dev/null +++ b/packages/backend/migration/1680582195041-cleanup.js @@ -0,0 +1,11 @@ +export class cleanup1680582195041 { + name = 'cleanup1680582195041' + + async up(queryRunner) { + await queryRunner.query(`DROP TABLE "notification" `); + } + + async down(queryRunner) { + + } +} 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/package.json b/packages/backend/package.json index 42efb881e..875774bbd 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -22,44 +22,46 @@ "test-and-coverage": "pnpm jest-and-coverage" }, "optionalDependencies": { - "@swc/core-android-arm64": "^1.3.11", - "@swc/core-darwin-arm64": "^1.3.36", - "@swc/core-darwin-x64": "^1.3.36", - "@swc/core-linux-arm-gnueabihf": "^1.3.36", - "@swc/core-linux-arm64-gnu": "^1.3.36", - "@swc/core-linux-arm64-musl": "^1.3.36", - "@swc/core-linux-x64-gnu": "^1.3.36", - "@swc/core-linux-x64-musl": "^1.3.36", - "@swc/core-win32-arm64-msvc": "^1.3.36", - "@swc/core-win32-ia32-msvc": "^1.3.36", - "@swc/core-win32-x64-msvc": "^1.3.36", + "@swc/core-android-arm64": "1.3.11", + "@swc/core-darwin-arm64": "1.3.46", + "@swc/core-darwin-x64": "1.3.46", + "@swc/core-linux-arm-gnueabihf": "1.3.46", + "@swc/core-linux-arm64-gnu": "1.3.46", + "@swc/core-linux-arm64-musl": "1.3.46", + "@swc/core-linux-x64-gnu": "1.3.46", + "@swc/core-linux-x64-musl": "1.3.46", + "@swc/core-win32-arm64-msvc": "1.3.46", + "@swc/core-win32-ia32-msvc": "1.3.46", + "@swc/core-win32-x64-msvc": "1.3.46", "@tensorflow/tfjs": "4.2.0", "@tensorflow/tfjs-node": "4.2.0" }, "dependencies": { - "@bull-board/api": "4.12.1", - "@bull-board/fastify": "4.12.1", - "@bull-board/ui": "4.12.1", - "@discordapp/twemoji": "14.0.2", + "@aws-sdk/client-s3": "3.306.0", + "@aws-sdk/lib-storage": "3.306.0", + "@aws-sdk/node-http-handler": "3.306.0", + "@bull-board/api": "5.0.0", + "@bull-board/fastify": "5.0.0", + "@bull-board/ui": "5.0.0", + "@discordapp/twemoji": "14.1.2", "@fastify/accepts": "4.1.0", "@fastify/cookie": "8.3.0", - "@fastify/cors": "8.2.0", - "@fastify/http-proxy": "8.4.0", - "@fastify/multipart": "7.4.1", - "@fastify/static": "6.9.0", + "@fastify/cors": "8.2.1", + "@fastify/http-proxy": "9.0.0", + "@fastify/multipart": "7.5.0", + "@fastify/static": "6.10.0", "@fastify/view": "7.4.1", - "@nestjs/common": "9.3.9", - "@nestjs/core": "9.3.9", - "@nestjs/testing": "9.3.9", + "@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.36", + "@swc/core": "1.3.46", "accepts": "1.3.8", "ajv": "8.12.0", "archiver": "5.3.1", "autwh": "0.1.0", - "aws-sdk": "2.1318.0", "bcryptjs": "2.4.3", "blurhash": "2.0.5", "bull": "4.10.4", @@ -74,35 +76,35 @@ "date-fns": "2.29.3", "deep-email-validator": "0.1.21", "escape-regexp": "0.0.1", - "fastify": "4.13.0", + "fastify": "4.15.0", "feed": "4.2.2", "file-type": "18.2.1", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", - "got": "12.5.3", + "got": "12.6.0", "happy-dom": "8.9.0", "hpagent": "1.2.0", "ioredis": "4.28.5", "ip-cidr": "3.1.0", "is-svg": "4.3.2", "js-yaml": "4.1.0", - "jsdom": "21.1.0", + "jsdom": "21.1.1", "json5": "2.2.3", "jsonld": "8.1.1", - "jsrsasign": "10.6.1", + "jsrsasign": "10.7.0", "mfm-js": "0.23.3", "mime-types": "2.1.35", - "misskey-js": "0.0.15", + "misskey-js": "workspace:*", "ms": "3.0.0-canary.1", "nested-property": "4.0.0", - "node-fetch": "3.3.0", + "node-fetch": "3.3.1", "nodemailer": "6.9.1", "nsfwjs": "2.4.2", "oauth": "0.10.0", "os-utils": "0.0.14", - "otpauth": "^9.0.2", + "otpauth": "9.1.1", "parse5": "7.1.2", - "pg": "8.9.0", + "pg": "8.10.0", "private-ip": "3.0.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", @@ -123,32 +125,33 @@ "sanitize-html": "2.10.0", "seedrandom": "3.0.5", "semver": "7.3.8", - "sharp": "0.31.3", + "sharp": "0.32.0", + "sharp-read-bmp": "github:misskey-dev/sharp-read-bmp", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "summaly": "github:misskey-dev/summaly", - "systeminformation": "5.17.10", + "systeminformation": "5.17.12", "tinycolor2": "1.6.0", "tmp": "0.2.1", - "tsc-alias": "1.8.2", - "tsconfig-paths": "4.1.2", + "tsc-alias": "1.8.5", + "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typeorm": "0.3.11", - "typescript": "4.9.5", + "typeorm": "0.3.13", + "typescript": "5.0.3", "ulid": "2.3.0", "unzipper": "0.10.11", "uuid": "9.0.0", "vary": "1.1.2", "web-push": "3.5.0", "websocket": "1.0.34", - "ws": "8.12.1", + "ws": "8.13.0", "xev": "3.0.2" }, "devDependencies": { - "@jest/globals": "29.4.3", + "@jest/globals": "29.5.0", "@swc/jest": "0.2.24", "@types/accepts": "1.3.5", - "@types/archiver": "5.3.1", + "@types/archiver": "5.3.2", "@types/bcryptjs": "2.4.2", "@types/bull": "4.10.0", "@types/cbor": "6.0.0", @@ -157,13 +160,13 @@ "@types/escape-regexp": "0.0.1", "@types/fluent-ffmpeg": "2.1.21", "@types/ioredis": "4.28.10", - "@types/jest": "29.4.0", + "@types/jest": "29.5.0", "@types/js-yaml": "4.0.5", - "@types/jsdom": "21.1.0", + "@types/jsdom": "21.1.1", "@types/jsonld": "1.5.8", - "@types/jsrsasign": "10.5.5", + "@types/jsrsasign": "10.5.8", "@types/mime-types": "2.1.1", - "@types/node": "18.14.1", + "@types/node": "18.15.11", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.7", "@types/oauth": "0.9.1", @@ -175,7 +178,7 @@ "@types/ratelimiter": "3.4.4", "@types/redis": "4.0.11", "@types/rename": "1.0.4", - "@types/sanitize-html": "2.8.0", + "@types/sanitize-html": "2.9.0", "@types/semver": "7.3.13", "@types/sharp": "0.31.1", "@types/sinonjs__fake-timers": "8.1.2", @@ -187,13 +190,14 @@ "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.52.0", - "@typescript-eslint/parser": "5.53.0", + "@typescript-eslint/eslint-plugin": "5.57.1", + "@typescript-eslint/parser": "5.57.1", + "aws-sdk-client-mock": "^2.1.1", "cross-env": "7.0.3", - "eslint": "8.35.0", + "eslint": "8.37.0", "eslint-plugin-import": "2.27.5", "execa": "6.1.0", - "jest": "29.4.3", - "jest-mock": "29.4.3" + "jest": "29.5.0", + "jest-mock": "29.5.0" } } diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 801f1db74..cb713b25a 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -2,18 +2,15 @@ import { setTimeout } from 'node:timers/promises'; import { Global, Inject, Module } from '@nestjs/common'; import 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,31 @@ const $db: Provider = { const $redis: Provider = { provide: DI.redis, useFactory: (config) => { - const redisClient = createRedisConnection(config); - return redisClient; + 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, + }); }, inject: [DI.config], }; -const $redisSubscriber: Provider = { - provide: DI.redisSubscriber, +const $redisForPubsub: Provider = { + provide: DI.redisForPubsub, useFactory: (config) => { - const redisSubscriber = createRedisConnection(config); - redisSubscriber.subscribe(config.host); - return redisSubscriber; + const redis = new 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 +57,14 @@ const $redisSubscriber: Provider = { @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $redis, $redisSubscriber], - exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule], + providers: [$config, $db, $redis, $redisForPubsub], + exports: [$config, $db, $redis, $redisForPubsub, 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.redisForPubsub) private redisForPubsub: Redis.Redis, ) {} async onApplicationShutdown(signal: string): Promise { @@ -69,7 +79,7 @@ export class GlobalModule implements OnApplicationShutdown { await Promise.all([ this.db.destroy(), this.redisClient.disconnect(), - this.redisSubscriber.disconnect(), + this.redisForPubsub.disconnect(), ]); } } diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index dec226c42..395d96dd3 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -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; @@ -93,6 +117,8 @@ export type Mixin = { mediaProxy: string; externalMediaProxyEnabled: boolean; videoThumbnailGenerator: string | null; + redisForPubsub: NonNullable; + redisForJobQueue: NonNullable; }; export type Config = Source & Mixin; @@ -153,6 +179,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/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts new file mode 100644 index 000000000..3f2a19b77 --- /dev/null +++ b/packages/backend/src/core/AccountMoveService.ts @@ -0,0 +1,114 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; + +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { LocalUser } from '@/models/entities/User.js'; +import { User } from '@/models/entities/User.js'; +import type { FollowingsRepository, UsersRepository } from '@/models/index.js'; + +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { RelayService } from '@/core/RelayService.js'; + +@Injectable() +export class AccountMoveService { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + private apRendererService: ApRendererService, + private apDeliverManagerService: ApDeliverManagerService, + private globalEventService: GlobalEventService, + private userFollowingService: UserFollowingService, + private accountUpdateService: AccountUpdateService, + private relayService: RelayService, + ) { + } + + /** + * Move a local account to a remote account. + * + * After delivering Move activity, its local followers unfollow the old account and then follow the new one. + */ + @bindThis + public async moveToRemote(src: LocalUser, dst: User): Promise { + // Make sure that the destination is a remote account. + if (this.userEntityService.isLocalUser(dst)) throw new Error('move destiantion is not remote'); + if (!dst.uri) throw new Error('destination uri is empty'); + + // add movedToUri to indicate that the user has moved + const update = {} as Partial; + update.alsoKnownAs = src.alsoKnownAs?.concat([dst.uri]) ?? [dst.uri]; + update.movedToUri = dst.uri; + await this.usersRepository.update(src.id, update); + + 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); + + // follow the new account and unfollow the old one + const followings = await this.followingsRepository.find({ + relations: { + follower: true, + }, + where: { + followeeId: src.id, + followerHost: IsNull(), // follower is local + }, + }); + for (const following of followings) { + if (!following.follower) continue; + try { + await this.userFollowingService.follow(following.follower, dst); + await this.userFollowingService.unfollow(following.follower, src); + } catch { + /* empty */ + } + } + + return iObj; + } + + /** + * Create an alias of an old remote account. + * + * The user's new profile will be published to the followers. + */ + @bindThis + public async createAlias(me: LocalUser, updates: Partial): Promise { + await this.usersRepository.update(me.id, updates); + + // Publish meUpdated event + const iObj = await this.userEntityService.pack(me.id, me, { + detail: true, + includeSecrets: true, + }); + this.globalEventService.publishMainStream(me.id, 'meUpdated', iObj); + + if (me.isLocked === false) { + await this.userFollowingService.acceptAllFollowRequests(me); + } + + this.accountUpdateService.publishToFollowers(me.id); + + return iObj; + } +} 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 2ebee0f7e..1ca38d8bb 100644 --- a/packages/backend/src/core/AchievementService.ts +++ b/packages/backend/src/core/AchievementService.ts @@ -3,7 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js' import type { User } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const ACHIEVEMENT_TYPES = [ 'notes1', @@ -90,7 +90,7 @@ export class AchievementService { @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, ) { } @@ -114,7 +114,7 @@ export class AchievementService { }], }); - this.createNotificationService.createNotification(userId, 'achievementEarned', { + this.notificationService.createNotification(userId, 'achievementEarned', { achievement: type, }); } diff --git a/packages/backend/src/core/AntennaService.ts b/packages/backend/src/core/AntennaService.ts index 05930350f..35266ac16 100644 --- a/packages/backend/src/core/AntennaService.ts +++ b/packages/backend/src/core/AntennaService.ts @@ -10,9 +10,9 @@ import { isUserRelated } from '@/misc/is-user-related.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { PushNotificationService } from '@/core/PushNotificationService.js'; import * as Acct from '@/misc/acct.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; -import type { MutingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js'; +import type { MutingsRepository, NotesRepository, AntennasRepository, UserListJoiningsRepository } from '@/models/index.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { StreamMessages } from '@/server/api/stream/types.js'; @@ -24,8 +24,11 @@ export class AntennaService implements OnApplicationShutdown { private antennas: Antenna[]; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.redisForPubsub) + private redisForPubsub: Redis.Redis, @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @@ -33,9 +36,6 @@ export class AntennaService implements OnApplicationShutdown { @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, @@ -52,12 +52,12 @@ export class AntennaService implements OnApplicationShutdown { this.antennasFetched = false; this.antennas = []; - this.redisSubscriber.on('message', this.onRedisMessage); + this.redisForPubsub.on('message', this.onRedisMessage); } @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onRedisMessage); + this.redisForPubsub.off('message', this.onRedisMessage); } @bindThis @@ -71,12 +71,14 @@ export class AntennaService implements OnApplicationShutdown { this.antennas.push({ ...body, createdAt: new Date(body.createdAt), + lastUsedAt: new Date(body.lastUsedAt), }); break; case 'antennaUpdated': this.antennas[this.antennas.findIndex(a => a.id === body.id)] = { ...body, createdAt: new Date(body.createdAt), + lastUsedAt: new Date(body.lastUsedAt), }; break; case 'antennaDeleted': @@ -90,54 +92,13 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise { - // 通知しない設定になっているか、自分自身の投稿なら既読にする - const read = !antenna.notify || (antenna.userId === noteUser.id); - - this.antennaNotesRepository.insert({ - id: this.idService.genId(), - antennaId: antenna.id, - noteId: note.id, - read: read, - }); - + 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); - - if (!read) { - const mutings = await this.mutingsRepository.find({ - where: { - muterId: antenna.userId, - }, - select: ['muteeId'], - }); - - // Copy - const _note: Note = { - ...note, - }; - - if (note.replyId != null) { - _note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId }); - } - if (note.renoteId != null) { - _note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); - } - - if (isUserRelated(_note, new Set(mutings.map(x => x.muteeId)))) { - return; - } - - // 2秒経っても既読にならなかったら通知 - setTimeout(async () => { - const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false }); - if (unread) { - this.globalEventService.publishMainStream(antenna.userId, 'unreadAntenna', antenna); - this.pushNotificationService.pushNotification(antenna.userId, 'unreadAntennaNote', { - antenna: { id: antenna.id, name: antenna.name }, - note: await this.noteEntityService.pack(note), - }); - } - }, 2000); - } } // NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている @@ -217,7 +178,9 @@ export class AntennaService implements OnApplicationShutdown { @bindThis public async getAntennas() { if (!this.antennasFetched) { - this.antennas = await this.antennasRepository.find(); + this.antennas = await this.antennasRepository.findBy({ + isActive: true, + }); this.antennasFetched = true; } diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts new file mode 100644 index 000000000..d74f3e878 --- /dev/null +++ b/packages/backend/src/core/CacheService.ts @@ -0,0 +1,172 @@ +import { Inject, Injectable } from '@nestjs/common'; +import 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'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { bindThis } from '@/decorators.js'; +import { StreamMessages } from '@/server/api/stream/types.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class CacheService implements OnApplicationShutdown { + public userByIdCache: MemoryKVCache; + public localUserByNativeTokenCache: MemoryKVCache; + public localUserByIdCache: MemoryKVCache; + public uriPersonCache: MemoryKVCache; + public userProfileCache: 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.redisForPubsub) + private redisForPubsub: 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); + + this.userByIdCache = new MemoryKVCache(Infinity); + this.localUserByNativeTokenCache = new MemoryKVCache(Infinity); + this.localUserByIdCache = new MemoryKVCache(Infinity); + this.uriPersonCache = new MemoryKVCache(Infinity); + + 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.redisForPubsub.on('message', this.onMessage); + } + + @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 'userChangeSuspendedState': + case 'remoteUserUpdated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }); + this.userByIdCache.set(user.id, user); + for (const [k, v] of this.uriPersonCache.cache.entries()) { + if (v.value?.id === user.id) { + this.uriPersonCache.set(k, user); + } + } + if (this.userEntityService.isLocalUser(user)) { + this.localUserByNativeTokenCache.set(user.token!, user); + this.localUserByIdCache.set(user.id, user); + } + break; + } + case 'userTokenRegenerated': { + const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as LocalUser; + this.localUserByNativeTokenCache.delete(body.oldToken); + this.localUserByNativeTokenCache.set(body.newToken, user); + break; + } + case 'follow': { + const follower = this.userByIdCache.get(body.followerId); + if (follower) follower.followingCount++; + const followee = this.userByIdCache.get(body.followeeId); + if (followee) followee.followersCount++; + break; + } + default: + break; + } + } + } + + @bindThis + public findUserById(userId: User['id']) { + return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined) { + this.redisForPubsub.off('message', this.onMessage); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 491d8ab11..8775536e4 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -1,11 +1,11 @@ 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'; import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; import { CaptchaService } from './CaptchaService.js'; -import { CreateNotificationService } from './CreateNotificationService.js'; import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; @@ -39,9 +39,9 @@ import { S3Service } from './S3Service.js'; import { SignupService } from './SignupService.js'; import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; import { UserBlockingService } from './UserBlockingService.js'; -import { UserCacheService } from './UserCacheService.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'; @@ -82,6 +82,7 @@ import { HashtagEntityService } from './entities/HashtagEntityService.js'; import { InstanceEntityService } from './entities/InstanceEntityService.js'; import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; import { MutingEntityService } from './entities/MutingEntityService.js'; +import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; import { NoteEntityService } from './entities/NoteEntityService.js'; import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; @@ -119,13 +120,13 @@ 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 }; const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; -const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useExisting: CreateNotificationService }; const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; @@ -160,9 +161,9 @@ const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService }; const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService }; -const $UserCacheService: Provider = { provide: 'UserCacheService', useExisting: UserCacheService }; +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 }; @@ -203,6 +204,7 @@ const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useEx const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService }; const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; +const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; @@ -242,13 +244,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ], providers: [ LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, AppLockService, AchievementService, CaptchaService, - CreateNotificationService, CreateSystemUserService, CustomEmojiService, DeleteAccountService, @@ -283,9 +285,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SignupService, TwoFactorAuthenticationService, UserBlockingService, - UserCacheService, + CacheService, UserFollowingService, - UserKeypairStoreService, + UserKeypairService, UserListService, UserMutingService, UserSuspendService, @@ -325,6 +327,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -359,13 +362,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, $AppLockService, $AchievementService, $CaptchaService, - $CreateNotificationService, $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, @@ -400,9 +403,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SignupService, $TwoFactorAuthenticationService, $UserBlockingService, - $UserCacheService, + $CacheService, $UserFollowingService, - $UserKeypairStoreService, + $UserKeypairService, $UserListService, $UserMutingService, $UserSuspendService, @@ -442,6 +445,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, @@ -477,13 +481,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting exports: [ QueueModule, LoggerService, + AccountMoveService, AccountUpdateService, AiService, AntennaService, AppLockService, AchievementService, CaptchaService, - CreateNotificationService, CreateSystemUserService, CustomEmojiService, DeleteAccountService, @@ -518,9 +522,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting SignupService, TwoFactorAuthenticationService, UserBlockingService, - UserCacheService, + CacheService, UserFollowingService, - UserKeypairStoreService, + UserKeypairService, UserListService, UserMutingService, UserSuspendService, @@ -559,6 +563,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -593,13 +598,13 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AccountMoveService, $AccountUpdateService, $AiService, $AntennaService, $AppLockService, $AchievementService, $CaptchaService, - $CreateNotificationService, $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, @@ -634,9 +639,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $SignupService, $TwoFactorAuthenticationService, $UserBlockingService, - $UserCacheService, + $CacheService, $UserFollowingService, - $UserKeypairStoreService, + $UserKeypairService, $UserListService, $UserMutingService, $UserSuspendService, @@ -675,6 +680,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, @@ -708,4 +714,4 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#endregion ], }) -export class CoreModule {} +export class CoreModule { } diff --git a/packages/backend/src/core/CreateNotificationService.ts b/packages/backend/src/core/CreateNotificationService.ts deleted file mode 100644 index eba7171fb..000000000 --- a/packages/backend/src/core/CreateNotificationService.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { setTimeout } from 'node:timers/promises'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; -import type { User } from '@/models/entities/User.js'; -import type { Notification } from '@/models/entities/Notification.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { IdService } from '@/core/IdService.js'; -import { DI } from '@/di-symbols.js'; -import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; -import { PushNotificationService } from '@/core/PushNotificationService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class CreateNotificationService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - private notificationEntityService: NotificationEntityService, - private idService: IdService, - private globalEventService: GlobalEventService, - private pushNotificationService: PushNotificationService, - ) { - } - - @bindThis - public async createNotification( - notifieeId: User['id'], - type: Notification['type'], - data: Partial, - ): Promise { - if (data.notifierId && (notifieeId === data.notifierId)) { - return null; - } - - const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); - - const isMuted = profile?.mutingNotificationTypes.includes(type); - - // Create notification - const notification = await this.notificationsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - notifieeId: notifieeId, - type: type, - // 相手がこの通知をミュートしているようなら、既読を予めつけておく - isRead: isMuted, - ...data, - } as Partial) - .then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0])); - - const packed = await this.notificationEntityService.pack(notification, {}); - - // Publish notification event - this.globalEventService.publishMainStream(notifieeId, 'notification', packed); - - // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する - setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { - const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); - if (fresh == null) return; // 既に削除されているかもしれない - if (fresh.isRead) return; - - //#region ただしミュートしているユーザーからの通知なら無視 - const mutings = await this.mutingsRepository.findBy({ - muterId: notifieeId, - }); - if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) { - return; - } - //#endregion - - this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); - this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); - - if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); - if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); - }, () => { /* aborted, ignore it */ }); - - return notification; - } - - // TODO - //const locales = await import('../../../../locales/index.js'); - - // TODO: locale ファイルをクライアント用とサーバー用で分けたい - - @bindThis - private async emailNotificationFollow(userId: User['id'], follower: User) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; - const locale = locales[userProfile.lang ?? 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); - */ - } - - @bindThis - private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { - /* - const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); - if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; - const locale = locales[userProfile.lang ?? 'ja-JP']; - const i18n = new I18n(locale); - // TODO: render user information html - sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); - */ - } - - onApplicationShutdown(signal?: string | undefined): void { - this.#shutdownController.abort(); - } -} diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index a1a257fbd..604a94707 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -1,24 +1,28 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource, In, IsNull } from 'typeorm'; +import 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 { Cache } 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'; @Injectable() export class CustomEmojiService { - private cache: Cache; + private cache: MemoryKVCache; + public localEmojisCache: RedisSingleCache>; constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.config) private config: Config, @@ -32,9 +36,16 @@ export class CustomEmojiService { private idService: IdService, private emojiEntityService: EmojiEntityService, private globalEventService: GlobalEventService, - private reactionService: ReactionService, ) { - this.cache = new Cache(1000 * 60 * 60 * 12); + 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(value.values()), + fromRedisConverter: (value) => new Map(JSON.parse(value).map((x: Emoji) => [x.name, x])), // TODO: Date型の変換 + }); } @bindThis @@ -44,6 +55,7 @@ export class CustomEmojiService { category: string | null; aliases: string[]; host: string | null; + license: string | null; }): Promise { const emoji = await this.emojisRepository.insert({ id: this.idService.genId(), @@ -55,10 +67,11 @@ export class CustomEmojiService { originalUrl: data.driveFile.url, publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, type: data.driveFile.webpublicType ?? data.driveFile.type, + license: data.license, }).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), @@ -68,6 +81,146 @@ 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 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 { // クエリに使うホスト @@ -82,7 +235,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 }; @@ -141,30 +294,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 852c1f32e..bd999c67d 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -6,6 +6,7 @@ import IPCIDR from 'ip-cidr'; import PrivateIp from 'private-ip'; import chalk from 'chalk'; import got, * as Got from 'got'; +import { parse } from 'content-disposition'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -32,13 +33,18 @@ export class DownloadService { } @bindThis - public async downloadUrl(url: string, path: string): Promise { + public async downloadUrl(url: string, path: string): Promise<{ + filename: string; + }> { this.logger.info(`Downloading ${chalk.cyan(url)} to ${chalk.cyanBright(path)} ...`); const timeout = 30 * 1000; const operationTimeout = 60 * 1000; const maxSize = this.config.maxFileSize ?? 262144000; + const urlObj = new URL(url); + let filename = urlObj.pathname.split('/').pop() ?? 'untitled'; + const req = got.stream(url, { headers: { 'User-Agent': this.config.userAgent, @@ -77,6 +83,14 @@ export class DownloadService { req.destroy(); } } + + const contentDisposition = res.headers['content-disposition']; + if (contentDisposition != null) { + const parsed = parse(contentDisposition); + if (parsed.parameters.filename) { + filename = parsed.parameters.filename; + } + } }).on('downloadProgress', (progress: Got.Progress) => { if (progress.transferred > maxSize) { this.logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`); @@ -95,6 +109,10 @@ export class DownloadService { } this.logger.succ(`Download finished: ${chalk.cyan(url)}`); + + return { + filename, + }; } @bindThis diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index b15c967c8..c6258474e 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -2,7 +2,9 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import { v4 as uuid } from 'uuid'; import sharp from 'sharp'; +import { sharpBmp } from 'sharp-read-bmp'; import { IsNull } from 'typeorm'; +import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; @@ -33,7 +35,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { FileInfoService } from '@/core/FileInfoService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; -import type S3 from 'aws-sdk/clients/s3.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; type AddFileArgs = { /** User who wish to add file */ @@ -78,6 +81,7 @@ type UploadFromUrlArgs = { export class DriveService { private registerLogger: Logger; private downloaderLogger: Logger; + private deleteLogger: Logger; constructor( @Inject(DI.config) @@ -115,6 +119,7 @@ export class DriveService { const logger = new Logger('drive', 'blue'); this.registerLogger = logger.createSubLogger('register', 'yellow'); this.downloaderLogger = logger.createSubLogger('downloader'); + this.deleteLogger = logger.createSubLogger('delete'); } /*** @@ -168,7 +173,7 @@ export class DriveService { //#region Uploads this.registerLogger.info(`uploading original: ${key}`); const uploads = [ - this.upload(key, fs.createReadStream(path), type, name), + this.upload(key, fs.createReadStream(path), type, ext, name), ]; if (alts.webpublic) { @@ -176,7 +181,7 @@ export class DriveService { webpublicUrl = `${ baseUrl }/${ webpublicKey }`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); - uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name)); + uploads.push(this.upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, alts.webpublic.ext, name)); } if (alts.thumbnail) { @@ -184,7 +189,7 @@ export class DriveService { thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); - uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type)); + uploads.push(this.upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type, alts.thumbnail.ext)); } await Promise.all(uploads); @@ -273,8 +278,8 @@ export class DriveService { } } - if (!['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/svg+xml'].includes(type)) { - this.registerLogger.debug('web image and thumbnail not created (not an required file)'); + if (!isMimeImage(type, 'sharp-convertible-image-with-bmp')) { + this.registerLogger.debug('web image and thumbnail not created (cannot convert by sharp)'); return { webpublic: null, thumbnail: null, @@ -283,22 +288,16 @@ export class DriveService { let img: sharp.Sharp | null = null; let satisfyWebpublic: boolean; + let isAnimated: boolean; try { - img = sharp(path); + img = await sharpBmp(path, type); const metadata = await img.metadata(); - const isAnimated = metadata.pages && metadata.pages > 1; - - // skip animated - if (isAnimated) { - return { - webpublic: null, - thumbnail: null, - }; - } + isAnimated = !!(metadata.pages && metadata.pages > 1); satisfyWebpublic = !!( - type !== 'image/svg+xml' && type !== 'image/webp' && type !== 'image/avif' && + type !== 'image/svg+xml' && // security reason + type !== 'image/avif' && // not supported by Mastodon and MS Edge !(metadata.exif ?? metadata.iptc ?? metadata.xmp ?? metadata.tifftagPhotoshop) && metadata.width && metadata.width <= 2048 && metadata.height && metadata.height <= 2048 @@ -314,15 +313,13 @@ export class DriveService { // #region webpublic let webpublic: IImage | null = null; - if (generateWeb && !satisfyWebpublic) { + if (generateWeb && !satisfyWebpublic && !isAnimated) { this.registerLogger.info('creating web image'); try { if (['image/jpeg', 'image/webp', 'image/avif'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048); - } else if (['image/png'].includes(type)) { - webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); - } else if (['image/svg+xml'].includes(type)) { + webpublic = await this.imageProcessingService.convertSharpToWebp(img, 2048, 2048); + } else if (['image/png', 'image/bmp', 'image/svg+xml'].includes(type)) { webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048); } else { this.registerLogger.debug('web image not created (not an required image)'); @@ -332,6 +329,7 @@ export class DriveService { } } else { if (satisfyWebpublic) this.registerLogger.info('web image not created (original satisfies webpublic)'); + else if (isAnimated) this.registerLogger.info('web image not created (animated image)'); else this.registerLogger.info('web image not created (from remote)'); } // #endregion webpublic @@ -340,10 +338,10 @@ export class DriveService { let thumbnail: IImage | null = null; try { - if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(type)) { - thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280); + if (isAnimated) { + thumbnail = await this.imageProcessingService.convertSharpToWebp(sharp(path, { animated: true }), 374, 317, { alphaQuality: 70 }); } else { - this.registerLogger.debug('thumbnail not created (not an required file)'); + thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422); } } catch (err) { this.registerLogger.warn('thumbnail not created (an error occured)', err as Error); @@ -360,7 +358,7 @@ export class DriveService { * Upload to ObjectStorage */ @bindThis - private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) { + private async upload(key: string, stream: fs.ReadStream | Buffer, type: string, ext?: string | null, filename?: string) { if (type === 'image/apng') type = 'image/png'; if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; @@ -372,26 +370,26 @@ export class DriveService { Body: stream, ContentType: type, CacheControl: 'max-age=31536000, immutable', - } as S3.PutObjectRequest; + } as PutObjectCommandInput; - if (filename) params.ContentDisposition = contentDisposition('inline', filename); + if (filename) params.ContentDisposition = contentDisposition( + 'inline', + // 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、 + // 許可されているファイル形式でしか拡張子をつけない + ext ? correctFilename(filename, ext) : filename, + ); if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; - const s3 = this.s3Service.getS3(meta); - - const upload = s3.upload(params, { - partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, - }); - - await upload.promise() + await this.s3Service.upload(meta, params) .then( result => { - if (result) { + if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`); - } else { - this.registerLogger.error(`Upload Result Empty: key = ${key}, filename = ${filename}`); + } else { // AbortMultipartUploadCommandOutput + this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`); } - }, + }) + .catch( err => { this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err); }, @@ -466,7 +464,12 @@ export class DriveService { //} // detect name - const detectedName = name ?? (info.type.ext ? `untitled.${info.type.ext}` : 'untitled'); + const detectedName = correctFilename( + // DriveFile.nameは256文字, validateFileNameは200文字制限であるため、 + // extを付加してデータベースの文字数制限に当たることはまずない + (name && this.driveFileEntityService.validateFileName(name)) ? name : 'untitled', + info.type.ext, + ); if (user && !force) { // Check if there is a file with the same hash @@ -522,10 +525,10 @@ export class DriveService { }; const properties: { - width?: number; - height?: number; - orientation?: number; - } = {}; + width?: number; + height?: number; + orientation?: number; + } = {}; if (info.width) { properties['width'] = info.width; @@ -610,17 +613,20 @@ export class DriveService { if (user) { this.driveFileEntityService.pack(file, { self: true }).then(packedFile => { - // Publish driveFileCreated event + // Publish driveFileCreated event this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile); this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile); }); } - // 統計を更新 this.driveChart.update(file, true); - this.perUserDriveChart.update(file, true); - if (file.userHost !== null) { - this.instanceChart.updateDrive(file, true); + if (file.userHost == null) { + // ローカルユーザーのみ + this.perUserDriveChart.update(file, true); + } else { + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateDrive(file, true); + } } return file; @@ -686,7 +692,7 @@ export class DriveService { @bindThis private async deletePostProcess(file: DriveFile, isExpired = false) { - // リモートファイル期限切れ削除後は直リンクにする + // リモートファイル期限切れ削除後は直リンクにする if (isExpired && file.userHost !== null && file.uri != null) { this.driveFilesRepository.update(file.id, { isLink: true, @@ -703,24 +709,37 @@ export class DriveService { this.driveFilesRepository.delete(file.id); } - // 統計を更新 this.driveChart.update(file, false); - this.perUserDriveChart.update(file, false); - if (file.userHost !== null) { - this.instanceChart.updateDrive(file, false); + if (file.userHost == null) { + // ローカルユーザーのみ + this.perUserDriveChart.update(file, false); + } else { + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateDrive(file, false); + } } } @bindThis public async deleteObjectStorageFile(key: string) { const meta = await this.metaService.fetch(); + try { + const param = { + Bucket: meta.objectStorageBucket, + Key: key, + } as DeleteObjectCommandInput; - const s3 = this.s3Service.getS3(meta); - - await s3.deleteObject({ - Bucket: meta.objectStorageBucket!, - Key: key, - }).promise(); + await this.s3Service.delete(meta, param); + } catch (err: any) { + if (err.name === 'NoSuchKey') { + this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error); + return; + } else { + throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, { + cause: err, + }); + } + } } @bindThis @@ -736,24 +755,19 @@ export class DriveService { requestIp = null, requestHeaders = null, }: UploadFromUrlArgs): Promise { - let name = new URL(url).pathname.split('/').pop() ?? null; - if (name == null || !this.driveFileEntityService.validateFileName(name)) { - name = null; - } - - // If the comment is same as the name, skip comment - // (image.name is passed in when receiving attachment) - if (comment !== null && name === comment) { - comment = null; - } - // Create temp file const [path, cleanup] = await createTemp(); - + try { // write content at URL to temp file - await this.downloadService.downloadUrl(url, path); - + const { filename: name } = await this.downloadService.downloadUrl(url, path); + + // If the comment is same as the name, skip comment + // (image.name is passed in when receiving attachment) + if (comment !== null && name === comment) { + comment = null; + } + const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); this.downloaderLogger.succ(`Got: ${driveFile.id}`); return driveFile!; diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index e83b037dd..1d0c87280 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 Redis from 'ioredis'; import type { InstancesRepository } from '@/models/index.js'; import type { Instance } from '@/models/entities/Instance.js'; -import { Cache } 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,40 @@ import { bindThis } from '@/decorators.js'; @Injectable() export class FederatedInstanceService { - private cache: Cache; + 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 Cache(1000 * 60 * 60); + this.federatedInstanceCache = new RedisKVCache(this.redisClient, 'federatedInstance', { + lifetime: 1000 * 60 * 60 * 24, // 24h + memoryCacheLifetime: 1000 * 60 * 30, // 30m + fetcher: (key) => this.instancesRepository.findOneBy({ host: key }), + toRedisConverter: (value) => JSON.stringify(value), + fromRedisConverter: (value) => { + const parsed = JSON.parse(value); + 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,10 +55,10 @@ 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; } } @@ -49,10 +67,10 @@ export class FederatedInstanceService { public async updateCachePartial(host: string, data: Partial): Promise { host = this.utilityService.toPuny(host); - const cached = this.cache.get(host); + const cached = await this.federatedInstanceCache.get(host); if (cached == null) return; - this.cache.set(host, { + this.federatedInstanceCache.set(host, { ...cached, ...data, }); diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 65a69a023..25c064a2b 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -14,9 +14,8 @@ import type { MainStreamTypes, NoteStreamTypes, UserListStreamTypes, - UserStreamTypes, } from '@/server/api/stream/types.js'; -import type { Packed } from '@/misc/schema.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'; @@ -49,11 +48,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); diff --git a/packages/backend/src/core/IdService.ts b/packages/backend/src/core/IdService.ts index 31c0819e5..94084ad84 100644 --- a/packages/backend/src/core/IdService.ts +++ b/packages/backend/src/core/IdService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { ulid } from 'ulid'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { genAid } from '@/misc/id/aid.js'; +import { genAid, parseAid } from '@/misc/id/aid.js'; import { genMeid } from '@/misc/id/meid.js'; import { genMeidg } from '@/misc/id/meidg.js'; import { genObjectId } from '@/misc/id/object-id.js'; @@ -32,4 +32,17 @@ export class IdService { default: throw new Error('unrecognized id generation method'); } } + + @bindThis + public parse(id: string): { date: Date; } { + switch (this.method) { + case 'aid': return parseAid(id); + // TODO + //case 'meid': + //case 'meidg': + //case 'ulid': + //case 'objectid': + default: throw new Error('unrecognized id generation method'); + } + } } diff --git a/packages/backend/src/core/ImageProcessingService.ts b/packages/backend/src/core/ImageProcessingService.ts index 7c88f5e9a..3246475d1 100644 --- a/packages/backend/src/core/ImageProcessingService.ts +++ b/packages/backend/src/core/ImageProcessingService.ts @@ -15,15 +15,28 @@ export type IImageStream = { type: string; }; -export type IImageStreamable = IImage | IImageStream; +export type IImageSharp = { + data: sharp.Sharp; + ext: string | null; + type: string; +}; + +export type IImageStreamable = IImage | IImageStream | IImageSharp; export const webpDefault: sharp.WebpOptions = { - quality: 85, + quality: 77, alphaQuality: 95, lossless: false, nearLossless: false, smartSubsample: true, mixed: true, + effort: 2, +}; + +export const avifDefault: sharp.AvifOptions = { + quality: 60, + lossless: false, + effort: 2, }; import { bindThis } from '@/decorators.js'; @@ -37,36 +50,6 @@ export class ImageProcessingService { ) { } - /** - * Convert to JPEG - * with resize, remove metadata, resolve orientation, stop animation - */ - @bindThis - public async convertToJpeg(path: string, width: number, height: number): Promise { - return this.convertSharpToJpeg(await sharp(path), width, height); - } - - @bindThis - public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .jpeg({ - quality: 85, - progressive: true, - }) - .toBuffer(); - - return { - data, - ext: 'jpg', - type: 'image/jpeg', - }; - } - /** * Convert to WebP * with resize, remove metadata, resolve orientation, stop animation @@ -78,29 +61,22 @@ export class ImageProcessingService { @bindThis public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): Promise { - const data = await sharp - .resize(width, height, { - fit: 'inside', - withoutEnlargement: true, - }) - .rotate() - .webp(options) - .toBuffer(); + const result = this.convertSharpToWebpStream(sharp, width, height, options); return { - data, - ext: 'webp', - type: 'image/webp', + data: await result.data.toBuffer(), + ext: result.ext, + type: result.type, }; } @bindThis - public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { + public convertToWebpStream(path: string, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { return this.convertSharpToWebpStream(sharp(path), width, height, options); } @bindThis - public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageStream { + public convertSharpToWebpStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.WebpOptions = webpDefault): IImageSharp { const data = sharp .resize(width, height, { fit: 'inside', @@ -115,13 +91,56 @@ export class ImageProcessingService { type: 'image/webp', }; } + + /** + * Convert to Avif + * with resize, remove metadata, resolve orientation, stop animation + */ + @bindThis + public async convertToAvif(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise { + return this.convertSharpToAvif(sharp(path), width, height, options); + } + + @bindThis + public async convertSharpToAvif(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): Promise { + const result = this.convertSharpToAvifStream(sharp, width, height, options); + + return { + data: await result.data.toBuffer(), + ext: result.ext, + type: result.type, + }; + } + + @bindThis + public convertToAvifStream(path: string, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { + return this.convertSharpToAvifStream(sharp(path), width, height, options); + } + + @bindThis + public convertSharpToAvifStream(sharp: sharp.Sharp, width: number, height: number, options: sharp.AvifOptions = avifDefault): IImageSharp { + const data = sharp + .resize(width, height, { + fit: 'inside', + withoutEnlargement: true, + }) + .rotate() + .avif(options); + + return { + data, + ext: 'avif', + type: 'image/avif', + }; + } + /** * Convert to PNG * with resize, remove metadata, resolve orientation, stop animation */ @bindThis public async convertToPng(path: string, width: number, height: number): Promise { - return this.convertSharpToPng(await sharp(path), width, height); + return this.convertSharpToPng(sharp(path), width, height); } @bindThis diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts index ee9ae0733..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 { Cache } 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: Cache; + private cache: MemorySingleCache; constructor( @Inject(DI.usersRepository) @@ -19,12 +19,12 @@ export class InstanceActorService { private createSystemUserService: CreateSystemUserService, ) { - this.cache = new Cache(Infinity); + this.cache = new MemorySingleCache(Infinity); } @bindThis public async getInstanceActor(): Promise { - const cached = this.cache.get(null); + const cached = this.cache.get(); if (cached) return cached; const user = await this.usersRepository.findOneBy({ @@ -33,11 +33,11 @@ export class InstanceActorService { }) as LocalUser | undefined; if (user) { - this.cache.set(null, user); + this.cache.set(user); return user; } else { const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as LocalUser; - this.cache.set(null, created); + this.cache.set(created); return created; } } diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index 4b792c083..2b6160c82 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -14,8 +14,8 @@ export class MetaService implements OnApplicationShutdown { private intervalId: NodeJS.Timer; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPubsub) + private redisForPubsub: 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.redisForPubsub.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.redisForPubsub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 4c4261ba7..5c4d13f17 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -1,6 +1,7 @@ import { setImmediate } from 'node:timers/promises'; import * as mfm from 'mfm-js'; import { In, DataSource } from 'typeorm'; +import 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'; @@ -19,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 { Cache } 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'; @@ -30,7 +31,7 @@ import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { WebhookService } from '@/core/WebhookService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { AntennaService } from '@/core/AntennaService.js'; @@ -44,8 +45,9 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; +import { MetaService } from '@/core/MetaService.js'; -const mutedWordsCache = new Cache<{ 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'; @@ -59,7 +61,7 @@ class NotificationManager { constructor( private mutingsRepository: MutingsRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, notifier: { id: User['id']; }, note: Note, ) { @@ -100,7 +102,7 @@ class NotificationManager { // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する if (!mentioneesMutedUserIds.includes(this.notifier.id)) { - this.createNotificationService.createNotification(x.target, x.reason, { + this.notificationService.createNotification(x.target, x.reason, { notifierId: this.notifier.id, noteId: this.note.id, }); @@ -125,6 +127,7 @@ type Option = { files?: DriveFile[] | null; poll?: IPoll | null; localOnly?: boolean | null; + reactionAcceptance?: Note['reactionAcceptance']; cw?: string | null; visibility?: string; visibleUsers?: MinimumUser[] | null; @@ -148,6 +151,9 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -181,7 +187,7 @@ export class NoteCreateService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private queueService: QueueService, private noteReadService: NoteReadService, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, private hashtagService: HashtagService, @@ -191,11 +197,12 @@ export class NoteCreateService implements OnApplicationShutdown { private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, private roleService: RoleService, + private metaService: MetaService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private activeUsersChart: ActiveUsersChart, private instanceChart: InstanceChart, - ) {} + ) { } @bindThis public async create(user: { @@ -229,7 +236,9 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.channel != null) data.localOnly = true; if (data.visibility === 'public' && data.channel == null) { - if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { + if ((data.text != null) && (await this.metaService.fetch()).sensitiveWords.some(w => data.text!.includes(w))) { + data.visibility = 'home'; + } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { data.visibility = 'home'; } } @@ -316,6 +325,14 @@ export class NoteCreateService implements OnApplicationShutdown { const note = await this.insertNote(user, data, tags, emojis, mentionedUsers); + if (data.channel) { + this.redisClient.xadd( + `channelTimeline:${data.channel.id}`, + 'MAXLEN', '~', '1000', + `${this.idService.parse(note.id).date.getTime()}-*`, + 'note', note.id); + } + setImmediate('post created', { signal: this.#shutdownController.signal }).then( () => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!), () => { /* aborted, ignore this */ }, @@ -346,6 +363,7 @@ export class NoteCreateService implements OnApplicationShutdown { emojis, userId: user.id, localOnly: data.localOnly!, + reactionAcceptance: data.reactionAcceptance, visibility: data.visibility as any, visibleUserIds: data.visibility === 'specified' ? data.visibleUsers @@ -385,7 +403,7 @@ export class NoteCreateService implements OnApplicationShutdown { // 投稿を作成 try { if (insert.hasPoll) { - // Start transaction + // Start transaction await this.db.transaction(async transactionalEntityManager => { await transactionalEntityManager.insert(Note, insert); @@ -408,7 +426,7 @@ export class NoteCreateService implements OnApplicationShutdown { return insert; } catch (e) { - // duplicate key error + // duplicate key error if (isDuplicateKeyValueError(e)) { const err = new Error('Duplicated note'); err.name = 'duplicated'; @@ -429,15 +447,20 @@ export class NoteCreateService implements OnApplicationShutdown { createdAt: User['createdAt']; isBot: User['isBot']; }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - // 統計を更新 + const meta = await this.metaService.fetch(); + this.notesChart.update(note, true); - this.perUserNotesChart.update(user, note, true); + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserNotesChart.update(user, note, true); + } // Register host if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(i => { + this.federatedInstanceService.fetch(user.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - this.instanceChart.updateNote(i.host, note, true); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, true); + } }); } @@ -450,7 +473,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.incNotesCountOfUser(user); // Word mute - mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({ + mutedWordsCache.fetch(() => this.userProfilesRepository.find({ where: { enableWordMute: true, }, @@ -479,18 +502,6 @@ export class NoteCreateService implements OnApplicationShutdown { }); } - // 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, - }); - } - }); - } - if (data.reply) { this.saveReply(data.reply, note); } @@ -552,7 +563,7 @@ export class NoteCreateService implements OnApplicationShutdown { } }); - const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note); + const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); await this.createMentionedEvents(mentionedUsers, note, nm); diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index 571b62552..dd878f7bb 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -16,6 +16,7 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class NoteDeleteService { @@ -39,6 +40,7 @@ export class NoteDeleteService { private federatedInstanceService: FederatedInstanceService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, + private metaService: MetaService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private instanceChart: InstanceChart, @@ -95,14 +97,19 @@ export class NoteDeleteService { } //#endregion - // 統計を更新 + const meta = await this.metaService.fetch(); + this.notesChart.update(note, false); - this.perUserNotesChart.update(user, note, false); + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserNotesChart.update(user, note, false); + } if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(i => { + this.federatedInstanceService.fetch(user.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - this.instanceChart.updateNote(i.host, note, false); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, false); + } }); } } diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts index d23fb8238..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/schema.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, AntennaNotesRepository } 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,21 +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, - - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - - private userEntityService: UserEntityService, private idService: IdService, private globalEventService: GlobalEventService, - private notificationService: NotificationService, - private antennaService: AntennaService, - private pushNotificationService: PushNotificationService, ) { } @@ -57,7 +36,6 @@ export class NoteReadService implements OnApplicationShutdown { isMentioned: boolean; }): Promise { //#region ミュートしているなら無視 - // TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする const mute = await this.mutingsRepository.findBy({ muterId: userId, }); @@ -77,7 +55,6 @@ export class NoteReadService implements OnApplicationShutdown { userId: userId, isSpecified: params.isSpecified, isMentioned: params.isMentioned, - noteChannelId: note.channelId, noteUserId: note.userId, }; @@ -95,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 */ }); } @@ -105,23 +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'>)[] = []; - const readAntennaNotes: (Note | Packed<'Note'>)[] = []; for (const note of notes) { if (note.mentions && note.mentions.includes(userId)) { @@ -129,25 +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 (note.user != null) { // たぶんnullになることは無いはずだけど一応 - for (const antenna of myAntennas) { - if (await this.antennaService.checkHitAntenna(antenna, note, note.user)) { - readAntennaNotes.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: ↓まとめてクエリしたい @@ -171,49 +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'); - } - }); - - this.notificationService.readNotificationByQuery(userId, { - noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]), - }); - } - - if (readAntennaNotes.length > 0) { - await this.antennaNotesRepository.update({ - antennaId: In(myAntennas.map(a => a.id)), - noteId: In(readAntennaNotes.map(n => n.id)), - }, { - read: true, - }); - - // TODO: まとめてクエリしたい - for (const antenna of myAntennas) { - const count = await this.antennaNotesRepository.countBy({ - antennaId: antenna.id, - read: false, - }); - - if (count === 0) { - this.globalEventService.publishMainStream(userId, 'readAntenna', antenna); - this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id }); - } - } - - this.userEntityService.getHasUnreadAntenna(userId).then(unread => { - if (!unread) { - this.globalEventService.publishMainStream(userId, 'readAllAntennas'); - this.pushNotificationService.pushNotification(userId, 'readAllAntennas', undefined); - } - }); } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 88173c230..c44dddea4 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -1,70 +1,157 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { setTimeout } from 'node:timers/promises'; +import Redis from 'ioredis'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { NotificationsRepository } from '@/models/index.js'; +import type { MutingsRepository, UserProfile, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import type { Notification } from '@/models/entities/Notification.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; -import { GlobalEventService } from './GlobalEventService.js'; -import { PushNotificationService } from './PushNotificationService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { CacheService } from '@/core/CacheService.js'; @Injectable() -export class NotificationService { - constructor( - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, +export class NotificationService implements OnApplicationShutdown { + #shutdownController = new AbortController(); + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private notificationEntityService: NotificationEntityService, private userEntityService: UserEntityService, + private idService: IdService, private globalEventService: GlobalEventService, private pushNotificationService: PushNotificationService, + private cacheService: CacheService, ) { } @bindThis - public async readNotification( + public async readAllNotification( userId: User['id'], - notificationIds: Notification['id'][], + force = false, ) { - if (notificationIds.length === 0) return; + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); + + const latestNotificationIdsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '-', + 'COUNT', 1); + const latestNotificationId = latestNotificationIdsRes[0]?.[0]; - // Update documents - const result = await this.notificationsRepository.update({ - notifieeId: userId, - id: In(notificationIds), - isRead: false, - }, { - isRead: true, - }); + if (latestNotificationId == null) return; - if (result.affected === 0) return; + this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId); - if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId); - else return this.postReadNotifications(userId, notificationIds); - } - - @bindThis - public async readNotificationByQuery( - userId: User['id'], - query: Record, - ) { - const notificationIds = await this.notificationsRepository.findBy({ - ...query, - notifieeId: userId, - isRead: false, - }).then(notifications => notifications.map(notification => notification.id)); - - return this.readNotification(userId, notificationIds); + if (force || latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) { + return this.postReadAllNotifications(userId); + } } @bindThis private postReadAllNotifications(userId: User['id']) { this.globalEventService.publishMainStream(userId, 'readAllNotifications'); - return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined); } @bindThis - private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) { - return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds }); + public async createNotification( + notifieeId: User['id'], + type: Notification['type'], + data: Partial, + ): Promise { + const profile = await this.cacheService.userProfileCache.fetch(notifieeId); + const isMuted = profile.mutingNotificationTypes.includes(type); + if (isMuted) return null; + + if (data.notifierId) { + if (notifieeId === data.notifierId) { + return null; + } + + const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); + if (mutings.has(data.notifierId)) { + return null; + } + } + + const notification = { + id: this.idService.genId(), + createdAt: new Date(), + type: type, + ...data, + } as Notification; + + 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, {}); + + // Publish notification event + this.globalEventService.publishMainStream(notifieeId, 'notification', packed); + + // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する + setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); + if (latestReadNotificationId && (latestReadNotificationId >= await redisIdPromise)) return; + + this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); + this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); + + if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); + }, () => { /* aborted, ignore it */ }); + + return notification; + } + + // TODO + //const locales = await import('../../../../locales/index.js'); + + // TODO: locale ファイルをクライアント用とサーバー用で分けたい + + @bindThis + private async emailNotificationFollow(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ + } + + @bindThis + private async emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) { + /* + const userProfile = await UserProfiles.findOneByOrFail({ userId: userId }); + if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return; + const locale = locales[userProfile.lang ?? 'ja-JP']; + const i18n = new I18n(locale); + // TODO: render user information html + sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`); + */ + } + + onApplicationShutdown(signal?: string | undefined): void { + this.#shutdownController.abort(); } } diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 2cad1bc07..69020f7e8 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import push from 'web-push'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/schema'; +import type { Packed } from '@/misc/json-schema'; import { getNoteSummary } from '@/misc/get-note-summary.js'; import type { SwSubscriptionsRepository } from '@/models/index.js'; import { MetaService } from '@/core/MetaService.js'; @@ -15,10 +15,6 @@ type PushNotificationsTypes = { antenna: { id: string, name: string }; note: Packed<'Note'>; }; - 'readNotifications': { notificationIds: string[] }; - 'readAllNotifications': undefined; - 'readAntenna': { antennaId: string }; - 'readAllAntennas': undefined; }; // Reduce length because push message servers have character limits @@ -72,14 +68,6 @@ export class PushNotificationService { }); for (const subscription of subscriptions) { - // Continue if sendReadMessage is false - if ([ - 'readNotifications', - 'readAllNotifications', - 'readAntenna', - 'readAllAntennas', - ].includes(type) && !subscription.sendReadMessage) continue; - const pushSubscription = { endpoint: subscription.endpoint, keys: { diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index c334d749e..0cee2076b 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, ObjectLiteral } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository } from '@/models/index.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { SelectQueryBuilder } from 'typeorm'; @@ -29,6 +29,9 @@ export class QueryService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, ) { } @@ -269,5 +272,24 @@ export class QueryService { q.setParameters({ meId: me.id }); } } -} + @bindThis + public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const mutingQuery = this.renoteMutingsRepository.createQueryBuilder('renote_muting') + .select('renote_muting.muteeId') + .where('renote_muting.muterId = :muterId', { muterId: me.id }); + + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb.where('note.renoteId IS NOT NULL'); + qb.andWhere('note.text IS NULL'); + qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); + })) + .orWhere('note.renoteId IS NULL') + .orWhere('note.text IS NOT NULL'); + })); + + q.setParameters(mutingQuery.getParameters()); + } +} diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index edd843977..8733a7d7e 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -8,13 +8,13 @@ import type { DeliverJobData, InboxJobData, DbJobData, ObjectStorageJobData, End 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, diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 4bf41e0ac..498ceced7 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -26,7 +26,7 @@ export class QueueService { ) {} @bindThis - public deliver(user: ThinUser, content: IActivity | null, to: string | null) { + public deliver(user: ThinUser, content: IActivity | null, to: string | null, isSharedInbox: boolean) { if (content == null) return null; if (to == null) return null; @@ -36,6 +36,7 @@ export class QueueService { }, content, to, + isSharedInbox, }; return this.deliverQueue.add(data, { diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index 9fccc14ee..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'; @@ -9,7 +8,7 @@ import { IdService } from '@/core/IdService.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js'; import { emojiRegex } from '@/misc/emoji-regex.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; @@ -20,6 +19,9 @@ 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 = '❤'; const legacies: Record = { 'like': '👍', @@ -58,9 +60,6 @@ export class ReactionService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -72,6 +71,7 @@ export class ReactionService { private utilityService: UtilityService, private metaService: MetaService, + private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private userBlockingService: UserBlockingService, @@ -79,7 +79,7 @@ export class ReactionService { private globalEventService: GlobalEventService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private perUserReactionsChart: PerUserReactionsChart, ) { } @@ -93,15 +93,18 @@ export class ReactionService { throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); } } - + // check visibility if (!await this.noteEntityService.isVisibleForMe(note, user.id)) { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } - - // TODO: cache - reaction = await this.toDbReaction(reaction, user.host); - + + if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote') && (user.host != null))) { + reaction = '❤️'; + } else { + reaction = await this.toDbReaction(reaction, user.host); + } + const record: NoteReaction = { id: this.idService.genId(), createdAt: new Date(), @@ -109,7 +112,7 @@ export class ReactionService { userId: user.id, reaction, }; - + // Create reaction try { await this.noteReactionsRepository.insert(record); @@ -119,7 +122,7 @@ export class ReactionService { noteId: note.id, userId: user.id, }); - + if (exists.reaction !== reaction) { // 別のリアクションがすでにされていたら置き換える await this.delete(user, note); @@ -132,7 +135,7 @@ export class ReactionService { throw e; } } - + // Increment reactions count const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; await this.notesRepository.createQueryBuilder().update() @@ -142,39 +145,45 @@ export class ReactionService { }) .where('id = :id', { id: note.id }) .execute(); - - this.perUserReactionsChart.update(user, note); - + + const meta = await this.metaService.fetch(); + + if (meta.enableChartsForRemoteUser || (user.host == null)) { + this.perUserReactionsChart.update(user, note); + } + // カスタム絵文字リアクションだったら絵文字情報も送る 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, }); - + // リアクションされたユーザーがローカルユーザーなら通知を作成 if (note.userHost === null) { - this.createNotificationService.createNotification(note.userId, 'reaction', { + this.notificationService.createNotification(note.userId, 'reaction', { notifierId: user.id, noteId: note.id, reaction: reaction, }); } - + //#region 配信 if (this.userEntityService.isLocalUser(user) && !note.localOnly) { const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note)); @@ -183,7 +192,7 @@ export class ReactionService { const reactee = await this.usersRepository.findOneBy({ id: note.userId }); dm.addDirectRecipe(reactee as RemoteUser); } - + if (['public', 'home', 'followers'].includes(note.visibility)) { dm.addFollowersRecipe(); } else if (note.visibility === 'specified') { @@ -192,7 +201,7 @@ export class ReactionService { dm.addDirectRecipe(u as RemoteUser); } } - + dm.execute(); } //#endregion @@ -205,18 +214,18 @@ export class ReactionService { noteId: note.id, userId: user.id, }); - + if (exist == null) { throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); } - + // Delete reaction const result = await this.noteReactionsRepository.delete(exist.id); - + if (result.affected !== 1) { throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); } - + // Decrement reactions count const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; await this.notesRepository.createQueryBuilder().update() @@ -225,14 +234,14 @@ export class ReactionService { }) .where('id = :id', { id: note.id }) .execute(); - + if (!user.isBot) this.notesRepository.decrement({ id: note.id }, 'score', 1); - + this.globalEventService.publishNoteStream(note.id, 'unreacted', { reaction: this.decodeReaction(exist.reaction).reaction, userId: user.id, }); - + //#region 配信 if (this.userEntityService.isLocalUser(user) && !note.localOnly) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user)); @@ -246,12 +255,6 @@ export class ReactionService { } //#endregion } - - @bindThis - public async getFallbackReaction(): Promise { - const meta = await this.metaService.fetch(); - return meta.useStarForReactionFallback ? '⭐' : '👍'; - } @bindThis public convertLegacyReactions(reactions: Record) { @@ -286,7 +289,7 @@ export class ReactionService { @bindThis public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise { - if (reaction == null) return await this.getFallbackReaction(); + if (reaction == null) return FALLBACK; reacterHost = this.utilityService.toPunyNullable(reacterHost); @@ -296,7 +299,7 @@ export class ReactionService { // Unicode絵文字 const match = emojiRegex.exec(reaction); if (match) { - // 合字を含む1つの絵文字 + // 合字を含む1つの絵文字 const unicode = match[0]; // 異体字セレクタ除去 @@ -306,15 +309,17 @@ 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}:`; } - return await this.getFallbackReaction(); + return FALLBACK; } @bindThis diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index 2e07825e9..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 { Cache } 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: Cache; + private relaysCache: MemorySingleCache; constructor( @Inject(DI.usersRepository) @@ -30,7 +30,7 @@ export class RelayService { private createSystemUserService: CreateSystemUserService, private apRendererService: ApRendererService, ) { - this.relaysCache = new Cache(1000 * 60 * 10); + this.relaysCache = new MemorySingleCache(1000 * 60 * 10); } @bindThis @@ -57,7 +57,7 @@ export class RelayService { const relayActor = await this.getRelayActor(); const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); const activity = this.apRendererService.addContext(follow); - this.queueService.deliver(relayActor, activity, relay.inbox); + this.queueService.deliver(relayActor, activity, relay.inbox, false); return relay; } @@ -76,7 +76,7 @@ export class RelayService { const follow = this.apRendererService.renderFollowRelay(relay, relayActor); const undo = this.apRendererService.renderUndo(follow, relayActor); const activity = this.apRendererService.addContext(undo); - this.queueService.deliver(relayActor, activity, relay.inbox); + this.queueService.deliver(relayActor, activity, relay.inbox, false); await this.relaysRepository.delete(relay.id); } @@ -109,7 +109,7 @@ export class RelayService { public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise { if (activity == null) return; - const relays = await this.relaysCache.fetch(null, () => this.relaysRepository.findBy({ + const relays = await this.relaysCache.fetch(() => this.relaysRepository.findBy({ status: 'accepted', })); if (relays.length === 0) return; @@ -120,7 +120,7 @@ export class RelayService { const signed = await this.apRendererService.attachLdSignature(copy, user); for (const relay of relays) { - this.queueService.deliver(user, signed, relay.inbox); + this.queueService.deliver(user, signed, relay.inbox, false); } } } diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 714959119..c8ebe1adb 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import { In } from 'typeorm'; import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; -import { Cache } 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'; import { MetaService } from '@/core/MetaService.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import type { RoleCondFormulaValue } from '@/models/entities/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { StreamMessages } from '@/server/api/stream/types.js'; @@ -21,6 +21,7 @@ export type RolePolicies = { canPublicNote: boolean; canInvite: boolean; canManageCustomEmojis: boolean; + canSearchNotes: boolean; canHideAds: boolean; driveCapacityMb: number; pinLimit: number; @@ -40,6 +41,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canPublicNote: true, canInvite: false, canManageCustomEmojis: false, + canSearchNotes: false, canHideAds: false, driveCapacityMb: 100, pinLimit: 5, @@ -55,15 +57,15 @@ export const DEFAULT_POLICIES: RolePolicies = { @Injectable() export class RoleService implements OnApplicationShutdown { - private rolesCache: Cache; - private roleAssignmentByUserIdCache: Cache; + 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.redisForPubsub) + private redisForPubsub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -75,17 +77,17 @@ export class RoleService implements OnApplicationShutdown { private roleAssignmentsRepository: RoleAssignmentsRepository, private metaService: MetaService, - private userCacheService: UserCacheService, + private cacheService: CacheService, private userEntityService: UserEntityService, private globalEventService: GlobalEventService, private idService: IdService, ) { //this.onMessage = this.onMessage.bind(this); - this.rolesCache = new Cache(Infinity); - this.roleAssignmentByUserIdCache = new Cache(Infinity); + this.rolesCache = new MemorySingleCache(1000 * 60 * 60 * 1); + this.roleAssignmentByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 1); - this.redisSubscriber.on('message', this.onMessage); + this.redisForPubsub.on('message', this.onMessage); } @bindThis @@ -96,7 +98,7 @@ export class RoleService implements OnApplicationShutdown { const { type, body } = obj.message as StreamMessages['internal']['payload']; switch (type) { case 'roleCreated': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { cached.push({ ...body, @@ -108,7 +110,7 @@ export class RoleService implements OnApplicationShutdown { break; } case 'roleUpdated': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { const i = cached.findIndex(x => x.id === body.id); if (i > -1) { @@ -123,9 +125,9 @@ export class RoleService implements OnApplicationShutdown { break; } case 'roleDeleted': { - const cached = this.rolesCache.get(null); + const cached = this.rolesCache.get(); if (cached) { - this.rolesCache.set(null, cached.filter(x => x.id !== body.id)); + this.rolesCache.set(cached.filter(x => x.id !== body.id)); } break; } @@ -190,6 +192,12 @@ export class RoleService implements OnApplicationShutdown { case 'followingMoreThanOrEq': { return user.followingCount >= value.value; } + case 'notesLessThanOrEq': { + return user.notesCount <= value.value; + } + case 'notesMoreThanOrEq': { + return user.notesCount >= value.value; + } default: return false; } @@ -206,9 +214,9 @@ export class RoleService implements OnApplicationShutdown { // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const assignedRoles = roles.filter(r => assignedRoleIds.includes(r.id)); - const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; + const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedCondRoles = roles.filter(r => r.target === 'conditional' && this.evalCond(user!, r.condFormula)); return [...assignedRoles, ...matchedCondRoles]; } @@ -223,11 +231,11 @@ export class RoleService implements OnApplicationShutdown { // 期限切れのロールを除外 assigns = assigns.filter(a => a.expiresAt == null || (a.expiresAt.getTime() > now)); const assignedRoleIds = assigns.map(x => x.roleId); - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const assignedBadgeRoles = roles.filter(r => r.asBadge && assignedRoleIds.includes(r.id)); const badgeCondRoles = roles.filter(r => r.asBadge && (r.target === 'conditional')); if (badgeCondRoles.length > 0) { - const user = roles.some(r => r.target === 'conditional') ? await this.userCacheService.findById(userId) : null; + const user = roles.some(r => r.target === 'conditional') ? await this.cacheService.findUserById(userId) : null; const matchedBadgeCondRoles = badgeCondRoles.filter(r => this.evalCond(user!, r.condFormula)); return [...assignedBadgeRoles, ...matchedBadgeCondRoles]; } else { @@ -264,6 +272,7 @@ export class RoleService implements OnApplicationShutdown { canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)), canInvite: calc('canInvite', vs => vs.some(v => v === true)), canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)), + canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)), canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), @@ -292,7 +301,7 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getModeratorIds(includeAdmins = true): Promise { - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)), @@ -312,7 +321,7 @@ export class RoleService implements OnApplicationShutdown { @bindThis public async getAdministratorIds(): Promise { - const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const administratorRoles = roles.filter(r => r.isAdministrator); const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(administratorRoles.map(r => r.id)), @@ -391,6 +400,6 @@ export class RoleService implements OnApplicationShutdown { @bindThis public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); + this.redisForPubsub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index 0ce69aaa7..01ce12ffd 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -1,11 +1,16 @@ import { URL } from 'node:url'; +import * as http from 'node:http'; +import * as https from 'node:https'; import { Inject, Injectable } from '@nestjs/common'; -import S3 from 'aws-sdk/clients/s3.js'; +import { DeleteObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { Upload } from '@aws-sdk/lib-storage'; +import { NodeHttpHandler, NodeHttpHandlerOptions } from '@aws-sdk/node-http-handler'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Meta } from '@/models/entities/Meta.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import type { DeleteObjectCommandInput, PutObjectCommandInput } from '@aws-sdk/client-s3'; @Injectable() export class S3Service { @@ -18,23 +23,47 @@ export class S3Service { } @bindThis - public getS3(meta: Meta) { - const u = meta.objectStorageEndpoint != null - ? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}` - : `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`; - - return new S3({ - endpoint: meta.objectStorageEndpoint ?? undefined, - accessKeyId: meta.objectStorageAccessKey!, - secretAccessKey: meta.objectStorageSecretKey!, - region: meta.objectStorageRegion ?? undefined, - sslEnabled: meta.objectStorageUseSSL, - s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted - ? false - : meta.objectStorageS3ForcePathStyle, - httpOptions: { - agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy), - }, + public getS3Client(meta: Meta): S3Client { + const u = meta.objectStorageEndpoint + ? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}` + : `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent + + const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy); + const handlerOption: NodeHttpHandlerOptions = {}; + if (meta.objectStorageUseSSL) { + handlerOption.httpsAgent = agent as https.Agent; + } else { + handlerOption.httpAgent = agent as http.Agent; + } + + return new S3Client({ + endpoint: meta.objectStorageEndpoint ? u : undefined, + credentials: (meta.objectStorageAccessKey !== null && meta.objectStorageSecretKey !== null) ? { + accessKeyId: meta.objectStorageAccessKey, + secretAccessKey: meta.objectStorageSecretKey, + } : undefined, + region: meta.objectStorageRegion ? meta.objectStorageRegion : undefined, // 空文字列もundefinedにするため ?? は使わない + tls: meta.objectStorageUseSSL, + forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted + requestHandler: new NodeHttpHandler(handlerOption), }); } + + @bindThis + public async upload(meta: Meta, input: PutObjectCommandInput) { + const client = this.getS3Client(meta); + return new Upload({ + client, + params: input, + partSize: (client.config.endpoint && (await client.config.endpoint()).hostname === 'storage.googleapis.com') + ? 500 * 1024 * 1024 + : 8 * 1024 * 1024, + }).done(); + } + + @bindThis + public delete(meta: Meta, input: DeleteObjectCommandInput) { + const client = this.getS3Client(meta); + return client.send(new DeleteObjectCommand(input)); + } } diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 90a718690..d7bc05b8b 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -90,7 +90,7 @@ export class SignupService { cipher: undefined, passphrase: undefined, }, - } as any, (err, publicKey, privateKey) => + }, (err, publicKey, privateKey) => err ? rej(err) : res([publicKey, privateKey]), )); diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index be37bad52..b3e306346 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -1,40 +1,30 @@ -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 { Cache } 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: Cache; + 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,47 +37,20 @@ 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 Cache(Infinity); - - this.redisSubscriber.on('message', this.onMessage); } - @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; - } - } + onModuleInit() { + this.userFollowingService = this.moduleRef.get('UserFollowingService'); } @bindThis @@ -95,8 +58,8 @@ export class UserBlockingService implements OnApplicationShutdown { await Promise.all([ this.cancelRequest(blocker, blockee), this.cancelRequest(blockee, blocker), - this.unFollow(blocker, blockee), - this.unFollow(blockee, blocker), + this.userFollowingService.unfollow(blocker, blockee), + this.userFollowingService.unfollow(blockee, blocker), 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, @@ -118,7 +84,7 @@ export class UserBlockingService implements OnApplicationShutdown { if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { const content = this.apRendererService.addContext(this.apRendererService.renderBlock(blocking)); - this.queueService.deliver(blocker, content, blockee.inbox); + this.queueService.deliver(blocker, content, blockee.inbox, false); } } @@ -148,7 +114,6 @@ export class UserBlockingService implements OnApplicationShutdown { 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')); @@ -163,61 +128,13 @@ export class UserBlockingService implements OnApplicationShutdown { // リモートにフォローリクエストをしていたら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); + this.queueService.deliver(follower, content, followee.inbox, false); } // リモートからフォローリクエストを受けていたらReject送信 if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee)); - this.queueService.deliver(followee, content, follower.inbox); - } - } - - @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); - } - - // リモートからフォローをされていたら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); + this.queueService.deliver(followee, content, follower.inbox, false); } } @@ -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, @@ -262,23 +182,12 @@ export class UserBlockingService implements OnApplicationShutdown { // deliver if remote bloking if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker)); - this.queueService.deliver(blocker, content, blockee.inbox); + this.queueService.deliver(blocker, content, blockee.inbox, false); } } @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/UserCacheService.ts b/packages/backend/src/core/UserCacheService.ts deleted file mode 100644 index fc383d1c0..000000000 --- a/packages/backend/src/core/UserCacheService.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import Redis from 'ioredis'; -import type { UsersRepository } from '@/models/index.js'; -import { Cache } from '@/misc/cache.js'; -import type { LocalUser, User } from '@/models/entities/User.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { bindThis } from '@/decorators.js'; -import { StreamMessages } from '@/server/api/stream/types.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -@Injectable() -export class UserCacheService implements OnApplicationShutdown { - public userByIdCache: Cache; - public localUserByNativeTokenCache: Cache; - public localUserByIdCache: Cache; - public uriPersonCache: Cache; - - constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private userEntityService: UserEntityService, - ) { - //this.onMessage = this.onMessage.bind(this); - - this.userByIdCache = new Cache(Infinity); - this.localUserByNativeTokenCache = new Cache(Infinity); - this.localUserByIdCache = new Cache(Infinity); - this.uriPersonCache = new Cache(Infinity); - - this.redisSubscriber.on('message', this.onMessage); - } - - @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 'userChangeSuspendedState': - case 'remoteUserUpdated': { - const user = await this.usersRepository.findOneByOrFail({ id: body.id }); - this.userByIdCache.set(user.id, user); - for (const [k, v] of this.uriPersonCache.cache.entries()) { - if (v.value?.id === user.id) { - this.uriPersonCache.set(k, user); - } - } - if (this.userEntityService.isLocalUser(user)) { - this.localUserByNativeTokenCache.set(user.token, user); - this.localUserByIdCache.set(user.id, user); - } - break; - } - case 'userTokenRegenerated': { - const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as LocalUser; - this.localUserByNativeTokenCache.delete(body.oldToken); - this.localUserByNativeTokenCache.set(body.newToken, user); - break; - } - case 'follow': { - const follower = this.userByIdCache.get(body.followerId); - if (follower) follower.followingCount++; - const followee = this.userByIdCache.get(body.followeeId); - if (followee) followee.followersCount++; - break; - } - default: - break; - } - } - } - - @bindThis - public findById(userId: User['id']) { - return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId })); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined) { - this.redisSubscriber.off('message', this.onMessage); - } -} diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index d8426512b..d7bb8f392 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -1,4 +1,5 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnModuleInit, forwardRef } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; @@ -6,17 +7,19 @@ import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { WebhookService } from '@/core/WebhookService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; 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 Logger from '../logger.js'; const logger = new Logger('following/create'); @@ -35,8 +38,12 @@ 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.usersRepository) private usersRepository: UsersRepository, @@ -52,12 +59,13 @@ 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, - private createNotificationService: CreateNotificationService, + private metaService: MetaService, + private notificationService: NotificationService, private federatedInstanceService: FederatedInstanceService, private webhookService: WebhookService, private apRendererService: ApRendererService, @@ -66,6 +74,10 @@ export class UserFollowingService { ) { } + onModuleInit() { + this.userBlockingService = this.moduleRef.get('UserBlockingService'); + } + @bindThis public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise { const [follower, followee] = await Promise.all([ @@ -82,7 +94,7 @@ export class UserFollowingService { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) { // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); return; } else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) { // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 @@ -131,7 +143,7 @@ export class UserFollowingService { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } } @@ -145,15 +157,15 @@ export class UserFollowingService { }, ): Promise { if (follower.id === followee.id) return; - + let alreadyFollowed = false as boolean; - + await this.followingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), followerId: follower.id, followeeId: followee.id, - + // 非正規化 followerHost: follower.host, followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null, @@ -169,59 +181,64 @@ export class UserFollowingService { throw err; } }); - + + this.cacheService.userFollowingsCache.refresh(follower.id); + const req = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, }); - + if (req) { await this.followRequestsRepository.delete({ followeeId: followee.id, followerId: follower.id, }); - + // 通知を作成 - this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', { + this.notificationService.createNotification(follower.id, 'followRequestAccepted', { notifierId: followee.id, }); } - + if (alreadyFollowed) return; 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), ]); //#endregion - + //#region Update instance stats if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(i => { + this.federatedInstanceService.fetch(follower.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - this.instanceChart.updateFollowing(i.host, true); + 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(i => { + this.federatedInstanceService.fetch(followee.host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - this.instanceChart.updateFollowers(i.host, true); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, true); + } }); } //#endregion - + this.perUserFollowingChart.update(follower, followee, true); - + // Publish follow event if (this.userEntityService.isLocalUser(follower)) { 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')); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'follow', { @@ -230,12 +247,12 @@ export class UserFollowingService { } }); } - + // Publish followed event if (this.userEntityService.isLocalUser(followee)) { this.userEntityService.pack(follower.id, followee).then(async packed => { this.globalEventService.publishMainStream(followee.id, 'followed', packed); - + const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'followed', { @@ -243,9 +260,9 @@ export class UserFollowingService { }); } }); - + // 通知を作成 - this.createNotificationService.createNotification(followee.id, 'follow', { + this.notificationService.createNotification(followee.id, 'follow', { notifierId: follower.id, }); } @@ -265,24 +282,25 @@ export class UserFollowingService { followerId: follower.id, followeeId: followee.id, }); - + if (following == null) { logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした'); return; } - + await this.followingsRepository.delete(following.id); - + + this.cacheService.userFollowingsCache.refresh(follower.id); + this.decrementFollowing(follower, 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')); for (const webhook of webhooks) { this.queueService.webhookDeliver(webhook, 'unfollow', { @@ -291,47 +309,51 @@ 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)); - this.queueService.deliver(follower, content, followee.inbox); + 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)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } } - + @bindThis private async decrementFollowing( - follower: {id: User['id']; host: User['host']; }, + follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, ): 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 - + //#region Update instance stats if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(i => { + this.federatedInstanceService.fetch(follower.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - this.instanceChart.updateFollowing(i.host, false); + 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(i => { + this.federatedInstanceService.fetch(followee.host).then(async i => { this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - this.instanceChart.updateFollowers(i.host, false); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } }); } //#endregion - + this.perUserFollowingChart.update(follower, followee, false); } @@ -346,23 +368,23 @@ export class UserFollowingService { requestId?: string, ): Promise { if (follower.id === followee.id) return; - + // check blocking const [blocking, blocked] = await Promise.all([ this.userBlockingService.checkBlocked(follower.id, followee.id), this.userBlockingService.checkBlocked(followee.id, follower.id), ]); - + if (blocking) throw new Error('blocking'); if (blocked) throw new Error('blocked'); - + const followRequest = await this.followRequestsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), followerId: follower.id, followeeId: followee.id, requestId, - + // 非正規化 followerHost: follower.host, followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined, @@ -371,25 +393,25 @@ export class UserFollowingService { followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined, followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined, }).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0])); - + // Publish receiveRequest event if (this.userEntityService.isLocalUser(followee)) { this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventService.publishMainStream(followee.id, 'receiveFollowRequest', packed)); - + this.userEntityService.pack(followee.id, followee, { detail: true, }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); - + // 通知を作成 - this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', { + this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { notifierId: follower.id, followRequestId: followRequest.id, }); } - + if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)); - this.queueService.deliver(follower, content, followee.inbox); + this.queueService.deliver(follower, content, followee.inbox, false); } } @@ -404,26 +426,26 @@ export class UserFollowingService { ): Promise { if (this.userEntityService.isRemoteUser(followee)) { const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower)); - + if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので - this.queueService.deliver(follower, content, followee.inbox); + this.queueService.deliver(follower, content, followee.inbox, false); } } - + const request = await this.followRequestsRepository.findOneBy({ followeeId: followee.id, followerId: follower.id, }); - + if (request == null) { throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found'); } - + await this.followRequestsRepository.delete({ followeeId: followee.id, followerId: follower.id, }); - + this.userEntityService.pack(followee.id, followee, { detail: true, }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); @@ -440,18 +462,18 @@ export class UserFollowingService { followeeId: followee.id, followerId: follower.id, }); - + if (request == null) { throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.'); } - + 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)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } - + this.userEntityService.pack(followee.id, followee, { detail: true, }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed)); @@ -466,13 +488,13 @@ export class UserFollowingService { const requests = await this.followRequestsRepository.findBy({ followeeId: user.id, }); - + for (const request of requests) { const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId }); this.acceptFollowRequest(user, follower); } } - + /** * API following/request/reject */ @@ -557,7 +579,7 @@ export class UserFollowingService { }); const content = this.apRendererService.addContext(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee)); - this.queueService.deliver(followee, content, follower.inbox); + this.queueService.deliver(followee, content, follower.inbox, false); } /** @@ -569,7 +591,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..22a9fb2b8 --- /dev/null +++ b/packages/backend/src/core/UserKeypairService.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import 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 1d3cc87c8..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 { Cache } 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: Cache; - - constructor( - @Inject(DI.userKeypairsRepository) - private userKeypairsRepository: UserKeypairsRepository, - ) { - this.cache = new Cache(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/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 02903a059..d00bb89c7 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -54,7 +54,7 @@ export class UserSuspendService { } for (const inbox of queue) { - this.queueService.deliver(user, content, inbox); + this.queueService.deliver(user, content, inbox, true); } } } @@ -84,7 +84,7 @@ export class UserSuspendService { } for (const inbox of queue) { - this.queueService.deliver(user as any, content, inbox); + this.queueService.deliver(user as any, content, inbox, true); } } } diff --git a/packages/backend/src/core/VideoProcessingService.ts b/packages/backend/src/core/VideoProcessingService.ts index eccfeb0e7..5869905db 100644 --- a/packages/backend/src/core/VideoProcessingService.ts +++ b/packages/backend/src/core/VideoProcessingService.ts @@ -37,7 +37,7 @@ export class VideoProcessingService { }); }); - return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 280); + return await this.imageProcessingService.convertToWebp(`${dir}/out.png`, 498, 422); } finally { cleanup(); } diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts index ac1e413de..85594f855 100644 --- a/packages/backend/src/core/WebhookService.ts +++ b/packages/backend/src/core/WebhookService.ts @@ -13,14 +13,14 @@ export class WebhookService implements OnApplicationShutdown { private webhooks: Webhook[] = []; constructor( - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPubsub) + private redisForPubsub: Redis.Redis, @Inject(DI.webhooksRepository) private webhooksRepository: WebhooksRepository, ) { //this.onMessage = this.onMessage.bind(this); - this.redisSubscriber.on('message', this.onMessage); + this.redisForPubsub.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.redisForPubsub.off('message', this.onMessage); } } diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index d0a4ad7a7..4b032be89 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -3,9 +3,9 @@ import escapeRegexp from 'escape-regexp'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type { UserPublickey } from '@/models/entities/UserPublickey.js'; -import { UserCacheService } from '@/core/UserCacheService.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'; @@ -31,8 +31,8 @@ export type UriParseResult = { @Injectable() export class ApDbResolverService { - private publicKeyCache: Cache; - private publicKeyByUserIdCache: Cache; + private publicKeyCache: MemoryKVCache; + private publicKeyByUserIdCache: MemoryKVCache; constructor( @Inject(DI.config) @@ -47,11 +47,11 @@ export class ApDbResolverService { @Inject(DI.userPublickeysRepository) private userPublickeysRepository: UserPublickeysRepository, - private userCacheService: UserCacheService, + private cacheService: CacheService, private apPersonService: ApPersonService, ) { - this.publicKeyCache = new Cache(Infinity); - this.publicKeyByUserIdCache = new Cache(Infinity); + this.publicKeyCache = new MemoryKVCache(Infinity); + this.publicKeyByUserIdCache = new MemoryKVCache(Infinity); } @bindThis @@ -107,11 +107,11 @@ export class ApDbResolverService { if (parsed.local) { if (parsed.type !== 'users') return null; - return await this.userCacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ + return await this.cacheService.userByIdCache.fetchMaybe(parsed.id, () => this.usersRepository.findOneBy({ id: parsed.id, }).then(x => x ?? undefined)) ?? null; } else { - return await this.userCacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ + return await this.cacheService.uriPersonCache.fetch(parsed.uri, () => this.usersRepository.findOneBy({ uri: parsed.uri, })); } @@ -138,7 +138,7 @@ export class ApDbResolverService { if (key == null) return null; return { - user: await this.userCacheService.findById(key.userId) as RemoteUser, + user: await this.cacheService.findUserById(key.userId) as RemoteUser, key, }; } diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 5e6ea6984..70a6d32fe 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -157,7 +157,8 @@ class DeliverManager { public async execute() { if (!this.userEntityService.isLocalUser(this.actor)) return; - const inboxes = new Set(); + // The value flags whether it is shared or not. + const inboxes = new Map(); /* build inbox list @@ -185,7 +186,7 @@ class DeliverManager { for (const following of followers) { const inbox = following.followerSharedInbox ?? following.followerInbox; - inboxes.add(inbox); + inboxes.set(inbox, following.followerSharedInbox === null); } } @@ -197,11 +198,12 @@ class DeliverManager { // check that they actually have an inbox && recipe.to.inbox != null, ) - .forEach(recipe => inboxes.add(recipe.to.inbox!)); + .forEach(recipe => inboxes.set(recipe.to.inbox!, false)); // deliver for (const inbox of inboxes) { - this.queueService.deliver(this.actor, this.activity, inbox); + // inbox[0]: inbox, inbox[1]: whether it is sharedInbox + this.queueService.deliver(this.actor, this.activity, inbox[0], inbox[1]); } } } diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 6d9569bce..3fca0bb1f 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; +import { In, IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -22,7 +22,7 @@ import { QueueService } from '@/core/QueueService.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 +31,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 { @@ -80,7 +80,7 @@ export class ApInboxService { ) { this.logger = this.apLoggerService.logger; } - + @bindThis public async performActivity(actor: RemoteUser, activity: IObject) { if (isCollectionOrOrderedCollection(activity)) { @@ -139,23 +139,25 @@ 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 as any).type}`); + this.logger.warn(`unrecognized activity type: ${activity.type}`); } } @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: フォローしようとしているユーザーはローカルユーザーではありません'; } - + await this.userFollowingService.follow(actor, followee, activity.id); return 'ok'; } @@ -183,16 +185,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 +227,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 +407,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 +422,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 +447,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 +538,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 +564,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 +584,24 @@ 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; }); - + 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 +611,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 +710,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 +730,59 @@ 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'; + let new_acc = await this.apPersonService.resolvePerson(targetUri); + let old_acc = await this.apPersonService.resolvePerson(actor.uri); + + // update them if they're remote + if (new_acc.uri) await this.apPersonService.updatePerson(new_acc.uri); + if (old_acc.uri) await this.apPersonService.updatePerson(old_acc.uri); + + // retrieve updated users + new_acc = await this.apPersonService.resolvePerson(targetUri); + old_acc = await this.apPersonService.resolvePerson(actor.uri); + + // check if alsoKnownAs of the new account is valid + let isValidMove = true; + if (old_acc.uri) { + if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) { + isValidMove = false; + } + } else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) { + isValidMove = false; + } + if (!isValidMove) { + return 'skip: accounts invalid'; + } + + // add target uri to movedToUri in order to indicate that the user has moved + await this.usersRepository.update(old_acc.id, { movedToUri: targetUri }); + + // follow the new account and unfollow the old one + const followings = await this.followingsRepository.find({ + relations: { + follower: true, + }, + where: { + followeeId: old_acc.id, + followerHost: IsNull(), // follower is local + }, + }); + for (const following of followings) { + if (!following.follower) continue; + try { + await this.userFollowingService.follow(following.follower, new_acc); + await this.userFollowingService.unfollow(following.follower, old_acc); + } catch { + /* empty */ + } + } + + return 'ok'; + } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 6a1f233bd..0b22aa9bc 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -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, ) { @@ -91,6 +94,9 @@ export class ApRendererService { } else if (note.visibility === 'home') { to = [`${attributedTo}/followers`]; cc = ['https://www.w3.org/ns/activitystreams#Public']; + } else if (note.visibility === 'followers') { + to = [`${attributedTo}/followers`]; + cc = []; } else { throw new Error('renderAnnounce: cannot render non-public note'); } @@ -116,7 +122,7 @@ export class ApRendererService { if (block.blockee?.uri == null) { throw new Error('renderBlock: missing blockee uri'); } - + return { type: 'Block', id: `${this.config.url}/blocks/${block.id}`, @@ -134,10 +140,10 @@ export class ApRendererService { published: note.createdAt.toISOString(), object, } as ICreate; - + if (object.to) activity.to = object.to; if (object.cc) activity.cc = object.cc; - + return activity; } @@ -155,7 +161,7 @@ export class ApRendererService { public renderDocument(file: DriveFile): IApDocument { return { type: 'Document', - mediaType: file.type, + mediaType: file.webpublicType ?? file.type, url: this.driveFileEntityService.getPublicUrl(file), name: file.comment, }; @@ -269,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)]; } @@ -290,6 +292,22 @@ export class ApRendererService { }; } + @bindThis + public renderMove( + src: { id: User['id']; host: User['host']; uri: User['host'] }, + dst: { id: User['id']; host: User['host']; uri: User['host'] }, + ): IMove { + const actor = this.userEntityService.isLocalUser(src) ? `${this.config.url}/users/${src.id}` : src.uri!; + const target = this.userEntityService.isLocalUser(dst) ? `${this.config.url}/users/${dst.id}` : dst.uri!; + 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[]) => { @@ -297,16 +315,16 @@ export class ApRendererService { const items = await this.driveFilesRepository.findBy({ id: In(ids) }); return ids.map(id => items.find(item => item.id === id)).filter(item => item != null) as DriveFile[]; }; - + let inReplyTo; let inReplyToNote: Note | null; - + if (note.replyId) { inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId }); - + if (inReplyToNote != null) { const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId }); - + if (inReplyToUser != null) { if (inReplyToNote.uri) { inReplyTo = inReplyToNote.uri; @@ -322,24 +340,24 @@ export class ApRendererService { } else { inReplyTo = null; } - + let quote; - + if (note.renoteId) { const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); - + if (renote) { quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`; } } - + const attributedTo = `${this.config.url}/users/${note.userId}`; - + const mentions = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri); - + let to: string[] = []; let cc: string[] = []; - + if (note.visibility === 'public') { to = ['https://www.w3.org/ns/activitystreams#Public']; cc = [`${attributedTo}/followers`].concat(mentions); @@ -352,44 +370,44 @@ export class ApRendererService { } else { to = mentions; } - + const mentionedUsers = note.mentions.length > 0 ? await this.usersRepository.findBy({ id: In(note.mentions), }) : []; - + const hashtagTags = (note.tags ?? []).map(tag => this.renderHashtag(tag)); const mentionTags = mentionedUsers.map(u => this.renderMention(u)); - + const files = await getPromisedFiles(note.fileIds); - + const text = note.text ?? ''; let poll: Poll | null = null; - + if (note.hasPoll) { poll = await this.pollsRepository.findOneBy({ noteId: note.id }); } - + let apText = text; - + if (quote) { apText += `\n\nRE: ${quote}`; } - + const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - + const content = this.apMfmService.getNoteHtml(Object.assign({}, note, { text: apText, })); - + const emojis = await this.getEmojis(note.emojis); const apemojis = emojis.map(emoji => this.renderEmoji(emoji)); - + const tag = [ ...hashtagTags, ...mentionTags, ...apemojis, ]; - + const asPoll = poll ? { type: 'Question', content: this.apMfmService.getNoteHtml(Object.assign({}, note, { @@ -470,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', @@ -496,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; } @@ -601,7 +627,7 @@ export class ApRendererService { if (typeof x === 'object' && x.id == null) { x.id = `${this.config.url}/${uuid()}`; } - + return Object.assign({ '@context': [ 'https://www.w3.org/ns/activitystreams', @@ -634,18 +660,18 @@ export class ApRendererService { ], }, x as T & { id: string; }); } - + @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; activity = await ldSignature.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); - + return activity; } - + /** * Render OrderedCollectionPage * @param id URL of self @@ -686,11 +712,11 @@ export class ApRendererService { type: 'OrderedCollection', totalItems, }; - + if (first) page.first = first; if (last) page.last = last; if (orderedItems) page.orderedItems = orderedItems; - + return page; } @@ -698,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/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index c36e8d4ed..5ca5f6e84 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'; @@ -124,7 +125,7 @@ export class ApNoteService { throw new Error('invalid note'); } - const note: IPost = object as any; + const note = object as IPost; this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); @@ -180,7 +181,7 @@ export class ApNoteService { const reply: Note | null = note.inReplyTo ? await this.resolveNote(note.inReplyTo, resolver).then(x => { if (x == null) { - this.logger.warn('Specified inReplyTo, but nout found'); + this.logger.warn('Specified inReplyTo, but not found'); throw new Error('inReplyTo not found'); } else { return x; @@ -341,15 +342,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 a1fdd7a19..21797cfcb 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -8,7 +8,7 @@ import type { Config } from '@/config.js'; import type { RemoteUser } from '@/models/entities/User.js'; import { User } from '@/models/entities/User.js'; import { truncate } from '@/misc/truncate.js'; -import type { UserCacheService } from '@/core/UserCacheService.js'; +import type { CacheService } from '@/core/CacheService.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import type Logger from '@/logger.js'; @@ -30,6 +30,8 @@ import { StatusError } from '@/misc/status-error.js'; import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { 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'; @@ -48,11 +50,13 @@ 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; private federatedInstanceService: FederatedInstanceService; private fetchInstanceMetadataService: FetchInstanceMetadataService; - private userCacheService: UserCacheService; + private cacheService: CacheService; private apResolverService: ApResolverService; private apNoteService: ApNoteService; private apImageService: ApImageService; @@ -92,9 +96,10 @@ export class ApPersonService implements OnModuleInit { //private userEntityService: UserEntityService, //private idService: IdService, //private globalEventService: GlobalEventService, + //private metaService: MetaService, //private federatedInstanceService: FederatedInstanceService, //private fetchInstanceMetadataService: FetchInstanceMetadataService, - //private userCacheService: UserCacheService, + //private cacheService: CacheService, //private apResolverService: ApResolverService, //private apNoteService: ApNoteService, //private apImageService: ApImageService, @@ -110,11 +115,13 @@ 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'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); - this.userCacheService = this.moduleRef.get('UserCacheService'); + this.cacheService = this.moduleRef.get('CacheService'); this.apResolverService = this.moduleRef.get('ApResolverService'); this.apNoteService = this.moduleRef.get('ApNoteService'); this.apImageService = this.moduleRef.get('ApImageService'); @@ -164,6 +171,9 @@ export class ApPersonService implements OnModuleInit { throw new Error('invalid Actor: wrong name'); } x.name = truncate(x.name, nameLength); + } else if (x.name === '') { + // Mastodon emits empty string when the name is not set. + x.name = undefined; } if (x.summary) { if (!(typeof x.summary === 'string' && x.summary.length > 0)) { @@ -200,14 +210,14 @@ export class ApPersonService implements OnModuleInit { public async fetchPerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); - const cached = this.userCacheService.uriPersonCache.get(uri); + const cached = this.cacheService.uriPersonCache.get(uri); if (cached) return cached; // URIがこのサーバーを指しているならデータベースからフェッチ if (uri.startsWith(this.config.url + '/')) { const id = uri.split('/').pop(); const u = await this.usersRepository.findOneBy({ id }); - if (u) this.userCacheService.uriPersonCache.set(uri, u); + if (u) this.cacheService.uriPersonCache.set(uri, u); return u; } @@ -215,7 +225,7 @@ export class ApPersonService implements OnModuleInit { const exist = await this.usersRepository.findOneBy({ uri }); if (exist) { - this.userCacheService.uriPersonCache.set(uri, exist); + this.cacheService.uriPersonCache.set(uri, exist); return exist; } //#endregion @@ -271,6 +281,8 @@ export class ApPersonService implements OnModuleInit { lastFetchedAt: new Date(), name: truncate(person.name, nameLength), isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo, + alsoKnownAs: person.alsoKnownAs, isExplorable: !!person.discoverable, username: person.preferredUsername, usernameLower: person.preferredUsername!.toLowerCase(), @@ -324,10 +336,12 @@ export class ApPersonService implements OnModuleInit { } // Register host - this.federatedInstanceService.fetch(host).then(i => { + this.federatedInstanceService.fetch(host).then(async i => { this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); - this.instanceChart.newUser(i.host); this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + this.instanceChart.newUser(i.host); + } }); this.usersChart.update(user!, true); @@ -347,32 +361,44 @@ 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!; } /** @@ -449,15 +475,21 @@ 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; 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 diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 7f2ca9c05..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; // 後方互換性のため @@ -195,7 +197,8 @@ export const isPropertyValue = (object: IObject): object is IApPropertyValue => object && getApType(object) === 'PropertyValue' && typeof object.name === 'string' && - typeof (object as any).value === 'string'; + 'value' in object && + typeof object.value === 'string'; export interface IApMention extends IObject { type: 'Mention'; @@ -299,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'; @@ -313,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/chart/charts/entities/active-users.ts b/packages/backend/src/core/chart/charts/entities/active-users.ts index 5767b76f8..e291e37c1 100644 --- a/packages/backend/src/core/chart/charts/entities/active-users.ts +++ b/packages/backend/src/core/chart/charts/entities/active-users.ts @@ -3,15 +3,15 @@ import Chart from '../../core.js'; export const name = 'activeUsers'; export const schema = { - 'readWrite': { intersection: ['read', 'write'], range: 'small' }, - 'read': { uniqueIncrement: true, range: 'small' }, - 'write': { uniqueIncrement: true, range: 'small' }, - 'registeredWithinWeek': { uniqueIncrement: true, range: 'small' }, - 'registeredWithinMonth': { uniqueIncrement: true, range: 'small' }, - 'registeredWithinYear': { uniqueIncrement: true, range: 'small' }, - 'registeredOutsideWeek': { uniqueIncrement: true, range: 'small' }, - 'registeredOutsideMonth': { uniqueIncrement: true, range: 'small' }, - 'registeredOutsideYear': { uniqueIncrement: true, range: 'small' }, + 'readWrite': { intersection: ['read', 'write'] }, + 'read': { uniqueIncrement: true }, + 'write': { uniqueIncrement: true }, + 'registeredWithinWeek': { uniqueIncrement: true }, + 'registeredWithinMonth': { uniqueIncrement: true }, + 'registeredWithinYear': { uniqueIncrement: true }, + 'registeredOutsideWeek': { uniqueIncrement: true }, + 'registeredOutsideMonth': { uniqueIncrement: true }, + 'registeredOutsideYear': { uniqueIncrement: true }, } as const; export const entity = Chart.schemaToEntity(name, schema); diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index c8a98c0be..328511f5d 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { AntennaNotesRepository, AntennasRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { AntennasRepository } from '@/models/index.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Antenna } from '@/models/entities/Antenna.js'; import { bindThis } from '@/decorators.js'; @@ -10,9 +10,6 @@ export class AntennaEntityService { constructor( @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, ) { } @@ -22,8 +19,6 @@ export class AntennaEntityService { ): Promise> { const antenna = typeof src === 'object' ? src : await this.antennasRepository.findOneByOrFail({ id: src }); - const hasUnreadNote = (await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false })) != null; - return { id: antenna.id, createdAt: antenna.createdAt.toISOString(), @@ -37,7 +32,8 @@ export class AntennaEntityService { notify: antenna.notify, withReplies: antenna.withReplies, withFile: antenna.withFile, - hasUnreadNote, + isActive: antenna.isActive, + hasUnreadNote: false, // TODO }; } } diff --git a/packages/backend/src/core/entities/AppEntityService.ts b/packages/backend/src/core/entities/AppEntityService.ts index 36cd48f3c..0b4c3935c 100644 --- a/packages/backend/src/core/entities/AppEntityService.ts +++ b/packages/backend/src/core/entities/AppEntityService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, AppsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { App } from '@/models/entities/App.js'; import type { User } from '@/models/entities/User.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/BlockingEntityService.ts b/packages/backend/src/core/entities/BlockingEntityService.ts index c9e15207b..e169c7e90 100644 --- a/packages/backend/src/core/entities/BlockingEntityService.ts +++ b/packages/backend/src/core/entities/BlockingEntityService.ts @@ -2,11 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { BlockingsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Blocking } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class BlockingEntityService { diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 0a9bcf85c..72e9b2554 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -1,13 +1,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NoteUnreadsRepository, NotesRepository } from '@/models/index.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Channel } from '@/models/entities/Channel.js'; -import { UserEntityService } from './UserEntityService.js'; -import { DriveFileEntityService } from './DriveFileEntityService.js'; import { bindThis } from '@/decorators.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { NoteEntityService } from './NoteEntityService.js'; +import { In } from 'typeorm'; @Injectable() export class ChannelEntityService { @@ -18,13 +19,19 @@ export class ChannelEntityService { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.noteUnreadsRepository) private noteUnreadsRepository: NoteUnreadsRepository, @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private userEntityService: UserEntityService, + private noteEntityService: NoteEntityService, private driveFileEntityService: DriveFileEntityService, ) { } @@ -33,6 +40,7 @@ export class ChannelEntityService { public async pack( src: Channel['id'] | Channel, me?: { id: User['id'] } | null | undefined, + detailed?: boolean, ): Promise> { const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); const meId = me ? me.id : null; @@ -46,6 +54,17 @@ export class ChannelEntityService { followeeId: channel.id, }) : null; + const favorite = meId ? await this.channelFavoritesRepository.findOneBy({ + userId: meId, + channelId: channel.id, + }) : null; + + const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ + where: { + id: In(channel.pinnedNoteIds), + }, + }) : []; + return { id: channel.id, createdAt: channel.createdAt.toISOString(), @@ -54,13 +73,19 @@ export class ChannelEntityService { description: channel.description, userId: channel.userId, bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, + pinnedNoteIds: channel.pinnedNoteIds, usersCount: channel.usersCount, notesCount: channel.notesCount, ...(me ? { isFollowing: following != null, + isFavorited: favorite != null, hasUnreadNote, } : {}), + + ...(detailed ? { + pinnedNotes: await this.noteEntityService.packMany(pinnedNotes, me), + } : {}), }; } } diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index 63c50865e..33d3c5380 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ClipsRepository } from '@/models/index.js'; +import type { ClipFavoritesRepository, ClipsRepository, User } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { Clip } from '@/models/entities/Clip.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class ClipEntityService { @@ -14,6 +14,9 @@ export class ClipEntityService { @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + private userEntityService: UserEntityService, ) { } @@ -21,25 +24,31 @@ export class ClipEntityService { @bindThis public async pack( src: Clip['id'] | Clip, + me?: { id: User['id'] } | null | undefined, ): Promise> { + const meId = me ? me.id : null; const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); return await awaitAll({ id: clip.id, createdAt: clip.createdAt.toISOString(), + lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, userId: clip.userId, user: this.userEntityService.pack(clip.user ?? clip.userId), name: clip.name, description: clip.description, isPublic: clip.isPublic, + favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), + isFavorited: meId ? await this.clipFavoritesRepository.findOneBy({ clipId: clip.id, userId: meId }).then(x => x != null) : undefined, }); } @bindThis public packMany( clips: Clip[], + me?: { id: User['id'] } | null | undefined, ) { - return Promise.all(clips.map(x => this.pack(x))); + return Promise.all(clips.map(x => this.pack(x, me))); } } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 158fafa9d..d82f36d97 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -1,9 +1,9 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; +import { DataSource, In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { User } from '@/models/entities/User.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; @@ -21,6 +21,7 @@ type PackOptions = { }; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; +import { isNotNull } from '@/misc/is-not-null.js'; @Injectable() export class DriveFileEntityService { @@ -88,9 +89,7 @@ export class DriveFileEntityService { if (file.type.startsWith('video')) { if (file.thumbnailUrl) return file.thumbnailUrl; - if (this.config.videoThumbnailGenerator == null) { - return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); - } + return this.videoProcessingService.getExternalVideoThumbnailUrl(file.webpublicUrl ?? file.url ?? file.uri); } else if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) { // 動画ではなくリモートかつメディアプロキシ return this.getProxiedUrl(file.uri, 'static'); @@ -105,7 +104,7 @@ export class DriveFileEntityService { const url = file.webpublicUrl ?? file.url; - return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? this.getProxiedUrl(url, 'static') : null); + return file.thumbnailUrl ?? (isMimeImage(file.type, 'sharp-convertible-image') ? url : null); } @bindThis @@ -255,10 +254,35 @@ export class DriveFileEntityService { @bindThis public async packMany( - files: (DriveFile['id'] | DriveFile)[], + files: DriveFile[], options?: PackOptions, ): Promise[]> { const items = await Promise.all(files.map(f => this.packNullable(f, options))); return items.filter((x): x is Packed<'DriveFile'> => x != null); } + + @bindThis + public async packManyByIdsMap( + 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])); + for (const id of fileIds) { + if (!map.has(id)) map.set(id, null); + } + return map; + } + + @bindThis + public async packManyByIds( + 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/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts index 93c52c91f..13929b145 100644 --- a/packages/backend/src/core/entities/DriveFolderEntityService.ts +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { DriveFolder } from '@/models/entities/DriveFolder.js'; import { bindThis } from '@/decorators.js'; diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index f5c8f2d4b..3bad048bc 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { EmojisRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { Emoji } from '@/models/entities/Emoji.js'; import { bindThis } from '@/decorators.js'; @@ -50,6 +50,7 @@ export class EmojiEntityService { host: emoji.host, // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url: emoji.publicUrl || emoji.originalUrl, + license: emoji.license, }; } diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index 61bd18c04..e52a59188 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Flash } from '@/models/entities/Flash.js'; diff --git a/packages/backend/src/core/entities/FollowingEntityService.ts b/packages/backend/src/core/entities/FollowingEntityService.ts index a833ae719..55ba4e67a 100644 --- a/packages/backend/src/core/entities/FollowingEntityService.ts +++ b/packages/backend/src/core/entities/FollowingEntityService.ts @@ -2,10 +2,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Following } from '@/models/entities/Following.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; type LocalFollowerFollowing = Following & { @@ -31,7 +32,6 @@ type RemoteFolloweeFollowing = Following & { followeeInbox: string; followeeSharedInbox: string; }; -import { bindThis } from '@/decorators.js'; @Injectable() export class FollowingEntityService { diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index ab29e7dba..632c75304 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -2,13 +2,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { GalleryLikesRepository, GalleryPostsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { GalleryPost } from '@/models/entities/GalleryPost.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class GalleryPostEntityService { @@ -41,7 +41,8 @@ export class GalleryPostEntityService { title: post.title, description: post.description, fileIds: post.fileIds, - files: this.driveFileEntityService.packMany(post.fileIds), + // TODO: packMany causes N+1 queries + files: this.driveFileEntityService.packManyByIds(post.fileIds), tags: post.tags.length > 0 ? post.tags : undefined, isSensitive: post.isSensitive, likedCount: post.likedCount, diff --git a/packages/backend/src/core/entities/HashtagEntityService.ts b/packages/backend/src/core/entities/HashtagEntityService.ts index 88b1ff3a3..2cd79b8f8 100644 --- a/packages/backend/src/core/entities/HashtagEntityService.ts +++ b/packages/backend/src/core/entities/HashtagEntityService.ts @@ -1,11 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { HashtagsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { Hashtag } from '@/models/entities/Hashtag.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class HashtagEntityService { diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 8a9706816..3bf84ed37 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { InstancesRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { Instance } from '@/models/entities/Instance.js'; import { MetaService } from '@/core/MetaService.js'; -import { UtilityService } from '../UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { UtilityService } from '../UtilityService.js'; @Injectable() export class InstanceEntityService { diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts index 4f02ef408..561d53292 100644 --- a/packages/backend/src/core/entities/MutingEntityService.ts +++ b/packages/backend/src/core/entities/MutingEntityService.ts @@ -2,12 +2,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { MutingsRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Muting } from '@/models/entities/Muting.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class MutingEntityService { diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 2ffe5f1c2..26debd6ad 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -3,7 +3,7 @@ import { DataSource, In } from 'typeorm'; import * as mfm from 'mfm-js'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { nyaize } from '@/misc/nyaize.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { User } from '@/models/entities/User.js'; @@ -11,6 +11,7 @@ import type { Note } from '@/models/entities/Note.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, DriveFilesRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; +import { isNotNull } from '@/misc/is-not-null.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; @@ -182,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, @@ -248,6 +254,21 @@ export class NoteEntityService implements OnModuleInit { return true; } + @bindThis + public async packAttachedFiles(fileIds: Note['fileIds'], packedFiles: Map | null>): Promise[]> { + const missingIds = []; + for (const id of fileIds) { + if (!packedFiles.has(id)) missingIds.push(id); + } + if (missingIds.length) { + const additionalMap = await this.driveFileEntityService.packManyByIdsMap(missingIds); + for (const [k, v] of additionalMap) { + packedFiles.set(k, v); + } + } + return fileIds.map(id => packedFiles.get(id)).filter(isNotNull); + } + @bindThis public async pack( src: Note['id'] | Note, @@ -257,6 +278,7 @@ export class NoteEntityService implements OnModuleInit { skipHide?: boolean; _hint_?: { myReactions: Map; + packedFiles: Map | null>; }; }, ): Promise> { @@ -266,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; @@ -284,6 +306,7 @@ export class NoteEntityService implements OnModuleInit { const reactionEmojiNames = Object.keys(note.reactions) .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); + const packedFiles = options?._hint_?.packedFiles; const packed: Packed<'Note'> = await awaitAll({ id: note.id, @@ -296,6 +319,7 @@ export class NoteEntityService implements OnModuleInit { cw: note.cw, visibility: note.visibility, localOnly: note.localOnly ?? undefined, + reactionAcceptance: note.reactionAcceptance, visibleUserIds: note.visibility === 'specified' ? note.visibleUserIds : undefined, renoteCount: note.renoteCount, repliesCount: note.repliesCount, @@ -304,7 +328,7 @@ export class NoteEntityService implements OnModuleInit { emojis: host != null ? this.customEmojiService.populateEmojis(note.emojis, host) : undefined, tags: note.tags.length > 0 ? note.tags : undefined, fileIds: note.fileIds, - files: this.driveFileEntityService.packMany(note.fileIds), + files: packedFiles != null ? this.packAttachedFiles(note.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(note.fileIds), replyId: note.replyId, renoteId: note.renoteId, channelId: note.channelId ?? undefined, @@ -376,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), @@ -387,16 +412,44 @@ 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 = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { myReactions: myReactionsMap, + packedFiles, }, }))); } + @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/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index 977948967..8f943ba24 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -1,7 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { NoteReactionsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; import type { OnModuleInit } from '@nestjs/common'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; @@ -10,7 +11,6 @@ import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; import { ModuleRef } from '@nestjs/core'; -import { bindThis } from '@/decorators.js'; @Injectable() export class NoteReactionEntityService implements OnModuleInit { diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index be88a213f..0dc63d969 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -1,11 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js'; +import type { AccessTokensRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Notification } from '@/models/entities/Notification.js'; import type { Note } from '@/models/entities/Note.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { notificationTypes } from '@/types.js'; @@ -25,8 +26,11 @@ export class NotificationEntityService implements OnModuleInit { constructor( private moduleRef: ModuleRef, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, @@ -48,30 +52,40 @@ export class NotificationEntityService implements OnModuleInit { @bindThis public async pack( - src: Notification['id'] | Notification, + src: Notification, + meId: User['id'], + // eslint-disable-next-line @typescript-eslint/ban-types options: { - _hint_?: { - packedNotes: Map>; - }; + + }, + hint?: { + packedNotes: Map>; + packedUsers: Map>; }, ): Promise> { - const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); + const notification = src; const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( - options._hint_?.packedNotes != null - ? options._hint_.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { + hint?.packedNotes != null + ? hint.packedNotes.get(notification.noteId) + : this.noteEntityService.pack(notification.noteId!, { id: meId }, { detail: true, }) ) : undefined; + const userIfNeed = notification.notifierId != null ? ( + hint?.packedUsers != null + ? hint.packedUsers.get(notification.notifierId) + : this.userEntityService.pack(notification.notifierId!, { id: meId }, { + detail: false, + }) + ) : undefined; return await awaitAll({ id: notification.id, - createdAt: notification.createdAt.toISOString(), + createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, - isRead: notification.isRead, userId: notification.notifierId, - user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, + ...(userIfNeed != null ? { user: userIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(notification.type === 'reaction' ? { reaction: notification.reaction, @@ -87,33 +101,39 @@ export class NotificationEntityService implements OnModuleInit { }); } - /** - * @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId - */ @bindThis public async packMany( notifications: Notification[], meId: User['id'], ) { if (notifications.length === 0) return []; - - for (const notification of notifications) { - if (meId !== notification.notifieeId) { - // because we call note packMany with meId, all notifieeId should be same as meId - throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION'); - } - } - const notes = notifications.map(x => x.note).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', '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])); - return await Promise.all(notifications.map(x => this.pack(x, { - _hint_: { - packedNotes, - }, + 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) }, + }) : []; + 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(validNotifications.map(x => this.pack(x, meId, {}, { + packedNotes, + packedUsers, }))); } } diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index 48e45dd01..d6da85663 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -2,14 +2,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { DriveFilesRepository, PagesRepository, PageLikesRepository } from '@/models/index.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { User } from '@/models/entities/User.js'; import type { Page } from '@/models/entities/Page.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; +import { bindThis } from '@/decorators.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class PageEntityService { diff --git a/packages/backend/src/core/entities/RenoteMutingEntityService.ts b/packages/backend/src/core/entities/RenoteMutingEntityService.ts new file mode 100644 index 000000000..f8871e049 --- /dev/null +++ b/packages/backend/src/core/entities/RenoteMutingEntityService.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class RenoteMutingEntityService { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + @bindThis + public async pack( + src: RenoteMuting['id'] | RenoteMuting, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + muteeId: muting.muteeId, + mutee: this.userEntityService.pack(muting.muteeId, me, { + detail: true, + }), + }); + } + + @bindThis + public packMany( + mutings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts index 2f1d51fa1..e111a10b7 100644 --- a/packages/backend/src/core/entities/RoleEntityService.ts +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -61,6 +61,7 @@ export class RoleEntityService { isModerator: role.isModerator, asBadge: role.asBadge, canEditMembersByModerator: role.canEditMembersByModerator, + displayOrder: role.displayOrder, policies: policies, usersCount: assignedCount, }); diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 8c36e47f1..e02f7535d 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -1,20 +1,22 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, Not } from 'typeorm'; +import Redis from 'ioredis'; import Ajv from 'ajv'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import type { Packed } from '@/misc/schema.js'; +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 { Cache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import type { LocalUser, 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, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.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 { 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'; @@ -24,7 +26,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'> : @@ -46,13 +48,14 @@ function isRemoteUser(user: User | { host: User['host'] }): boolean { @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: Cache; + private federatedInstanceService: FederatedInstanceService; constructor( private moduleRef: ModuleRef, @@ -60,6 +63,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -78,6 +84,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -87,9 +96,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, @@ -105,9 +111,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - @Inject(DI.pagesRepository) private pagesRepository: PagesRepository, @@ -118,16 +121,17 @@ export class UserEntityService implements OnModuleInit { //private antennaService: AntennaService, //private roleService: RoleService, ) { - this.userInstanceCache = new Cache(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 @@ -195,6 +199,13 @@ export class UserEntityService implements OnModuleInit { }, take: 1, }).then(n => n > 0), + isRenoteMuted: this.renoteMutingsRepository.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then(n => n > 0), }); } @@ -213,6 +224,7 @@ export class UserEntityService implements OnModuleInit { @bindThis public async getHasUnreadAntenna(userId: User['id']): Promise { + /* const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId); const unread = myAntennas.length > 0 ? await this.antennaNotesRepository.findOneBy({ @@ -221,37 +233,22 @@ export class UserEntityService implements OnModuleInit { }) : null; return unread != null; - } - - @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; + */ + return false; // TODO } @bindThis public async getHasUnreadNotification(userId: User['id']): Promise { - const mute = await this.mutingsRepository.findBy({ - muterId: userId, - }); - const mutedUserIds = mute.map(m => m.muteeId); + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - const count = await this.notificationsRepository.count({ - where: { - notifieeId: userId, - ...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), - isRead: false, - }, - take: 1, - }); + const latestNotificationIdsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '-', + 'COUNT', 1); + const latestNotificationId = latestNotificationIdsRes[0]?.[0]; - return count > 0; + return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId); } @bindThis @@ -276,29 +273,8 @@ 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.id); - } else if (user.avatarId) { - const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); - return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id); - } else { - return this.getIdenticonUrl(user.id); - } - } - - @bindThis - public getAvatarUrlSync(user: User): string { - if (user.avatar) { - return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id); - } else { - return this.getIdenticonUrl(user.id); - } - } - - @bindThis - public getIdenticonUrl(userId: User['id']): string { - return `${this.config.url}/identicon/${userId}`; + public getIdenticonUrl(user: User): string { + return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; } public async pack( @@ -315,19 +291,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, }); } @@ -362,14 +342,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, @@ -380,19 +357,22 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由でローカルユーザーのみ - badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.map(r => ({ + badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.sort((a, b) => b.displayOrder - a.displayOrder).map(r => ({ name: r.name, iconUrl: r.iconUrl, + displayOrder: r.displayOrder, }))) : undefined, ...(opts.detail ? { url: profile!.url, uri: user.uri, + movedToUri: user.movedToUri ? await this.apPersonService.resolvePerson(user.movedToUri) : null, + alsoKnownAs: user.alsoKnownAs, 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, @@ -419,7 +399,7 @@ export class UserEntityService implements OnModuleInit { userId: user.id, }).then(result => result >= 1) : false, - roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).map(role => ({ + roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, color: role.color, @@ -427,6 +407,7 @@ export class UserEntityService implements OnModuleInit { description: role.description, isModerator: role.isModerator, isAdministrator: role.isAdministrator, + displayOrder: role.displayOrder, }))), } : {}), @@ -455,7 +436,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, @@ -493,6 +474,7 @@ export class UserEntityService implements OnModuleInit { isBlocking: relation.isBlocking, isBlocked: relation.isBlocked, isMuted: relation.isMuted, + isRenoteMuted: relation.isRenoteMuted, } : {}), } as Promiseable> as Promiseable>; diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index 3c1087881..2461cb2c1 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -1,11 +1,11 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import type { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/entities/Blocking.js'; import type { UserList } from '@/models/entities/UserList.js'; -import { UserEntityService } from './UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; @Injectable() export class UserListEntityService { diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 05603093b..482e8f83e 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -2,7 +2,7 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), redis: Symbol('redis'), - redisSubscriber: Symbol('redisSubscriber'), + redisForPubsub: Symbol('redisForPubsub'), //#region Repositories usersRepository: Symbol('usersRepository'), @@ -33,9 +33,9 @@ export const DI = { emojisRepository: Symbol('emojisRepository'), driveFilesRepository: Symbol('driveFilesRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'), - notificationsRepository: Symbol('notificationsRepository'), metasRepository: Symbol('metasRepository'), mutingsRepository: Symbol('mutingsRepository'), + renoteMutingsRepository: Symbol('renoteMutingsRepository'), blockingsRepository: Symbol('blockingsRepository'), swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), hashtagsRepository: Symbol('hashtagsRepository'), @@ -51,15 +51,15 @@ export const DI = { moderationLogsRepository: Symbol('moderationLogsRepository'), clipsRepository: Symbol('clipsRepository'), clipNotesRepository: Symbol('clipNotesRepository'), + clipFavoritesRepository: Symbol('clipFavoritesRepository'), antennasRepository: Symbol('antennasRepository'), - antennaNotesRepository: Symbol('antennaNotesRepository'), promoNotesRepository: Symbol('promoNotesRepository'), promoReadsRepository: Symbol('promoReadsRepository'), relaysRepository: Symbol('relaysRepository'), mutedNotesRepository: Symbol('mutedNotesRepository'), channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), - channelNotePiningsRepository: Symbol('channelNotePiningsRepository'), + channelFavoritesRepository: Symbol('channelFavoritesRepository'), registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), adsRepository: Symbol('adsRepository'), diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index 43a71a2b5..d35414acf 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -1,18 +1,187 @@ +import Redis from 'ioredis'; import { bindThis } from '@/decorators.js'; +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; + + 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 = opts.lifetime; + this.memoryCache = new MemoryKVCache(opts.memoryCacheLifetime); + this.fetcher = opts.fetcher; + this.toRedisConverter = opts.toRedisConverter; + this.fromRedisConverter = opts.fromRedisConverter; + } + + @bindThis + public async set(key: string, value: T): Promise { + this.memoryCache.set(key, value); + if (this.lifetime === Infinity) { + await this.redisClient.set( + `kvcache:${this.name}:${key}`, + this.toRedisConverter(value), + ); + } else { + await this.redisClient.set( + `kvcache:${this.name}:${key}`, + this.toRedisConverter(value), + 'ex', Math.round(this.lifetime / 1000), + ); + } + } + + @bindThis + 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 this.fromRedisConverter(cached); + } + + @bindThis + public async delete(key: string): Promise { + this.memoryCache.delete(key); + await this.redisClient.del(`kvcache:${this.name}:${key}`); + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + */ + @bindThis + public async fetch(key: string): Promise { + const cachedValue = await this.get(key); + if (cachedValue !== undefined) { + // Cache HIT + return cachedValue; + } + + // Cache MISS + 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; + + 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: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする? -export class Cache { - public cache: Map; +export class MemoryKVCache { + public cache: Map; private lifetime: number; - constructor(lifetime: Cache['lifetime']) { + constructor(lifetime: MemoryKVCache['lifetime']) { this.cache = new Map(); this.lifetime = lifetime; } @bindThis - public set(key: string | null, value: T): void { + public set(key: string, value: T): void { this.cache.set(key, { date: Date.now(), value, @@ -20,7 +189,7 @@ export class Cache { } @bindThis - public get(key: string | null): T | undefined { + public get(key: string): T | undefined { const cached = this.cache.get(key); if (cached == null) return undefined; if ((Date.now() - cached.date) > this.lifetime) { @@ -31,7 +200,7 @@ export class Cache { } @bindThis - public delete(key: string | null) { + public delete(key: string) { this.cache.delete(key); } @@ -40,7 +209,7 @@ export class Cache { * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします */ @bindThis - public async fetch(key: string | null, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetch(key: string, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -65,7 +234,7 @@ export class Cache { * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします */ @bindThis - public async fetchMaybe(key: string | null, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + public async fetchMaybe(key: string, fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { const cachedValue = this.get(key); if (cachedValue !== undefined) { if (validator) { @@ -87,3 +256,88 @@ export class Cache { return value; } } + +export class MemorySingleCache { + private cachedAt: number | null = null; + private value: T | undefined; + private lifetime: number; + + constructor(lifetime: MemorySingleCache['lifetime']) { + this.lifetime = lifetime; + } + + @bindThis + public set(value: T): void { + this.cachedAt = Date.now(); + this.value = value; + } + + @bindThis + public get(): T | undefined { + if (this.cachedAt == null) return undefined; + if ((Date.now() - this.cachedAt) > this.lifetime) { + this.value = undefined; + this.cachedAt = null; + return undefined; + } + return this.value; + } + + @bindThis + public delete() { + this.value = undefined; + this.cachedAt = null; + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + */ + @bindThis + public async fetch(fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + const cachedValue = this.get(); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + this.set(value); + return value; + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + * optional: キャッシュが存在してもvalidatorでfalseを返すとキャッシュ無効扱いにします + */ + @bindThis + public async fetchMaybe(fetcher: () => Promise, validator?: (cachedValue: T) => boolean): Promise { + const cachedValue = this.get(); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + if (value !== undefined) { + this.set(value); + } + return value; + } +} diff --git a/packages/backend/src/misc/correct-filename.ts b/packages/backend/src/misc/correct-filename.ts new file mode 100644 index 000000000..23a0699f3 --- /dev/null +++ b/packages/backend/src/misc/correct-filename.ts @@ -0,0 +1,15 @@ +// 与えられた拡張子とファイル名が一致しているかどうかを確認し、 +// 一致していない場合は拡張子を付与して返す +export function correctFilename(filename: string, ext: string | null) { + const dotExt = ext ? ext.startsWith('.') ? ext : `.${ext}` : '.unknown'; + if (filename.endsWith(dotExt)) { + return filename; + } + if (ext === 'jpg' && filename.endsWith('.jpeg')) { + return filename; + } + if (ext === 'tif' && filename.endsWith('.tiff')) { + return filename; + } + return `${filename}${dotExt}`; +} diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts index 85bc2ec94..964f20b25 100644 --- a/packages/backend/src/misc/get-note-summary.ts +++ b/packages/backend/src/misc/get-note-summary.ts @@ -1,4 +1,4 @@ -import type { Packed } from './schema.js'; +import type { Packed } from './json-schema.js'; /** * 投稿を表す文字列を取得します。 diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts index 158e5699c..b69cc0c4f 100644 --- a/packages/backend/src/misc/id/aid.ts +++ b/packages/backend/src/misc/id/aid.ts @@ -23,3 +23,8 @@ export function genAid(date: Date): string { counter++; return getTimeId(t) + getNoise(); } + +export function parseAid(id: string): { date: Date; } { + const time = parseInt(id.slice(0, 8), 36) + TIME2000; + return { date: new Date(time) }; +} diff --git a/packages/backend/src/misc/is-instance-muted.ts b/packages/backend/src/misc/is-instance-muted.ts index e11a18bb7..73ad0b3b8 100644 --- a/packages/backend/src/misc/is-instance-muted.ts +++ b/packages/backend/src/misc/is-instance-muted.ts @@ -1,4 +1,4 @@ -import type { Packed } from './schema.js'; +import type { Packed } from './json-schema.js'; export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set): boolean { if (mutedInstances.has(note.user.host ?? '')) return true; diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts index acf5c1ede..46a66efc0 100644 --- a/packages/backend/src/misc/is-mime-image.ts +++ b/packages/backend/src/misc/is-mime-image.ts @@ -2,8 +2,10 @@ import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; const dictionary = { 'safe-file': FILE_TYPE_BROWSERSAFE, - 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], - 'sharp-animation-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-animation-convertible-image': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml'], + 'sharp-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], + 'sharp-animation-convertible-image-with-bmp': ['image/jpeg', 'image/tiff', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/svg+xml', 'image/x-icon', 'image/bmp'], }; export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/json-schema.ts similarity index 78% rename from packages/backend/src/misc/schema.ts rename to packages/backend/src/misc/json-schema.ts index 6a0802f8a..e748f93a2 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -6,28 +6,29 @@ import { packedMeDetailedSchema, packedUserDetailedSchema, packedUserSchema, -} from '@/models/schema/user.js'; -import { packedNoteSchema } from '@/models/schema/note.js'; -import { packedUserListSchema } from '@/models/schema/user-list.js'; -import { packedAppSchema } from '@/models/schema/app.js'; -import { packedNotificationSchema } from '@/models/schema/notification.js'; -import { packedDriveFileSchema } from '@/models/schema/drive-file.js'; -import { packedDriveFolderSchema } from '@/models/schema/drive-folder.js'; -import { packedFollowingSchema } from '@/models/schema/following.js'; -import { packedMutingSchema } from '@/models/schema/muting.js'; -import { packedBlockingSchema } from '@/models/schema/blocking.js'; -import { packedNoteReactionSchema } from '@/models/schema/note-reaction.js'; -import { packedHashtagSchema } from '@/models/schema/hashtag.js'; -import { packedPageSchema } from '@/models/schema/page.js'; -import { packedNoteFavoriteSchema } from '@/models/schema/note-favorite.js'; -import { packedChannelSchema } from '@/models/schema/channel.js'; -import { packedAntennaSchema } from '@/models/schema/antenna.js'; -import { packedClipSchema } from '@/models/schema/clip.js'; -import { packedFederationInstanceSchema } from '@/models/schema/federation-instance.js'; -import { packedQueueCountSchema } from '@/models/schema/queue.js'; -import { packedGalleryPostSchema } from '@/models/schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/schema/emoji.js'; -import { packedFlashSchema } from '@/models/schema/flash.js'; +} from '@/models/json-schema/user.js'; +import { packedNoteSchema } from '@/models/json-schema/note.js'; +import { packedUserListSchema } from '@/models/json-schema/user-list.js'; +import { packedAppSchema } from '@/models/json-schema/app.js'; +import { packedNotificationSchema } from '@/models/json-schema/notification.js'; +import { packedDriveFileSchema } from '@/models/json-schema/drive-file.js'; +import { packedDriveFolderSchema } from '@/models/json-schema/drive-folder.js'; +import { packedFollowingSchema } from '@/models/json-schema/following.js'; +import { packedMutingSchema } from '@/models/json-schema/muting.js'; +import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; +import { packedBlockingSchema } from '@/models/json-schema/blocking.js'; +import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; +import { packedHashtagSchema } from '@/models/json-schema/hashtag.js'; +import { packedPageSchema } from '@/models/json-schema/page.js'; +import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; +import { packedChannelSchema } from '@/models/json-schema/channel.js'; +import { packedAntennaSchema } from '@/models/json-schema/antenna.js'; +import { packedClipSchema } from '@/models/json-schema/clip.js'; +import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; +import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; +import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; +import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { packedFlashSchema } from '@/models/json-schema/flash.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -48,6 +49,7 @@ export const refs = { DriveFolder: packedDriveFolderSchema, Following: packedFollowingSchema, Muting: packedMutingSchema, + RenoteMuting: packedRenoteMutingSchema, Blocking: packedBlockingSchema, Hashtag: packedHashtagSchema, Page: packedPageSchema, @@ -93,7 +95,7 @@ export interface Schema extends OfSchema { readonly example?: any; readonly format?: string; readonly ref?: keyof typeof refs; - readonly enum?: ReadonlyArray; + readonly enum?: ReadonlyArray; readonly default?: (this['type'] extends TypeStringef ? StringDefToType : any) | null; readonly maxLength?: number; readonly minLength?: number; @@ -159,7 +161,7 @@ export type SchemaTypeDef

= p['type'] extends 'integer' ? number : p['type'] extends 'number' ? number : p['type'] extends 'string' ? ( - p['enum'] extends readonly string[] ? + p['enum'] extends readonly (string | null)[] ? p['enum'][number] : p['format'] extends 'date-time' ? string : // Dateにする?? string diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 311f875ba..7be7b8190 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, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } 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 } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -172,12 +172,6 @@ const $driveFoldersRepository: Provider = { inject: [DI.db], }; -const $notificationsRepository: Provider = { - provide: DI.notificationsRepository, - useFactory: (db: DataSource) => db.getRepository(Notification), - inject: [DI.db], -}; - const $metasRepository: Provider = { provide: DI.metasRepository, useFactory: (db: DataSource) => db.getRepository(Meta), @@ -190,6 +184,12 @@ const $mutingsRepository: Provider = { inject: [DI.db], }; +const $renoteMutingsRepository: Provider = { + provide: DI.renoteMutingsRepository, + useFactory: (db: DataSource) => db.getRepository(RenoteMuting), + inject: [DI.db], +}; + const $blockingsRepository: Provider = { provide: DI.blockingsRepository, useFactory: (db: DataSource) => db.getRepository(Blocking), @@ -280,15 +280,15 @@ const $clipNotesRepository: Provider = { inject: [DI.db], }; -const $antennasRepository: Provider = { - provide: DI.antennasRepository, - useFactory: (db: DataSource) => db.getRepository(Antenna), +const $clipFavoritesRepository: Provider = { + provide: DI.clipFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(ClipFavorite), inject: [DI.db], }; -const $antennaNotesRepository: Provider = { - provide: DI.antennaNotesRepository, - useFactory: (db: DataSource) => db.getRepository(AntennaNote), +const $antennasRepository: Provider = { + provide: DI.antennasRepository, + useFactory: (db: DataSource) => db.getRepository(Antenna), inject: [DI.db], }; @@ -328,9 +328,9 @@ const $channelFollowingsRepository: Provider = { inject: [DI.db], }; -const $channelNotePiningsRepository: Provider = { - provide: DI.channelNotePiningsRepository, - useFactory: (db: DataSource) => db.getRepository(ChannelNotePining), +const $channelFavoritesRepository: Provider = { + provide: DI.channelFavoritesRepository, + useFactory: (db: DataSource) => db.getRepository(ChannelFavorite), inject: [DI.db], }; @@ -420,9 +420,9 @@ const $roleAssignmentsRepository: Provider = { $emojisRepository, $driveFilesRepository, $driveFoldersRepository, - $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, @@ -438,15 +438,15 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, - $antennaNotesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, - $channelNotePiningsRepository, + $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, @@ -486,9 +486,9 @@ const $roleAssignmentsRepository: Provider = { $emojisRepository, $driveFilesRepository, $driveFoldersRepository, - $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, @@ -504,15 +504,15 @@ const $roleAssignmentsRepository: Provider = { $moderationLogsRepository, $clipsRepository, $clipNotesRepository, + $clipFavoritesRepository, $antennasRepository, - $antennaNotesRepository, $promoNotesRepository, $promoReadsRepository, $relaysRepository, $mutedNotesRepository, $channelsRepository, $channelFollowingsRepository, - $channelNotePiningsRepository, + $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, $adsRepository, diff --git a/packages/backend/src/models/entities/Antenna.ts b/packages/backend/src/models/entities/Antenna.ts index 5b2164ef1..e63e7f2c7 100644 --- a/packages/backend/src/models/entities/Antenna.ts +++ b/packages/backend/src/models/entities/Antenna.ts @@ -13,6 +13,10 @@ export class Antenna { }) public createdAt: Date; + @Index() + @Column('timestamp with time zone') + public lastUsedAt: Date; + @Index() @Column({ ...id(), @@ -83,4 +87,10 @@ export class Antenna { @Column('boolean') public notify: boolean; + + @Index() + @Column('boolean', { + default: true, + }) + public isActive: boolean; } diff --git a/packages/backend/src/models/entities/AntennaNote.ts b/packages/backend/src/models/entities/AntennaNote.ts deleted file mode 100644 index 5524a8936..000000000 --- a/packages/backend/src/models/entities/AntennaNote.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; -import { id } from '../id.js'; -import { Note } from './Note.js'; -import { Antenna } from './Antenna.js'; - -@Entity() -@Index(['noteId', 'antennaId'], { unique: true }) -export class AntennaNote { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column({ - ...id(), - comment: 'The note ID.', - }) - public noteId: Note['id']; - - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; - - @Index() - @Column({ - ...id(), - comment: 'The antenna ID.', - }) - public antennaId: Antenna['id']; - - @ManyToOne(type => Antenna, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public antenna: Antenna | null; - - @Index() - @Column('boolean', { - default: false, - }) - public read: boolean; -} diff --git a/packages/backend/src/models/entities/Channel.ts b/packages/backend/src/models/entities/Channel.ts index a6e32d54f..2d346fdf9 100644 --- a/packages/backend/src/models/entities/Channel.ts +++ b/packages/backend/src/models/entities/Channel.ts @@ -59,6 +59,11 @@ export class Channel { @JoinColumn() public banner: DriveFile | null; + @Column('varchar', { + array: true, length: 128, default: '{}', + }) + public pinnedNoteIds: string[]; + @Index() @Column('integer', { default: 0, diff --git a/packages/backend/src/models/entities/ChannelNotePining.ts b/packages/backend/src/models/entities/ChannelFavorite.ts similarity index 59% rename from packages/backend/src/models/entities/ChannelNotePining.ts rename to packages/backend/src/models/entities/ChannelFavorite.ts index ab5796626..cfb2c892c 100644 --- a/packages/backend/src/models/entities/ChannelNotePining.ts +++ b/packages/backend/src/models/entities/ChannelFavorite.ts @@ -1,21 +1,24 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; import { id } from '../id.js'; -import { Note } from './Note.js'; +import { User } from './User.js'; import { Channel } from './Channel.js'; @Entity() -@Index(['channelId', 'noteId'], { unique: true }) -export class ChannelNotePining { +@Index(['userId', 'channelId'], { unique: true }) +export class ChannelFavorite { @PrimaryColumn(id()) public id: string; + @Index() @Column('timestamp with time zone', { - comment: 'The created date of the ChannelNotePining.', + comment: 'The created date of the ChannelFavorite.', }) public createdAt: Date; @Index() - @Column(id()) + @Column({ + ...id(), + }) public channelId: Channel['id']; @ManyToOne(type => Channel, { @@ -24,12 +27,15 @@ export class ChannelNotePining { @JoinColumn() public channel: Channel | null; - @Column(id()) - public noteId: Note['id']; + @Index() + @Column({ + ...id(), + }) + public userId: User['id']; - @ManyToOne(type => Note, { + @ManyToOne(type => User, { onDelete: 'CASCADE', }) @JoinColumn() - public note: Note | null; + public user: User | null; } diff --git a/packages/backend/src/models/entities/Clip.ts b/packages/backend/src/models/entities/Clip.ts index 57a310ac0..825a32c98 100644 --- a/packages/backend/src/models/entities/Clip.ts +++ b/packages/backend/src/models/entities/Clip.ts @@ -12,6 +12,12 @@ export class Clip { }) public createdAt: Date; + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public lastClippedAt: Date | null; + @Index() @Column({ ...id(), diff --git a/packages/backend/src/models/entities/ClipFavorite.ts b/packages/backend/src/models/entities/ClipFavorite.ts new file mode 100644 index 000000000..623471e67 --- /dev/null +++ b/packages/backend/src/models/entities/ClipFavorite.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { Clip } from './Clip.js'; + +@Entity() +@Index(['userId', 'clipId'], { unique: true }) +export class ClipFavorite { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public clipId: Clip['id']; + + @ManyToOne(type => Clip, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public clip: Clip | null; +} diff --git a/packages/backend/src/models/entities/Emoji.ts b/packages/backend/src/models/entities/Emoji.ts index 7332dd185..dbb437d43 100644 --- a/packages/backend/src/models/entities/Emoji.ts +++ b/packages/backend/src/models/entities/Emoji.ts @@ -55,4 +55,9 @@ export class Emoji { array: true, length: 128, default: '{}', }) public aliases: string[]; + + @Column('varchar', { + length: 1024, nullable: true, + }) + public license: string | null; } diff --git a/packages/backend/src/models/entities/Flash.ts b/packages/backend/src/models/entities/Flash.ts index 192f7e7bc..4ccc908a6 100644 --- a/packages/backend/src/models/entities/Flash.ts +++ b/packages/backend/src/models/entities/Flash.ts @@ -43,7 +43,7 @@ export class Flash { public user: User | null; @Column('varchar', { - length: 32768, + length: 65536, }) public script: string; diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts index 9d777c623..2e4f90b57 100644 --- a/packages/backend/src/models/entities/Meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -12,7 +12,7 @@ export class Meta { public id: string; @Column('varchar', { - length: 128, nullable: true, + length: 1024, nullable: true, }) public name: string | null; @@ -25,7 +25,7 @@ export class Meta { * メンテナの名前 */ @Column('varchar', { - length: 128, nullable: true, + length: 1024, nullable: true, }) public maintainerName: string | null; @@ -33,7 +33,7 @@ export class Meta { * メンテナの連絡先 */ @Column('varchar', { - length: 128, nullable: true, + length: 1024, nullable: true, }) public maintainerEmail: string | null; @@ -42,82 +42,69 @@ export class Meta { }) public disableRegistration: boolean; - @Column('boolean', { - default: false, - }) - public useStarForReactionFallback: boolean; - @Column('varchar', { - length: 64, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public langs: string[]; @Column('varchar', { - length: 256, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public pinnedUsers: string[]; @Column('varchar', { - length: 256, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public hiddenTags: string[]; @Column('varchar', { - length: 256, array: true, default: '{}', + length: 1024, array: true, default: '{}', }) public blockedHosts: string[]; @Column('varchar', { - length: 512, array: true, default: '{/featured,/channels,/explore,/pages,/about-misskey}', + length: 1024, array: true, default: '{}', }) - public pinnedPages: string[]; - - @Column({ - ...id(), - nullable: true, - }) - public pinnedClipId: Clip['id'] | null; + public sensitiveWords: string[]; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public themeColor: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, - default: '/assets/ai.png', }) public mascotImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public bannerUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public backgroundImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public logoImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, - default: 'https://xn--931a.moe/aiart/yubitun.png', }) public errorImageUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public iconUrl: string | null; @@ -150,13 +137,13 @@ export class Meta { public enableHcaptcha: boolean; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public hcaptchaSiteKey: string | null; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public hcaptchaSecretKey: string | null; @@ -167,13 +154,13 @@ export class Meta { public enableRecaptcha: boolean; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public recaptchaSiteKey: string | null; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public recaptchaSecretKey: string | null; @@ -184,13 +171,13 @@ export class Meta { public enableTurnstile: boolean; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public turnstileSiteKey: string | null; @Column('varchar', { - length: 64, + length: 1024, nullable: true, }) public turnstileSecretKey: string | null; @@ -218,7 +205,7 @@ export class Meta { public enableSensitiveMediaDetectionForVideos: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public summalyProxy: string | null; @@ -229,7 +216,7 @@ export class Meta { public enableEmail: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public email: string | null; @@ -240,7 +227,7 @@ export class Meta { public smtpSecure: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpHost: string | null; @@ -251,13 +238,13 @@ export class Meta { public smtpPort: number | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpUser: string | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public smtpPass: string | null; @@ -268,19 +255,19 @@ export class Meta { public enableServiceWorker: boolean; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public swPublicKey: string | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public swPrivateKey: string | null; @Column('varchar', { - length: 128, + length: 1024, nullable: true, }) public deeplAuthKey: string | null; @@ -291,20 +278,20 @@ export class Meta { public deeplIsPro: boolean; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) - public ToSUrl: string | null; + public termsOfServiceUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, default: 'https://github.com/misskey-dev/misskey', nullable: false, }) public repositoryUrl: string; @Column('varchar', { - length: 512, + length: 1024, default: 'https://github.com/misskey-dev/misskey/issues/new', nullable: true, }) @@ -328,43 +315,43 @@ export class Meta { public useObjectStorage: boolean; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageBucket: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStoragePrefix: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageBaseUrl: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageEndpoint: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageRegion: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageAccessKey: string | null; @Column('varchar', { - length: 512, + length: 1024, nullable: true, }) public objectStorageSecretKey: string | null; @@ -404,6 +391,16 @@ export class Meta { }) public enableActiveEmailValidation: boolean; + @Column('boolean', { + default: true, + }) + public enableChartsForRemoteUser: boolean; + + @Column('boolean', { + default: true, + }) + public enableChartsForFederatedInstances: boolean; + @Column('jsonb', { default: { }, }) diff --git a/packages/backend/src/models/entities/Note.ts b/packages/backend/src/models/entities/Note.ts index 82d042f0c..df508b4dc 100644 --- a/packages/backend/src/models/entities/Note.ts +++ b/packages/backend/src/models/entities/Note.ts @@ -87,6 +87,11 @@ export class Note { }) public localOnly: boolean; + @Column('varchar', { + length: 64, nullable: true, + }) + public reactionAcceptance: 'likeOnly' | 'likeOnlyForRemote' | null; + @Column('smallint', { default: 0, }) diff --git a/packages/backend/src/models/entities/Notification.ts b/packages/backend/src/models/entities/Notification.ts index 51117efba..aa6f99712 100644 --- a/packages/backend/src/models/entities/Notification.ts +++ b/packages/backend/src/models/entities/Notification.ts @@ -1,54 +1,19 @@ -import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm'; -import { notificationTypes, obsoleteNotificationTypes } from '@/types.js'; -import { id } from '../id.js'; +import { notificationTypes } from '@/types.js'; import { User } from './User.js'; import { Note } from './Note.js'; import { FollowRequest } from './FollowRequest.js'; import { AccessToken } from './AccessToken.js'; -@Entity() -export class Notification { - @PrimaryColumn(id()) - public id: string; +export type Notification = { + id: string; - @Index() - @Column('timestamp with time zone', { - comment: 'The created date of the Notification.', - }) - public createdAt: Date; - - /** - * 通知の受信者 - */ - @Index() - @Column({ - ...id(), - comment: 'The ID of recipient user of the Notification.', - }) - public notifieeId: User['id']; - - @ManyToOne(type => User, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public notifiee: User | null; + // RedisのためDateではなくstring + createdAt: string; /** * 通知の送信者(initiator) */ - @Index() - @Column({ - ...id(), - nullable: true, - comment: 'The ID of sender user of the Notification.', - }) - public notifierId: User['id'] | null; - - @ManyToOne(type => User, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public notifier: User | null; + notifierId: User['id'] | null; /** * 通知の種類。 @@ -64,104 +29,37 @@ export class Notification { * achievementEarned - 実績を獲得 * app - アプリ通知 */ - @Index() - @Column('enum', { - enum: [ - ...notificationTypes, - ...obsoleteNotificationTypes, - ], - comment: 'The type of the Notification.', - }) - public type: typeof notificationTypes[number]; + type: typeof notificationTypes[number]; - /** - * 通知が読まれたかどうか - */ - @Index() - @Column('boolean', { - default: false, - comment: 'Whether the Notification is read.', - }) - public isRead: boolean; + noteId: Note['id'] | null; - @Column({ - ...id(), - nullable: true, - }) - public noteId: Note['id'] | null; + followRequestId: FollowRequest['id'] | null; - @ManyToOne(type => Note, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: Note | null; + reaction: string | null; - @Column({ - ...id(), - nullable: true, - }) - public followRequestId: FollowRequest['id'] | null; + choice: number | null; - @ManyToOne(type => FollowRequest, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public followRequest: FollowRequest | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public reaction: string | null; - - @Column('integer', { - nullable: true, - }) - public choice: number | null; - - @Column('varchar', { - length: 128, nullable: true, - }) - public achievement: string | null; + achievement: string | null; /** * アプリ通知のbody */ - @Column('varchar', { - length: 2048, nullable: true, - }) - public customBody: string | null; + customBody: string | null; /** * アプリ通知のheader * (省略時はアプリ名で表示されることを期待) */ - @Column('varchar', { - length: 256, nullable: true, - }) - public customHeader: string | null; + customHeader: string | null; /** * アプリ通知のicon(URL) * (省略時はアプリアイコンで表示されることを期待) */ - @Column('varchar', { - length: 1024, nullable: true, - }) - public customIcon: string | null; + customIcon: string | null; /** * アプリ通知のアプリ(のトークン) */ - @Index() - @Column({ - ...id(), - nullable: true, - }) - public appAccessTokenId: AccessToken['id'] | null; - - @ManyToOne(type => AccessToken, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public appAccessToken: AccessToken | null; + appAccessTokenId: AccessToken['id'] | null; } diff --git a/packages/backend/src/models/entities/RenoteMuting.ts b/packages/backend/src/models/entities/RenoteMuting.ts new file mode 100644 index 000000000..2f803a5fa --- /dev/null +++ b/packages/backend/src/models/entities/RenoteMuting.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; + +@Entity() +@Index(['muterId', 'muteeId'], { unique: true }) +export class RenoteMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Muting.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The mutee user ID.', + }) + public muteeId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The muter user ID.', + }) + public muterId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/entities/RetentionAggregation.ts b/packages/backend/src/models/entities/RetentionAggregation.ts index c79b762d7..c7bf38b3a 100644 --- a/packages/backend/src/models/entities/RetentionAggregation.ts +++ b/packages/backend/src/models/entities/RetentionAggregation.ts @@ -18,6 +18,12 @@ export class RetentionAggregation { }) public updatedAt: Date; + @Index({ unique: true }) + @Column('varchar', { + length: 512, nullable: false, + }) + public dateKey: string; + @Column({ ...id(), array: true, diff --git a/packages/backend/src/models/entities/Role.ts b/packages/backend/src/models/entities/Role.ts index 399e9ead0..eca9bcf27 100644 --- a/packages/backend/src/models/entities/Role.ts +++ b/packages/backend/src/models/entities/Role.ts @@ -54,6 +54,16 @@ type CondFormulaValueFollowingMoreThanOrEq = { value: number; }; +type CondFormulaValueNotesLessThanOrEq = { + type: 'notesLessThanOrEq'; + value: number; +}; + +type CondFormulaValueNotesMoreThanOrEq = { + type: 'notesMoreThanOrEq'; + value: number; +}; + export type RoleCondFormulaValue = CondFormulaValueAnd | CondFormulaValueOr | @@ -65,7 +75,9 @@ export type RoleCondFormulaValue = CondFormulaValueFollowersLessThanOrEq | CondFormulaValueFollowersMoreThanOrEq | CondFormulaValueFollowingLessThanOrEq | - CondFormulaValueFollowingMoreThanOrEq; + CondFormulaValueFollowingMoreThanOrEq | + CondFormulaValueNotesLessThanOrEq | + CondFormulaValueNotesMoreThanOrEq; @Entity() export class Role { @@ -144,6 +156,12 @@ export class Role { }) public canEditMembersByModerator: boolean; + // UIに表示する際の並び順用(大きいほど先頭) + @Column('integer', { + default: 0, + }) + public displayOrder: number; + @Column('jsonb', { default: { }, }) diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts index 0a8b89ea0..04dfa2110 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -68,6 +68,19 @@ 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('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 +113,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: '{}', diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 25ed9b89d..48d6e15f2 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -4,15 +4,15 @@ import { Ad } from '@/models/entities/Ad.js'; import { Announcement } from '@/models/entities/Announcement.js'; import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; import { Antenna } from '@/models/entities/Antenna.js'; -import { AntennaNote } from '@/models/entities/AntennaNote.js'; import { App } from '@/models/entities/App.js'; import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; +import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; @@ -26,12 +26,12 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js'; -import { Notification } from '@/models/entities/Notification.js'; import { Page } from '@/models/entities/Page.js'; import { PageLike } from '@/models/entities/PageLike.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; @@ -71,15 +71,15 @@ export { Announcement, AnnouncementRead, Antenna, - AntennaNote, App, AttestationChallenge, AuthSession, Blocking, ChannelFollowing, - ChannelNotePining, + ChannelFavorite, Clip, ClipNote, + ClipFavorite, DriveFile, DriveFolder, Emoji, @@ -93,12 +93,12 @@ export { ModerationLog, MutedNote, Muting, + RenoteMuting, Note, NoteFavorite, NoteReaction, NoteThreadMuting, NoteUnread, - Notification, Page, PageLike, PasswordResetRequest, @@ -137,15 +137,15 @@ export type AdsRepository = Repository; export type AnnouncementsRepository = Repository; export type AnnouncementReadsRepository = Repository; export type AntennasRepository = Repository; -export type AntennaNotesRepository = Repository; export type AppsRepository = Repository; export type AttestationChallengesRepository = Repository; export type AuthSessionsRepository = Repository; export type BlockingsRepository = Repository; export type ChannelFollowingsRepository = Repository; -export type ChannelNotePiningsRepository = Repository; +export type ChannelFavoritesRepository = Repository; export type ClipsRepository = Repository; export type ClipNotesRepository = Repository; +export type ClipFavoritesRepository = Repository; export type DriveFilesRepository = Repository; export type DriveFoldersRepository = Repository; export type EmojisRepository = Repository; @@ -159,12 +159,12 @@ export type MetasRepository = Repository; export type ModerationLogsRepository = Repository; export type MutedNotesRepository = Repository; export type MutingsRepository = Repository; +export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; export type NoteThreadMutingsRepository = Repository; export type NoteUnreadsRepository = Repository; -export type NotificationsRepository = Repository; export type PagesRepository = Repository; export type PageLikesRepository = Repository; export type PasswordResetRequestsRepository = Repository; diff --git a/packages/backend/src/models/schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts similarity index 95% rename from packages/backend/src/models/schema/antenna.ts rename to packages/backend/src/models/json-schema/antenna.ts index f0994e48f..448351061 100644 --- a/packages/backend/src/models/schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -75,6 +75,10 @@ export const packedAntennaSchema = { type: 'boolean', optional: false, nullable: false, }, + isActive: { + type: 'boolean', + optional: false, nullable: false, + }, hasUnreadNote: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/app.ts b/packages/backend/src/models/json-schema/app.ts similarity index 100% rename from packages/backend/src/models/schema/app.ts rename to packages/backend/src/models/json-schema/app.ts diff --git a/packages/backend/src/models/schema/blocking.ts b/packages/backend/src/models/json-schema/blocking.ts similarity index 100% rename from packages/backend/src/models/schema/blocking.ts rename to packages/backend/src/models/json-schema/blocking.ts diff --git a/packages/backend/src/models/schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts similarity index 81% rename from packages/backend/src/models/schema/channel.ts rename to packages/backend/src/models/json-schema/channel.ts index 7f4f2a48b..745b39a6b 100644 --- a/packages/backend/src/models/schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -42,10 +42,22 @@ export const packedChannelSchema = { type: 'boolean', optional: true, nullable: false, }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, userId: { type: 'string', nullable: true, optional: false, format: 'id', }, + pinnedNoteIds: { + type: 'array', + nullable: false, optional: false, + items: { + type: 'string', + format: 'id', + }, + }, }, } as const; diff --git a/packages/backend/src/models/schema/clip.ts b/packages/backend/src/models/json-schema/clip.ts similarity index 72% rename from packages/backend/src/models/schema/clip.ts rename to packages/backend/src/models/json-schema/clip.ts index f0ee2ce0c..7310e5901 100644 --- a/packages/backend/src/models/schema/clip.ts +++ b/packages/backend/src/models/json-schema/clip.ts @@ -12,6 +12,11 @@ export const packedClipSchema = { optional: false, nullable: false, format: 'date-time', }, + lastClippedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, userId: { type: 'string', optional: false, nullable: false, @@ -34,5 +39,13 @@ export const packedClipSchema = { type: 'boolean', optional: false, nullable: false, }, + isFavorited: { + type: 'boolean', + optional: true, nullable: false, + }, + favoritedCount: { + type: 'number', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/schema/drive-file.ts b/packages/backend/src/models/json-schema/drive-file.ts similarity index 100% rename from packages/backend/src/models/schema/drive-file.ts rename to packages/backend/src/models/json-schema/drive-file.ts diff --git a/packages/backend/src/models/schema/drive-folder.ts b/packages/backend/src/models/json-schema/drive-folder.ts similarity index 100% rename from packages/backend/src/models/schema/drive-folder.ts rename to packages/backend/src/models/json-schema/drive-folder.ts diff --git a/packages/backend/src/models/schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts similarity index 94% rename from packages/backend/src/models/schema/emoji.ts rename to packages/backend/src/models/json-schema/emoji.ts index c00c3dac1..db4fd62cf 100644 --- a/packages/backend/src/models/schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -59,5 +59,9 @@ export const packedEmojiDetailedSchema = { type: 'string', optional: false, nullable: false, }, + license: { + type: 'string', + optional: false, nullable: true, + }, }, } as const; diff --git a/packages/backend/src/models/schema/federation-instance.ts b/packages/backend/src/models/json-schema/federation-instance.ts similarity index 100% rename from packages/backend/src/models/schema/federation-instance.ts rename to packages/backend/src/models/json-schema/federation-instance.ts diff --git a/packages/backend/src/models/schema/flash.ts b/packages/backend/src/models/json-schema/flash.ts similarity index 100% rename from packages/backend/src/models/schema/flash.ts rename to packages/backend/src/models/json-schema/flash.ts diff --git a/packages/backend/src/models/schema/following.ts b/packages/backend/src/models/json-schema/following.ts similarity index 100% rename from packages/backend/src/models/schema/following.ts rename to packages/backend/src/models/json-schema/following.ts diff --git a/packages/backend/src/models/schema/gallery-post.ts b/packages/backend/src/models/json-schema/gallery-post.ts similarity index 100% rename from packages/backend/src/models/schema/gallery-post.ts rename to packages/backend/src/models/json-schema/gallery-post.ts diff --git a/packages/backend/src/models/schema/hashtag.ts b/packages/backend/src/models/json-schema/hashtag.ts similarity index 100% rename from packages/backend/src/models/schema/hashtag.ts rename to packages/backend/src/models/json-schema/hashtag.ts diff --git a/packages/backend/src/models/schema/muting.ts b/packages/backend/src/models/json-schema/muting.ts similarity index 100% rename from packages/backend/src/models/schema/muting.ts rename to packages/backend/src/models/json-schema/muting.ts diff --git a/packages/backend/src/models/schema/note-favorite.ts b/packages/backend/src/models/json-schema/note-favorite.ts similarity index 100% rename from packages/backend/src/models/schema/note-favorite.ts rename to packages/backend/src/models/json-schema/note-favorite.ts diff --git a/packages/backend/src/models/schema/note-reaction.ts b/packages/backend/src/models/json-schema/note-reaction.ts similarity index 100% rename from packages/backend/src/models/schema/note-reaction.ts rename to packages/backend/src/models/json-schema/note-reaction.ts diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/json-schema/note.ts similarity index 97% rename from packages/backend/src/models/schema/note.ts rename to packages/backend/src/models/json-schema/note.ts index 72c0c6228..58ef425dc 100644 --- a/packages/backend/src/models/schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -141,6 +141,10 @@ export const packedNoteSchema = { type: 'boolean', optional: true, nullable: false, }, + reactionAcceptance: { + type: 'string', + optional: false, nullable: true, + }, reactions: { type: 'object', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts similarity index 93% rename from packages/backend/src/models/schema/notification.ts rename to packages/backend/src/models/json-schema/notification.ts index d3f2405cd..e88ca61ba 100644 --- a/packages/backend/src/models/schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -14,10 +14,6 @@ export const packedNotificationSchema = { optional: false, nullable: false, format: 'date-time', }, - isRead: { - type: 'boolean', - optional: false, nullable: false, - }, type: { type: 'string', optional: false, nullable: false, diff --git a/packages/backend/src/models/schema/page.ts b/packages/backend/src/models/json-schema/page.ts similarity index 100% rename from packages/backend/src/models/schema/page.ts rename to packages/backend/src/models/json-schema/page.ts diff --git a/packages/backend/src/models/schema/queue.ts b/packages/backend/src/models/json-schema/queue.ts similarity index 100% rename from packages/backend/src/models/schema/queue.ts rename to packages/backend/src/models/json-schema/queue.ts diff --git a/packages/backend/src/models/json-schema/renote-muting.ts b/packages/backend/src/models/json-schema/renote-muting.ts new file mode 100644 index 000000000..69ed8510d --- /dev/null +++ b/packages/backend/src/models/json-schema/renote-muting.ts @@ -0,0 +1,26 @@ +export const packedRenoteMutingSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + muteeId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + mutee: { + type: 'object', + optional: false, nullable: false, + ref: 'UserDetailed', + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user-list.ts b/packages/backend/src/models/json-schema/user-list.ts similarity index 100% rename from packages/backend/src/models/schema/user-list.ts rename to packages/backend/src/models/json-schema/user-list.ts diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/json-schema/user.ts similarity index 96% rename from packages/backend/src/models/schema/user.ts rename to packages/backend/src/models/json-schema/user.ts index c390018b4..7d40979e3 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -29,7 +29,7 @@ export const packedUserLiteSchema = { nullable: true, optional: false, }, avatarBlurhash: { - type: 'any', + type: 'string', nullable: true, optional: false, }, isAdmin: { @@ -72,6 +72,18 @@ export const packedUserDetailedNotMeOnlySchema = { format: 'uri', nullable: true, optional: false, }, + movedToUri: { + type: 'string', + format: 'uri', + nullable: true, + optional: false, + }, + alsoKnownAs: { + type: 'array', + format: 'uri', + nullable: true, + optional: false, + }, createdAt: { type: 'string', nullable: false, optional: false, @@ -93,7 +105,7 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: true, optional: false, }, bannerBlurhash: { - type: 'any', + type: 'string', nullable: true, optional: false, }, isLocked: { @@ -234,6 +246,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + isRenoteMuted: { + type: 'boolean', + nullable: false, optional: true, + }, //#endregion }, } as const; @@ -307,10 +323,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 c2ee14b0f..bb21ed827 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -12,15 +12,15 @@ import { Ad } from '@/models/entities/Ad.js'; import { Announcement } from '@/models/entities/Announcement.js'; import { AnnouncementRead } from '@/models/entities/AnnouncementRead.js'; import { Antenna } from '@/models/entities/Antenna.js'; -import { AntennaNote } from '@/models/entities/AntennaNote.js'; import { App } from '@/models/entities/App.js'; import { AttestationChallenge } from '@/models/entities/AttestationChallenge.js'; import { AuthSession } from '@/models/entities/AuthSession.js'; import { Blocking } from '@/models/entities/Blocking.js'; import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js'; -import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js'; +import { ChannelFavorite } from '@/models/entities/ChannelFavorite.js'; import { Clip } from '@/models/entities/Clip.js'; import { ClipNote } from '@/models/entities/ClipNote.js'; +import { ClipFavorite } from '@/models/entities/ClipFavorite.js'; import { DriveFile } from '@/models/entities/DriveFile.js'; import { DriveFolder } from '@/models/entities/DriveFolder.js'; import { Emoji } from '@/models/entities/Emoji.js'; @@ -34,12 +34,12 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js'; -import { Notification } from '@/models/entities/Notification.js'; import { Page } from '@/models/entities/Page.js'; import { PageLike } from '@/models/entities/PageLike.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; @@ -139,6 +139,7 @@ export const entities = [ Following, FollowRequest, Muting, + RenoteMuting, Blocking, Note, NoteFavorite, @@ -153,7 +154,6 @@ export const entities = [ DriveFolder, Poll, PollVote, - Notification, Emoji, Hashtag, SwSubscription, @@ -163,15 +163,15 @@ export const entities = [ ModerationLog, Clip, ClipNote, + ClipFavorite, Antenna, - AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, - ChannelNotePining, + ChannelFavorite, RegistryItem, Ad, PasswordResetRequest, @@ -200,6 +200,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/processors/AggregateRetentionProcessorService.ts b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts index 02324c6cd..e2720b4fe 100644 --- a/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts +++ b/packages/backend/src/queue/processors/AggregateRetentionProcessorService.ts @@ -7,6 +7,7 @@ import { bindThis } from '@/decorators.js'; import type { RetentionAggregationsRepository, UsersRepository } from '@/models/index.js'; import { deepClone } from '@/misc/clone.js'; import { IdService } from '@/core/IdService.js'; +import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; @@ -49,13 +50,23 @@ export class AggregateRetentionProcessorService { }); const targetUserIds = targetUsers.map(u => u.id); - await this.retentionAggregationsRepository.insert({ - id: this.idService.genId(), - createdAt: now, - updatedAt: now, - userIds: targetUserIds, - usersCount: targetUserIds.length, - }); + try { + await this.retentionAggregationsRepository.insert({ + id: this.idService.genId(), + createdAt: now, + updatedAt: now, + dateKey, + userIds: targetUserIds, + usersCount: targetUserIds.length, + }); + } catch (err) { + if (isDuplicateKeyValueError(err)) { + this.logger.succ('Skip because it has already been processed by another worker.'); + done(); + return; + } + throw err; + } // 今日活動したユーザーを全て取得 const activeUsers = await this.usersRepository.findBy({ 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/CleanProcessorService.ts b/packages/backend/src/queue/processors/CleanProcessorService.ts index 7fd2cde9c..1936e8df2 100644 --- a/packages/backend/src/queue/processors/CleanProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanProcessorService.ts @@ -1,7 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In, LessThan } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { AntennaNotesRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; +import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; @@ -20,14 +20,11 @@ export class CleanProcessorService { @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - @Inject(DI.mutedNotesRepository) private mutedNotesRepository: MutedNotesRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, + @Inject(DI.antennasRepository) + private antennasRepository: AntennasRepository, @Inject(DI.roleAssignmentsRepository) private roleAssignmentsRepository: RoleAssignmentsRepository, @@ -46,8 +43,9 @@ export class CleanProcessorService { createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), }); - this.notificationsRepository.delete({ - createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), + this.mutedNotesRepository.delete({ + id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), + reason: 'word', }); this.mutedNotesRepository.delete({ @@ -55,8 +53,11 @@ export class CleanProcessorService { reason: 'word', }); - this.antennaNotesRepository.delete({ - id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), + // 7日以上使われてないアンテナを停止 + this.antennasRepository.update({ + lastUsedAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 7))), + }, { + isActive: false, }); const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign') diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 2a053a12e..0e99b7bcd 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 { Cache } 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: Cache; + 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 Cache(1000 * 60 * 60); + this.suspendedHostsCache = new MemorySingleCache(1000 * 60 * 60); } @bindThis @@ -60,14 +60,14 @@ export class DeliverProcessorService { } // isSuspendedなら中断 - let suspendedHosts = this.suspendedHostsCache.get(null); + let suspendedHosts = this.suspendedHostsCache.get(); if (suspendedHosts == null) { suspendedHosts = await this.instancesRepository.find({ where: { isSuspended: true, }, }); - this.suspendedHostsCache.set(null, suspendedHosts); + this.suspendedHostsCache.set(suspendedHosts); } if (suspendedHosts.map(x => x.host).includes(this.utilityService.toPuny(host))) { return 'skip (suspended)'; @@ -88,10 +88,12 @@ export class DeliverProcessorService { } this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - - this.instanceChart.requestSent(i.host, true); this.apRequestChart.deliverSucc(); this.federationChart.deliverd(i.host, true); + + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestSent(i.host, true); + } }); return 'Success'; @@ -107,14 +109,29 @@ export class DeliverProcessorService { }); } - this.instanceChart.requestSent(i.host, false); this.apRequestChart.deliverFail(); this.federationChart.deliverd(i.host, false); + + if (meta.enableChartsForFederatedInstances) { + this.instanceChart.requestSent(i.host, false); + } }); if (res instanceof StatusError) { // 4xx if (res.isClientError) { + // 相手が閉鎖していることを明示しているため、配送停止する + if (job.data.isSharedInbox && res.statusCode === 410) { + this.federatedInstanceService.fetch(host).then(i => { + this.instancesRepository.update(i.id, { + isSuspended: true, + }); + this.federatedInstanceService.updateCachePartial(host, { + isSuspended: true, + }); + }); + return `${host} is gone`; + } // HTTPステータスコード4xxはクライアントエラーであり、それはつまり // 何回再送しても成功することはないということなのでエラーにはしないでおく return `${res.statusCode} ${res.statusMessage}`; diff --git a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts index 037dfa1a5..501ed4090 100644 --- a/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts +++ b/packages/backend/src/queue/processors/EndedPollNotificationProcessorService.ts @@ -3,11 +3,11 @@ import { DI } from '@/di-symbols.js'; import type { PollVotesRepository, NotesRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type Bull from 'bull'; import type { EndedPollNotificationJobData } from '../types.js'; -import { bindThis } from '@/decorators.js'; @Injectable() export class EndedPollNotificationProcessorService { @@ -23,7 +23,7 @@ export class EndedPollNotificationProcessorService { @Inject(DI.pollVotesRepository) private pollVotesRepository: PollVotesRepository, - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('ended-poll-notification'); @@ -47,7 +47,7 @@ export class EndedPollNotificationProcessorService { const userIds = [...new Set([note.userId, ...votes.map(v => v.userId)])]; for (const userId of userIds) { - this.createNotificationService.createNotification(userId, 'pollEnded', { + this.notificationService.createNotification(userId, 'pollEnded', { noteId: note.id, }); } diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index c65f0a97a..e9330772b 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -148,6 +148,7 @@ function serialize(favorite: NoteFavorite & { note: Note & { user: User } }, pol visibility: favorite.note.visibility, visibleUserIds: favorite.note.visibleUserIds, localOnly: favorite.note.localOnly, + reactionAcceptance: favorite.note.reactionAcceptance, uri: favorite.note.uri, url: favorite.note.url, user: { diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index 3f4f16a2e..2f74dd63c 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -10,10 +10,10 @@ import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import type { Poll } from '@/models/entities/Poll.js'; 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 { bindThis } from '@/decorators.js'; @Injectable() export class ExportNotesProcessorService { @@ -141,5 +141,6 @@ function serialize(note: Note, poll: Poll | null = null): Record { - this.logger.error(err); - reply.code(500); - reply.header('Cache-Control', 'max-age=300'); - }; - } - @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { fastify.addHook('onRequest', (request, reply, done) => { @@ -67,11 +59,6 @@ export class FileServerService { done(); }); - fastify.register(fastifyStatic, { - root: _dirname, - serve: false, - }); - fastify.get('/files/app-default.jpg', (request, reply) => { const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); reply.header('Content-Type', 'image/jpeg'); @@ -140,7 +127,7 @@ export class FileServerService { let image: IImageStreamable | null = null; if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image')) { + if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { reply.header('Cache-Control', 'max-age=31536000, immutable'); const url = new URL(`${this.config.mediaProxy}/static.webp`); @@ -190,13 +177,19 @@ export class FileServerService { } reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext) + ) + ); return image.data; } if (file.fileRole !== 'original') { - const filename = rename(file.file.name, { + const filename = rename(file.filename, { suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : undefined, + extname: file.ext ? `.${file.ext}` : '.unknown', }).toString(); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); @@ -204,12 +197,10 @@ export class FileServerService { reply.header('Content-Disposition', contentDisposition('inline', filename)); return fs.createReadStream(file.path); } else { - const stream = fs.createReadStream(file.path); - stream.on('error', this.commonReadableHandlerGenerator(reply)); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.file.name)); - return stream; + reply.header('Content-Disposition', contentDisposition('inline', file.filename)); + return fs.createReadStream(file.path); } } catch (e) { if ('cleanup' in file) file.cleanup(); @@ -261,8 +252,8 @@ export class FileServerService { } try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image'); - const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image'); + const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); + const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); if ( 'emoji' in request.query || @@ -286,7 +277,7 @@ export class FileServerService { type: file.mime, }; } else { - const data = sharp(file.path, { animated: !('static' in request.query) }) + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) .resize({ height: 'emoji' in request.query ? 128 : 320, withoutEnlargement: true, @@ -300,11 +291,11 @@ export class FileServerService { }; } } else if ('static' in request.query) { - image = this.imageProcessingService.convertToWebpStream(file.path, 498, 280); + image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); } else if ('preview' in request.query) { - image = this.imageProcessingService.convertToWebpStream(file.path, 200, 200); + image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); } else if ('badge' in request.query) { - const mask = sharp(file.path) + const mask = (await sharpBmp(file.path, file.mime)) .resize(96, 96, { fit: 'inside', withoutEnlargement: false, @@ -314,20 +305,20 @@ export class FileServerService { .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast .flatten({ background: '#000' }) .toColorspace('b-w'); - + const stats = await mask.clone().stats(); - + if (stats.entropy < 0.1) { // エントロピーがあまりない場合は404にする throw new StatusError('Skip to provide badge', 404); } - + const data = sharp({ create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, }) .pipelineColorspace('b-w') .boolean(await mask.png().toBuffer(), 'eor'); - + image = { data: await data.png().toBuffer(), ext: 'png', @@ -360,6 +351,12 @@ export class FileServerService { reply.header('Content-Type', image.type); reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext) + ) + ); return image.data; } catch (e) { if ('cleanup' in file) file.cleanup(); @@ -369,8 +366,8 @@ export class FileServerService { @bindThis private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } + { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } + | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; } | '404' | '204' > { @@ -386,18 +383,19 @@ export class FileServerService { @bindThis private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; } + { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } > { const [path, cleanup] = await createTemp(); try { - await this.downloadService.downloadUrl(url, path); + const { filename } = await this.downloadService.downloadUrl(url, path); const { mime, ext } = await this.fileInfoService.detectType(path); - + return { state: 'remote', mime, ext, path, cleanup, + filename, }; } catch (e) { cleanup(); @@ -407,8 +405,8 @@ export class FileServerService { @bindThis private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } + { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } + | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; filename: string; mime: string; ext: string | null; path: string; } | '404' | '204' > { @@ -432,6 +430,7 @@ export class FileServerService { url: file.uri, fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', file, + filename: file.name, }; } @@ -443,6 +442,7 @@ export class FileServerService { state: 'stored_internal', fileRole: isThumbnail ? 'thumbnail' : 'webpublic', file, + filename: file.name, mime, ext, path, }; @@ -452,6 +452,7 @@ export class FileServerService { state: 'stored_internal', fileRole: 'original', file, + filename: file.name, mime: file.type, ext: null, path, diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 00a0d9309..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 { Cache } 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'; @@ -100,7 +100,7 @@ export class NodeinfoServerService { email: meta.maintainerEmail, }, langs: meta.langs, - tosUrl: meta.ToSUrl, + tosUrl: meta.termsOfServiceUrl, repositoryUrl: meta.repositoryUrl, feedbackUrl: meta.feedbackUrl, disableRegistration: meta.disableRegistration, @@ -118,17 +118,17 @@ export class NodeinfoServerService { }; }; - const cache = new Cache>>(1000 * 60 * 10); + const cache = new MemorySingleCache>>(1000 * 60 * 10); fastify.get(nodeinfo2_1path, async (request, reply) => { - const base = await cache.fetch(null, () => nodeinfo2()); + const base = await cache.fetch(() => nodeinfo2()); reply.header('Cache-Control', 'public, max-age=600'); return { version: '2.1', ...base }; }); fastify.get(nodeinfo2_0path, async (request, reply) => { - const base = await cache.fetch(null, () => nodeinfo2()); + const base = await cache.fetch(() => nodeinfo2()); delete (base as any).software.repository; diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index a5a5f9e7f..6bae0bafd 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -33,6 +33,7 @@ import { LocalTimelineChannelService } from './api/stream/channels/local-timelin 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'; @Module({ imports: [ @@ -72,6 +73,7 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; QueueStatsChannelService, ServerStatsChannelService, UserListChannelService, + OpenApiServerService, ], exports: [ ServerService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 1c5ce7f2d..68850d5b4 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -1,8 +1,10 @@ import cluster from 'node:cluster'; import os from 'node:os'; import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import Fastify, { FastifyInstance } from 'fastify'; +import fastifyStatic from '@fastify/static'; import { IsNull } from 'typeorm'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; @@ -22,6 +24,9 @@ import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; import { FileServerService } from './FileServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; +import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; + +const _dirname = fileURLToPath(new URL('.', import.meta.url)); @Injectable() export class ServerService implements OnApplicationShutdown { @@ -43,6 +48,7 @@ export class ServerService implements OnApplicationShutdown { private userEntityService: UserEntityService, private apiServerService: ApiServerService, + private openApiServerService: OpenApiServerService, private streamingApiServerService: StreamingApiServerService, private activityPubServerService: ActivityPubServerService, private wellKnownServerService: WellKnownServerService, @@ -72,13 +78,15 @@ export class ServerService implements OnApplicationShutdown { }); } - const hostname = os.hostname(); - fastify.addHook('onRequest', (request, reply, done) => { - reply.header('x-worker-host', hostname); - done(); + // Register non-serving static server so that the child services can use reply.sendFile. + // `root` here is just a placeholder and each call must use its own `rootPath`. + fastify.register(fastifyStatic, { + root: _dirname, + serve: false, }); fastify.register(this.apiServerService.createServer, { prefix: '/api' }); + fastify.register(this.openApiServerService.createServer); fastify.register(this.fileServerService.createServer); fastify.register(this.activityPubServerService.createServer); fastify.register(this.nodeinfoServerService.createServer); @@ -142,13 +150,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/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index f84a3aa59..bf5cb2091 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -75,7 +75,7 @@ export class ApiCallService implements OnApplicationShutdown { } this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -129,7 +129,7 @@ export class ApiCallService implements OnApplicationShutdown { }, request).then((res) => { this.send(reply, res); }).catch((err: ApiError) => { - this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : 500, err); + this.send(reply, err.httpStatusCode ? err.httpStatusCode : err.kind === 'client' ? 400 : err.kind === 'permission' ? 403 : 500, err); }); if (user) { @@ -321,7 +321,7 @@ export class ApiCallService implements OnApplicationShutdown { // API invoking return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { - if (err instanceof ApiError) { + if (err instanceof ApiError || err instanceof AuthenticationError) { throw err; } else { const errId = uuid(); diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 115d60986..b806ad5ca 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -167,7 +167,7 @@ export class ApiServerService { // Make sure any unknown path under /api returns HTTP 404 Not Found, // because otherwise ClientServerService will return the base client HTML // page with HTTP 200. - fastify.get('*', (request, reply) => { + fastify.get('/*', (request, reply) => { reply.code(404); // Mock ApiCallService.send's error handling reply.send({ diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 87438c348..6548c475b 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -3,9 +3,9 @@ import { DI } from '@/di-symbols.js'; import type { AccessTokensRepository, AppsRepository, UsersRepository } from '@/models/index.js'; import type { LocalUser } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; -import { Cache } from '@/misc/cache.js'; +import { MemoryKVCache } from '@/misc/cache.js'; import type { App } from '@/models/entities/App.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import isNativeToken from '@/misc/is-native-token.js'; import { bindThis } from '@/decorators.js'; @@ -18,7 +18,7 @@ export class AuthenticationError extends Error { @Injectable() export class AuthenticateService { - private appCache: Cache; + private appCache: MemoryKVCache; constructor( @Inject(DI.usersRepository) @@ -30,9 +30,9 @@ export class AuthenticateService { @Inject(DI.appsRepository) private appsRepository: AppsRepository, - private userCacheService: UserCacheService, + private cacheService: CacheService, ) { - this.appCache = new Cache(Infinity); + this.appCache = new MemoryKVCache(Infinity); } @bindThis @@ -42,7 +42,7 @@ export class AuthenticateService { } if (isNativeToken(token)) { - const user = await this.userCacheService.localUserByNativeTokenCache.fetch(token, + const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise); if (user == null) { @@ -67,7 +67,7 @@ export class AuthenticateService { lastUsedAt: new Date(), }); - const user = await this.userCacheService.localUserByIdCache.fetch(accessToken.userId, + const user = await this.cacheService.localUserByIdCache.fetch(accessToken.userId, () => this.usersRepository.findOneBy({ id: accessToken.userId, }) as Promise); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index d3e2219bd..5a53b3faf 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -42,6 +42,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; +import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; @@ -94,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js'; import * as ep___channels_timeline from './endpoints/channels/timeline.js'; import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; 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___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'; @@ -114,6 +118,9 @@ import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_notes from './endpoints/clips/notes.js'; import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; +import * as ep___clips_favorite from './endpoints/clips/favorite.js'; +import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; +import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; @@ -213,6 +220,8 @@ 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_knownAs from './endpoints/i/known-as.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'; @@ -220,10 +229,14 @@ import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; +import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; import * as ep___mute_list from './endpoints/mute/list.js'; +import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; +import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; +import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; import * as ep___my_apps from './endpoints/my/apps.js'; import * as ep___notes from './endpoints/notes.js'; import * as ep___notes_children from './endpoints/notes/children.js'; @@ -257,7 +270,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_read from './endpoints/notifications/read.js'; import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_delete from './endpoints/pages/delete.js'; @@ -363,6 +375,7 @@ const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useCla const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default }; +const $admin_queue_promote: Provider = { provide: 'ep:admin/queue/promote', useClass: ep___admin_queue_promote.default }; const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default }; const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default }; const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default }; @@ -415,6 +428,9 @@ const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___c const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default }; const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default }; const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default }; +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 $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 }; @@ -435,6 +451,9 @@ const $clips_list: Provider = { provide: 'ep:clips/list', useClass: ep___clips_l const $clips_notes: Provider = { provide: 'ep:clips/notes', useClass: ep___clips_notes.default }; const $clips_show: Provider = { provide: 'ep:clips/show', useClass: ep___clips_show.default }; const $clips_update: Provider = { provide: 'ep:clips/update', useClass: ep___clips_update.default }; +const $clips_favorite: Provider = { provide: 'ep:clips/favorite', useClass: ep___clips_favorite.default }; +const $clips_unfavorite: Provider = { provide: 'ep:clips/unfavorite', useClass: ep___clips_unfavorite.default }; +const $clips_myFavorites: Provider = { provide: 'ep:clips/my-favorites', useClass: ep___clips_myFavorites.default }; const $drive: Provider = { provide: 'ep:drive', useClass: ep___drive.default }; const $drive_files: Provider = { provide: 'ep:drive/files', useClass: ep___drive_files.default }; const $drive_files_attachedNotes: Provider = { provide: 'ep:drive/files/attached-notes', useClass: ep___drive_files_attachedNotes.default }; @@ -534,6 +553,8 @@ 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_knownAs: Provider = { provide: 'ep:i/known-as', useClass: ep___i_knownAs.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 }; @@ -541,10 +562,14 @@ const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default }; const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default }; +const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default }; const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default }; const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default }; const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default }; +const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default }; +const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default }; +const $renoteMute_list: Provider = { provide: 'ep:renote-mute/list', useClass: ep___renoteMute_list.default }; const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default }; const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default }; const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default }; @@ -578,7 +603,6 @@ const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep__ const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; -const $notifications_read: Provider = { provide: 'ep:notifications/read', useClass: ep___notifications_read.default }; const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default }; const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default }; @@ -688,6 +712,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_queue_clear, $admin_queue_deliverDelayed, $admin_queue_inboxDelayed, + $admin_queue_promote, $admin_queue_stats, $admin_relays_add, $admin_relays_list, @@ -740,6 +765,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_timeline, $channels_unfollow, $channels_update, + $channels_favorite, + $channels_unfavorite, + $channels_myFavorites, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -760,6 +788,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $clips_notes, $clips_show, $clips_update, + $clips_favorite, + $clips_unfavorite, + $clips_myFavorites, $drive, $drive_files, $drive_files_attachedNotes, @@ -859,6 +890,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_move, + $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -866,10 +899,14 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_delete, $meta, $emojis, + $emoji, $miauth_genToken, $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, @@ -903,7 +940,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_userListTimeline, $notifications_create, $notifications_markAllAsRead, - $notifications_read, $pagePush, $pages_create, $pages_delete, @@ -1007,6 +1043,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_queue_clear, $admin_queue_deliverDelayed, $admin_queue_inboxDelayed, + $admin_queue_promote, $admin_queue_stats, $admin_relays_add, $admin_relays_list, @@ -1059,6 +1096,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $channels_timeline, $channels_unfollow, $channels_update, + $channels_favorite, + $channels_unfavorite, + $channels_myFavorites, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -1079,6 +1119,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $clips_notes, $clips_show, $clips_update, + $clips_favorite, + $clips_unfavorite, + $clips_myFavorites, $drive, $drive_files, $drive_files_attachedNotes, @@ -1178,6 +1221,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_unpin, $i_updateEmail, $i_update, + $i_move, + $i_knownAs, $i_webhooks_create, $i_webhooks_list, $i_webhooks_show, @@ -1185,10 +1230,14 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_delete, $meta, $emojis, + $emoji, $miauth_genToken, $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, @@ -1222,7 +1271,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $notes_userListTimeline, $notifications_create, $notifications_markAllAsRead, - $notifications_read, $pagePush, $pages_create, $pages_delete, diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 41e8365d0..fbabf47af 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import rndstr from 'rndstr'; import bcrypt from 'bcryptjs'; import { DI } from '@/di-symbols.js'; -import type { RegistrationTicketsRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; +import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; @@ -15,6 +15,7 @@ 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 { @@ -31,6 +32,9 @@ export class SignupApiService { @Inject(DI.userPendingsRepository) private userPendingsRepository: UserPendingsRepository, + @Inject(DI.usedUsernamesRepository) + private usedUsernamesRepository: UsedUsernamesRepository, + @Inject(DI.registrationTicketsRepository) private registrationTicketsRepository: RegistrationTicketsRepository, @@ -124,12 +128,21 @@ export class SignupApiService { } if (instance.emailRequiredForSignup) { + if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) { + throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); + } + + // Check deleted username duplication + if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) { + throw new FastifyReplyError(400, 'USED_USERNAME'); + } + const code = rndstr('a-z0-9', 16); - + // Generate hash of password const salt = await bcrypt.genSalt(8); const hash = await bcrypt.hash(password, salt); - + await this.userPendingsRepository.insert({ id: this.idService.genId(), createdAt: new Date(), @@ -138,13 +151,13 @@ export class SignupApiService { username: username, password: hash, }); - + const link = `${this.config.url}/signup-complete/${code}`; - + this.emailService.sendEmail(emailAddress!, 'Signup', `To complete signup, please click this link:
${link}`, `To complete signup, please click this link: ${link}`); - + reply.code(204); return; } else { diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 487eef2d5..e0e5b71a8 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -3,17 +3,18 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import * as websocket from 'websocket'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository } from '@/models/index.js'; +import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; 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'; import type { ParsedUrlQuery } from 'querystring'; import type * as http from 'node:http'; -import { bindThis } from '@/decorators.js'; @Injectable() export class StreamingApiServerService { @@ -21,8 +22,8 @@ export class StreamingApiServerService { @Inject(DI.config) private config: Config, - @Inject(DI.redisSubscriber) - private redisSubscriber: Redis.Redis, + @Inject(DI.redisForPubsub) + private redisForPubsub: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -33,6 +34,9 @@ export class StreamingApiServerService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -42,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, @@ -70,8 +74,6 @@ export class StreamingApiServerService { return; } - const connection = request.accept(); - const ev = new EventEmitter(); async function onRedisMessage(_: string, data: string): Promise { @@ -79,21 +81,22 @@ export class StreamingApiServerService { ev.emit(parsed.channel, parsed.message); } - this.redisSubscriber.on('message', onRedisMessage); + this.redisForPubsub.on('message', onRedisMessage); const main = new MainStreamConnection( - this.followingsRepository, - this.mutingsRepository, - 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(), @@ -108,7 +111,7 @@ export class StreamingApiServerService { connection.once('close', () => { ev.removeAllListeners(); main.dispose(); - this.redisSubscriber.off('message', onRedisMessage); + this.redisForPubsub.off('message', onRedisMessage); if (intervalId) clearInterval(intervalId); }); diff --git a/packages/backend/src/server/api/endpoint-base.ts b/packages/backend/src/server/api/endpoint-base.ts index ed283eb83..1555a3ca4 100644 --- a/packages/backend/src/server/api/endpoint-base.ts +++ b/packages/backend/src/server/api/endpoint-base.ts @@ -1,6 +1,6 @@ import * as fs from 'node:fs'; import Ajv from 'ajv'; -import type { Schema, SchemaType } from '@/misc/schema.js'; +import type { Schema, SchemaType } from '@/misc/json-schema.js'; import type { LocalUser } from '@/models/entities/User.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import { ApiError } from './error.js'; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 4f521148e..fd268c791 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -1,4 +1,4 @@ -import type { Schema } from '@/misc/schema.js'; +import type { Schema } from '@/misc/json-schema.js'; import { RolePolicies } from '@/core/RoleService.js'; import * as ep___admin_meta from './endpoints/admin/meta.js'; @@ -42,6 +42,7 @@ import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; +import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; @@ -94,6 +95,9 @@ import * as ep___channels_show from './endpoints/channels/show.js'; import * as ep___channels_timeline from './endpoints/channels/timeline.js'; import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; 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___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'; @@ -114,6 +118,9 @@ import * as ep___clips_list from './endpoints/clips/list.js'; import * as ep___clips_notes from './endpoints/clips/notes.js'; import * as ep___clips_show from './endpoints/clips/show.js'; import * as ep___clips_update from './endpoints/clips/update.js'; +import * as ep___clips_favorite from './endpoints/clips/favorite.js'; +import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; +import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; import * as ep___drive from './endpoints/drive.js'; import * as ep___drive_files from './endpoints/drive/files.js'; import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; @@ -213,6 +220,8 @@ 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_knownAs from './endpoints/i/known-as.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'; @@ -220,10 +229,14 @@ import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; +import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; import * as ep___mute_list from './endpoints/mute/list.js'; +import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; +import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; +import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; import * as ep___my_apps from './endpoints/my/apps.js'; import * as ep___notes from './endpoints/notes.js'; import * as ep___notes_children from './endpoints/notes/children.js'; @@ -257,7 +270,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_read from './endpoints/notifications/read.js'; import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_delete from './endpoints/pages/delete.js'; @@ -361,6 +373,7 @@ const eps = [ ['admin/queue/clear', ep___admin_queue_clear], ['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed], ['admin/queue/inbox-delayed', ep___admin_queue_inboxDelayed], + ['admin/queue/promote', ep___admin_queue_promote], ['admin/queue/stats', ep___admin_queue_stats], ['admin/relays/add', ep___admin_relays_add], ['admin/relays/list', ep___admin_relays_list], @@ -413,6 +426,9 @@ const eps = [ ['channels/timeline', ep___channels_timeline], ['channels/unfollow', ep___channels_unfollow], ['channels/update', ep___channels_update], + ['channels/favorite', ep___channels_favorite], + ['channels/unfavorite', ep___channels_unfavorite], + ['channels/my-favorites', ep___channels_myFavorites], ['charts/active-users', ep___charts_activeUsers], ['charts/ap-request', ep___charts_apRequest], ['charts/drive', ep___charts_drive], @@ -433,6 +449,9 @@ const eps = [ ['clips/notes', ep___clips_notes], ['clips/show', ep___clips_show], ['clips/update', ep___clips_update], + ['clips/favorite', ep___clips_favorite], + ['clips/unfavorite', ep___clips_unfavorite], + ['clips/my-favorites', ep___clips_myFavorites], ['drive', ep___drive], ['drive/files', ep___drive_files], ['drive/files/attached-notes', ep___drive_files_attachedNotes], @@ -532,6 +551,8 @@ const eps = [ ['i/unpin', ep___i_unpin], ['i/update-email', ep___i_updateEmail], ['i/update', ep___i_update], + //['i/move', ep___i_move], + //['i/known-as', ep___i_knownAs], ['i/webhooks/create', ep___i_webhooks_create], ['i/webhooks/list', ep___i_webhooks_list], ['i/webhooks/show', ep___i_webhooks_show], @@ -539,10 +560,14 @@ const eps = [ ['i/webhooks/delete', ep___i_webhooks_delete], ['meta', ep___meta], ['emojis', ep___emojis], + ['emoji', ep___emoji], ['miauth/gen-token', ep___miauth_genToken], ['mute/create', ep___mute_create], ['mute/delete', ep___mute_delete], ['mute/list', ep___mute_list], + ['renote-mute/create', ep___renoteMute_create], + ['renote-mute/delete', ep___renoteMute_delete], + ['renote-mute/list', ep___renoteMute_list], ['my/apps', ep___my_apps], ['notes', ep___notes], ['notes/children', ep___notes_children], @@ -576,7 +601,6 @@ const eps = [ ['notes/user-list-timeline', ep___notes_userListTimeline], ['notifications/create', ep___notifications_create], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], - ['notifications/read', ep___notifications_read], ['page-push', ep___pagePush], ['pages/create', ep___pages_create], ['pages/delete', ep___pages_delete], 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 0cc60e919..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/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 04c58050f..2fb3e489e 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -56,6 +56,7 @@ export default class extends Endpoint { category: null, aliases: [], host: null, + license: null, }); this.moderationLogService.insertModerationLog(me, 'addEmoji', { 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 8885a40fd..82dca9cc7 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -87,10 +87,9 @@ export default class extends Endpoint { originalUrl: driveFile.url, publicUrl: driveFile.webpublicUrl ?? driveFile.url, type: driveFile.webpublicType ?? driveFile.type, + 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 f298baaed..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 a5fbe3f4e..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/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index 66547024f..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 c8992eeb0..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 8a538c100..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/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 809bf77d6..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 } 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 = { @@ -19,6 +15,11 @@ export const meta = { code: 'NO_SUCH_EMOJI', id: '684dec9d-a8c2-4364-9aa8-456c49cb1dc8', }, + sameNameEmojiExists: { + message: 'Emoji that have same name already exists.', + code: 'SAME_NAME_EMOJI_EXISTS', + id: '7180fe9d-1ee3-bff9-647d-fe9896d2ffb8', + }, }, } as const; @@ -26,7 +27,7 @@ export const paramDef = { type: 'object', properties: { id: { type: 'string', format: 'misskey:id' }, - name: { type: 'string' }, + name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, category: { type: 'string', nullable: true, @@ -35,54 +36,24 @@ export const paramDef = { aliases: { type: 'array', items: { type: 'string', } }, + license: { type: 'string', nullable: true }, }, 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 }); - - if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); - - 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 ?? 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/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 9eef1b29c..fc318a621 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -110,6 +110,14 @@ export const meta = { optional: false, nullable: false, }, }, + sensitiveWords: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, hcaptchaSecretKey: { type: 'string', optional: true, nullable: true, @@ -231,6 +239,14 @@ export const meta = { type: 'boolean', optional: true, nullable: false, }, + enableChartsForRemoteUser: { + type: 'boolean', + optional: false, nullable: false, + }, + enableChartsForFederatedInstances: { + type: 'boolean', + optional: false, nullable: false, + }, policies: { type: 'object', optional: false, nullable: false, @@ -266,7 +282,7 @@ export default class extends Endpoint { uri: this.config.url, description: instance.description, langs: instance.langs, - tosUrl: instance.ToSUrl, + tosUrl: instance.termsOfServiceUrl, repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, disableRegistration: instance.disableRegistration, @@ -290,13 +306,11 @@ export default class extends Endpoint { enableEmail: instance.enableEmail, enableServiceWorker: instance.enableServiceWorker, translatorAvailable: instance.deeplAuthKey != null, - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, - useStarForReactionFallback: instance.useStarForReactionFallback, pinnedUsers: instance.pinnedUsers, hiddenTags: instance.hiddenTags, blockedHosts: instance.blockedHosts, + sensitiveWords: instance.sensitiveWords, hcaptchaSecretKey: instance.hcaptchaSecretKey, recaptchaSecretKey: instance.recaptchaSecretKey, turnstileSecretKey: instance.turnstileSecretKey, @@ -330,6 +344,8 @@ export default class extends Endpoint { deeplIsPro: instance.deeplIsPro, enableIpLogging: instance.enableIpLogging, enableActiveEmailValidation: instance.enableActiveEmailValidation, + enableChartsForRemoteUser: instance.enableChartsForRemoteUser, + enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, policies: { ...DEFAULT_POLICIES, ...instance.policies }, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/promote.ts b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts new file mode 100644 index 000000000..4e57e6613 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/promote.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QueueService } from '@/core/QueueService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + type: { type: 'string', enum: ['deliver', 'inbox'] }, + }, + required: ['type'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + private moderationLogService: ModerationLogService, + private queueService: QueueService, + ) { + super(meta, paramDef, async (ps, me) => { + let delayedQueues; + + switch (ps.type) { + case 'deliver': + delayedQueues = await this.queueService.deliverQueue.getDelayed(); + for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { + const queue = delayedQueues[queueIndex]; + await queue.promote(); + } + break; + + case 'inbox': + delayedQueues = await this.queueService.inboxQueue.getDelayed(); + for (let queueIndex = 0; queueIndex < delayedQueues.length; queueIndex++) { + const queue = delayedQueues[queueIndex]; + await queue.promote(); + } + break; + } + + this.moderationLogService.insertModerationLog(me, 'promoteQueue'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts index d0d52089e..aead89461 100644 --- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -49,7 +49,7 @@ export default class extends Endpoint { const actor = await this.instanceActorService.getInstanceActor(); const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); - this.queueService.deliver(actor, this.apRendererService.addContext(this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment)), targetUser.inbox); + this.queueService.deliver(actor, this.apRendererService.addContext(this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment)), targetUser.inbox, false); } await this.abuseUserReportsRepository.update(report.id, { 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 df60c6be9..135989463 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -27,6 +27,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, + displayOrder: { type: 'number' }, policies: { type: 'object', }, @@ -43,6 +44,7 @@ export const paramDef = { 'isAdministrator', 'asBadge', 'canEditMembersByModerator', + 'displayOrder', 'policies', ], } as const; @@ -76,6 +78,7 @@ export default class extends Endpoint { isModerator: ps.isModerator, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, + displayOrder: ps.displayOrder, policies: ps.policies, }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); 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 b939ccdbf..37b68c4c4 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -35,6 +35,7 @@ export const paramDef = { isAdministrator: { type: 'boolean' }, asBadge: { type: 'boolean' }, canEditMembersByModerator: { type: 'boolean' }, + displayOrder: { type: 'number' }, policies: { type: 'object', }, @@ -52,6 +53,7 @@ export const paramDef = { 'isAdministrator', 'asBadge', 'canEditMembersByModerator', + 'displayOrder', 'policies', ], } as const; @@ -85,6 +87,7 @@ export default class extends Endpoint { isAdministrator: ps.isAdministrator, asBadge: ps.asBadge, canEditMembersByModerator: ps.canEditMembersByModerator, + displayOrder: ps.displayOrder, policies: ps.policies, }); const updated = await this.rolesRepository.findOneByOrFail({ id: ps.roleId }); 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 3ad6c7c48..3c9922527 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -1,6 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, FollowingsRepository, NotificationsRepository } from '@/models/index.js'; +import type { UsersRepository, FollowingsRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -36,9 +36,6 @@ export default class extends Endpoint { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - private userEntityService: UserEntityService, private userFollowingService: UserFollowingService, private userSuspendService: UserSuspendService, @@ -65,15 +62,9 @@ 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 => {}); - await this.readAllNotify(user).catch(e => {}); })(); }); } @@ -96,14 +87,4 @@ export default class extends Endpoint { await this.userFollowingService.unfollow(follower, followee, true); } } - - @bindThis - private async readAllNotify(notifier: User) { - await this.notificationsRepository.update({ - notifierId: notifier.id, - isRead: false, - }, { - isRead: true, - }); - } } 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 a7531aae8..11de29bf8 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -17,7 +17,6 @@ export const paramDef = { type: 'object', properties: { disableRegistration: { type: 'boolean', nullable: true }, - useStarForReactionFallback: { type: 'boolean', nullable: true }, pinnedUsers: { type: 'array', nullable: true, items: { type: 'string', } }, @@ -27,6 +26,9 @@ export const paramDef = { blockedHosts: { type: 'array', nullable: true, items: { type: 'string', } }, + sensitiveWords: { type: 'array', nullable: true, items: { + type: 'string', + } }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, @@ -56,10 +58,6 @@ export const paramDef = { proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true }, maintainerName: { type: 'string', nullable: true }, maintainerEmail: { type: 'string', nullable: true }, - pinnedPages: { type: 'array', items: { - type: 'string', - } }, - pinnedClipId: { type: 'string', format: 'misskey:id', nullable: true }, langs: { type: 'array', items: { type: 'string', } }, @@ -94,6 +92,8 @@ export const paramDef = { objectStorageS3ForcePathStyle: { type: 'boolean' }, enableIpLogging: { type: 'boolean' }, enableActiveEmailValidation: { type: 'boolean' }, + enableChartsForRemoteUser: { type: 'boolean' }, + enableChartsForFederatedInstances: { type: 'boolean' }, }, required: [], } as const; @@ -115,10 +115,6 @@ export default class extends Endpoint { set.disableRegistration = ps.disableRegistration; } - if (typeof ps.useStarForReactionFallback === 'boolean') { - set.useStarForReactionFallback = ps.useStarForReactionFallback; - } - if (Array.isArray(ps.pinnedUsers)) { set.pinnedUsers = ps.pinnedUsers.filter(Boolean); } @@ -131,6 +127,10 @@ export default class extends Endpoint { set.blockedHosts = ps.blockedHosts.filter(Boolean).map(x => x.toLowerCase()); } + if (Array.isArray(ps.sensitiveWords)) { + set.sensitiveWords = ps.sensitiveWords.filter(Boolean); + } + if (ps.themeColor !== undefined) { set.themeColor = ps.themeColor; } @@ -247,14 +247,6 @@ export default class extends Endpoint { set.langs = ps.langs.filter(Boolean); } - if (Array.isArray(ps.pinnedPages)) { - set.pinnedPages = ps.pinnedPages.filter(Boolean); - } - - if (ps.pinnedClipId !== undefined) { - set.pinnedClipId = ps.pinnedClipId; - } - if (ps.summalyProxy !== undefined) { set.summalyProxy = ps.summalyProxy; } @@ -304,7 +296,7 @@ export default class extends Endpoint { } if (ps.tosUrl !== undefined) { - set.ToSUrl = ps.tosUrl; + set.termsOfServiceUrl = ps.tosUrl; } if (ps.repositoryUrl !== undefined) { @@ -387,6 +379,14 @@ export default class extends Endpoint { set.enableActiveEmailValidation = ps.enableActiveEmailValidation; } + if (ps.enableChartsForRemoteUser !== undefined) { + set.enableChartsForRemoteUser = ps.enableChartsForRemoteUser; + } + + if (ps.enableChartsForFederatedInstances !== undefined) { + set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; + } + 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 bc5d249ae..b7ce3363a 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -79,6 +79,10 @@ export default class extends Endpoint { private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { + if ((ps.keywords.length === 0) || ps.keywords[0].every(x => x === '')) { + throw new Error('invalid param'); + } + const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id, }); @@ -99,9 +103,12 @@ export default class extends Endpoint { } } + const now = new Date(); + const antenna = await this.antennasRepository.insert({ id: this.idService.genId(), - createdAt: new Date(), + createdAt: now, + lastUsedAt: now, userId: me.id, name: ps.name, src: ps.src, diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index fbb5acf61..f08c20ae4 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -1,10 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { NotesRepository, AntennaNotesRepository, AntennasRepository } from '@/models/index.js'; +import type { NotesRepository, AntennasRepository } from '@/models/index.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteReadService } from '@/core/NoteReadService.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 = { @@ -50,15 +52,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.antennasRepository) private antennasRepository: AntennasRepository, - @Inject(DI.antennaNotesRepository) - private antennaNotesRepository: AntennaNotesRepository, - + private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, private noteReadService: NoteReadService, @@ -73,34 +76,45 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchAntenna); } - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), - ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .innerJoin(this.antennaNotesRepository.metadata.targetName, 'antennaNote', 'antennaNote.noteId = note.id') + const noteIdsRes = await this.redisClient.xrevrange( + `antennaTimeline:${antenna.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + + if (noteIdsRes.length === 0) { + return []; + } + + const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId); + + if (noteIds.length === 0) { + return []; + } + + 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') - .andWhere('antennaNote.antennaId = :antennaId', { antennaId: antenna.id }); + .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); - const notes = await query - .take(ps.limit) - .getMany(); + const notes = await query.getMany(); + notes.sort((a, b) => a.id > b.id ? -1 : 1); if (notes.length > 0) { this.noteReadService.read(me.id, notes); } + this.antennasRepository.update(antenna.id, { + lastUsedAt: new Date(), + }); + return await this.noteEntityService.packMany(notes, me); }); } diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 61e05531e..a103d4196 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -5,7 +5,7 @@ import type { UsersRepository, NotesRepository } from '@/models/index.js'; import type { Note } from '@/models/entities/Note.js'; import type { LocalUser, User } from '@/models/entities/User.js'; import { isActor, isPost, getApId } from '@/core/activitypub/type.js'; -import type { SchemaType } from '@/misc/schema.js'; +import type { SchemaType } from '@/misc/json-schema.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { MetaService } from '@/core/MetaService.js'; diff --git a/packages/backend/src/server/api/endpoints/channels/favorite.ts b/packages/backend/src/server/api/endpoints/channels/favorite.ts new file mode 100644 index 000000000..f52b45ccf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/favorite.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '4938f5f3-6167-4c04-9149-6607b7542861', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + userId: me.id, + channelId: channel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts index 91693918f..8ab59991c 100644 --- a/packages/backend/src/server/api/endpoints/channels/follow.ts +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -41,7 +41,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 +57,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/my-favorites.ts b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts new file mode 100644 index 000000000..60525ed06 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/my-favorites.ts @@ -0,0 +1,54 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['channels', 'account'], + + requireCredential: true, + + kind: 'read:channels', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + + private channelEntityService: ChannelEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.channelFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.channel', 'channel'); + + const favorites = await query + .getMany(); + + return await Promise.all(favorites.map(x => this.channelEntityService.pack(x.channel!, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts index 8718615db..070d14631 100644 --- a/packages/backend/src/server/api/endpoints/channels/show.ts +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -51,7 +51,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - return await this.channelEntityService.pack(channel, me); + return await this.channelEntityService.pack(channel, me, true); }); } } diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index cdaa40013..2556557b2 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -1,10 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; +import 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'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -48,12 +50,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, + private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, private activeUsersChart: ActiveUsersChart, @@ -67,30 +73,60 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - //#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('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'); + let timeline: Note[] = []; - if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateMutedNoteQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + const noteIdsRes = await this.redisClient.xrevrange( + `channelTimeline:${channel.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 + + if (noteIdsRes.length === 0) { + //#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 (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); } - //#endregion - - const timeline = await query.take(ps.limit).getMany(); if (me) this.activeUsersChart.read(me); diff --git a/packages/backend/src/server/api/endpoints/channels/unfavorite.ts b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts new file mode 100644 index 000000000..0c3f6c485 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/unfavorite.ts @@ -0,0 +1,56 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelFavoritesRepository, ChannelsRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['channels'], + + requireCredential: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such channel.', + code: 'NO_SUCH_CHANNEL', + id: '353c68dd-131a-476c-aa99-88a345e83668', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + + @Inject(DI.channelFavoritesRepository) + private channelFavoritesRepository: ChannelFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const channel = await this.channelsRepository.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await this.channelFavoritesRepository.delete({ + userId: me.id, + channelId: channel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts index ac2ef825b..855ba47f8 100644 --- a/packages/backend/src/server/api/endpoints/channels/unfollow.ts +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -38,8 +38,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 +52,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/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index d006e89bd..084b3f919 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -3,6 +3,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import type { DriveFilesRepository, ChannelsRepository } from '@/models/index.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -46,6 +47,12 @@ export const paramDef = { name: { type: 'string', minLength: 1, maxLength: 128 }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, + pinnedNoteIds: { + type: 'array', + items: { + type: 'string', format: 'misskey:id', + }, + }, }, required: ['channelId'], } as const; @@ -61,6 +68,8 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private channelEntityService: ChannelEntityService, + + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const channel = await this.channelsRepository.findOneBy({ @@ -71,7 +80,8 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchChannel); } - if (channel.userId !== me.id) { + const iAmModerator = await this.roleService.isModerator(me); + if (channel.userId !== me.id && !iAmModerator) { throw new ApiError(meta.errors.accessDenied); } @@ -93,6 +103,7 @@ export default class extends Endpoint { await this.channelsRepository.update(channel.id, { ...(ps.name !== undefined ? { name: ps.name } : {}), ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(ps.pinnedNoteIds !== undefined ? { pinnedNoteIds: ps.pinnedNoteIds } : {}), ...(banner ? { bannerId: banner.id } : {}), }); 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 f3f9c3477..b9d8dce47 100644 --- a/packages/backend/src/server/api/endpoints/clips/add-note.ts +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -106,6 +106,10 @@ export default class extends Endpoint { noteId: note.id, clipId: clip.id, }); + + await this.clipsRepository.update(clip.id, { + lastClippedAt: new Date(), + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index c095de702..a770dc986 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -67,7 +67,7 @@ export default class extends Endpoint { description: ps.description, }).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0])); - return await this.clipEntityService.pack(clip); + return await this.clipEntityService.pack(clip, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/favorite.ts b/packages/backend/src/server/api/endpoints/clips/favorite.ts new file mode 100644 index 000000000..6addf743a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/favorite.ts @@ -0,0 +1,76 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ClipsRepository, ClipFavoritesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['clip'], + + requireCredential: true, + + kind: 'write:clip-favorite', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + }, + + alreadyFavorited: { + message: 'The clip has already been favorited.', + code: 'ALREADY_FAVORITED', + id: '92658936-c625-4273-8326-2d790129256e', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + if ((clip.userId !== me.id) && !clip.isPublic) { + throw new ApiError(meta.errors.noSuchClip); + } + + const exist = await this.clipFavoritesRepository.findOneBy({ + clipId: clip.id, + userId: me.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + await this.clipFavoritesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + clipId: clip.id, + userId: me.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts index 63ca06936..3b8deab70 100644 --- a/packages/backend/src/server/api/endpoints/clips/list.ts +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -42,7 +42,7 @@ export default class extends Endpoint { userId: me.id, }); - return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/my-favorites.ts b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts new file mode 100644 index 000000000..fc727e93b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/my-favorites.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ClipFavoritesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; + +export const meta = { + tags: ['account', 'clip'], + + requireCredential: true, + + kind: 'read:clip-favorite', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Clip', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + + private clipEntityService: ClipEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.clipFavoritesRepository.createQueryBuilder('favorite') + .andWhere('favorite.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('favorite.clip', 'clip'); + + const favorites = await query + .getMany(); + + return this.clipEntityService.packMany(favorites.map(x => x.clip!), me); + }); + } +} 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/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts index e6d3f4f1f..99d630a9b 100644 --- a/packages/backend/src/server/api/endpoints/clips/show.ts +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -58,7 +58,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchClip); } - return await this.clipEntityService.pack(clip); + return await this.clipEntityService.pack(clip, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/clips/unfavorite.ts b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts new file mode 100644 index 000000000..244843d50 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/unfavorite.ts @@ -0,0 +1,65 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ClipsRepository, ClipFavoritesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['clip'], + + requireCredential: true, + + kind: 'write:clip-favorite', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: '2603966e-b865-426c-94a7-af4a01241dc1', + }, + + notFavorited: { + message: 'You have not favorited the clip.', + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.clipsRepository) + private clipsRepository: ClipsRepository, + + @Inject(DI.clipFavoritesRepository) + private clipFavoritesRepository: ClipFavoritesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const clip = await this.clipsRepository.findOneBy({ id: ps.clipId }); + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const exist = await this.clipFavoritesRepository.findOneBy({ + clipId: clip.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notFavorited); + } + + await this.clipFavoritesRepository.delete(exist.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index 597b67c44..a103c3f7d 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -64,7 +64,7 @@ export default class extends Endpoint { isPublic: ps.isPublic, }); - return await this.clipEntityService.pack(clip.id); + return await this.clipEntityService.pack(clip.id, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts index f6fad50fd..460930777 100644 --- a/packages/backend/src/server/api/endpoints/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -31,6 +31,7 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) }, + sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size'] }, }, required: [], } as const; @@ -63,6 +64,15 @@ export default class extends Endpoint { } } + switch (ps.sort) { + case '+createdAt': query.orderBy('file.createdAt', 'DESC'); break; + case '-createdAt': query.orderBy('file.createdAt', 'ASC'); break; + case '+name': query.orderBy('file.name', 'DESC'); break; + case '-name': query.orderBy('file.name', 'ASC'); break; + case '+size': query.orderBy('file.size', 'DESC'); break; + case '-size': query.orderBy('file.size', 'ASC'); break; + } + const files = await query.take(ps.limit).getMany(); return await this.driveFileEntityService.packMany(files, { detail: false, self: true }); diff --git a/packages/backend/src/server/api/endpoints/emoji.ts b/packages/backend/src/server/api/endpoints/emoji.ts new file mode 100644 index 000000000..681d3e649 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji.ts @@ -0,0 +1,56 @@ +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { EmojisRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: false, + allowGet: true, + cacheSec: 3600, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'EmojiDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + required: ['name'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.emojisRepository) + private emojisRepository: EmojisRepository, + + private emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const emoji = await this.emojisRepository.findOneOrFail({ + where: { + name: ps.name, + host: IsNull(), + }, + }); + + return this.emojiEntityService.packDetailed(emoji); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/emojis.ts b/packages/backend/src/server/api/endpoints/emojis.ts index 325b75835..13cc709d3 100644 --- a/packages/backend/src/server/api/endpoints/emojis.ts +++ b/packages/backend/src/server/api/endpoints/emojis.ts @@ -23,24 +23,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - properties: { - name: { - type: 'string', - optional: false, nullable: false, - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - }, + ref: 'EmojiSimple', }, }, }, @@ -75,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/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 60b24e958..061c6eb5b 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -76,9 +76,9 @@ export default class extends Endpoint { if (typeof ps.blocked === 'boolean') { const meta = await this.metaService.fetch(true); if (ps.blocked) { - query.andWhere('instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); + query.andWhere(meta.blockedHosts.length === 0 ? '1=0' : 'instance.host IN (:...blocks)', { blocks: meta.blockedHosts }); } else { - query.andWhere('instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); + query.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocks)', { blocks: meta.blockedHosts }); } } diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts index 6beef5ab8..a3e3e02a1 100644 --- a/packages/backend/src/server/api/endpoints/i.ts +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -3,6 +3,7 @@ import type { UserProfilesRepository, UsersRepository } from '@/models/index.js' import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; +import { ApiError } from '../error.js'; export const meta = { tags: ['account'], @@ -14,6 +15,15 @@ export const meta = { optional: false, nullable: false, ref: 'MeDetailed', }, + + errors: { + userIsDeleted: { + message: 'User is deleted.', + code: 'USER_IS_DELETED', + id: 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a', + kind: 'permission', + }, + } } as const; export const paramDef = { @@ -41,13 +51,17 @@ export default class extends Endpoint { const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`; // 渡ってきている user はキャッシュされていて古い可能性があるので改めて取得 - const userProfile = await this.userProfilesRepository.findOneOrFail({ + const userProfile = await this.userProfilesRepository.findOne({ where: { userId: user.id, }, relations: ['user'], }); + if (userProfile == null) { + throw new ApiError(meta.errors.userIsDeleted); + } + if (!userProfile.loggedInDates.includes(today)) { this.userProfilesRepository.update({ userId: user.id }, { loggedInDates: [...userProfile.loggedInDates, today], diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts new file mode 100644 index 000000000..964704d82 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/known-as.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; + +import { User } from '@/models/entities/User.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; + +import { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + + limit: { + duration: ms('1day'), + max: 30, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', + }, + notRemote: { + message: 'User is not remote. You can only migrate from other instances.', + code: 'NOT_REMOTE', + id: '4362f8dc-731f-4ad8-a694-be2a88922a24', + }, + uriNull: { + message: 'User ActivityPup URI is null.', + code: 'URI_NULL', + id: 'bf326f31-d430-4f97-9933-5d61e4d48a23', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + alsoKnownAs: { type: 'string' }, + }, + required: ['alsoKnownAs'], +} as const; + +@Injectable() +export default class extends Endpoint { + constructor( + private userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check parameter + if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser); + + let unfiltered = ps.alsoKnownAs; + const updates = {} as Partial; + + if (!unfiltered) { + updates.alsoKnownAs = null; + } else { + // Parse user's input into the old account + if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); + if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + + const userAddress = unfiltered.split('@'); + // Retrieve the old account + const knownAs = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + + const toUrl: string | null = knownAs.uri; + if (!toUrl) throw new ApiError(meta.errors.uriNull); + // Only allow moving from a remote account + if (this.userEntityService.isLocalUser(knownAs)) throw new ApiError(meta.errors.notRemote); + + updates.alsoKnownAs = updates.alsoKnownAs?.concat([toUrl]) ?? [toUrl]; + } + + return await this.accountMoveService.createAlias(me, updates); + }); + } +} 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..ac76e1f62 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -0,0 +1,140 @@ +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 { AccountMoveService } from '@/core/AccountMoveService.js'; +import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApiLoggerService } from '@/server/api/ApiLoggerService.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; + +export const meta = { + tags: ['users'], + + secure: true, + requireCredential: true, + limit: { + duration: ms('1day'), + max: 5, + }, + + errors: { + noSuchMoveTarget: { + message: 'No such move target.', + code: 'NO_SUCH_MOVE_TARGET', + id: 'b5c90186-4ab0-49c8-9bba-a1f76c202ba4', + }, + remoteAccountForbids: { + message: + 'Remote account doesn\'t have proper \'Known As\' alias. Did you remember to set it?', + code: 'REMOTE_ACCOUNT_FORBIDS', + id: 'b5c90186-4ab0-49c8-9bba-a1f766282ba4', + }, + notRemote: { + message: 'User is not remote. You can only migrate to other instances.', + code: 'NOT_REMOTE', + id: '4362f8dc-731f-4ad8-a694-be2a88922a24', + }, + 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 userEntityService: UserEntityService, + private remoteUserResolveService: RemoteUserResolveService, + private apiLoggerService: ApiLoggerService, + private accountMoveService: AccountMoveService, + private getterService: GetterService, + private apPersonService: ApPersonService, + ) { + super(meta, paramDef, async (ps, me) => { + // check parameter + if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget); + // 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); + + let unfiltered = ps.moveToAccount; + if (!unfiltered) throw new ApiError(meta.errors.noSuchMoveTarget); + + // parse user's input into the destination account + if (unfiltered.startsWith('acct:')) unfiltered = unfiltered.substring(5); + if (unfiltered.startsWith('@')) unfiltered = unfiltered.substring(1); + if (!unfiltered.includes('@')) throw new ApiError(meta.errors.notRemote); + + const userAddress = unfiltered.split('@'); + // retrieve the destination account + let moveTo = await this.remoteUserResolveService.resolveUser(userAddress[0], userAddress[1]).catch((e) => { + this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchMoveTarget); + }); + const remoteMoveTo = await this.getterService.getRemoteUser(moveTo.id); + if (!remoteMoveTo.uri) throw new ApiError(meta.errors.uriNull); + + // update local db + await this.apPersonService.updatePerson(remoteMoveTo.uri); + // retrieve updated user + moveTo = await this.apPersonService.resolvePerson(remoteMoveTo.uri); + // only allow moving to a remote account + if (this.userEntityService.isLocalUser(moveTo)) throw new ApiError(meta.errors.notRemote); + + let allowed = false; + + const fromUrl = `${this.config.url}/users/${me.id}`; + // make sure that the user has indicated the old account as an alias + moveTo.alsoKnownAs?.forEach((elem) => { + if (fromUrl.includes(elem)) allowed = true; + }); + + // abort if unintended + if (!(allowed && moveTo.uri && fromUrl)) throw new ApiError(meta.errors.remoteAccountForbids); + + return await this.accountMoveService.moveToRemote(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 e3897d38b..f27b4e86d 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -1,6 +1,7 @@ -import { Brackets } from 'typeorm'; +import { Brackets, In } from 'typeorm'; +import Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotificationsRepository } from '@/models/index.js'; +import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js'; import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; @@ -8,6 +9,8 @@ import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { Notification } from '@/models/entities/Notification.js'; export const meta = { tags: ['account', 'notifications'], @@ -38,8 +41,6 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, - following: { type: 'boolean', default: false }, - unreadOnly: { type: 'boolean', default: false }, markAsRead: { type: 'boolean', default: true }, // 後方互換のため、廃止された通知タイプも受け付ける includeTypes: { type: 'array', items: { @@ -56,21 +57,22 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, private queryService: QueryService, @@ -89,85 +91,39 @@ 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 followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); + const notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + '-', + 'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1 - const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') - .select('muting.muteeId') - .where('muting.muterId = :muterId', { muterId: me.id }); - - const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile') - .select('user_profile.mutedInstances') - .where('user_profile.userId = :muterId', { muterId: me.id }); - - const suspendedQuery = this.usersRepository.createQueryBuilder('users') - .select('users.id') - .where('users.isSuspended = TRUE'); - - const query = this.queryService.makePaginationQuery(this.notificationsRepository.createQueryBuilder('notification'), ps.sinceId, ps.untilId) - .andWhere('notification.notifieeId = :meId', { meId: me.id }) - .leftJoinAndSelect('notification.notifier', 'notifier') - .leftJoinAndSelect('notification.note', 'note') - .leftJoinAndSelect('notifier.avatar', 'notifierAvatar') - .leftJoinAndSelect('notifier.banner', 'notifierBanner') - .leftJoinAndSelect('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'); - - // muted users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - query.setParameters(mutingQuery.getParameters()); - - // muted instances - query.andWhere(new Brackets(qb => { qb - .andWhere('notifier.host IS NULL') - .orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`); - })); - query.setParameters(mutingInstanceQuery.getParameters()); - - // suspended users - query.andWhere(new Brackets(qb => { qb - .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) - .orWhere('notification.notifierId IS NULL'); - })); - - if (ps.following) { - query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: me.id }); - query.setParameters(followingQuery.getParameters()); + if (notificationsRes.length === 0) { + return []; } + let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[]; + if (includeTypes && includeTypes.length > 0) { - query.andWhere('notification.type IN (:...includeTypes)', { includeTypes }); + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); } else if (excludeTypes && excludeTypes.length > 0) { - query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes }); + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); } - if (ps.unreadOnly) { - query.andWhere('notification.isRead = false'); + if (notifications.length === 0) { + return []; } - const notifications = await query.take(ps.limit).getMany(); - // Mark all as read - if (notifications.length > 0 && ps.markAsRead) { - this.notificationService.readNotification(me.id, notifications.map(x => x.id)); + if (ps.markAsRead) { + this.notificationService.readAllNotification(me.id); } - const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); + const noteIds = notifications + .filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId!); - if (notes.length > 0) { + if (noteIds.length > 0) { + const notes = await this.notesRepository.findBy({ id: In(noteIds) }); this.noteReadService.read(me.id, notes); } 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 f942f43cc..23ff63f5e 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -34,7 +34,7 @@ export default class extends Endpoint { ) { super(meta, paramDef, async (ps, me) => { const freshUser = await this.usersRepository.findOneByOrFail({ id: me.id }); - const oldToken = freshUser.token; + const oldToken = freshUser.token!; const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id }); @@ -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 b1eaab390..be1c72b20 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -18,6 +18,8 @@ import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -147,11 +149,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 hashtagService: HashtagService, private roleService: RoleService, + private cacheService: CacheService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }); @@ -168,8 +172,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; @@ -215,6 +217,10 @@ 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; } if (ps.bannerId) { @@ -222,6 +228,10 @@ 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; } if (ps.pinnedPageId) { @@ -276,9 +286,12 @@ export default class extends Endpoint { includeSecrets: isSecure, }); + const updatedProfile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + this.cacheService.userProfileCache.set(user.id, updatedProfile); + // Publish meUpdated event this.globalEventService.publishMainStream(user.id, 'meUpdated', iObj); - this.globalEventService.publishUserEvent(user.id, 'updateUserProfile', await this.userProfilesRepository.findOneByOrFail({ userId: user.id })); // 鍵垢を解除したとき、溜まっていたフォローリクエストがあるならすべて承認 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 cdb314a87..37974ce2a 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -276,7 +276,7 @@ export default class extends Endpoint { uri: this.config.url, description: instance.description, langs: instance.langs, - tosUrl: instance.ToSUrl, + tosUrl: instance.termsOfServiceUrl, repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, disableRegistration: instance.disableRegistration, @@ -315,8 +315,6 @@ export default class extends Endpoint { mediaProxy: this.config.mediaProxy, ...(ps.detail ? { - pinnedPages: instance.pinnedPages, - pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, requireSetup: (await this.usersRepository.countBy({ host: IsNull(), diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts index 9099eea52..6e24e1024 100644 --- a/packages/backend/src/server/api/endpoints/mute/create.ts +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -1,12 +1,10 @@ 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 { UserMutingService } from '@/core/UserMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -62,9 +60,8 @@ export default class extends Endpoint { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, - private globalEventService: GlobalEventService, private getterService: GetterService, - private idService: IdService, + private userMutingService: UserMutingService, ) { super(meta, paramDef, async (ps, me) => { const muter = me; @@ -94,16 +91,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.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/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts index d5caec6e1..0a5542f49 100644 --- a/packages/backend/src/server/api/endpoints/notes/clips.ts +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -4,8 +4,8 @@ import type { ClipNotesRepository, ClipsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.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: ['clips', 'notes'], @@ -67,7 +67,7 @@ export default class extends Endpoint { isPublic: true, }); - return await Promise.all(clips.map(x => this.clipEntityService.pack(x))); + return await this.clipEntityService.packMany(clips, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 786ad103b..69fafcb9c 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -97,6 +97,7 @@ export const paramDef = { } }, cw: { type: 'string', nullable: true, maxLength: 100 }, localOnly: { type: 'boolean', default: false }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote'], default: null }, noExtractMentions: { type: 'boolean', default: false }, noExtractHashtags: { type: 'boolean', default: false }, noExtractEmojis: { type: 'boolean', default: false }, @@ -110,7 +111,7 @@ export const paramDef = { type: 'string', minLength: 1, maxLength: MAX_NOTE_TEXT_LENGTH, - nullable: false + nullable: false, }, fileIds: { type: 'array', @@ -280,6 +281,7 @@ export default class extends Endpoint { renote, cw: ps.cw, localOnly: ps.localOnly, + reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, visibleUsers, channel, diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts index cf939f663..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 }); @@ -71,7 +65,7 @@ export default class extends Endpoint { let notes = await query .orderBy('note.score', 'DESC') - .take(50) + .take(100) .getMany(); notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 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 5d0cdc3fc..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,22 +73,17 @@ 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) { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } if (ps.withFiles) { 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 2819abb12..89abd91c7 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -8,6 +8,7 @@ import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -69,6 +70,7 @@ export default class extends Endpoint { private metaService: MetaService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me.id); @@ -83,22 +85,16 @@ export default class extends Endpoint { const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで + .andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで .andWhere(new Brackets(qb => { qb.where(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id }) .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); @@ -107,6 +103,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { 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 18ed6d4e2..afdafc7c5 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -8,6 +8,7 @@ import { MetaService } from '@/core/MetaService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; +import { IdService } from '@/core/IdService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -65,6 +66,7 @@ export default class extends Endpoint { private metaService: MetaService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const policies = await this.roleService.getUserPolicies(me ? me.id : null); @@ -75,19 +77,13 @@ export default class extends Endpoint { //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.createdAt > :minDate', { minDate: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) }) // 30日前まで + .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); @@ -95,6 +91,7 @@ export default class extends Endpoint { if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateMutedNoteQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); 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 b9e06a783..2a44dc537 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -8,7 +8,6 @@ import { QueueService } from '@/core/QueueService.js'; import { PollService } from '@/core/PollService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; import { DI } from '@/di-symbols.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { ApiError } from '../../../error.js'; @@ -89,7 +88,6 @@ export default class extends Endpoint { private pollService: PollService, private apRendererService: ApRendererService, private globalEventService: GlobalEventService, - private createNotificationService: CreateNotificationService, private userBlockingService: UserBlockingService, ) { super(meta, paramDef, async (ps, me) => { @@ -161,7 +159,7 @@ export default class extends Endpoint { if (note.userHost != null) { const pollOwner = await this.usersRepository.findOneByOrFail({ id: note.userId }) as RemoteUser; - this.queueService.deliver(me, this.apRendererService.addContext(await this.apRendererService.renderVote(me, vote, note, poll, pollOwner)), pollOwner.inbox); + this.queueService.deliver(me, this.apRendererService.addContext(await this.apRendererService.renderVote(me, vote, note, poll, pollOwner)), pollOwner.inbox, false); } // リモートフォロワーにUpdate配信 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/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 ef47a3004..fb5abd917 100644 --- a/packages/backend/src/server/api/endpoints/notes/search.ts +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -6,6 +6,8 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -23,6 +25,11 @@ export const meta = { }, errors: { + unavailable: { + message: 'Search of notes unavailable.', + code: 'UNAVAILABLE', + id: '0b44998d-77aa-4427-80d0-d2c9b8523011', + }, }, } as const; @@ -59,8 +66,14 @@ export default class extends Endpoint { private noteEntityService: NoteEntityService, private queryService: QueryService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { + const policies = await this.roleService.getUserPolicies(me ? me.id : null); + if (!policies.canSearchNotes) { + throw new ApiError(meta.errors.unavailable); + } + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); if (ps.userId) { @@ -72,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/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index d0036f0fb..93517ab10 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -59,7 +59,7 @@ export default class extends Endpoint { this.noteThreadMutingsRepository.count({ where: { userId: me.id, - threadId: note.threadId || note.id, + threadId: note.threadId ?? note.id, }, take: 1, }), diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 07179553b..7eea1d850 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -6,7 +6,7 @@ import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; -import { getTimeId } from '@/misc/id/aid.js'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['notes'], @@ -57,6 +57,7 @@ export default class extends Endpoint { private noteEntityService: NoteEntityService, private queryService: QueryService, private activeUsersChart: ActiveUsersChart, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { const followees = await this.followingsRepository.createQueryBuilder('following') @@ -68,18 +69,12 @@ export default class extends Endpoint { //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('note.id > :minId', { minId }) + .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)]; @@ -95,6 +90,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { 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/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts index 2e63eee26..4102a924a 100644 --- a/packages/backend/src/server/api/endpoints/notifications/create.ts +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { CreateNotificationService } from '@/core/CreateNotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const meta = { tags: ['notifications'], @@ -27,10 +27,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - private createNotificationService: CreateNotificationService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, user, token) => { - this.createNotificationService.createNotification(user.id, 'app', { + this.notificationService.createNotification(user.id, 'app', { appAccessTokenId: token ? token.id : null, customBody: ps.body, customHeader: ps.header, 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 09134cf48..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 @@ -1,9 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import type { NotificationsRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { PushNotificationService } from '@/core/PushNotificationService.js'; import { DI } from '@/di-symbols.js'; +import { NotificationService } from '@/core/NotificationService.js'; export const meta = { tags: ['notifications', 'account'], @@ -23,24 +21,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { constructor( - @Inject(DI.notificationsRepository) - private notificationsRepository: NotificationsRepository, - - private globalEventService: GlobalEventService, - private pushNotificationService: PushNotificationService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { - // Update documents - await this.notificationsRepository.update({ - notifieeId: me.id, - isRead: false, - }, { - isRead: true, - }); - - // 全ての通知を読みましたよというイベントを発行 - this.globalEventService.publishMainStream(me.id, 'readAllNotifications'); - this.pushNotificationService.pushNotification(me.id, 'readAllNotifications', undefined); + this.notificationService.readAllNotification(me.id, true); }); } } diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts deleted file mode 100644 index 6262c47fd..000000000 --- a/packages/backend/src/server/api/endpoints/notifications/read.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NotificationService } from '@/core/NotificationService.js'; - -export const meta = { - tags: ['notifications', 'account'], - - requireCredential: true, - - kind: 'write:notifications', - - description: 'Mark a notification as read.', - - errors: { - noSuchNotification: { - message: 'No such notification.', - code: 'NO_SUCH_NOTIFICATION', - id: 'efa929d5-05b5-47d1-beec-e6a4dbed011e', - }, - }, -} as const; - -export const paramDef = { - oneOf: [ - { - type: 'object', - properties: { - notificationId: { type: 'string', format: 'misskey:id' }, - }, - required: ['notificationId'], - }, - { - type: 'object', - properties: { - notificationIds: { - type: 'array', - items: { type: 'string', format: 'misskey:id' }, - maxItems: 100, - }, - }, - required: ['notificationIds'], - }, - ], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - private notificationService: NotificationService, - ) { - super(meta, paramDef, async (ps, me) => { - if ('notificationId' in ps) return this.notificationService.readNotification(me.id, [ps.notificationId]); - return this.notificationService.readNotification(me.id, ps.notificationIds); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts new file mode 100644 index 000000000..b28526961 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -0,0 +1,97 @@ +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 { RenoteMutingsRepository } from '@/models/index.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { GlobalEventService } from '@/core/GlobalEventService.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:mutes', + + limit: { + duration: ms('1hour'), + max: 20, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '5e0a5dff-1e94-4202-87ae-4d9c89eb2271', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '37285718-52f7-4aef-b7de-c38b8e8a8420', + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING', + id: 'ccfecbe4-1f1c-4fc2-8a3d-c3ffee61cb7b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // 自分自身 + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check if already muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await this.renoteMutingsRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as RenoteMuting); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts new file mode 100644 index 000000000..70901a140 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -0,0 +1,85 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.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:mutes', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9b6728cf-638c-4aa1-bedb-e07d8101474d', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '619b1314-0850-4597-a242-e245f3da42af', + }, + + notMuting: { + message: 'You are not muting that user.', + code: 'NOT_MUTING', + id: '2e4ef874-8bf0-4b4b-b069-4598f6d05817', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // Check if the mutee is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await this.renoteMutingsRepository.delete({ + id: exist.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts new file mode 100644 index 000000000..b2d7addb6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RenoteMutingEntityService } from '@/core/entities/RenoteMutingEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'read:mutes', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'RenoteMuting', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private renoteMutingEntityService: RenoteMutingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere('muting.muterId = :meId', { meId: me.id }); + + const mutings = await query + .take(ps.limit) + .getMany(); + + return await this.renoteMutingEntityService.packMany(mutings, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts index e3fd0920c..c5aa93baa 100644 --- a/packages/backend/src/server/api/endpoints/users/clips.ts +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -51,7 +51,7 @@ export default class extends Endpoint { .take(ps.limit) .getMany(); - return await this.clipEntityService.packMany(clips); + return await this.clipEntityService.packMany(clips, me); }); } } 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/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index ac9104bf9..3267c1884 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -50,6 +50,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, { @@ -91,6 +95,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, }, diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 29f24b045..ba432c273 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -48,6 +48,7 @@ export const meta = { message: 'No such user.', code: 'NO_SUCH_USER', id: '4362f8dc-731f-4ad8-a694-be5a88922a24', + httpStatusCode: 404, }, }, } as const; diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts index 347d5650a..34f452160 100644 --- a/packages/backend/src/server/api/error.ts +++ b/packages/backend/src/server/api/error.ts @@ -1,4 +1,4 @@ -type E = { message: string, code: string, id: string, kind?: 'client' | 'server', httpStatusCode?: number }; +type E = { message: string, code: string, id: string, kind?: 'client' | 'server' | 'permission', httpStatusCode?: number }; export class ApiError extends Error { public message: string; diff --git a/packages/backend/src/server/api/openapi/OpenApiServerService.ts b/packages/backend/src/server/api/openapi/OpenApiServerService.ts new file mode 100644 index 000000000..e804ba276 --- /dev/null +++ b/packages/backend/src/server/api/openapi/OpenApiServerService.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from 'node:url'; +import { Inject, Injectable } from '@nestjs/common'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { genOpenapiSpec } from './gen-spec.js'; +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; + +const staticAssets = fileURLToPath(new URL('../../../../assets/', import.meta.url)); + +@Injectable() +export class OpenApiServerService { + constructor( + @Inject(DI.config) + private config: Config, + ) { + } + + @bindThis + public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { + fastify.get('/api-doc', async (_request, reply) => { + reply.header('Cache-Control', 'public, max-age=86400'); + return await reply.sendFile('/redoc.html', staticAssets); + }); + fastify.get('/api.json', (_request, reply) => { + reply.header('Cache-Control', 'public, max-age=600'); + reply.send(genOpenapiSpec(this.config)); + }); + done(); + } +} diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts new file mode 100644 index 000000000..fa62480c0 --- /dev/null +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -0,0 +1,193 @@ +import type { Config } from '@/config.js'; +import endpoints from '../endpoints.js'; +import { errors as basicErrors } from './errors.js'; +import { schemas, convertSchemaToOpenApiSchema } from './schemas.js'; + +export function genOpenapiSpec(config: Config) { + const spec = { + openapi: '3.0.0', + + info: { + version: config.version, + title: 'Misskey API', + 'x-logo': { url: '/static-assets/api-doc.png' }, + }, + + externalDocs: { + description: 'Repository', + url: 'https://github.com/misskey-dev/misskey', + }, + + servers: [{ + url: config.apiUrl, + }], + + paths: {} as any, + + components: { + schemas: schemas, + + securitySchemes: { + ApiKeyAuth: { + type: 'apiKey', + in: 'body', + name: 'i', + }, + }, + }, + }; + + for (const endpoint of endpoints.filter(ep => !ep.meta.secure)) { + const errors = {} as any; + + if (endpoint.meta.errors) { + for (const e of Object.values(endpoint.meta.errors)) { + errors[e.code] = { + value: { + error: e, + }, + }; + } + } + + const resSchema = endpoint.meta.res ? convertSchemaToOpenApiSchema(endpoint.meta.res) : {}; + + let desc = (endpoint.meta.description ? endpoint.meta.description : 'No description provided.') + '\n\n'; + desc += `**Credential required**: *${endpoint.meta.requireCredential ? 'Yes' : 'No'}*`; + if (endpoint.meta.kind) { + const kind = endpoint.meta.kind; + desc += ` / **Permission**: *${kind}*`; + } + + const requestType = endpoint.meta.requireFile ? 'multipart/form-data' : 'application/json'; + const schema = { ...endpoint.params }; + + if (endpoint.meta.requireFile) { + schema.properties = { + ...schema.properties, + file: { + type: 'string', + format: 'binary', + description: 'The file contents.', + }, + }; + schema.required = [...schema.required ?? [], 'file']; + } + + const info = { + operationId: endpoint.name, + summary: endpoint.name, + description: desc, + externalDocs: { + description: 'Source code', + url: `https://github.com/misskey-dev/misskey/blob/develop/packages/backend/src/server/api/endpoints/${endpoint.name}.ts`, + }, + ...(endpoint.meta.tags ? { + tags: [endpoint.meta.tags[0]], + } : {}), + ...(endpoint.meta.requireCredential ? { + security: [{ + ApiKeyAuth: [], + }], + } : {}), + requestBody: { + required: true, + content: { + [requestType]: { + schema, + }, + }, + }, + responses: { + ...(endpoint.meta.res ? { + '200': { + description: 'OK (with results)', + content: { + 'application/json': { + schema: resSchema, + }, + }, + }, + } : { + '204': { + description: 'OK (without any results)', + }, + }), + '400': { + description: 'Client error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: { ...errors, ...basicErrors['400'] }, + }, + }, + }, + '401': { + description: 'Authentication error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['401'], + }, + }, + }, + '403': { + description: 'Forbidden error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['403'], + }, + }, + }, + '418': { + description: 'I\'m Ai', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['418'], + }, + }, + }, + ...(endpoint.meta.limit ? { + '429': { + description: 'To many requests', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['429'], + }, + }, + }, + } : {}), + '500': { + description: 'Internal server error', + content: { + 'application/json': { + schema: { + $ref: '#/components/schemas/Error', + }, + examples: basicErrors['500'], + }, + }, + }, + }, + }; + + spec.paths['/' + endpoint.name] = { + post: info, + }; + } + + return spec; +} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 796383f5e..0cef361ca 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -1,5 +1,5 @@ -import type { Schema } from '@/misc/schema.js'; -import { refs } from '@/misc/schema.js'; +import type { Schema } from '@/misc/json-schema.js'; +import { refs } from '@/misc/json-schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema) { const res: any = schema; diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 3e67880b4..e67aec9ec 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -23,12 +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 blocking() { - return this.connection.blocking; + protected get userIdsWhoMeMutingRenotes() { + return this.connection.userIdsWhoMeMutingRenotes; + } + + 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 18604d94f..d48dea725 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -35,9 +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.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 f5ef1d110..9e5b40997 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.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'; @@ -47,9 +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.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 b8c0076ed..5454836fe 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -64,9 +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.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 00f8d8ecd..0268fdedd 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.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'; @@ -46,9 +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.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 04a9f2968..ee874ad81 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import type { Packed } from '@/misc/schema.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'; @@ -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,16 +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.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 ab52aabb3..4f7b4e78b 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -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,9 +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.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 d8532c477..836c5aae6 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { checkWordMute } from '@/misc/check-word-mute.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; @@ -61,9 +61,11 @@ class LocalTimelineChannel 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.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/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 7254d0a6d..8802fc5ab 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UserListJoiningsRepository, UserListsRepository } from '@/models/index.js'; import type { User } from '@/models/entities/User.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -89,9 +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.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 d3056aca5..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, 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/schema.js'; -import type { GlobalEventService } from '@/core/GlobalEventService.js'; +import type { Packed } from '@/misc/json-schema.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,102 +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 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 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.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: 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; - } } /** @@ -182,17 +149,13 @@ 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]); } } @bindThis private onReadNotification(payload: any) { - if (!payload.id) return; - this.notificationService.readNotification(this.user!.id, [payload.id]); + this.notificationService.readAllNotification(this.user!.id); } /** @@ -318,66 +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 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 c45077305..ed73897e7 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -9,7 +9,7 @@ import type { UserList } from '@/models/entities/UserList.js'; import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; import type { Signin } from '@/models/entities/Signin.js'; import type { Page } from '@/models/entities/Page.js'; -import type { Packed } from '@/misc/schema.js'; +import type { Packed } from '@/misc/json-schema.js'; import type { Webhook } from '@/models/entities/Webhook.js'; import type { Meta } from '@/models/entities/Meta.js'; import { Role, RoleAssignment } from '@/models'; @@ -19,7 +19,7 @@ import type { EventEmitter } from 'events'; //#region Stream type-body definitions export interface InternalStreamTypes { userChangeSuspendedState: { id: User['id']; isSuspended: User['isSuspended']; }; - userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; }; + userTokenRegenerated: { id: User['id']; oldToken: string; newToken: string; }; remoteUserUpdated: { id: User['id']; }; follow: { followerId: User['id']; followeeId: User['id']; }; unfollow: { followerId: User['id']; followeeId: User['id']; }; @@ -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: { @@ -202,10 +193,6 @@ export type StreamMessages = { name: 'broadcast'; payload: EventUnionFromDictionary>; }; - user: { - name: `user:${User['id']}`; - payload: EventUnionFromDictionary>; - }; main: { name: `mainStream:${User['id']}`; payload: EventUnionFromDictionary>; diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 761cf4ba7..335f6bb1c 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -208,11 +208,6 @@ export class ClientServerService { //#region static assets - fastify.register(fastifyStatic, { - root: _dirname, - serve: false, - }); - fastify.register(fastifyStatic, { root: staticAssets, prefix: '/static-assets/', @@ -438,7 +433,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, @@ -483,7 +478,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', @@ -522,7 +517,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, @@ -546,7 +541,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, @@ -570,7 +565,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, @@ -592,7 +587,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, 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/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 2ce7293a5..b3e193cd3 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -1,7 +1,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { summaly } from 'summaly'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -9,6 +8,7 @@ import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; +import { ApiError } from '@/server/api/error.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @Injectable() @@ -19,9 +19,6 @@ export class UrlPreviewService { @Inject(DI.config) private config: Config, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - private metaService: MetaService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, @@ -43,23 +40,23 @@ export class UrlPreviewService { @bindThis public async handle( - request: FastifyRequest<{ Querystring: { url: string; lang: string; } }>, + request: FastifyRequest<{ Querystring: { url: string; lang?: string; } }>, reply: FastifyReply, - ) { + ): Promise { const url = request.query.url; if (typeof url !== 'string') { reply.code(400); return; } - + const lang = request.query.lang; if (Array.isArray(lang)) { reply.code(400); return; } - + const meta = await this.metaService.fetch(); - + this.logger.info(meta.summalyProxy ? `(Proxy) Getting preview of ${url}@${lang} ...` : `Getting preview of ${url}@${lang} ...`); @@ -81,26 +78,32 @@ export class UrlPreviewService { this.logger.succ(`Got preview of ${url}: ${summary.title}`); - if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { + if (!(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) { throw new Error('unsupported schema included'); } - if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { + if (summary.player.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) { throw new Error('unsupported schema included'); } - + summary.icon = this.wrap(summary.icon); summary.thumbnail = this.wrap(summary.thumbnail); - + // Cache 7days reply.header('Cache-Control', 'max-age=604800, immutable'); - + return summary; } catch (err) { this.logger.warn(`Failed to get preview of ${url}: ${err}`); - reply.code(200); + reply.code(422); reply.header('Cache-Control', 'max-age=86400, immutable'); - return {}; + return { + error: new ApiError({ + message: 'Failed to get preview', + code: 'URL_PREVIEW_FAILED', + id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', + }), + }; } } } diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 4b9565f4c..4ebe310ac 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -61,6 +61,13 @@ renderError('META_FETCH_V'); return; } + + // for https://github.com/misskey-dev/misskey/issues/10202 + if (lang == null || lang.toString == null || lang.toString() === 'null') { + console.error('invalid lang value detected!!!', typeof lang, lang); + lang = 'en-US'; + } + const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`); if (localRes.status === 200) { localStorage.setItem('lang', lang); diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 5b3217aa9..979acc4ba 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -36,7 +36,7 @@ html link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.2.0') + link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.12.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) if !config.clientManifestExists 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/2fa.ts b/packages/backend/test/e2e/2fa.ts new file mode 100644 index 000000000..0addb430c --- /dev/null +++ b/packages/backend/test/e2e/2fa.ts @@ -0,0 +1,439 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import * as crypto from 'node:crypto'; +import * as cbor from 'cbor'; +import * as OTPAuth from 'otpauth'; +import { loadConfig } from '../../src/config.js'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('2要素認証', () => { + let app: INestApplicationContext; + let alice: unknown; + + const config = loadConfig(); + const password = 'test'; + const username = 'alice'; + + // https://datatracker.ietf.org/doc/html/rfc8152 + // 各値の定義は上記規格に基づく。鍵ペアは適当に生成したやつ + const coseKtyEc2 = 2; + const coseKid = 'meriadoc.brandybuck@buckland.example'; + const coseAlgEs256 = -7; + const coseEc2CrvP256 = 1; + const coseEc2X = '4932eaacc657565705e4287e7870ce3aad55545d99d35a98a472dc52880cfc8f'; + const coseEc2Y = '5ca68303bf2c0433473e3d5cb8586bc2c8c43a4945a496fce8dbeda8b23ab0b1'; + + // private key only for testing + const pemToSign = '-----BEGIN EC PRIVATE KEY-----\n' + + 'MHcCAQEEIHqe/keuXyolbXzgLOu+YFJjDBGWVgXc3QCXfyqwDPf2oAoGCCqGSM49\n' + + 'AwEHoUQDQgAESTLqrMZXVlcF5Ch+eHDOOq1VVF2Z01qYpHLcUogM/I9cpoMDvywE\n' + + 'M0c+PVy4WGvCyMQ6SUWklvzo2+2osjqwsQ==\n' + + '-----END EC PRIVATE KEY-----\n'; + + const otpToken = (secret: string): string => { + return OTPAuth.TOTP.generate({ + secret: OTPAuth.Secret.fromBase32(secret), + digits: 6, + }); + }; + + const rpIdHash = (): Buffer => { + return crypto.createHash('sha256') + .update(Buffer.from(config.hostname, 'utf-8')) + .digest(); + }; + + const keyDoneParam = (param: { + keyName: string, + challengeId: string, + challenge: string, + credentialId: Buffer, + }): { + attestationObject: string, + challengeId: string, + clientDataJSON: string, + password: string, + name: string, + } => { + // A COSE encoded public key + const credentialPublicKey = cbor.encode(new Map([ + [-1, coseEc2CrvP256], + [-2, Buffer.from(coseEc2X, 'hex')], + [-3, Buffer.from(coseEc2Y, 'hex')], + [1, coseKtyEc2], + [2, coseKid], + [3, coseAlgEs256], + ])); + + // AuthenticatorAssertionResponse.authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + const credentialIdLength = Buffer.allocUnsafe(2); + credentialIdLength.writeUInt16BE(param.credentialId.length); + const authData = Buffer.concat([ + rpIdHash(), // rpIdHash(32) + Buffer.from([0x45]), // flags(1) + Buffer.from([0x00, 0x00, 0x00, 0x00]), // signCount(4) + Buffer.from([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), // AAGUID(16) + credentialIdLength, + param.credentialId, + credentialPublicKey, + ]); + + return { + attestationObject: cbor.encode({ + fmt: 'none', + attStmt: {}, + authData, + }).toString('hex'), + challengeId: param.challengeId, + clientDataJSON: JSON.stringify({ + type: 'webauthn.create', + challenge: param.challenge, + origin: config.scheme + '://' + config.host, + androidPackageName: 'org.mozilla.firefox', + }), + password, + name: param.keyName, + }; + }; + + const signinParam = (): { + username: string, + password: string, + 'g-recaptcha-response'?: string | null, + 'hcaptcha-response'?: string | null, + } => { + return { + username, + password, + 'g-recaptcha-response': null, + 'hcaptcha-response': null, + }; + }; + + const signinWithSecurityKeyParam = (param: { + keyName: string, + challengeId: string, + challenge: string, + credentialId: Buffer, + }): { + authenticatorData: string, + credentialId: string, + challengeId: string, + clientDataJSON: string, + username: string, + password: string, + signature: string, + 'g-recaptcha-response'?: string | null, + 'hcaptcha-response'?: string | null, + } => { + // AuthenticatorAssertionResponse.authenticatorData + // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + const authenticatorData = Buffer.concat([ + rpIdHash(), + Buffer.from([0x05]), // flags(1) + Buffer.from([0x00, 0x00, 0x00, 0x01]), // signCount(4) + ]); + const clientDataJSONBuffer = Buffer.from(JSON.stringify({ + type: 'webauthn.get', + challenge: param.challenge, + origin: config.scheme + '://' + config.host, + androidPackageName: 'org.mozilla.firefox', + })); + const hashedclientDataJSON = crypto.createHash('sha256') + .update(clientDataJSONBuffer) + .digest(); + const privateKey = crypto.createPrivateKey(pemToSign); + const signature = crypto.createSign('SHA256') + .update(Buffer.concat([authenticatorData, hashedclientDataJSON])) + .sign(privateKey); + return { + authenticatorData: authenticatorData.toString('hex'), + credentialId: param.credentialId.toString('base64'), + challengeId: param.challengeId, + clientDataJSON: clientDataJSONBuffer.toString('hex'), + username, + password, + signature: signature.toString('hex'), + 'g-recaptcha-response': null, + 'hcaptcha-response': null, + }; + }; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username, password }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + test('が設定でき、OTPでログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + assert.notEqual(registerResponse.body.qr, undefined); + assert.notEqual(registerResponse.body.url, undefined); + assert.notEqual(registerResponse.body.secret, undefined); + assert.strictEqual(registerResponse.body.label, username); + assert.strictEqual(registerResponse.body.issuer, config.host); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }, alice); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + token: otpToken(registerResponse.body.secret), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); + + test('が設定でき、セキュリティキーでログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + assert.notEqual(registerKeyResponse.body.challengeId, undefined); + assert.notEqual(registerKeyResponse.body.challenge, undefined); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('hex')); + assert.strictEqual(keyDoneResponse.body.name, keyName); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.securityKeys, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + }); + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.i, undefined); + assert.notEqual(signinResponse.body.challengeId, undefined); + assert.notEqual(signinResponse.body.challenge, undefined); + assert.notEqual(signinResponse.body.securityKeys, undefined); + assert.strictEqual(signinResponse.body.securityKeys[0].id, credentialId.toString('hex')); + + const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({ + keyName, + challengeId: signinResponse.body.challengeId, + challenge: signinResponse.body.challenge, + credentialId, + })); + assert.strictEqual(signinResponse2.status, 200); + assert.notEqual(signinResponse2.body.i, undefined); + }); + + test('が設定でき、セキュリティキーでパスワードレスログインできる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + const passwordLessResponse = await api('/i/2fa/password-less', { + value: true, + }, alice); + assert.strictEqual(passwordLessResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.usePasswordLessLogin, true); + + const signinResponse = await api('/signin', { + ...signinParam(), + password: '', + }); + assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.i, undefined); + + const signinResponse2 = await api('/signin', { + ...signinWithSecurityKeyParam({ + keyName, + challengeId: signinResponse.body.challengeId, + challenge: signinResponse.body.challenge, + credentialId, + }), + password: '', + }); + assert.strictEqual(signinResponse2.status, 200); + assert.notEqual(signinResponse2.body.i, undefined); + }); + + test('が設定でき、設定したセキュリティキーの名前を変更できる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + const renamedKey = 'other-key'; + const updateKeyResponse = await api('/i/2fa/update-key', { + name: renamedKey, + credentialId: credentialId.toString('hex'), + }, alice); + assert.strictEqual(updateKeyResponse.status, 200); + + const iResponse = await api('/i', { + }, alice); + assert.strictEqual(iResponse.status, 200); + const securityKeys = iResponse.body.securityKeysList.filter(s => s.id === credentialId.toString('hex')); + assert.strictEqual(securityKeys.length, 1); + assert.strictEqual(securityKeys[0].name, renamedKey); + assert.notEqual(securityKeys[0].lastUsed, undefined); + }); + + test('が設定でき、設定したセキュリティキーを削除できる。', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const registerKeyResponse = await api('/i/2fa/register-key', { + password, + }, alice); + assert.strictEqual(registerKeyResponse.status, 200); + + const keyName = 'example-key'; + const credentialId = crypto.randomBytes(0x41); + const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({ + keyName, + challengeId: registerKeyResponse.body.challengeId, + challenge: registerKeyResponse.body.challenge, + credentialId, + }), alice); + assert.strictEqual(keyDoneResponse.status, 200); + + // テストの実行順によっては複数残ってるので全部消す + const iResponse = await api('/i', { + }, alice); + assert.strictEqual(iResponse.status, 200); + for (const key of iResponse.body.securityKeysList) { + const removeKeyResponse = await api('/i/2fa/remove-key', { + password, + credentialId: key.id, + }, alice); + assert.strictEqual(removeKeyResponse.status, 200); + } + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.securityKeys, false); + + const signinResponse = await api('/signin', { + ...signinParam(), + token: otpToken(registerResponse.body.secret), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); + + test('が設定でき、設定解除できる。(パスワードのみでログインできる。)', async () => { + const registerResponse = await api('/i/2fa/register', { + password, + }, alice); + assert.strictEqual(registerResponse.status, 200); + + const doneResponse = await api('/i/2fa/done', { + token: otpToken(registerResponse.body.secret), + }, alice); + assert.strictEqual(doneResponse.status, 204); + + const usersShowResponse = await api('/users/show', { + username, + }); + assert.strictEqual(usersShowResponse.status, 200); + assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + + const unregisterResponse = await api('/i/2fa/unregister', { + password, + }, alice); + assert.strictEqual(unregisterResponse.status, 204); + + const signinResponse = await api('/signin', { + ...signinParam(), + }); + assert.strictEqual(signinResponse.status, 200); + assert.notEqual(signinResponse.body.i, undefined); + }); +}); diff --git a/packages/backend/test/e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts index 4e162f42d..3af0d3518 100644 --- a/packages/backend/test/e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -5,14 +5,14 @@ import { signup, api, post, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('API visibility', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; beforeAll(async () => { - p = await startServer(); + app = await startServer(); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); describe('Note visibility', () => { diff --git a/packages/backend/test/e2e/api.ts b/packages/backend/test/e2e/api.ts index 6ceccf66e..a46f336a7 100644 --- a/packages/backend/test/e2e/api.ts +++ b/packages/backend/test/e2e/api.ts @@ -5,20 +5,20 @@ import { signup, api, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('API', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let bob: any; let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); describe('General validation', () => { diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index 4e9030f85..57a46ab38 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -5,7 +5,7 @@ import { signup, api, post, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Block', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; // alice blocks bob let alice: any; @@ -13,14 +13,14 @@ describe('Block', () => { let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('Block作成', async () => { @@ -70,9 +70,9 @@ describe('Block', () => { // TODO: ユーザーリストから除外されるテスト test('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const res = await api('/notes/local-timeline', {}, bob); diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts new file mode 100644 index 000000000..f35aae9dc --- /dev/null +++ b/packages/backend/test/e2e/clips.ts @@ -0,0 +1,962 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { JTDDataType } from 'ajv/dist/jtd'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { paramDef as CreateParamDef } from '@/server/api/endpoints/clips/create.js'; +import { paramDef as UpdateParamDef } from '@/server/api/endpoints/clips/update.js'; +import { paramDef as DeleteParamDef } from '@/server/api/endpoints/clips/delete.js'; +import { paramDef as ShowParamDef } from '@/server/api/endpoints/clips/show.js'; +import { paramDef as FavoriteParamDef } from '@/server/api/endpoints/clips/favorite.js'; +import { paramDef as UnfavoriteParamDef } from '@/server/api/endpoints/clips/unfavorite.js'; +import { paramDef as AddNoteParamDef } from '@/server/api/endpoints/clips/add-note.js'; +import { paramDef as RemoveNoteParamDef } from '@/server/api/endpoints/clips/remove-note.js'; +import { paramDef as NotesParamDef } from '@/server/api/endpoints/clips/notes.js'; +import { + signup, + post, + startServer, + api, + successfulApiCall, + failedApiCall, + ApiRequest, + hiddenNote, +} from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('クリップ', () => { + type User = Packed<'User'>; + type Note = Packed<'Note'>; + type Clip = Packed<'Clip'>; + + let app: INestApplicationContext; + + let alice: User; + let bob: User; + let aliceNote: Note; + let aliceHomeNote: Note; + let aliceFollowersNote: Note; + let aliceSpecifiedNote: Note; + let bobNote: Note; + let bobHomeNote: Note; + let bobFollowersNote: Note; + let bobSpecifiedNote: Note; + + const compareBy = (selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { + return selector(a).localeCompare(selector(b)); + }; + + type CreateParam = JTDDataType; + const defaultCreate = (): Partial => ({ + name: 'test', + }); + const create = async (parameters: Partial = {}, request: Partial = {}): Promise => { + const clip = await successfulApiCall({ + endpoint: '/clips/create', + parameters: { + ...defaultCreate(), + ...parameters, + }, + user: alice, + ...request, + }); + + // 入力が結果として入っていること + assert.deepStrictEqual(clip, { + ...clip, + ...defaultCreate(), + ...parameters, + }); + return clip; + }; + + const createMany = async (parameters: Partial, count = 10, user = alice): Promise => { + return await Promise.all([...Array(count)].map((_, i) => create({ + name: `test${i}`, + ...parameters, + }, { user }))); + }; + + type UpdateParam = JTDDataType; + const update = async (parameters: Partial, request: Partial = {}): Promise => { + const clip = await successfulApiCall({ + endpoint: '/clips/update', + parameters: { + name: 'updated', + ...parameters, + }, + user: alice, + ...request, + }); + + // 入力が結果として入っていること。clipIdはidになるので消しておく + delete (parameters as { clipId?: string }).clipId; + assert.deepStrictEqual(clip, { + ...clip, + ...parameters, + }); + return clip; + }; + + type DeleteParam = JTDDataType; + const deleteClip = async (parameters: DeleteParam, request: Partial = {}): Promise => { + return await successfulApiCall({ + endpoint: '/clips/delete', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type ShowParam = JTDDataType; + const show = async (parameters: ShowParam, request: Partial = {}): Promise => { + return await successfulApiCall({ + endpoint: '/clips/show', + parameters, + user: alice, + ...request, + }); + }; + + const list = async (request: Partial): Promise => { + return successfulApiCall({ + endpoint: '/clips/list', + parameters: {}, + user: alice, + ...request, + }); + }; + + const usersClips = async (request: Partial): Promise => { + return await successfulApiCall({ + endpoint: '/users/clips', + parameters: {}, + user: alice, + ...request, + }); + }; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + + // FIXME: misskey-jsのNoteはoutdatedなので直接変換できない + aliceNote = await post(alice, { text: 'test' }) as any; + aliceHomeNote = await post(alice, { text: 'home only', visibility: 'home' }) as any; + aliceFollowersNote = await post(alice, { text: 'followers only', visibility: 'followers' }) as any; + aliceSpecifiedNote = await post(alice, { text: 'specified only', visibility: 'specified' }) as any; + bobNote = await post(bob, { text: 'test' }) as any; + bobHomeNote = await post(bob, { text: 'home only', visibility: 'home' }) as any; + bobFollowersNote = await post(bob, { text: 'followers only', visibility: 'followers' }) as any; + bobSpecifiedNote = await post(bob, { text: 'specified only', visibility: 'specified' }) as any; + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + afterEach(async () => { + // テスト間で影響し合わないように毎回全部消す。 + for (const user of [alice, bob]) { + const list = await api('/clips/list', { limit: 11 }, user); + for (const clip of list.body) { + await api('/clips/delete', { clipId: clip.id }, user); + } + } + }); + + test('の作成ができる', async () => { + const res = await create(); + // ISO 8601で日付が返ってくること + assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.lastClippedAt, null); + assert.strictEqual(res.name, 'test'); + assert.strictEqual(res.description, null); + assert.strictEqual(res.isPublic, false); + assert.strictEqual(res.favoritedCount, 0); + assert.strictEqual(res.isFavorited, false); + }); + + test('の作成はポリシーで定められた数以上はできない。', async () => { + // ポリシー + 1まで作れるという所がミソ + const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + for (let i = 0; i < clipLimit; i++) { + await create(); + } + + await failedApiCall({ + endpoint: '/clips/create', + parameters: defaultCreate(), + user: alice, + }, { + status: 400, + code: 'TOO_MANY_CLIPS', + id: '920f7c2d-6208-4b76-8082-e632020f5883', + }); + }); + + const createClipAllowedPattern = [ + { label: 'nameが最大長', parameters: { name: 'x'.repeat(100) } }, + { label: 'private', parameters: { isPublic: false } }, + { label: 'public', parameters: { isPublic: true } }, + { label: 'descriptionがnull', parameters: { description: null } }, + { label: 'descriptionが最大長', parameters: { description: 'a'.repeat(2048) } }, + ]; + test.each(createClipAllowedPattern)('の作成は$labelでもできる', async ({ parameters }) => await create(parameters)); + + const createClipDenyPattern = [ + { label: 'nameがnull', parameters: { name: null } }, + { label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } }, + { label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } }, + { label: 'descriptionがゼロ長', parameters: { description: '' } }, + { label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } }, + ]; + test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({ + endpoint: '/clips/create', + parameters: { + ...defaultCreate(), + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + })); + + test('の更新ができる', async () => { + const res = await update({ + clipId: (await create()).id, + name: 'updated', + description: 'new description', + isPublic: true, + }); + + // ISO 8601で日付が返ってくること + assert.strictEqual(res.createdAt, new Date(res.createdAt).toISOString()); + assert.strictEqual(res.lastClippedAt, null); + assert.strictEqual(res.name, 'updated'); + assert.strictEqual(res.description, 'new description'); + assert.strictEqual(res.isPublic, true); + assert.strictEqual(res.favoritedCount, 0); + assert.strictEqual(res.isFavorited, false); + }); + + test.each(createClipAllowedPattern)('の更新は$labelでもできる', async ({ parameters }) => await update({ + clipId: (await create()).id, + name: 'updated', + ...parameters, + })); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: 'b4d92d70-b216-46fa-9a3f-a8c811699257', + } }, + ...createClipDenyPattern as any, + ])('の更新は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/update', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + name: 'updated', + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('の削除ができる', async () => { + await deleteClip({ + clipId: (await create()).id, + }); + assert.deepStrictEqual(await list({}), []); + }); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '70ca08ba-6865-4630-b6fb-8494759aa754', + } }, + ])('の削除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/delete', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('のID指定取得ができる', async () => { + const clip = await create(); + const res = await show({ clipId: clip.id }); + assert.deepStrictEqual(res, clip); + }); + + test('のID指定取得は他人のPrivateなクリップは取得できない', async () => { + const clip = await create({ isPublic: false }, { user: bob } ); + failedApiCall({ + endpoint: '/clips/show', + parameters: { clipId: clip.id }, + user: alice, + }, { + status: 400, + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', + }); + }); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'c3c5fe33-d62c-44d2-9ea5-d997703f5c20', + } }, + ])('のID指定取得は$labelならできない', async ({ parameters, assetion }) => failedApiCall({ + endpoint: '/clips/show', + parameters: { + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('の一覧(clips/list)が取得できる(空)', async () => { + const res = await list({}); + assert.deepStrictEqual(res, []); + }); + + test('の一覧(clips/list)が取得できる(上限いっぱい)', async () => { + const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clips = await createMany({}, clipLimit); + const res = await list({ + parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる + }); + + // 返ってくる配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + clips.sort(compareBy(s => s.id)), + ); + }); + + test('の一覧が取得できる(空)', async () => { + const res = await usersClips({ + parameters: { + userId: alice.id, + }, + }); + assert.deepStrictEqual(res, []); + }); + + test.each([ + { label: '' }, + { label: '他人アカウントから', user: (): User => bob }, + ])('の一覧が$label取得できる', async () => { + const clips = await createMany({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + }, + }); + + // 返ってくる配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + clips.sort(compareBy(s => s.id))); + + // 認証状態で見たときだけisFavoritedが入っている + for (const clip of res) { + assert.strictEqual(clip.isFavorited, false); + } + }); + + test.each([ + { label: '未認証', user: (): undefined => undefined }, + { label: '存在しないユーザーのもの', parameters: { userId: 'xxxxxxx' } }, + ])('の一覧は$labelでも取得できる', async ({ parameters, user }) => { + const clips = await createMany({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + limit: clips.length, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }); + + // 未認証で見たときはisFavoritedは入らない + for (const clip of res) { + assert.strictEqual('isFavorited' in clip, false); + } + }); + + test('の一覧はPrivateなクリップを含まない(自分のものであっても。)', async () => { + await create({ isPublic: false }); + const aliceClip = await create({ isPublic: true }); + const res = await usersClips({ + parameters: { + userId: alice.id, + limit: 2, + }, + }); + assert.deepStrictEqual(res, [aliceClip]); + }); + + test('の一覧はID指定で範囲選択ができる', async () => { + const clips = await createMany({ isPublic: true }, 7); + clips.sort(compareBy(s => s.id)); + const res = await usersClips({ + parameters: { + userId: alice.id, + sinceId: clips[1].id, + untilId: clips[5].id, + limit: 4, + }, + }); + + // Promise.allで返ってくる配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + [clips[2], clips[3], clips[4]], // sinceIdとuntilId自体は結果に含まれない + clips[1].id + ' ... ' + clips[3].id + ' with ' + clips.map(s => s.id) + ' vs. ' + res.map(s => s.id)); + }); + + test.each([ + { label: 'userId未指定', parameters: { userId: undefined } }, + { label: 'limitゼロ', parameters: { limit: 0 } }, + { label: 'limit最大+1', parameters: { limit: 101 } }, + ])('の一覧は$labelだと取得できない', async ({ parameters }) => failedApiCall({ + endpoint: '/users/clips', + parameters: { + userId: alice.id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + })); + + test.each([ + { label: '作成', endpoint: '/clips/create' }, + { label: '更新', endpoint: '/clips/update' }, + { label: '削除', endpoint: '/clips/delete' }, + { label: '取得', endpoint: '/clips/list' }, + { label: 'お気に入り設定', endpoint: '/clips/favorite' }, + { label: 'お気に入り解除', endpoint: '/clips/unfavorite' }, + { label: 'お気に入り取得', endpoint: '/clips/my-favorites' }, + { label: 'ノート追加', endpoint: '/clips/add-note' }, + { label: 'ノート削除', endpoint: '/clips/remove-note' }, + ])('の$labelは未認証ではできない', async ({ endpoint }) => await failedApiCall({ + endpoint: endpoint, + parameters: {}, + user: undefined, + }, { + status: 401, + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + })); + + describe('のお気に入り', () => { + let aliceClip: Clip; + + type FavoriteParam = JTDDataType; + const favorite = async (parameters: FavoriteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/favorite', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type UnfavoriteParam = JTDDataType; + const unfavorite = async (parameters: UnfavoriteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/unfavorite', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + const myFavorites = async (request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/my-favorites', + parameters: {}, + user: alice, + ...request, + }); + }; + + beforeEach(async () => { + aliceClip = await create(); + }); + + test('を設定できる。', async () => { + await favorite({ clipId: aliceClip.id }); + const clip = await show({ clipId: aliceClip.id }); + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + }); + + test('はPublicな他人のクリップに設定できる。', async () => { + const publicClip = await create({ isPublic: true }); + await favorite({ clipId: publicClip.id }, { user: bob }); + const clip = await show({ clipId: publicClip.id }, { user: bob }); + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + + // isFavoritedは見る人によって切り替わる。 + const clip2 = await show({ clipId: publicClip.id }); + assert.strictEqual(clip2.favoritedCount, 1); + assert.strictEqual(clip2.isFavorited, false); + }); + + test('は1つのクリップに対して複数人が設定できる。', async () => { + const publicClip = await create({ isPublic: true }); + await favorite({ clipId: publicClip.id }, { user: bob }); + await favorite({ clipId: publicClip.id }); + const clip = await show({ clipId: publicClip.id }, { user: bob }); + assert.strictEqual(clip.favoritedCount, 2); + assert.strictEqual(clip.isFavorited, true); + + const clip2 = await show({ clipId: publicClip.id }); + assert.strictEqual(clip2.favoritedCount, 2); + assert.strictEqual(clip2.isFavorited, true); + }); + + test('は11を超えて設定できる。', async () => { + const clips = [ + aliceClip, + ...await createMany({}, 10, alice), + ...await createMany({ isPublic: true }, 10, bob), + ]; + for (const clip of clips) { + await favorite({ clipId: clip.id }); + } + + // pagenationはない。全部一気にとれる。 + const favorited = await myFavorites(); + assert.strictEqual(favorited.length, clips.length); + for (const clip of favorited) { + assert.strictEqual(clip.favoritedCount, 1); + assert.strictEqual(clip.isFavorited, true); + } + }); + + test('は同じクリップに対して二回設定できない。', async () => { + await favorite({ clipId: aliceClip.id }); + await failedApiCall({ + endpoint: '/clips/favorite', + parameters: { + clipId: aliceClip.id, + }, + user: alice, + }, { + status: 400, + code: 'ALREADY_FAVORITED', + id: '92658936-c625-4273-8326-2d790129256e', + }); + }); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5', + } }, + ])('の設定は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/favorite', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('を設定解除できる。', async () => { + await favorite({ clipId: aliceClip.id }); + await unfavorite({ clipId: aliceClip.id }); + const clip = await show({ clipId: aliceClip.id }); + assert.strictEqual(clip.favoritedCount, 0); + assert.strictEqual(clip.isFavorited, false); + assert.deepStrictEqual(await myFavorites(), []); + }); + + test.each([ + { label: 'clipIdがnull', parameters: { clipId: null } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '2603966e-b865-426c-94a7-af4a01241dc1', + } }, + { label: '他人のクリップ', user: (): User => bob, assertion: { + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + } }, + { label: 'お気に入りしていないクリップ', assertion: { + code: 'NOT_FAVORITED', + id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187', + } }, + ])('の設定解除は$labelならできない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/unfavorite', + parameters: { + clipId: (await create({}, { user: (user ?? ((): User => alice))() })).id, + ...parameters, + }, + user: alice, + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + + test('を取得できる。', async () => { + await favorite({ clipId: aliceClip.id }); + const favorited = await myFavorites(); + assert.deepStrictEqual(favorited, [await show({ clipId: aliceClip.id })]); + }); + + test('を取得したとき他人のお気に入りは含まない。', async () => { + await favorite({ clipId: aliceClip.id }); + const favorited = await myFavorites({ user: bob }); + assert.deepStrictEqual(favorited, []); + }); + }); + + describe('に紐づくノート', () => { + let aliceClip: Clip; + + const sampleNotes = (): Note[] => [ + aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, + bobNote, bobHomeNote, bobFollowersNote, bobSpecifiedNote, + ]; + + type AddNoteParam = JTDDataType; + const addNote = async (parameters: AddNoteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/add-note', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type RemoveNoteParam = JTDDataType; + const removeNote = async (parameters: RemoveNoteParam, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/remove-note', + parameters, + user: alice, + ...request, + }, { + status: 204, + }); + }; + + type NotesParam = JTDDataType; + const notes = async (parameters: Partial, request: Partial = {}): Promise => { + return successfulApiCall({ + endpoint: '/clips/notes', + parameters, + user: alice, + ...request, + }); + }; + + beforeEach(async () => { + aliceClip = await create(); + }); + + test('を追加できる。', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + const res = await show({ clipId: aliceClip.id }); + assert.strictEqual(res.lastClippedAt, new Date(res.lastClippedAt ?? '').toISOString()); + assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), [aliceNote]); + + // 他人の非公開ノートも突っ込める + await addNote({ clipId: aliceClip.id, noteId: bobHomeNote.id }); + await addNote({ clipId: aliceClip.id, noteId: bobFollowersNote.id }); + await addNote({ clipId: aliceClip.id, noteId: bobSpecifiedNote.id }); + }); + + test('として同じノートを二回紐づけることはできない', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: alice, + }, { + status: 400, + code: 'ALREADY_CLIPPED', + id: '734806c4-542c-463a-9311-15c512803965', + }); + }); + + // TODO: 17000msくらいかかる... + test('をポリシーで定められた上限いっぱい(200)を超えて追加はできない。', async () => { + const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1; + const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, { + text: `test ${i}`, + }) as unknown)) as Note[]; + await Promise.all(noteList.map(s => addNote({ clipId: aliceClip.id, noteId: s.id }))); + + await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: alice, + }, { + status: 400, + code: 'TOO_MANY_CLIP_NOTES', + id: 'f0dba960-ff73-4615-8df4-d6ac5d9dc118', + }); + }); + + test('は他人のクリップへ追加できない。', async () => await failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + }, + user: bob, + }, { + status: 400, + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + })); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + } }, + { label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_NOTE', + id: 'fc8c0b49-c7a3-4664-a0a6-b418d386bb8b', + } }, + { label: '他人のクリップ', user: (): object => bob, assetion: { + code: 'NO_SUCH_CLIP', + id: 'd6e76cc0-a1b5-4c7c-a287-73fa9c716dcf', + } }, + ])('の追加は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ + endpoint: '/clips/add-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('を削除できる。', async () => { + await addNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + await removeNote({ clipId: aliceClip.id, noteId: aliceNote.id }); + assert.deepStrictEqual(await notes({ clipId: aliceClip.id }), []); + }); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'noteId未指定', parameters: { noteId: undefined } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる + } }, + { label: '存在しないノート', parameters: { noteId: 'xxxxxx' }, assetion: { + code: 'NO_SUCH_NOTE', + id: 'aff017de-190e-434b-893e-33a9ff5049d8', // add-noteと異なる + } }, + { label: '他人のクリップ', user: (): object => bob, assetion: { + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', // add-noteと異なる + } }, + ])('の削除は$labelだとできない', async ({ parameters, user, assetion }) => failedApiCall({ + endpoint: '/clips/remove-note', + parameters: { + clipId: aliceClip.id, + noteId: aliceNote.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assetion, + })); + + test('を取得できる。', async () => { + const noteList = sampleNotes(); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ clipId: aliceClip.id }); + + // 自分のノートは非公開でも入れられるし、見える + // 他人の非公開ノートは入れられるけど、除外される + const expects = [ + aliceNote, aliceHomeNote, aliceFollowersNote, aliceSpecifiedNote, + bobNote, bobHomeNote, + ]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test('を始端IDとlimitで取得できる。', async () => { + const noteList = sampleNotes(); + noteList.sort(compareBy(s => s.id)); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ + clipId: aliceClip.id, + sinceId: noteList[2].id, + limit: 3, + }); + + // Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較 + const expects = [noteList[3], noteList[4], noteList[5]]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test('をID範囲指定で取得できる。', async () => { + const noteList = sampleNotes(); + noteList.sort(compareBy(s => s.id)); + for (const note of noteList) { + await addNote({ clipId: aliceClip.id, noteId: note.id }); + } + + const res = await notes({ + clipId: aliceClip.id, + sinceId: noteList[1].id, + untilId: noteList[4].id, + }); + + // Promise.allで返ってくる配列はID順で並んでないのでソートして厳密比較 + const expects = [noteList[2], noteList[3]]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test.todo('Remoteのノートもクリップできる。どうテストしよう?'); + + test('は他人のPublicなクリップからも取得できる。', async () => { + const bobClip = await create({ isPublic: true }, { user: bob } ); + await addNote({ clipId: bobClip.id, noteId: aliceNote.id }, { user: bob }); + const res = await notes({ clipId: bobClip.id }); + assert.deepStrictEqual(res, [aliceNote]); + }); + + test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => { + const publicClip = await create({ isPublic: true }); + await addNote({ clipId: publicClip.id, noteId: aliceNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceFollowersNote.id }); + await addNote({ clipId: publicClip.id, noteId: aliceSpecifiedNote.id }); + + const res = await notes({ clipId: publicClip.id }, { user: undefined }); + const expects = [ + aliceNote, aliceHomeNote, + // 認証なしだと非公開ノートは結果には含むけどhideされる。 + hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote), + ]; + assert.deepStrictEqual( + res.sort(compareBy(s => s.id)), + expects.sort(compareBy(s => s.id))); + }); + + test.todo('ブロック、ミュートされたユーザーからの設定&取得etc.'); + + test.each([ + { label: 'clipId未指定', parameters: { clipId: undefined } }, + { label: 'limitゼロ', parameters: { limit: 0 } }, + { label: 'limit最大+1', parameters: { limit: 101 } }, + { label: '存在しないクリップ', parameters: { clipId: 'xxxxxx' }, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + { label: '他人のPrivateなクリップから', user: (): object => bob, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + { label: '未認証でPrivateなクリップから', user: (): undefined => undefined, assertion: { + code: 'NO_SUCH_CLIP', + id: '1d7645e6-2b6d-4635-b0fe-fe22b0e72e00', + } }, + ])('は$labelだと取得できない', async ({ parameters, user, assertion }) => failedApiCall({ + endpoint: '/clips/notes', + parameters: { + clipId: aliceClip.id, + ...parameters, + }, + user: (user ?? ((): User => alice))(), + }, { + status: 400, + code: 'INVALID_PARAM', + id: '3d81ceae-475f-4600-b2a8-2bc116157532', + ...assertion, + })); + }); +}); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index e864eab6c..afb72c84d 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -4,11 +4,11 @@ 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 } from '../utils.js'; +import { startServer, signup, post, api, uploadFile, simpleGet } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Endpoints', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let bob: any; @@ -16,7 +16,7 @@ describe('Endpoints', () => { let dave: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); @@ -24,7 +24,7 @@ describe('Endpoints', () => { }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); describe('signup', () => { @@ -162,14 +162,14 @@ describe('Endpoints', () => { const res = await api('/users/show', { userId: '000000000000000000000000', }); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); }); test('間違ったIDで怒られる', async () => { const res = await api('/users/show', { userId: 'kyoppie', }); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 404); }); }); @@ -206,7 +206,7 @@ describe('Endpoints', () => { describe('notes/reactions/create', () => { test('リアクションできる', async () => { - const bobPost = await post(bob); + const bobPost = await post(bob, { text: 'hi' }); const res = await api('/notes/reactions/create', { noteId: bobPost.id, @@ -224,7 +224,7 @@ describe('Endpoints', () => { }); test('自分の投稿にもリアクションできる', async () => { - const myPost = await post(alice); + const myPost = await post(alice, { text: 'hi' }); const res = await api('/notes/reactions/create', { noteId: myPost.id, @@ -235,7 +235,7 @@ describe('Endpoints', () => { }); test('二重にリアクションすると上書きされる', async () => { - const bobPost = await post(bob); + const bobPost = await post(bob, { text: 'hi' }); await api('/notes/reactions/create', { noteId: bobPost.id, @@ -410,11 +410,19 @@ describe('Endpoints', () => { }); test('ファイルに名前を付けられる', async () => { + const res = await uploadFile(alice, { name: 'Belmond.jpg' }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.name, 'Belmond.jpg'); + }); + + test('ファイルに名前を付けられるが、拡張子は正しいものになる', async () => { const res = await uploadFile(alice, { name: 'Belmond.png' }); assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.name, 'Belmond.png'); + assert.strictEqual(res.body.name, 'Belmond.png.jpg'); }); test('ファイル無しで怒られる', async () => { @@ -431,6 +439,45 @@ describe('Endpoints', () => { assert.strictEqual(res.body.name, 'image.svg'); assert.strictEqual(res.body.type, 'image/svg+xml'); }); + + for (const type of ['webp', 'avif']) { + const mediaType = `image/${type}`; + + const getWebpublicType = async (user: any, fileId: string): Promise => { + // drive/files/create does not expose webpublicType directly, so get it by posting it + const res = await post(user, { + text: mediaType, + fileIds: [fileId], + }); + const apRes = await simpleGet(`notes/${res.id}`, 'application/activity+json'); + assert.strictEqual(apRes.status, 200); + assert.ok(Array.isArray(apRes.body.attachment)); + return apRes.body.attachment[0].mediaType; + }; + + test(`透明な${type}ファイルを作成できる`, async () => { + const path = `with-alpha.${type}`; + const res = await uploadFile(alice, { path }); + + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, path); + assert.strictEqual(res.body.type, mediaType); + + const webpublicType = await getWebpublicType(alice, res.body.id); + assert.strictEqual(webpublicType, 'image/webp'); + }); + + test(`透明じゃない${type}ファイルを作成できる`, async () => { + const path = `without-alpha.${type}`; + const res = await uploadFile(alice, { path }); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, path); + assert.strictEqual(res.body.type, mediaType); + + const webpublicType = await getWebpublicType(alice, res.body.id); + assert.strictEqual(webpublicType, 'image/webp'); + }); + } }); describe('drive/files/update', () => { @@ -794,4 +841,12 @@ describe('Endpoints', () => { assert.strictEqual(res.body[0].id, carolPost.id); }); }); + + describe('URL preview', () => { + test('Error from summaly becomes HTTP 422', async () => { + const res = await simpleGet('/url?url=https://e:xample.com'); + assert.strictEqual(res.status, 422); + assert.strictEqual(res.body.error.code, 'URL_PREVIEW_FAILED'); + }); + }); }); diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 6b3c79523..78ca8b43b 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -1,7 +1,8 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { startServer, signup, post, api, simpleGet } from '../utils.js'; +import { startServer, channel, clip, cookie, galleryPost, signup, page, play, post, simpleGet, uploadFile } from '../utils.js'; +import type { SimpleGetResponse } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; // Request Accept @@ -13,181 +14,448 @@ const UNSPECIFIED = '*/*'; // Response Content-Type const AP = 'application/activity+json; charset=utf-8'; const HTML = 'text/html; charset=utf-8'; +const JSON_UTF8 = 'application/json; charset=utf-8'; -describe('Fetch resource', () => { - let p: INestApplicationContext; +describe('Webリソース', () => { + let app: INestApplicationContext; let alice: any; + let aliceUploadedFile: any; let alicesPost: any; + let alicePage: any; + let alicePlay: any; + let aliceClip: any; + let aliceGalleryPost: any; + let aliceChannel: any; + + type Request = { + path: string, + accept?: string, + cookie?: string, + }; + const ok = async (param: Request & { + type?: string, + }):Promise => { + const { path, accept, cookie, type } = param; + const res = await simpleGet(path, accept, cookie); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.type, type ?? HTML); + return res; + }; + + const notOk = async (param: Request & { + status?: number, + code?: string, + }): Promise => { + const { path, accept, cookie, status, code } = param; + const res = await simpleGet(path, accept, cookie); + assert.notStrictEqual(res.status, 200); + if (status != null) { + assert.strictEqual(res.status, status); + } + if (code != null) { + assert.strictEqual(res.body.error.code, code); + } + return res; + }; + + const notFound = async (param: Request): Promise => { + return await notOk({ + ...param, + status: 404, + }); + }; + + const metaTag = (res: SimpleGetResponse, key: string, superkey = 'name'): string => { + return res.body.window.document.querySelector('meta[' + superkey + '="' + key + '"]')?.content; + }; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); + aliceUploadedFile = await uploadFile(alice); alicesPost = await post(alice, { text: 'test', }); + alicePage = await page(alice, {}); + alicePlay = await play(alice, {}); + aliceClip = await clip(alice, {}); + aliceGalleryPost = await galleryPost(alice, { + fileIds: [aliceUploadedFile.body.id], + }); + aliceChannel = await channel(alice, {}); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); - describe('Common', () => { - test('meta', async () => { - const res = await api('/meta', { - }); + describe.each([ + { path: '/', type: HTML }, + { path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。" + // fastify-static gives charset=UTF-8 instead of utf-8 and that's okay + { path: '/api-doc', type: 'text/html; charset=UTF-8' }, + { path: '/api.json', type: JSON_UTF8 }, + { path: '/api-console', type: HTML }, + { path: '/_info_card_', type: HTML }, + { path: '/bios', type: HTML }, + { path: '/cli', type: HTML }, + { path: '/flush', type: HTML }, + { path: '/robots.txt', type: 'text/plain; charset=UTF-8' }, + { path: '/favicon.ico', type: 'image/vnd.microsoft.icon' }, + { path: '/opensearch.xml', type: 'application/opensearchdescription+xml' }, + { path: '/apple-touch-icon.png', type: 'image/png' }, + { path: '/twemoji/2764.svg', type: 'image/svg+xml' }, + { path: '/twemoji/2764-fe0f-200d-1f525.svg', type: 'image/svg+xml' }, + { path: '/twemoji-badge/2764.png', type: 'image/png' }, + { path: '/twemoji-badge/2764-fe0f-200d-1f525.png', type: 'image/png' }, + { path: '/fluent-emoji/2764.png', type: 'image/png' }, + { path: '/fluent-emoji/2764-fe0f-200d-1f525.png', type: 'image/png' }, + ])('$path', (p) => { + test('がGETできる。', async () => await ok({ ...p })); - assert.strictEqual(res.status, 200); - }); + // 注意: Webページが200で取得できても、実際のHTMLが正しく表示できるとは限らない + // 例えば、 /@xxx/pages/yyy に存在しないIDを渡した場合、HTTPレスポンスではエラーを区別できない + // こういったアサーションはフロントエンドE2EやAPI Endpointのテストで担保する。 + }); - test('GET root', async () => { - const res = await simpleGet('/'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); + describe.each([ + { path: '/twemoji/2764.png' }, + { path: '/twemoji/2764-fe0f-200d-1f525.png' }, + { path: '/twemoji-badge/2764.svg' }, + { path: '/twemoji-badge/2764-fe0f-200d-1f525.svg' }, + { path: '/fluent-emoji/2764.svg' }, + { path: '/fluent-emoji/2764-fe0f-200d-1f525.svg' }, + ])('$path', ({ path }) => { + test('はGETできない。', async () => await notFound({ path })); + }); - test('GET docs', async () => { - const res = await simpleGet('/docs/ja-JP/about'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); + describe.each([ + { ext: 'rss', type: 'application/rss+xml; charset=utf-8' }, + { ext: 'atom', type: 'application/atom+xml; charset=utf-8' }, + { ext: 'json', type: 'application/json; charset=utf-8' }, + ])('/@:username.$ext', ({ ext, type }) => { + const path = (username: string): string => `/@${username}.${ext}`; - test('GET api-doc (廃止)', async () => { - const res = await simpleGet('/api-doc'); - assert.strictEqual(res.status, 404); - }); + test('がGETできる。', async () => await ok({ + path: path(alice.username), + type, + })); - test('GET api.json (廃止)', async () => { - const res = await simpleGet('/api.json'); - assert.strictEqual(res.status, 404); - }); + test('は存在しないユーザーはGETできない。', async () => await notOk({ + path: path('nonexisting'), + status: 404, + })); + }); - test('GET api/foo (存在しない)', async () => { - const res = await simpleGet('/api/foo'); - assert.strictEqual(res.status, 404); - assert.strictEqual(res.body.error.code, 'UNKNOWN_API_ENDPOINT'); - }); + describe.each([{ path: '/api/foo' }])('$path', ({ path }) => { + test('はGETできない。', async () => await notOk({ + path, + status: 404, + code: 'UNKNOWN_API_ENDPOINT', + })); + }); - test('GET favicon.ico', async () => { - const res = await simpleGet('/favicon.ico'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/vnd.microsoft.icon'); - }); + describe.each([{ path: '/queue' }])('$path', ({ path }) => { + test('はadminでなければGETできない。', async () => await notOk({ + path, + status: 500, // FIXME? 403ではない。 + })); + + test('はadminならGETできる。', async () => await ok({ + path, + cookie: cookie(alice), + })); + }); - test('GET apple-touch-icon.png', async () => { - const res = await simpleGet('/apple-touch-icon.png'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/png'); - }); - - test('GET twemoji svg', async () => { - const res = await simpleGet('/twemoji/2764.svg'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/svg+xml'); - }); - - test('GET twemoji svg with hyphen', async () => { - const res = await simpleGet('/twemoji/2764-fe0f-200d-1f525.svg'); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'image/svg+xml'); - }); + describe.each([{ path: '/streaming' }])('$path', ({ path }) => { + test('はGETできない。', async () => await notOk({ + path, + status: 503, + })); }); describe('/@:username', () => { - test('Only AP => AP', async () => { - const res = await simpleGet(`/@${alice.username}`, ONLY_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); + const path = (username: string): string => `/@${username}`; + + describe.each([ + { accept: PREFER_HTML }, + { accept: UNSPECIFIED }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username), + accept, + type: HTML, + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + // TODO の検証 + }); + test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + type: HTML, + })); }); - test('Prefer AP => AP', async () => { - const res = await simpleGet(`/@${alice.username}`, PREFER_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); + describe.each([ + { accept: ONLY_AP }, + { accept: PREFER_AP }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はActivityPubとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username), + accept, + type: AP, + }); + assert.strictEqual(res.body.type, 'Person'); + }); - test('Prefer HTML => HTML', async () => { - const res = await simpleGet(`/@${alice.username}`, PREFER_HTML); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ + path: path('xxxxxxxxxx'), + accept, + })); }); + }); - test('Unspecified => HTML', async () => { - const res = await simpleGet(`/@${alice.username}`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); + describe.each([ + // 実際のハンドルはフロントエンド(index.vue)で行われる + { sub: 'home' }, + { sub: 'notes' }, + { sub: 'activity' }, + { sub: 'achievements' }, + { sub: 'reactions' }, + { sub: 'clips' }, + { sub: 'pages' }, + { sub: 'gallery' }, + ])('/@:username/$sub', ({ sub }) => { + const path = (username: string): string => `/@${username}/${sub}`; + + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); }); }); + + describe('/@:user/pages/:page', () => { + const path = (username: string, pagename: string): string => `/@${username}/pages/${pagename}`; + + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.username, alicePage.name), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:page-id'), alicePage.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('はGETできる。(存在しないIDでも。)', async () => await ok({ + path: path(alice.username, 'xxxxxxxxxx'), + })); + }); describe('/users/:id', () => { - test('Only AP => AP', async () => { - const res = await simpleGet(`/users/${alice.id}`, ONLY_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); + const path = (id: string): string => `/users/${id}`; + + describe.each([ + { accept: PREFER_HTML }, + { accept: UNSPECIFIED }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('は/@:usernameにリダイレクトする', async () => { + const res = await simpleGet(path(alice.id), accept); + assert.strictEqual(res.status, 302); + assert.strictEqual(res.location, `/@${alice.username}`); + }); + + test('は存在しないユーザーはGETできない。', async () => await notFound({ + path: path('xxxxxxxx'), + })); }); - test('Prefer AP => AP', async () => { - const res = await simpleGet(`/users/${alice.id}`, PREFER_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); + describe.each([ + { accept: ONLY_AP }, + { accept: PREFER_AP }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はActivityPubとしてGETできる。', async () => { + const res = await ok({ + path: path(alice.id), + accept, + type: AP, + }); + assert.strictEqual(res.body.type, 'Person'); + }); - test('Prefer HTML => Redirect to /@:username', async () => { - const res = await simpleGet(`/users/${alice.id}`, PREFER_HTML); - assert.strictEqual(res.status, 302); - assert.strictEqual(res.location, `/@${alice.username}`); - }); - - test('Undecided => HTML', async () => { - const res = await simpleGet(`/users/${alice.id}`, UNSPECIFIED); - assert.strictEqual(res.status, 302); - assert.strictEqual(res.location, `/@${alice.username}`); + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notOk({ + path: path('xxxxxxxx'), + accept, + status: 404, + })); }); }); + + describe('/users/inbox', () => { + test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ + path: '/inbox', + })); + // test.todo('POSTできる?'); + }); + + describe('/users/:id/inbox', () => { + const path = (id: string): string => `/users/${id}/inbox`; + + test('がGETできる。(POST専用だけど4xx/5xxにならずHTMLが返ってくる)', async () => await ok({ + path: path(alice.id), + })); + + // test.todo('POSTできる?'); + }); + + describe('/users/:id/outbox', () => { + const path = (id: string): string => `/users/${id}/outbox`; + + test('がGETできる。', async () => { + const res = await ok({ + path: path(alice.id), + type: AP, + }); + assert.strictEqual(res.body.type, 'OrderedCollection'); + }); + }); + describe('/notes/:id', () => { - test('Only AP => AP', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, ONLY_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); + const path = (noteId: string): string => `/notes/${noteId}`; + + describe.each([ + { accept: PREFER_HTML }, + { accept: UNSPECIFIED }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はHTMLとしてGETできる。', async () => { + const res = await ok({ + path: path(alicesPost.id), + accept, + type: HTML, + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:note-id'), alicesPost.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('はHTMLとしてGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); }); - test('Prefer AP => AP', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_AP); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, AP); - }); + describe.each([ + { accept: ONLY_AP }, + { accept: PREFER_AP }, + ])('(Acceptヘッダ: $accept)', ({ accept }) => { + test('はActivityPubとしてGETできる。', async () => { + const res = await ok({ + path: path(alicesPost.id), + accept, + type: AP, + }); + assert.strictEqual(res.body.type, 'Note'); + }); - test('Prefer HTML => HTML', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, PREFER_HTML); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); - }); - - test('Unspecified => HTML', async () => { - const res = await simpleGet(`/notes/${alicesPost.id}`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, HTML); + test('は存在しないIDのときActivityPubとしてGETできない。', async () => await notFound({ + path: path('xxxxxxxxxx'), + accept, + })); }); }); + + describe('/play/:id', () => { + const path = (playid: string): string => `/play/${playid}`; - describe('Feeds', () => { - test('RSS', async () => { - const res = await simpleGet(`/@${alice.username}.rss`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'application/rss+xml; charset=utf-8'); + test('がGETできる。', async () => { + const res = await ok({ + path: path(alicePlay.id), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:flash-id'), alicePlay.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 }); - test('ATOM', async () => { - const res = await simpleGet(`/@${alice.username}.atom`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'application/atom+xml; charset=utf-8'); - }); + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe('/clips/:clip', () => { + const path = (clip: string): string => `/clips/${clip}`; - test('JSON', async () => { - const res = await simpleGet(`/@${alice.username}.json`, UNSPECIFIED); - assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, 'application/json; charset=utf-8'); + test('がGETできる。', async () => { + const res = await ok({ + path: path(aliceClip.id), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + assert.strictEqual(metaTag(res, 'misskey:clip-id'), aliceClip.id); + + // TODO ogタグの検証 + // TODO profile.noCrawleの検証 }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe('/gallery/:post', () => { + const path = (post: string): string => `/gallery/${post}`; + + test('がGETできる。', async () => { + const res = await ok({ + path: path(aliceGalleryPost.id), + }); + assert.strictEqual(metaTag(res, 'misskey:user-username'), alice.username); + assert.strictEqual(metaTag(res, 'misskey:user-id'), alice.id); + + // FIXME: misskey:gallery-post-idみたいなmetaタグの設定がない + // TODO profile.noCrawleの検証 + // TODO twitter:creatorの検証 + }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); + }); + + describe('/channels/:channel', () => { + const path = (channel: string): string => `/channels/${channel}`; + + test('はGETできる。', async () => { + const res = await ok({ + path: path(aliceChannel.id), + }); + + // FIXME: misskey関連のmetaタグの設定がない + // TODO ogタグの検証 + }); + + test('がGETできる。(存在しないIDでも。)', async () => await ok({ + path: path('xxxxxxxxxx'), + })); }); }); diff --git a/packages/backend/test/e2e/ff-visibility.ts b/packages/backend/test/e2e/ff-visibility.ts index d53919ca1..7b75005a3 100644 --- a/packages/backend/test/e2e/ff-visibility.ts +++ b/packages/backend/test/e2e/ff-visibility.ts @@ -5,19 +5,19 @@ import { signup, api, startServer, simpleGet } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('FF visibility', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let bob: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('ffVisibility が public なユーザーのフォロー/フォロワーを誰でも見れる', async () => { diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index 6654a290b..25bd532cf 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -5,7 +5,7 @@ import { signup, api, post, react, startServer, waitFire } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Mute', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; // alice mutes carol let alice: any; @@ -13,14 +13,14 @@ describe('Mute', () => { let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('ミュート作成', async () => { @@ -76,9 +76,9 @@ describe('Mute', () => { describe('Timeline', () => { test('タイムラインにミュートしているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const res = await api('/notes/local-timeline', {}, alice); @@ -90,8 +90,8 @@ describe('Mute', () => { }); test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { - const aliceNote = await post(alice); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { renoteId: carolNote.id, }); @@ -108,7 +108,7 @@ describe('Mute', () => { describe('Notification', () => { test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => { - const aliceNote = await post(alice); + const aliceNote = await post(alice, { text: 'hi' }); await react(bob, aliceNote, 'like'); await react(carol, aliceNote, 'like'); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index 98ee34d8d..e87045a8c 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -2,18 +2,18 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { Note } from '@/models/entities/Note.js'; -import { signup, post, uploadUrl, startServer, initTestDb, api } from '../utils.js'; +import { signup, post, uploadUrl, startServer, initTestDb, api, uploadFile } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Note', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let Notes: any; let alice: any; let bob: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); const connection = await initTestDb(true); Notes = connection.getRepository(Note); alice = await signup({ username: 'alice' }); @@ -21,7 +21,7 @@ describe('Note', () => { }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('投稿できる', async () => { @@ -136,6 +136,31 @@ describe('Note', () => { assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); }); + test('visibility: followersでrenoteできる', async () => { + const createRes = await api('/notes/create', { + text: 'test', + visibility: 'followers', + }, alice); + + assert.strictEqual(createRes.status, 200); + + const renoteId = createRes.body.createdNote.id; + const renoteRes = await api('/notes/create', { + visibility: 'followers', + renoteId, + }, alice); + + assert.strictEqual(renoteRes.status, 200); + assert.strictEqual(renoteRes.body.createdNote.renoteId, renoteId); + assert.strictEqual(renoteRes.body.createdNote.visibility, 'followers'); + + const deleteRes = await api('/notes/delete', { + noteId: renoteRes.body.createdNote.id, + }, alice); + + assert.strictEqual(deleteRes.status, 204); + }); + test('文字数ぎりぎりで怒られない', async () => { const post = { text: '!'.repeat(3000), @@ -213,6 +238,122 @@ describe('Note', () => { assert.deepStrictEqual(noteDoc.mentions, [bob.id]); }); + describe('添付ファイル情報', () => { + test('ファイルを添付した場合、投稿成功時にファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const res = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.createdNote.files.length, 1); + assert.strictEqual(res.body.createdNote.files[0].id, file.body.id); + }); + + test('ファイルを添付した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const res = await api('/notes', { + withFiles: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.files.length, 1); + assert.strictEqual(myNote.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートをリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const renoted = await api('/notes/create', { + renoteId: createdNote.body.createdNote.id, + }, alice); + assert.strictEqual(renoted.status, 200); + + const res = await api('/notes', { + renote: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.renote.files.length, 1); + assert.strictEqual(myNote.renote.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートに返信した場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const reply = await api('/notes/create', { + replyId: createdNote.body.createdNote.id, + text: 'this is reply', + }, alice); + assert.strictEqual(reply.status, 200); + + const res = await api('/notes', { + reply: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.reply.files.length, 1); + assert.strictEqual(myNote.reply.files[0].id, file.body.id); + }); + + test('ファイルが添付されたノートへの返信をリノートした場合、タイムラインでファイル情報入りのレスポンスが帰ってくる', async () => { + const file = await uploadFile(alice); + const createdNote = await api('/notes/create', { + fileIds: [file.body.id], + }, alice); + + assert.strictEqual(createdNote.status, 200); + + const reply = await api('/notes/create', { + replyId: createdNote.body.createdNote.id, + text: 'this is reply', + }, alice); + assert.strictEqual(reply.status, 200); + + const renoted = await api('/notes/create', { + renoteId: reply.body.createdNote.id, + }, alice); + assert.strictEqual(renoted.status, 200); + + const res = await api('/notes', { + renote: true, + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); + assert.notEqual(myNote, null); + assert.strictEqual(myNote.renote.reply.files.length, 1); + assert.strictEqual(myNote.renote.reply.files[0].id, file.body.id); + }); + }); + describe('notes/create', () => { test('投票を添付できる', async () => { const res = await api('/notes/create', { diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts new file mode 100644 index 000000000..0f73b8d09 --- /dev/null +++ b/packages/backend/test/e2e/renote-mute.ts @@ -0,0 +1,85 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Renote Mute', () => { + let app: INestApplicationContext; + + // alice mutes carol + let alice: any; + let bob: any; + let carol: any; + + beforeAll(async () => { + app = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await app.close(); + }); + + test('ミュート作成', async () => { + const res = await api('/renote-mute/create', { + userId: carol.id, + }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('タイムラインにリノートミュートしているユーザーのリノートが含まれない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, false); + }); + + test('ストリームにリノートミュートしているユーザーの引用が流れる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id, text: 'kore' }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, true); + }); +}); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index 23c431f2e..d1394ef7a 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -6,7 +6,7 @@ import { connectStream, signup, api, post, startServer, initTestDb, waitFire } f import type { INestApplicationContext } from '@nestjs/common'; describe('Streaming', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let Followings: any; const follow = async (follower: any, followee: any) => { @@ -38,7 +38,7 @@ describe('Streaming', () => { let list: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); const connection = await initTestDb(true); Followings = connection.getRepository(Following); @@ -74,7 +74,7 @@ describe('Streaming', () => { }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); describe('Events', () => { @@ -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/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 792436d88..2ae2eb67c 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -5,21 +5,21 @@ import { signup, api, post, connectStream, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('Note thread mute', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let bob: any; let carol: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); bob = await signup({ username: 'bob' }); carol = await signup({ username: 'carol' }); }, 1000 * 60 * 2); afterAll(async () => { - await p.close(); + await app.close(); }); test('notes/mentions にミュートしているスレッドの投稿が含まれない', async () => { diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index 690cba174..c11099e7b 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -5,7 +5,7 @@ import { signup, api, post, uploadUrl, startServer } from '../utils.js'; import type { INestApplicationContext } from '@nestjs/common'; describe('users/notes', () => { - let p: INestApplicationContext; + let app: INestApplicationContext; let alice: any; let jpgNote: any; @@ -13,7 +13,7 @@ describe('users/notes', () => { let jpgPngNote: any; beforeAll(async () => { - p = await startServer(); + app = await startServer(); alice = await signup({ username: 'alice' }); const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png'); @@ -29,7 +29,7 @@ describe('users/notes', () => { }, 1000 * 60 * 2); afterAll(async() => { - await p.close(); + await app.close(); }); test('ファイルタイプ指定 (jpg)', async () => { diff --git a/packages/backend/test/resources/with-alpha.avif b/packages/backend/test/resources/with-alpha.avif new file mode 100644 index 000000000..05f494212 Binary files /dev/null and b/packages/backend/test/resources/with-alpha.avif differ diff --git a/packages/backend/test/resources/with-alpha.webp b/packages/backend/test/resources/with-alpha.webp new file mode 100644 index 000000000..d7b0d70b7 Binary files /dev/null and b/packages/backend/test/resources/with-alpha.webp differ diff --git a/packages/backend/test/resources/without-alpha.avif b/packages/backend/test/resources/without-alpha.avif new file mode 100644 index 000000000..9ea23608b Binary files /dev/null and b/packages/backend/test/resources/without-alpha.avif differ diff --git a/packages/backend/test/resources/without-alpha.webp b/packages/backend/test/resources/without-alpha.webp new file mode 100644 index 000000000..a51091efe Binary files /dev/null and b/packages/backend/test/resources/without-alpha.webp differ diff --git a/packages/backend/test/unit/DriveService.ts b/packages/backend/test/unit/DriveService.ts new file mode 100644 index 000000000..406566557 --- /dev/null +++ b/packages/backend/test/unit/DriveService.ts @@ -0,0 +1,56 @@ +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { DeleteObjectCommandOutput, DeleteObjectCommand, NoSuchKey, InvalidObjectState, S3Client } from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DriveService } from '@/core/DriveService.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import type { TestingModule } from '@nestjs/testing'; + +describe('DriveService', () => { + let app: TestingModule; + let driveService: DriveService; + const s3Mock = mockClient(S3Client); + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + providers: [DriveService], + }).compile(); + app.enableShutdownHooks(); + driveService = app.get(DriveService); + }); + + beforeEach(async () => { + s3Mock.reset(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Object storage', () => { + test('delete a file', async () => { + s3Mock.on(DeleteObjectCommand) + .resolves({} as DeleteObjectCommandOutput); + + await driveService.deleteObjectStorageFile('peace of the world'); + }); + + test('delete a file then unexpected error', async () => { + s3Mock.on(DeleteObjectCommand) + .rejects(new InvalidObjectState({ $metadata: {}, message: '' })); + + await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrowError(Error); + }); + + test('delete a file with no valid key', async () => { + // Some S3 implementations returns 404 Not Found on deleting with a non-existent key + s3Mock.on(DeleteObjectCommand) + .rejects(new NoSuchKey({ $metadata: {}, message: 'allowed error.' })); + + await driveService.deleteObjectStorageFile('lol no way'); + }); + }); +}); diff --git a/packages/backend/test/unit/ReactionService.ts b/packages/backend/test/unit/ReactionService.ts index 6a20a1e08..38db081ac 100644 --- a/packages/backend/test/unit/ReactionService.ts +++ b/packages/backend/test/unit/ReactionService.ts @@ -74,19 +74,19 @@ describe('ReactionService', () => { }); test('fallback - undefined', async () => { - assert.strictEqual(await reactionService.toDbReaction(undefined), '👍'); + assert.strictEqual(await reactionService.toDbReaction(undefined), '❤'); }); test('fallback - null', async () => { - assert.strictEqual(await reactionService.toDbReaction(null), '👍'); + assert.strictEqual(await reactionService.toDbReaction(null), '❤'); }); test('fallback - empty', async () => { - assert.strictEqual(await reactionService.toDbReaction(''), '👍'); + assert.strictEqual(await reactionService.toDbReaction(''), '❤'); }); test('fallback - unknown', async () => { - assert.strictEqual(await reactionService.toDbReaction('unknown'), '👍'); + assert.strictEqual(await reactionService.toDbReaction('unknown'), '❤'); }); }); }); diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 6fe04274e..907f1f2ed 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -11,7 +11,7 @@ import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { genAid } from '@/misc/id/aid.js'; -import { UserCacheService } from '@/core/UserCacheService.js'; +import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { sleep } from '../utils.js'; @@ -65,7 +65,7 @@ describe('RoleService', () => { ], providers: [ RoleService, - UserCacheService, + CacheService, IdService, GlobalEventService, ], diff --git a/packages/backend/test/unit/S3Service.ts b/packages/backend/test/unit/S3Service.ts new file mode 100644 index 000000000..1dfa22afd --- /dev/null +++ b/packages/backend/test/unit/S3Service.ts @@ -0,0 +1,77 @@ +process.env.NODE_ENV = 'test'; + +import { Test } from '@nestjs/testing'; +import { UploadPartCommand, CompleteMultipartUploadCommand, CreateMultipartUploadCommand, S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { mockClient } from 'aws-sdk-client-mock'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { S3Service } from '@/core/S3Service'; +import { Meta } from '@/models'; +import type { TestingModule } from '@nestjs/testing'; + +describe('S3Service', () => { + let app: TestingModule; + let s3Service: S3Service; + const s3Mock = mockClient(S3Client); + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + providers: [S3Service], + }).compile(); + app.enableShutdownHooks(); + s3Service = app.get(S3Service); + }); + + beforeEach(async () => { + s3Mock.reset(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('upload', () => { + test('upload a file', async () => { + s3Mock.on(PutObjectCommand).resolves({}); + + await s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x', + }); + }); + + test('upload a large file', async () => { + s3Mock.on(CreateMultipartUploadCommand).resolves({ UploadId: '1' }); + s3Mock.on(UploadPartCommand).resolves({ ETag: '1' }); + s3Mock.on(CompleteMultipartUploadCommand).resolves({ Bucket: 'fake', Key: 'fake' }); + + await s3Service.upload({} as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ + }); + }); + + test('upload a file error', async () => { + s3Mock.on(PutObjectCommand).rejects({ name: 'Fake Error' }); + + await expect(s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x', + })).rejects.toThrowError(Error); + }); + + test('upload a large file error', async () => { + s3Mock.on(UploadPartCommand).rejects(); + + await expect(s3Service.upload({} as Meta, { + Bucket: 'fake', + Key: 'fake', + Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ + })).rejects.toThrowError(Error); + }); + }); +}); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 3d0032507..146998937 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -7,15 +7,35 @@ import { jest } from '@jest/globals'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { GlobalModule } from '@/GlobalModule.js'; import { CoreModule } from '@/core/CoreModule.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { LoggerService } from '@/core/LoggerService.js'; +import type { IActor } from '@/core/activitypub/type.js'; import { MockResolver } from '../misc/mock-resolver.js'; +import { Note } from '@/models/index.js'; + +const host = 'https://host1.test'; + +function createRandomActor(): IActor & { id: string } { + const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; + const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; + + return { + '@context': 'https://www.w3.org/ns/activitystreams', + id: actorId, + type: 'Person', + preferredUsername, + inbox: `${actorId}/inbox`, + outbox: `${actorId}/outbox`, + }; +} describe('ActivityPub', () => { let noteService: ApNoteService; let personService: ApPersonService; + let rendererService: ApRendererService; let resolver: MockResolver; beforeEach(async () => { @@ -28,6 +48,7 @@ describe('ActivityPub', () => { noteService = app.get(ApNoteService); personService = app.get(ApPersonService); + rendererService = app.get(ApRendererService); resolver = new MockResolver(await app.resolve(LoggerService)); // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error @@ -36,18 +57,7 @@ describe('ActivityPub', () => { }); describe('Parse minimum object', () => { - const host = 'https://host1.test'; - const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; - const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; - - const actor = { - '@context': 'https://www.w3.org/ns/activitystreams', - id: actorId, - type: 'Person', - preferredUsername, - inbox: `${actorId}/inbox`, - outbox: `${actorId}/outbox`, - }; + const actor = createRandomActor(); const post = { '@context': 'https://www.w3.org/ns/activitystreams', @@ -80,29 +90,40 @@ describe('ActivityPub', () => { }); }); - describe('Truncate long name', () => { - const host = 'https://host1.test'; - const preferredUsername = `${rndstr('A-Z', 4)}${rndstr('a-z', 4)}`; - const actorId = `${host}/users/${preferredUsername.toLowerCase()}`; + describe('Name field', () => { + test('Truncate long name', async () => { + const actor = { + ...createRandomActor(), + name: rndstr('0-9a-z', 129), + }; - const name = rndstr('0-9a-z', 129); - - const actor = { - '@context': 'https://www.w3.org/ns/activitystreams', - id: actorId, - type: 'Person', - preferredUsername, - name, - inbox: `${actorId}/inbox`, - outbox: `${actorId}/outbox`, - }; - - test('Actor', async () => { resolver._register(actor.id, actor); const user = await personService.createPerson(actor.id, resolver); - assert.deepStrictEqual(user.name, actor.name.substr(0, 128)); + assert.deepStrictEqual(user.name, actor.name.slice(0, 128)); + }); + + test('Normalize empty name', async () => { + const actor = { + ...createRandomActor(), + name: '', + }; + + resolver._register(actor.id, actor); + + const user = await personService.createPerson(actor.id, resolver); + + assert.strictEqual(user.name, null); + }); + }); + + describe('Renderer', () => { + test('Render an announce with visibility: followers', () => { + rendererService.renderAnnounce(null, { + createdAt: new Date(0), + visibility: 'followers', + } as Note); }); }); }); diff --git a/packages/backend/test/unit/misc/others.ts b/packages/backend/test/unit/misc/others.ts new file mode 100644 index 000000000..c476aef33 --- /dev/null +++ b/packages/backend/test/unit/misc/others.ts @@ -0,0 +1,42 @@ +import { describe, test, expect } from '@jest/globals'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; + +describe('misc:content-disposition', () => { + test('inline', () => { + expect(contentDisposition('inline', 'foo bar')).toBe('inline; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); + }); + test('attachment', () => { + expect(contentDisposition('attachment', 'foo bar')).toBe('attachment; filename=\"foo_bar\"; filename*=UTF-8\'\'foo%20bar'); + }); + test('non ascii', () => { + expect(contentDisposition('attachment', 'ファイル名')).toBe('attachment; filename=\"_____\"; filename*=UTF-8\'\'%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E5%90%8D'); + }); +}); + +describe('misc:correct-filename', () => { + test('simple', () => { + expect(correctFilename('filename', 'jpg')).toBe('filename.jpg'); + }); + test('with same ext', () => { + expect(correctFilename('filename.jpg', 'jpg')).toBe('filename.jpg'); + }); + test('.ext', () => { + expect(correctFilename('filename.jpg', '.jpg')).toBe('filename.jpg'); + }); + test('with different ext', () => { + expect(correctFilename('filename.webp', 'jpg')).toBe('filename.webp.jpg'); + }); + test('non ascii with space', () => { + expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg'); + }); + test('jpeg', () => { + expect(correctFilename('filename.jpeg', 'jpg')).toBe('filename.jpeg'); + }); + test('tiff', () => { + expect(correctFilename('filename.tiff', 'tif')).toBe('filename.tiff'); + }); + test('null ext', () => { + expect(correctFilename('filename', null)).toBe('filename.unknown'); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 8203e4935..4f501a872 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -1,8 +1,11 @@ +import * as assert from 'node:assert'; import { readFile } from 'node:fs/promises'; import { isAbsolute, basename } from 'node:path'; +import { inspect } from 'node:util'; import WebSocket from 'ws'; import fetch, { Blob, File, RequestInit } from 'node-fetch'; import { DataSource } from 'typeorm'; +import { JSDOM } from 'jsdom'; import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; @@ -12,11 +15,45 @@ export { server as startServer } from '@/boot/common.js'; const config = loadConfig(); export const port = config.port; +export const cookie = (me: any): string => { + return `token=${me.token};`; +}; + export const api = async (endpoint: string, params: any, me?: any) => { const normalized = endpoint.replace(/^\//, ''); return await request(`api/${normalized}`, params, me); }; +export type ApiRequest = { + endpoint: string, + parameters: object, + user: object | undefined, +}; + +export const successfulApiCall = async (request: ApiRequest, assertion: { + status: number, +} = { status: 200 }): Promise => { + const { endpoint, parameters, user } = request; + const { status } = assertion; + const res = await api(endpoint, parameters, user); + assert.strictEqual(res.status, status, inspect(res.body)); + return res.body; +}; + +export const failedApiCall = async (request: ApiRequest, assertion: { + status: number, + code: string, + id: string +}): Promise => { + const { endpoint, parameters, user } = request; + const { status, code, id } = assertion; + const res = await api(endpoint, parameters, user); + assert.strictEqual(res.status, status, inspect(res.body)); + assert.strictEqual(res.body.error.code, code, inspect(res.body)); + assert.strictEqual(res.body.error.id, id, inspect(res.body)); + return res.body; +}; + const request = async (path: string, params: any, me?: any): Promise<{ body: any, status: number }> => { const auth = me ? { i: me.token, @@ -57,15 +94,28 @@ export const signup = async (params?: any): Promise => { }; export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise => { - const q = Object.assign({ - text: 'test', - }, params); + const q = params; const res = await api('notes/create', q, user); return res.body ? res.body.createdNote : null; }; +// 非公開ノートをAPI越しに見たときのノート NoteEntityService.ts +export const hiddenNote = (note: any): any => { + const temp = { + ...note, + fileIds: [], + files: [], + text: null, + cw: null, + isHidden: true, + }; + delete temp.visibleUserIds; + delete temp.poll; + return temp; +}; + export const react = async (user: any, note: any, reaction: string): Promise => { await api('notes/reactions/create', { noteId: note.id, @@ -73,6 +123,71 @@ export const react = async (user: any, note: any, reaction: string): Promise => { + const res = await api('pages/create', { + alignCenter: false, + content: [ + { + id: '2be9a64b-5ada-43a3-85f3-ec3429551ded', + text: 'Hello World!', + type: 'text', + }, + ], + eyeCatchingImageId: null, + font: 'sans-serif', + hideTitleWhenPinned: false, + name: '1678594845072', + script: '', + summary: null, + title: '', + variables: [], + ...page, + }, user); + return res.body; +}; + +export const play = async (user: any, play: any = {}): Promise => { + const res = await api('flash/create', { + permissions: [], + script: 'test', + summary: '', + title: 'test', + ...play, + }, user); + return res.body; +}; + +export const clip = async (user: any, clip: any = {}): Promise => { + const res = await api('clips/create', { + description: null, + isPublic: true, + name: 'test', + ...clip, + }, user); + return res.body; +}; + +export const galleryPost = async (user: any, channel: any = {}): Promise => { + const res = await api('gallery/posts/create', { + description: null, + fileIds: [], + isSensitive: false, + title: 'test', + ...channel, + }, user); + return res.body; +}; + +export const channel = async (user: any, channel: any = {}): Promise => { + const res = await api('channels/create', { + bannerId: null, + description: null, + name: 'test', + ...channel, + }, user); + return res.body; +}; + interface UploadOptions { /** Optional, absolute path or relative from ./resources/ */ path?: string | URL; @@ -198,17 +313,33 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond }); }; -export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status: number, body: any, type: string | null, location: string | null }> => { +export type SimpleGetResponse = { + status: number, + body: any | JSDOM | null, + type: string | null, + location: string | null +}; +export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined): Promise => { const res = await relativeFetch(path, { headers: { Accept: accept, + Cookie: cookie, }, redirect: 'manual', }); - const body = res.headers.get('content-type') === 'application/json; charset=utf-8' - ? await res.json() - : null; + const jsonTypes = [ + 'application/json; charset=utf-8', + 'application/activity+json; charset=utf-8', + ]; + const htmlTypes = [ + 'text/html; charset=utf-8', + ]; + + const body = + jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : + htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : + null; return { status: res.status, diff --git a/packages/frontend/.gitignore b/packages/frontend/.gitignore new file mode 100644 index 000000000..1aa0ac14e --- /dev/null +++ b/packages/frontend/.gitignore @@ -0,0 +1 @@ +/storybook-static diff --git a/packages/frontend/.storybook/.gitignore b/packages/frontend/.storybook/.gitignore new file mode 100644 index 000000000..e421532a5 --- /dev/null +++ b/packages/frontend/.storybook/.gitignore @@ -0,0 +1,7 @@ +/changes.js +/generate.js +/preload-locale.js +/locale.ts +/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..f0827331f --- /dev/null +++ b/packages/frontend/.storybook/changes.ts @@ -0,0 +1,80 @@ +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.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 new file mode 100644 index 000000000..23b82a8ac --- /dev/null +++ b/packages/frontend/.storybook/fakes.ts @@ -0,0 +1,116 @@ +import type { entities } from 'misskey-js' + +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 + }, + 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', + 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 new file mode 100644 index 000000000..b3bbeeb51 --- /dev/null +++ b/packages/frontend/.storybook/generate.tsx @@ -0,0 +1,406 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import { basename, dirname } from 'node:path/posix'; +import { GENERATOR, type State, generate } from 'astring'; +import type * as estree from 'estree'; +import glob from 'fast-glob'; +import { format } from 'prettier'; + +interface SatisfiesExpression extends estree.BaseExpression { + type: 'SatisfiesExpression'; + expression: estree.Expression; + reference: estree.Identifier; +} + +const generator = { + ...GENERATOR, + SatisfiesExpression(node: SatisfiesExpression, state: State) { + switch (node.expression.type) { + case 'ArrowFunctionExpression': { + state.write('('); + this[node.expression.type](node.expression, state); + state.write(')'); + break; + } + default: { + // @ts-ignore + this[node.expression.type](node.expression, state); + break; + } + } + state.write(' satisfies ', node as unknown as estree.Expression); + this[node.reference.type](node.reference, state); + }, +}; + +type SplitCamel< + T extends string, + YC extends string = '', + YN extends readonly string[] = [] +> = T extends `${infer XH}${infer XR}` + ? XR extends '' + ? [...YN, Uncapitalize<`${YC}${XH}`>] + : XH extends Uppercase + ? SplitCamel, [...YN, YC]> + : SplitCamel + : YN; + +// @ts-ignore +type SplitKebab = T extends `${infer XH}-${infer XR}` + ? [XH, ...SplitKebab] + : [T]; + +type ToKebab = T extends readonly [ + infer XO extends string +] + ? XO + : T extends readonly [ + infer XH extends string, + ...infer XR extends readonly string[] + ] + ? `${XH}${XR extends readonly string[] ? `-${ToKebab}` : ''}` + : ''; + +// @ts-ignore +type ToPascal = T extends readonly [ + infer XH extends string, + ...infer XR extends readonly string[] +] + ? `${Capitalize}${ToPascal}` + : ''; + +function h( + component: T['type'], + props: Omit +): T { + const type = component.replace(/(?:^|-)([a-z])/g, (_, c) => c.toUpperCase()); + return Object.assign(props || {}, { type }) as T; +} + +declare global { + namespace JSX { + type Element = estree.Node; + type ElementClass = never; + type ElementAttributesProperty = never; + type ElementChildrenAttribute = never; + type IntrinsicAttributes = never; + type IntrinsicClassAttributes = never; + type IntrinsicElements = { + [T in keyof typeof generator as ToKebab>>]: { + [K in keyof Omit< + Parameters<(typeof generator)[T]>[0], + 'type' + >]?: Parameters<(typeof generator)[T]>[0][K]; + }; + }; + } +} + +function toStories(component: string): string { + const msw = `${component.slice(0, -'.vue'.length)}.msw`; + const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`; + const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`; + const hasMsw = existsSync(`${msw}.ts`); + const hasImplStories = existsSync(`${implStories}.ts`); + const hasMetaStories = existsSync(`${metaStories}.ts`); + const base = basename(component); + const dir = dirname(component); + const literal = + as estree.Literal; + const identifier = + as estree.Identifier; + const parameters = ( + as estree.Identifier} + value={ as estree.Literal} + kind={'init' as const} + /> as estree.Property, + ...(hasMsw + ? [ + as estree.Identifier} + value={ as estree.Identifier} + kind={'init' as const} + shorthand + /> as estree.Property, + ] + : []), + ]} + /> + ) as estree.ObjectExpression; + const program = ( + as estree.Literal} + specifiers={[ + as estree.Identifier} + imported={ as estree.Identifier} + /> as estree.ImportSpecifier, + ...(hasImplStories + ? [] + : [ + as estree.Identifier} + imported={ as estree.Identifier} + /> as estree.ImportSpecifier, + ]), + ]} + /> as estree.ImportDeclaration, + ...(hasMsw + ? [ + as estree.Literal} + specifiers={[ + as estree.Identifier} + /> as estree.ImportNamespaceSpecifier, + ]} + /> as estree.ImportDeclaration, + ] + : []), + ...(hasImplStories + ? [] + : [ + as estree.Literal} + specifiers={[ + as estree.ImportDefaultSpecifier, + ]} + /> as estree.ImportDeclaration, + ]), + ...(hasMetaStories + ? [ + as estree.Literal} + specifiers={[ + as estree.Identifier} + /> as estree.ImportNamespaceSpecifier, + ]} + /> as estree.ImportDeclaration, + ] + : []), + as estree.Identifier} + init={ + as estree.Identifier} + value={literal} + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={identifier} + kind={'init' as const} + /> as estree.Property, + ...(hasMetaStories + ? [ + as estree.Identifier} + /> as estree.SpreadElement, + ] + : []) + ]} + /> as estree.ObjectExpression + } + reference={`} /> as estree.Identifier} + /> as estree.Expression + } + /> as estree.VariableDeclarator, + ]} + /> as estree.VariableDeclaration, + ...(hasImplStories + ? [] + : [ + as estree.Identifier} + init={ + as estree.Identifier} + value={ + as estree.Identifier, + ]} + body={ + as estree.Identifier} + value={ + as estree.Property, + ]} + /> as estree.ObjectExpression + } + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={ + as estree.Identifier} + value={ as estree.Identifier} + kind={'init' as const} + shorthand + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={ + as estree.Identifier} + value={ + as estree.ThisExpression} + property={ as estree.Identifier} + /> as estree.MemberExpression + } + /> as estree.SpreadElement, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={`} /> as estree.Literal} + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + /> as estree.ReturnStatement, + ]} + /> as estree.BlockStatement + } + /> as estree.FunctionExpression + } + method + kind={'init' as const} + /> as estree.Property, + as estree.Identifier} + value={parameters} + kind={'init' as const} + /> as estree.Property, + ]} + /> as estree.ObjectExpression + } + reference={`} /> as estree.Identifier} + /> as estree.Expression + } + /> as estree.VariableDeclarator, + ]} + /> as estree.VariableDeclaration + } + /> as estree.ExportNamedDeclaration, + ]), + ) as estree.Identifier} + /> as estree.ExportDefaultDeclaration, + ]} + /> + ) as estree.Program; + return format( + '/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' + + '/* eslint-disable import/no-default-export */\n' + + generate(program, { generator }) + + (hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''), + { + parser: 'babel-ts', + singleQuote: true, + useTabs: true, + } + ); +} + +// glob('src/{components,pages,ui,widgets}/**/*.vue') +Promise.all([ + glob('src/components/global/*.vue'), + glob('src/components/MkGalleryPostPreview.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 new file mode 100644 index 000000000..45db48fa1 --- /dev/null +++ b/packages/frontend/.storybook/main.ts @@ -0,0 +1,41 @@ +import { resolve } from 'node:path'; +import type { StorybookConfig } from '@storybook/vue3-vite'; +import { mergeConfig } from 'vite'; +import turbosnap from 'vite-plugin-turbosnap'; +const config = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-links', + '@storybook/addon-storysource', + resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'), + ], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + docs: { + autodocs: 'tag', + }, + core: { + disableTelemetry: true, + }, + async viteFinal(config) { + return mergeConfig(config, { + plugins: [ + turbosnap({ + rootDir: config.root ?? process.cwd(), + }), + ], + build: { + target: [ + 'chrome108', + 'firefox109', + 'safari16', + ], + }, + }); + }, +} satisfies StorybookConfig; +export default config; diff --git a/packages/frontend/.storybook/manager.ts b/packages/frontend/.storybook/manager.ts new file mode 100644 index 000000000..5653deee8 --- /dev/null +++ b/packages/frontend/.storybook/manager.ts @@ -0,0 +1,12 @@ +import { addons } from '@storybook/manager-api'; +import { create } from '@storybook/theming/create'; + +addons.setConfig({ + theme: create({ + base: 'dark', + brandTitle: 'Misskey Storybook', + brandUrl: 'https://misskey-hub.net', + brandImage: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhgAAABgCAYAAAEobTsDAAAACXBIWXMAACxKAAAsSgF3enRNAAA4fklEQVR42uydaZBU1RXHX/frjWkG2ZR9HXDYkaCAEI0awSX5lA/G0i/5lLXKUoEwgAYjoIPgCMNioUAYUEipJKYs1mFREWXpASpEU1gMZaosNRY1KUsQFPDknMw7cOs/78573TSmwXerfvX63X057/S9r8+97RCRE9FMyVYs6oxS6gx2CSZu3Ltyz8QkrFQbUSwcs+FLD42npYd+QF4HJBfsSpIwb/d4enbvbeIfU4gdfg5RYH3Yimne33zzzY1YXmtpzp49O7EYnZFYtH8ILTlYyfSlp+qz9PT2LFWtT0hg6r5pgx/XynjSQ3I10UprxfEe/c2wL774oqN3m9Myzp8//wKWgXkY6ShE+aZrUn/fzlh9tIwW7etBixs6M+X02F8yNH29QzV7xghm410vo4Ty7bffbvf8XA0XP71XP72in9lYLYNJsGT8WvwgrXTUr4z4rnLmzJlJmDfeSxzs3BadseJIioTaXBsmTjNedmjGOoelpILmbqsgiaOV9DJJMim5CuqHiP+ePXuu03C9wueEYHbyxx9/fL3kr2ga6SCNB/mkWFLuVj9vMJJYlsTRthidHzM7w11+0CVBe3Lm+ubOmPW39jTr9fak/kbG6dOnT8/Uz0eOHBnM14wgfuovnDt37jW990vrkTI7eePGjV0ljuan8Xw+Z7jjbparpNE6aNiXX375O/O+qanpHu3cFp2hsGvDJIz7jJcowZQxcR0NrYwW7KUta4U2JupP7Ay/jHaKmT+mhTRB5WYVqIe2LXnhMc1Lu9s7QwvQwtt6ZE00vK6urgd5Tv3NzgAy2BHr16/vjg1uhbaKpazCO0MfE8voWSpmqThWUKVCsUtHWR5kFewIlIySnQBF0/GoM0pzOh7r0ieeNQMrRjqD5dq1X7aDhJdqI4reGUsP3cLrkjE8FR9xXu51XdJrUKxbDa9Lnn57HIm/z7S60Vg/OAFQHsoay2kKKkPTXHJnLNo3kqfgQ7kzKnjG2Y5kXSL0GRrv++iqsX+Vr1SoJK4XdJEkrhHWAtV6r1frOsW+/pEpeBXUwbr2wDBbOt/OkHUJwx3SlelAszZ2oGkvOdSzMlYp65Jn3xlDUMmEonN+nv6+KlfBb93gc98IU2LFFTRvLU/j4hrGrwwemOmWcjGPWIu1ia5Lat7Jnq/NJWnqKodkOt6jMj5I1yZaOK5L9N6Pr7766n5zTSLrDQ07ceLEWCN9wkTzV8w8zDiCLhI1XlCdNA8yp+LmdNxYl6SZRNVLzinpjFTGaSfrEgEWaSnbOgGuaduVG3Fc73ExZjQs09raRNciWLauV9DPLE8lxnehxpQZ93G593otyyRh9gnrEibPdQmL8k7LrLBl/oouvLBMOzgLxrIS2hmXOhXP4FTctjYxK0ee40Y9DtPkwHWJOImf79rE0iHaGW5enSHoY4IdUtDaxL5wSlmkowjrkpaLNFMySnYCFBEtTSIiwYi4UvD9Dp27ZczBpYd+xEvYCcw4ncQ7SocuTueaN+OkcHhi4f4fn15y8G5e5d1Ftz/Y+2fWn7osrtjvB8ysLwf6Ux+1dI1Xw7sOvwYnWBhYIG5kbuCBHs4Mpqlru2/lsNS8HRlC+gyN9ete2WbErPqRNGvbSH2FH0NInP0nyXoVkCsBW1tUWIpVjj5Mxax7wYKx5lhbEmreHUCLG/qxYPTma3fmOvrjpnY0Z1szj6xyaMoah3oOig2s3X8bKb+sGb4SKhMXjM50/ZAwfnczz4yP74/0d2h5beIXbn623YeNwwveTsY94tJF13T06NHO0B6skxLDMqUcWx0MwYhj3U+dOnWTcd8U0H51ORDuar0PFIxVH2ToAu9naNGBTiwU1zBZqs2lGZemskDMWO/QdKYnv/p55s1hJMzb1YzZOebA21636BsS+TlZP9uchGMc7sBnxE8x4/JPzGP0M4L5obCiH6RPIma5CtobmO/fWMAbsG0mJ0+evAv8Q/eNJR9XAeGJKVbBePFwkgzEs/yhFxNv1OYcks9Va5vfBerP8z0q3UGzN3Uj4cmNzRhrYtdnsFKIhpn3X3/99QyM99lnn42TKz8tv7XlYcsP49vjtLRfMIG0aQTLYkOQ34BlTFLD2Cxhh372e4UnpgJooqCmDJrGjGurg9hfmG2B8XBROHwnVfwmfMTzB2IkzN8e+5ckRJOFx1gghJkvO+KRmfgL95GZr6ZIcBNOW3zCjA5FJ1piheQBtOGGPkjg8I0XOk0v71X1XpFyNB4L3cpPP/30J5onvCNNCzaB0nB5t2qrA2IL88tjw4YN3aCdodpvvlqFuBmxmfGzgZF+Mt7yxS/LCy6cU5hvBwWUcBQEBd7WhaEsCGJ37NixCWY6MAvBt4FCOgCoO5CnXU3INmbDgu3Ctql2VMEQLqdgKHHUGH6mJyYBgtEWCOiQcPY7SoBgpACbYCNl+RBS6LPh+8UuFOEE4/JrDRQOJQ1YhKQoZJV8BQLnF0qQkF9Wwv9Sh23CdiUEFYrot5KI6JV4RCQYEZFgRESCEREJRkQJCwa7+IBRZSP56trCr+vllKnBtOfnDhzd8UYJK9XGRhQsGLrN+XZq3lIwnsraxTugUJi2GFVr4rvYL7m4YRLHv5tqc5PITzjAUv27sGEQN+1y2mPY7EquSnuMPkOS3dVAR20yZOBVKBbsTJ7XfScK+2cXvHsr1ey7nebvuYU0vmDbK4/+GFYkwXCKSYg659TvUttillMSgtF3WLqCNx/JwQiyAYkZylRKTVMPPZ/a5GOoI2FtHt86nJ6ov4Fmbh56RrQOdiTYISDaActL1TAnbFvEX2woilxOrJgUpjGGpSpeONKDag8ME8stZiDTbKwzd5tsyrrInC1l7FdGIjTpMre8dv9dH6L1VtCBEhZDk5hgU9N6mERr4Xgv5yeQ4dDSSgfUL08ABSBuQ8uxDDa2ETer1Qd8VTViezBPdBimG9rCaYzh8Qq14Ko9UEFygsjihp5MV6Yzzd560YJrSp1Dk1c7JMJgWHDJfcLSmVbrLbFG8rE4atJ7DQ9r+WSzxML4WJ4ONsZBdM8XGt4oKOyKDqjdCs1+j2VjPGyLaeGFgsU2Hfeh4AcKhu5YrN3XmWpz/zPpYzoy19DC/Vl6YnMHmrK62Xpr2tpm4x3ZyajwfVoLs5+60hJTcGzWVNKgICHD9DrBDRknbjera2nJpYMvDuwbrCfEsLYboAMcrh3BfipwGo5xVEPYjuZRWhEMt8I07at5txMLRwc5asc7ZSZJj650qEpOmvmzI1tcJfN2hmmf3GdQKEAwkog8dRKuZmhB5mtBJnGmX1izPikXj+nJx7RPT8yRATfzlbraBtVunmive1DfBJkXKiD0McUyx3ArdFuvwlpCbT154GPnTNM++SyCIdt9jS2/GdseZb+fsMFWMglmaIGYal398LMMmt7b4ki5NntLICnmelJ3RC3HUFgkHWgUBrY8e2779u1d8jU3xHCMg+1AwQg07esz1K3ws/mU86imvhTfJp9nrGspGKbNJwoGSG0KkY7AML+4x48fr0RbSUxj+ywuIL4Khg6CVUAhbdoEy7LvO29ptwrpwf7Tam6YDlMHFDDUIoGCkS6LZWXfu/J8zj2pg+td0zNbCka5ntclSBzUFtoxOri6V138VN1C48TvP2g3aX6W9Gh/aabH/LxzvbDzMnqvxrQWA1oFhS2tdfr888/vNfPBc8iwTn55eF8pG8x7PUtMkX7RuiveHOKgn32p2R+opdFIxyoYkkiMgBVZcUC4O3Wl8281Br62V6xSCp/5mhgCNyOForbQioQ1nvUzdjX3/H/00UfjMdzvgDYTMhzaYUL6tCBC3JoxMIHztM4DUC4Ickv/1gyepc1+YSoc6uRerbdEQDRukKFzaAsu8WRSXiEZy+vthFZCzxDwCst6VzesMXCwQXB48jWFI3ZhzOFQKMLbfOLABNereHai9nah5vjeWonLqSTEzsxTnqrvykpcvx4iK/ECBaOYVuKYB/m4AIvqSxYMKCvwyS+QtkgpC0a0fSDaPtC6YFi0hlVzFP/rxC4cIbVFykK62AJS/C0RwUKBc4xoX0m0ryTaVxIRERHZAkdEREQKIyIiolQp2YpFRERECiMiIuIK5tI2YhVlM5c9ztWy0y8i4mohnwc8Ub1j3D+WHb6Dlh66jbmV+SF17ZfsFmZvu8Tp1t/ptfCtGJnMft05rMfEeiT5mPrzSw/dQ8sO30tynVw3bg37uwUojzuZHNldPbNc4pXqAPkwmmlilpdo/fKlP1PN5GBcppVofb/XOGEedCZlKAlmPHMzM5a5iRmtlrJxv/SxmJN69s0EtYb+Ri1/I8//bUG1uYnMJFp0YCI9t/8O4m30Z82DOGxYNnU2MfWIuX0MN4CW6oBBVV8p0TpaMTYQo2vUcbnCzpXob1bzaifMALt9h8uRCBNQSTCjmJHef5nI1sSBn+iWAY/k7DdSn8zfmaYg5L9OOH72yR2j6am3xlL12zdT9e7xJP/1OnfXGLrvD/2WeXnHUAD9QGELAwqy+kfLo3CEHJfl5DnexVeB4Qhsyehoi/f/apfsa44UBmwdkaMyljTcREsaRjOjWDmMZEYww5ghzCDmemYA05+q1nXcPaUus7d6exlV7wiH/P+xWsbxUJT/dHKXxffP6bH5hnvaPSCKBGcw+QiapAuiQGWjAqPfijk91iIM3kzmFWOmUy9KKyiNbvYPqQDrjbyrzHD9Vsd0XppGLBe+/ZdbHuDij4s9r5wQplzpL509FjQWGDecWx4wPlXm+MCYWsvVesEYW+ssilnj6HL28iqMoamKtY3lpDz33gBeKlTS4oYLSkLP2mF6sX8Phrc2by6nOVvLee97CzgsSw+vcOjhlQxfew9xRWGk+o+8ZujihjsJOKezixDEBRTMfPAGkozBibc2E5H4ODA6OBZBN11O8vMUTzWEVec7c/LJ4xXJ23tgcua3tS0f9Zd0mJfXNzlsf9B44NE12L+FENDP8MBAn17iWEgcwZQV9VOkj4PGXuLZznmypcU8sB7iQPmRhEs88feToaIqDDlap+7DLJms/KCcFu7tS7W5PqaSYLow1zKdmA4c3p6e2NSentzSnmZvbWby6hhNrZOTVppPXfl987/sVcpMYuHeCeQDK6kJpLOMMIIJneLmi7FDeDvkmwujiCQdxAutyOSYHYgD6a1hjZYwy0MbXIbUpZU2vlqAUnYFPR3H5ox/Q3SBOJ65BUcEITFQLEUbCz1qKWwfQLvyjodK2vJlNy/MDBnPT0UuUWG4FX/6ZxvyY8E7XWnR/i6sGFRJdGTaM+2YtkwZh2UYOY0nQZNXsYJY69D0dRf/eVEUh/z7IpdVvmD3KPJj/tuj9MWoazbMJpB45FA+6D8t6l98+ikSEdZ8lQ4KmPFPkW5YWlOE+eTJ/wP386B8JI7Wu4CHxbWQQPDkIp4NvUg+DrZPtlA88remYRRsMcYCj+ASV+i4IVIPjZtnHlifuI2wy+4C32G4FSvfT5ONZawUntvbnmoPtFASTJJxqWpdTA73kxmFKAk5y01AhdGueucgMnl6x0VAYVgE1KowkiFJ6NFZqGx0IPN18k2MDwYfuDNQp4w2d/DgwWtbO4sNHzTwDwTjo7/kWYSHJWEhGZYA5Y/j5SrS70H1K2QsCul3KtAFj729PuEVWPA7o7wVxoq/p6gVJGLHmvcyPNu4oCSYGOOQhE2rY+XAykJP7DL/wldO7urhKYy5W3uTyZwtF1CFkWhNUVg6N+lDCtCDJJ+BmUFSOXHixFjI87Igfz1snkZme3hsD5akt+UN7bTng+cWKoUrKazjfOn3IPToPU0XUqnoLNH+sBc+Fqn/snc+rU0EYRjfNUk3m9RSCmJVsEgQ23pQEfTgP1QQBC+ClyLoB5DSc7EFPXjTi9AiBQ8VpXqIHuo38OTFinjsd8gt4KXrvMJbpi+ZvJvJWLLpE/hRmp1/2Xnn2ZlJ9llCOrN18lNU+k3BrwzRnrJEEQxVOHJuepYaa1vlrAtsKJXY9+Pb7y2+l2IhBaN0hgTj+dcjmc2zzV0UwXAruhaQdsdrAd1LufYAlu9J/0m9Hr1+qseaHcyJ4yKduxwhGPy+FpwVBdl+bmPihq0c9XNuDygyXe0iniM5+yJhRP2JfE66fUwiPUplPHTKIwQyYfTzoAuYY3yUmBCCQZmrb4zhq4vrDw7N8bcYju+tK5fuxo+WPpDl416e0hLlXbTrf7X8pZ794/Nenqyk36QDoI28crCXZq8v9v50wc+El8+iJ09PQh6Tnpz0THXpNU51UuDRXzk1lvU7jok0+kv6f8r80p9UC05FKGwSMrqV7eGBS3R53n+iQWmlV6wLs+cx3akvqB/pr7TI5D6UiM3IH4Qrj7Tc5PooTrT6lLa4BGxE4FrmScFgdMGQsAFwtR5NvNiMf61+jzLDzp3HMQ2GmhQLl2hQ2qv34wUjEjuGbP51vDWSRuNUtjXgq9OXo1uLH+M/i5/ibGEt/l1JojEWC21m0S1QFao5SW1MBz3kIOegZ19aDTNVnjVXm7eUh4PNXB1fNpvNY1o7fNor293JX9ZVjuvKmR/3eadBw+eABy6dBxJXOq6hDyodpS9SDUpnfwbKq+WhNPRZWTyo/o2NjeP5rWr9+03MOBhNOFgwdKQho1VoHKgMmaYs/eOUpUhFoAaqgodnb1BSHyjoKHi18q3fDjTz1OshIL7nP/Wkag9Yj/y1/cXfg9K/v9wzjjyCMdAWfXIqFFowAghFPTC1ftne3r7SwYV/2fzu4h5BYmIfo/+1+pUA9KZfobCvzHIZE0IYAvZjPTR6f+nCoQkGA8H4/4IxqhA0QFzp19fXT7Tb7Vf2epqM9Vut1nyP9aSCgRAMnk2xaJh9kaVQV/x9ZjQPEAzPm5cC7mFUuzHAghEkIHsVjAB7GkGXJAVYKohywsaLr1BAMJSZRr9r6gIsTbyEwyMAg25+FlBAguIbF6GFYmj2MLSlicdMI8i3JgUI1LpO+BlF3q9VQ8z8hoSaRojN6LybnUMjGACAAvlhAAAABAMAAMEAAEAwAAAQDABAkRjYhgEAIBgAgAIzsA0DAEAwAAAFZmAbBgAYAsEwr5jBk9sBOFjkTsg3ek2eKo03LqQzlhlv3KPbVrlxLmocnYoO800undKcvjhx/uTM2CSlgXAAUCDBoEE8NZueXf15O1vZumm4YbiW/WXvzIKjOM4AvDu7O7urAySbw7KEBJIJt4wPQGDHBocAhlRsUqmC4IeEB8AkfsAHGBD3fR8CY05zGhwuGwfCseJGBowk4wBJ4Yp4igJFqOUBqniIw5//X22L5md7ujUWIAFd9ZXXM709PfN3f/3PLDATvuxwogZvVPeP/8LzzaKjXpDJbOlpKcSD+AZNaDfuk+/ehGVn+8ZYWt4HQin+ZCGWGrIVnEsE+biuBsfhnCqQ9Drav5oyFImwt7ivQHLraH8fa4xkEQx7gsvOvoGS6I68hryKdEO6wpTd7cuENJxkMXWXdX7BEQsS4fN77Ko3rDXIKyr9JUqiN7bdJyaLJWW9YfGZnsD+oWHtLQ+4KPQmrDp+KxQTYH1+Wzh7kbJRTJ7cooKnXghDrPoTv3rhSDyrQF6Jy6IA6Yy8DINnZC6VpcFlMWSOtWr+ET+oGLXedwDrhcbt6lKy8PQbMUEUlfYCksci/LzgVHdolBVqQPJyKYyIgkQlWocHZvRREIYq2yMUIllRh4UhZ0aeRx2TyRcQWUWVLLoiXZBOyEvIC8jzUPDr1P7ieYMsm25vWb+ddzgAOrBu0pRI5/NzSn4O80++DgtO94iJYh5+nn3iFWjWLiWT2lMJgsPfLO4Evdk8kTQYdWHApstvE4c6Oqg0camQ3zyuqkdvLwep0BvYnWL4sM6rvgv8fgjDTpBVIC8iHfG2IR/pgLSDjDx/rpAG4svI9ebOPRQEE7B+cpf+jX8/7XAnmHmsAGYd74Z0hRlHu8DUQy/R/lBtCUP3Hf69JylxzTG5xmyfVhpPhFFvhJEwq4jLoj3SFmmN/AyC4aoXG9n439kHQ2AKZRjE6K/bXp1wIB8mRTrCxMjzMGF/B8jvmfYW9QOxeOAMhWHpUA3oJ7igFuNS1+NR1/sneFDCsBB7aXlnWFr2MvIiSuGurAJpg7RCWiJ5SAsYMMaeO7M4CWYdNEZ+XV9ym9dT+g6Ylrm7f2HGajvsTZff2v4AhLFC+u4w3XGofel5SIRub1gd0++WIrMMUm+qV6FrG19j+FT8XCqQKLKVtknHHgZYxDa+qtN+p/Nk+90IY7ZJXHS3kdRfw+NWIFtrIxainrroBUIPc/nxDM4hIsWej9cofTYZo8jQ+yqMojMvwqdnW8Dy77Px7ewdoKi0HSwpq84qkOeQXKQ5kg1LSrNg2r5kmBFJhpnFagq3BWDcDps+C2EEkFCPQTmDF57sdX7c9lcPP/VsOEvOLgwGh0VohGG6qkXFNsUAVBQ2kF0MODGROfzXA0WdKKhLlK2MEacVkyaZm34KNBmDpcd95sHkyK6b+1iAYaGxpxCFsggh6GJPcnA47jAhVVOh1aowVl9oDBsrUmOs+6EBLDrVGqVxd1ZRLYuyLORZWHiqCUzdlwrTD6SiOO7l/TUeGBFn5EafEIa9+Ntf/GdJWU+Q6TW4+UCRYTwwYfBBrR4wpRRAMTC5NHSyoP7R9+m7XEKmwuD7Fe2PlqVhIgwmi4p4G7OAlRoKI8qF7Abqj5G0uARrIRYUV6pH8J+AZVTi4kKhTIb1JarJzobxPlOsuOT4GKW6XE73RRgb/pkCMp+UNYXFp/Mwk8iNyyIHaYaTOxPJQJoijWHusXSURgOYth85cIexW234YK0HPtqArPfA+5956GBJPd7J+kPRt90hESLLMB2YUhDHkGxqgtMqiG/ZKhb78G1bnRSDOar6PguWxZEHIR5rG9/PhKGYRGpR4vaV7A3ixYna101qGoRsRbNM4NdXPlecOL0M4mMR+OrHp/k5KPCyerUQC3cZk+6a0niSrulKvl8XFyYkpUxNZP+ThLH+h2TgLCjB25MzOSiG6qwCeQZpgjRCnkLSYXqkIUzZlwZT91cxZW8D+BAFMWqjB0Z/7oGPN6E01sWEkTzvWNcLi069AonIaZ/aDOv4H7YwVAE1qscnvP67szXCMJYRF4uJMHQZAG/DEB8hpOpQovgqxAE8NjWdrJS68zruY+FeGCRYXT2+IJkdS11H9TyNssT7Kox1F5MgEQtPZqE0MqGotDqrQJ6OyyINaYCkwOS9DWHS3nSYvC8dPliDstiAstjsgbFbqqQxcn1MGKnzjnf614KSzpCIFvmp+QphqAakLAx/TWCB8QmoLbZdiTwpHUTicyszWo3d9o1gk10lS5+LWzeBT4FfBgf1KtAUfEfswESv8sNbg3dFHSEXXR9rJxbq66WCZSpGbXJhmvTXRCokSCYMJa6EsfYfYUjEiu+TURpNURp3ZRVIQyQVSUaZhJEgEoBRm7wkh5gkxmyJC2PzHWHMOdqxct7xFyARzfOTSRiBhyMM88HBBzMPHEvDyy5evNioNoRBK3J9FAaH4sWyDx5Hn2l/MR1/jq22li4W91sYdNvhJjY89nVaGJ/9PQQqFn6TDotOP41CuCurQJJwWwixET/MiFjw0Vq6BYnL4ot7hTH7UPvKOUfag8zsw1U075D0PAlDJwoHYQRM4MJgIjEuqjbKy8sb8/3yAKHV1ERmN2/e7K2WnLkU6ZiadtxNFr0gAjp49iHiI0MrNuuDsURJEO5jYX7dKVbgsuhizzGJCQlHJRWNOMyEseZCEJyYfyINf25tiGK4J6tAfJiBWNXPLcbQrYhCGDOLW1XOOtQaZGYerCKnffiREIZpKk4T4XEXRlwa26UUfaziXFiqbn4bYB4L98IQ2aZJ4cetl8JYfc4GJ5aXB68vOJmCYqjOKoQsEC+MWOH996j1KAvMLgo3I1vibMZtn6NI1lUJY/r+3MoZkTyQmX4gBgojJIShS339BBtkAY7q5bQomDmq77JJFqgtrl271oWOlWDAlPG60tPvPmw7W4kVaM6Ft+NudVULAgfr8JKSkiZ0zU2R+npJcS5R3g9aKDR9cxELdd+oiHHEoVjx2Jihjz1HFxONMLTiMBPG32xwollrK6/g7cDgRaeD4hYEsRAPvDbQGtK0hTd39EbMKIQsFMKYuje7cvr+bJCZtq+KnHbB+ywM/cTj+2of8z7wQUMDuq4Lg08uU0Aqqkku9l+9erWlSd/cxMKtMKh/j5UwVp0NgBPZbawcmvAtOlpdFp/x/YhZBWYbnv+1KvD2oO0ZeVb2WBRDoUYYk3dnVE79awbITNlTRXZbWycMP6EQhunAjEoPyQ7y/XJ6bNIeBTZRG5hS/s7pe5cuXWoljsPbUG2nNsU++r6uX6rzVE8Qvdg02EJqrP9BJ/bs2fOMqq8yTIDqyWUeiyCBzzlai7ai0eibvH/sGgQ5vH98PPL64pj450y68u2sH7YbgfFnKnzB/anC8CL2yu/84ABOZisL64XiJAnEtoznrEyRXTgJY+LXjSon724MMpP+EgOPEch3LwxzWTgJobi4uKlpuzS4RV31NjXmwtBPdkW9ByoMfv2omAhDjsuVK1cKVOck30rSMwllv8xjERTUljAUxwuq2rx161ZhvRTG8jLfjyvKfaCC5MAekPHPSU7CGLnO81+skzJ+Z9qlibvSIMZXd9Osja8VE4bq4RpPuS+pAkQXXqx6cqFVTTWAsf51US++CoQ4N27c+JP0IK5cbMd0ua/Ur+u0TTdJxDaTwYsSGaQbwGIfE0ZINwFMbxU4GjFHpfZDHIqdqj+G57ZK3s9jb9IPOd58H8H6F1Jx7ty5Njz2pu3xfTSO5O2a+NkM/kzFT3BhCNwII9B7sDV8eakFKvi/VcH+VqkPCRVu8sC4zQyUxVjcXtDPO4Sk8qvh9szxXyZDjJ13I6RkLAxXRT8oeVAp+LQSUBBJFPT/TkFn+ylTmUuDl8Cn6avZgN+uEwaHtcGL+LMAf3xYwuDHkMVKfSFM48JJIBhbl73wWFAcCX4dMYPZwWNJ8Hpy/2mfqq7YrzoejaV6JwxpwoeLSrzRT894gdMkx9tc/NzpJJ3GzbytEwnjw5Wey9Q+EkSSCrfbEGPbHQbPCOwUUtLdirDf8E1LVGQLBoQJPvF5of2iLocmB2gKPpia57QC0YplsprxUl5e3pb6wISRsG98QDJsnsVpCMpwMfDC2g6ZQJkhX8V11CAWYRW8Pr+2Mij6dwz+dux74rY+0XF27NiRwduVxge/bkGGeEbGhGEoDgAwyjKQpH5DvSOXnfbcRmDyTm+pHfY0jHfCZyCdoB3ypL1X5C0vRFEgt1/9jXcEtSs9jbeR5CHzvfvH/NkLyO1ub3vfFXVMnl04rWwaQoaEZWgwUXDEIMVVao0IqA5c6cfJE4c+04ASA6A20A1y6gMfeAKdMBjGwuC3UbR6CwGL6yCvsG6EwRYALZpYGMWSzkF89/Lly/14HS4OcTx2zkmMsAa3cQsQ7oWh/7UkgITYidiIz/A1A754/TB7KOqv7tD/2buflyjCOI7js6NTuzteOpWlFz0YFBQUHiozrfwR0tFTkBkJUYfo1KFAiMgOkVAudMsu3YrqECFJ1qFYLD116tgt8Q8IdHoe2Y3pgXHGx8fledb3wOuyu+58Z+fZzzzPM7PO/+sJK/KVxxpSAiOhK6wdEIUExRop6EuvM6ossmFnWa9GgOh+/gUd6pyH9udVe2FGRYXW/kq6nMBYYKg3GVImNf3N/r2QSwiXQHmdT2CsT375ZQ8nrU55NIx9sYr1EhjxIQSBsTWBYe8/GxXFKYwGhoGgCA0rblZ8/JpUp7jK8qDyupT1pweGDv2gSJ8XMhEMBvdjqEs/QNKDIy0wqgiMOg4MOYZWZ/bFWH5IEuPjO+rzcr2uBkZ87K9OUBIYBIZLgdGUwnQDCePEkORllGHZwHoKcTYEhpwcTDo1a2KIUGNNWRAYBIbRwFAtLS1dED2M2fjwQ/xc++H09PQ++bzLgaGeZZDbWS6XD8h6CQwCQ2HstGp+PRYHhpEGqREYmzrNanrS04HJSOV9zLYX3aAgMAgMAoPAIDCyBkbWoYnuEMWByU+t4NBogEYv4HIwQIzSbRemg6KeT6sSGAQGgUFg6E1+agxNjFwq7kBDDdOZH4Jk/fGZiaFinSimMHJBXdYLtggMAoPAsBuBAQAAIFlbGAAAcJe1hQEAAHdZWxgAAHCXtYUBAAB3WVsYAABwl7WFAQAAd1lbGAAAcJe1hQEAAHdZWxgAAHCXtYUBAAB31eJ+AL6UdL+1Gt2XwFdsqIas22LrTgYAwOkORuwg3JAPc8Xz11qu35/pnJ9a6F0tLfZEUws90ZPv3avjrw997h/dfSnY6QWxA3XOdA078l7Qf9Ebuf3C+zj5yVsRojVz3srdV97XoSve1Xzo5eM1KKqdicajg81d42+6308tDP4pLZ6LKlYezJ4u9422DTcG/r9tobMBANjuTHcsGls7Ch2TX7p+lxZ7I0F2KoRTQrdwUugSTgjHhWPRxIfDP5rbgpbYndlyujVUOwN72732e29zPx/N+VEWE+9yv5rbvNZqDTHB8K39Nx5/6xMdo35hQNQ8qBhYe1y+5uazzud+Q64WHY2n0dYsy5X3PmNrg3XMEWFZUJd5YZelNW8XY8JMpL/MC2OWbhtQHx2M2IE9ODuy53LpL3tn09NEEAbgfu7WxWoCHLxZKYnQSgVaKMVaFUu8q5FfYLyZKCEaI4kikdpSisjFxJM3CV68aARbq4hCSZP+gJ692mhM7Ude37fuJhvDst2ykAX2TZ5sM1+dw0zmmZlNVl4qkAAygPiRfqQPxl62v2FstdMEi7A4K5UbljMcGn1hej/z0QyNELpqHBF/Fnjk/smpxNoQPM2EYW5jmCQCufSPLP/ENMqjMon1Ibi36E9TfbUkg+qLAYAF2N1YKBQKzfo1UANsHUsa7fO+Qjx3qtXqDdihwLbviv9Lny+KZS8vmhdejfZTRyGqnVw0HTVy89nQHwVSgfh4enl64PLt1pv1LND/XWEwV26Zx+IpK2yH6aT1N2c3HBa+vz+VHszHVs9C/Os5mFm7UBOI2fWLMJshwvS7lpbAvPi38zC9GoLoShAcHns7CRdiVl0wNo+8IAFKKZfLwwAQQfJypxulUsmn1YGsUSIgEfop0e4gGuNbxfNKpXJNbg5RGbnTQ5IYXTAUEYZNQqN91VGIWqcXjKOLbatPKgSx8ApSgXQjp2E+60G6YC7j+tkZsPWLFmmTxLsRVnfQNBBLMr9iKQbU4LjbeALbbSKuP+t4/Tjth8inADxZOQPRL0FA4eAJ0ZPSanmRz4NAZSeWfd8tjJGrVzCUCoHM7kkVZBbGDal6+g5OkjByh9B3Z/KoOV9ICqTGcaNSTlBdakNKWMRlD/J8qWeDowvG/kVNwXBuQyqQU4gbcSGdSAdMvnXk7M2mVn6xtiBm/mm1txhaxhfZXDTJgoqIBYNjbKYjo69cuQdL3TDxoRceJX0wmeoTQ2mY54WHyz0w/s7z45jT1oZ1WeGqZy8KhgC1CxJRLBadf9s79+AqqjOA3937fgRCQEIgCXkQSAIkVRGw0vqY1rbaTv9prdr+YztTrbY+8AFEFASBAPIIOlMBeQgIFkWtNoAkiA8imATGgm1FScY/yOhYe5mpIuO09ev3wS4uN3v23HPP3svuzZ6Z3wzcu3cfZ/f7zu887s1ATJgezm6wUmFIwHYbZXw1KyY9wfAEY6Aj9WF9egQJVUwgwZiCYjAZuUxjEiIkFcg4ZCxSozEGfrti2HpFPTNtEfEHfdFfLwqtW7w3ClkABUOt0KZIIvpUSXFVuP7WNZX7Z7XWQtPOOnhwZz0QTURrHdy1dcwHtVcUXEPbImHG6AUn4KQFQ7UJxQiJBGvKRO8Beng4lVzEC02LGKYYe2j/Tq0PZ8AXDKeer9PJW8EYPSFU/fihSfB496WwqvsS5GLkW/j/RlwI2aAxEWFKBaFLBVKNVCGVSAV+bjQ07y2EBbtjsHBPDBa1naW53V5G158RjIhxsacmGnFkEFKIDNEo1F5LIFGzkQu3Cwanl7FdcnSkB9IvSenEbWwMxEsb1UMG96sn09EfhuytBrHSnCqCZtuIXgPt0+QZaZO7n/bDOKduW+NFvkHtsfF6k7yOQO7jQn5xOivvOTXncGJGn7pLQnqlh7Zn5QTBuk0iP3efYIwPVbd0jocNxwphc2/BOZ7+cBCs6qqClQfroaWrDqWjDmWhFiGpQNhSgYxGypEypBQ/MwpaOkvg0VcLkAQs2HOWhW1izH0lAnc/5YO71n7DPesVmL8rBuXjUwQDqWosnNDcftUR+qqqCXh91/7ryhvLf0bbpgqGDUlDNSIhGFLgMdaASTl16tRl3IRr/yr+JAWcYMOcBOkiPn8PJkVUVASkgrtIl3FObZlcg2EdgnR9ysUHG4tnLqk/u7nEQtZXZ0mmkg6IC61BtbUkdVF3Ts7hxwzJPEgUPXdQLINkMbseBwtGoHrT8QSwWPveEFjeUQMr3xmLDXINjmwgh9KSCmQkUqIxAimGZR1DYd6uApi/m2QD2VMAC9qsoW3u2+yHu9ehUGzwwfSNGhtIMHxA0lFWp1bpoxHhmD++qG3akce7roE0wOu6+lRV4+Aao2Tki2CwEiMdmyMYejAkMw1a7dhJzjSNaILppvNKs9eT5IiCvGCIJ+XtvARL75s0OqvtEAzG+pzVep1aPAfdNk+5qTys1hIZpzXsEA6Z86Fn1e71UvQMOCsu7J8icUbO4cdMSiwm6RhCx2fHcE+m+6K6cIVgVExUq5/+MA6WfBCHlQdKYcWBKmjprIRV3ZUoCxVIWlKBDEcuQoYhQ5EiaN5XCI/sHAzzdg+G+a+eAUWiP3NejsN0FIt7USbuf9oHD2z2wYwtBP2bXjv7Xuk4ZSytpSDuWdf4Yss73wERlr897Z/BiBplfANGOmEyAnoWHSvbgEn5+uuv27k9SHYSUkVgNbgY8GMs6quHkchtGcHJ8H59X6IRWiw3AiV+D3m91hMnTgwVORe6X4xrW5OFBt1PHDt2bBhdJ2RWevCzz1GcaRLiF4BzPzjPMh/FQv5vdWpccDou3M87OecYYkZ65IzuE28UR3LBc5tI+3RBBYMEIh2efLcQG+JyHM0ow15/GY5mGKQC4UgFMgQpRAYjNAVDIxmDYW4rysauQpSN85nxTADuXe+D+zZ+IxYzt/pglsbMZ+h1ev+MYIyjNRXEsv1TT6x4eyqIsLxjKlQ2FkzU12MMZMGgQBIITpmElGRtz7L2TI9NCZVgJ2N5waB64523fOKVFwx+HYgLVLYEw8inn35aQ6Jm0/RAD8UfCYyIYBAM4ekRlLUiKxF1alzICoaTcw7BEgK7YpZkQbRtYe3LJYLhr974fgzSZf3fY9gYF8PyAyUoGiXQ0lUCq7pHIGlJBVKAJJA4EsPPxXAfUVj8RgIWtA+CRfsSsOi1MExfr49aaGLxzFmpaNp2llkIycaMzbQNCYZKglFALH3z0r5l+yeBCI+9NQkqGxINqd8mYSGaIC0EI5BtLBonvxn0XjZkiJJnutNElPScJmXY67iW3hOoN73B8Gej7uh4vGtgnY/dU27CwsAnkC7YCE3GofXb8NyWCIx2cGKR+3wkGSM5fg2ewHbzhNGJcaFDsQAmhfc5B+Qcy/1y6la6fjKZzsvGdFROBWPDP6IgyhNdg2FZx3BYefAiaOm8CFZ1DUNZSEsqNLGIIhEkjISQIBKAh15QYLo+arHJMGpBYkE8myIYW84JRq0mGIOW7GvoW/pGI4iw5PVGqJgYJ8GI5JNgfPHFFz8QCRpWT8KOpPTVV1/9QqTnl05jgdscoqQrMAwuJRgSn5GCrk1WMPTzyTfBSFdCsKe/ljf6QfHCEwx9NMUqrjiSsFggDmyJCycLxoXIORIxLFs/6sASjAn+6vV/i0AmrD0Sxd7/EFybUQQrO4dAS1chSoKJVBBsqUD8KCsqjlgYRi02p4xaEM+mJxjNe+v6Fu+rByuaX+sHCkas0SgYMkKRgWAEbSZAUFJlDD1OZgpJjotVw3D48OGL6BokV5GvZVwvE1bjIypydjaSDMHI6Boc0LAEBAnaCV7H74BRPvvssyky8k4NpmCDByQsIvWXq7iw+3l3es7hxr18/fhzJCuKCNn7FgkKxrr3wiBDy8E4rs0YhFMmBSgZCRSGOMKVCkRFFJj7sgL3rvPB/RvOjlrM3IICgXLRpMmFKVtpG22KZOP5grFwT03fovYasGJhW39GT4jmlWBQ8gAsIg0TnRPkqNACPEqUosH75Zdf3igxHJ6kevEEY2ALBkEiIRAfIvGSNLtmWu8BJoWkQ7b+7I8L+wXDDTnHE4wsCMZTR0IgAy4Uvbjucv/3lrwR//fKgzFo6YyiaFhKBeKDx/b7TlZMVK4aWaNc8sBGlIVNKA0oF02aXDxIbDNh6zeCMRMFgz47yiAYj+6u6Fu4pxKsWPBqf0aPjxgFI8ATCpEEwEgsTQIJMcQhaKSjo2M4azgYvz0wlnUcTDI/NBtupffcAl079VDpvAFLJnUPJoXqRuIzUlDjYdYQSpxPgENORYozupDs7e0dx48BcbAXvxdMisi9oZEAs4Ys9RrxtV6JadJcxkWIh1meoMLKR4Qbco5dMUyfkYkTOcGQFw5bBWPtX4MgQ1mtWon7KiSuvClw2/IDodMtnUGUjH5SQeDrvv/99C7/DP0zI6qUChQFlAu2WPAFw39OMOa1jup7dFcpWDF/Z3/Kx4fyQjD0nlmmwcIaNnaqUPCgXh2YFIlEw2lo7E+U1DgNBMGgRpDR8+zNoWAkRe8Pe5Eie6qSXruAIzghzD9LwaRkQzDcknM8wbBbMMb7q9e8GwAZyuvVMv0PjWnEkUTRSHVUw9Xq9dNu8N/QcLVy/dDSM9slUrctqVZLtZELTS7kBGPuK8V981qLwYpH/tIPvI6gjGAErOD3FuShXp5V7+STTz6Zms5+6LyykeDp+JS8jY0vnTPn+Pq89u0yx2YlDuHt+cdJmjQkz2ejl02v23bN8okzKAPz/rOvNywLPVOMuFyawX0Kmt17EibG6FMvrz5sjouwGWBSksnkj3h1R9uASRGof9mcE04HHK29PDXn4FRVbQb1ERJAWsBkp1tkBcO2v6ZaWusfufqwH2QYUakOpYY5teJSHwTG+5FhpcpQzugFVzBKqv3lJBjEwy8V9c19uQismPPnfqBgBBrcJhjUQFMDBlqxSswiMBaQJXmSwpcWfoKkY3MaRyFYDYmdQqLT3t5eTPXEqTvh8x5IgmF8BizqMZwpra2tI1giTq9nGItBmno0G6VgiYdAfUjHhYhg0XuZCobeeHORzzlhHqdPn36QlXM8wciNYASR6Jzn/W8+eUiFTJi5yd9K+0BCjF/AZF2kqksOEr1lgbIrU8H4TbPyumFkJHHnmkTHwy8VwHm8yOU/hcVKkeGHtvwyUyKMIe6cFmqAKJlmmIgjn3/++R2MJHySglfbd4QFvc8KcirUu2B9ls6ddWw6L4EeapKRlG+mbeR6dmzwdxmeslho1kv1QtefmrSp90zvm9UVQzAihOw1yCZOCaFgEaZ7BHrh12OEBb1P2+HXNg+DXtgjF2EJuOdMRUaQGHFBJZnScEZYUPxQHFnERSQdzPZBr6Xej6NHj9bp8UD3gJU3LnTOIRjf8LmOWx/iIzwhDkLTLQzBYJILwdCnScKhqK+geZf/6JNdKojw0J/UtxTVF+P/NVL+OfiDvvgdK5XO2SgNs7dyoKkUpGmLD25foXQHQr4CkhSNWEm1Mnb2jugpBM7xvDVTfhK42ShKsoLB6vnxi7xU6L0ISSI6OAf4GNhYKAHo++Y1DFoSs61QD1WXLlnBEOhFJYFTeI0GJemBJhgpopi1Qo2feHzwe86cEbswA8Fhf/nCaPi50DOZYQyeZOwzJznHEwxhwZCXDCRWXqdc3Lxbff+PnQpY8cgL6qERlUqNz8eQC4lzKB6tTLjzCeUISYQVtM3wcqXecA4h47QLEr/uVnVO03OB/yLQtN2cWxb6X4nEfMMNchGwY0qElVApwWhTGkkZiaB9UNAYGoyIzURT2bFjRwme/2zRRp+2p4RBn6f9ZAL1bDIcBaKe6Tp2kmDLjd7jpeMK9Owse1d0HdSgpfZE6Rj0GtUvfo2uPvX6P/744+vNrotVX/o14ELDHbRfwuoaRIa0ccj2Jv1e0H71IWxJwukOzdOzL9uYaqNgkWxB9cIQmYidyMYFPVf0vMjAGBFhjjp99NFH3+bvVz7n4NqXZdu2bRtJbYQJUTPo3Iwxg8/bL9M5T969oX1RPGYSb4T27Z9efXE35yvF8sIBAHZKhj5dEtYqK44UDK9QR1Y2KFMqG9RJw0rVEfQaEte2Caf+YbBsnMPQkWpJWa06GbmsCP/NOQd9PwGDaMSQxKBhvpLScb4pZbW+7xZX+sZo+0kYBCVoFKVsCAaHsBz2C0aeEXEYUREoyUJKoddEr1dGMLKL+PPuVEj8zaTGuI0Xr1knxiEqRe7jLZhCwPGCoZOyLiJgbBAZCzQDxpPK3jkQ/HNgYJSNoDEpGfeVejMkFnUGrXBAQhUO0DzD6QmPCfXCwKTYer25F5CwCC5ocCMEjRKZ9aad/Hy5lLjNxNjkPr5Ef/fI0YJhJPVArBMAAFecg3EfnBW2KuEJRt4mrGguoCHV1J6rNjUUy4TOzs7xZsPD2JD93hMMZwmG2UJcKrRWwRMMTzA8wchjsCgs8lQw7E5QcYcTcwI0f2uxwG+2YU44bsXJkyf/QGLB+AGiX8lfv7xg5JILLRS0LobWEdG6hdR5eRJI1n3X19OI4oIG2ZI8yCeS8WW/cIgLBhtPMDzB8ATDhYJBHD9+/Ao7vv3CWNQZJzzBSCHro1L8whi5inqC4cp84gmGhycYDhaMhAwuSCDxdMBh8x+jGLwgIxX4teNpWbieqBWeYJw/QkHCkO79olGqPJxCcBoJO/EEwxMMTzA8wXCdYDi4x+UJRn6veXDqc+kJhicY+QcWJV0c+jXViAyeYHDIcYLOen3kfookJIMLFnk6VSTcsijS8nxckF9yKhSeYLgMTzA8wfAEwxMMTzA8wfAEwxMMNwiG1JSJC77G5/YhWLcl7ASHrCbAAfjDW56AuIt8X8QpJBTe11Q9wfAEwxMMTzA8wfBwZrx5guEJhqsXfXKEQy7hukBABjpxEfJgCsTWP3bmgilFj/xaVJvDH6bjx1de/VT4QMQTDE8wPMHwBMMTDNfgCUYOBeP/FczIFptfb3AAAAAASUVORK5CYII=', + brandTarget: '_blank', + }), +}); diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts new file mode 100644 index 000000000..41c3c5c4d --- /dev/null +++ b/packages/frontend/.storybook/mocks.ts @@ -0,0 +1,16 @@ +import { type SharedOptions, rest } from 'msw'; + +export const onUnhandledRequest = ((req, print) => { + if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) { + return + } + print.warning() +}) satisfies SharedOptions['onUnhandledRequest']; + +export const commonHandlers = [ + 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()); + return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value)); + }), +]; diff --git a/packages/frontend/.storybook/preload-locale.ts b/packages/frontend/.storybook/preload-locale.ts new file mode 100644 index 000000000..a54164742 --- /dev/null +++ b/packages/frontend/.storybook/preload-locale.ts @@ -0,0 +1,9 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import * as locales from '../../../locales'; + +writeFile( + resolve(__dirname, 'locale.ts'), + `export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`, + 'utf8', +) diff --git a/packages/frontend/.storybook/preload-theme.ts b/packages/frontend/.storybook/preload-theme.ts new file mode 100644 index 000000000..1ff8f71ec --- /dev/null +++ b/packages/frontend/.storybook/preload-theme.ts @@ -0,0 +1,39 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import * as JSON5 from 'json5'; + +const keys = [ + '_dark', + '_light', + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-botanical', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', +] + +Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => { + writeFile( + resolve(__dirname, './themes.ts'), + `export default ${JSON.stringify( + Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])), + undefined, + 2, + )} as const;`, + 'utf8' + ); +}); diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html new file mode 100644 index 000000000..64e537b93 --- /dev/null +++ b/packages/frontend/.storybook/preview-head.html @@ -0,0 +1,10 @@ + + + + diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts new file mode 100644 index 000000000..b2974276a --- /dev/null +++ b/packages/frontend/.storybook/preview.ts @@ -0,0 +1,113 @@ +import { addons } from '@storybook/addons'; +import { FORCE_REMOUNT } from '@storybook/core-events'; +import { type Preview, setup } from '@storybook/vue3'; +import isChromatic from 'chromatic/isChromatic'; +import { initialize, mswDecorator } from 'msw-storybook-addon'; +import locale from './locale'; +import { commonHandlers, onUnhandledRequest } from './mocks'; +import themes from './themes'; +import '../src/style.scss'; + +const appInitialized = Symbol(); + +let moduleInitialized = false; +let unobserve = () => {}; +let misskeyOS = null; + +function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) { + unobserve(); + const theme = themes[document.documentElement.dataset.misskeyTheme]; + if (theme) { + applyTheme(themes[document.documentElement.dataset.misskeyTheme]); + } else if (isChromatic()) { + applyTheme(themes['l-light']); + } + const observer = new MutationObserver((entries) => { + for (const entry of entries) { + if (entry.attributeName === 'data-misskey-theme') { + const target = entry.target as HTMLElement; + const theme = themes[target.dataset.misskeyTheme]; + if (theme) { + applyTheme(themes[target.dataset.misskeyTheme]); + } else { + target.removeAttribute('style'); + } + } + } + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-misskey-theme'], + }); + unobserve = () => observer.disconnect(); +} + +initialize({ + onUnhandledRequest, +}); +localStorage.setItem("locale", JSON.stringify(locale)); +queueMicrotask(() => { + Promise.all([ + import('../src/components'), + import('../src/directives'), + import('../src/widgets'), + import('../src/scripts/theme'), + import('../src/store'), + import('../src/os'), + ]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => { + setup((app) => { + moduleInitialized = true; + if (app[appInitialized]) { + return; + } + app[appInitialized] = true; + loadTheme(applyTheme); + components(app); + directives(app); + widgets(app); + misskeyOS = os; + if (isChromatic()) { + defaultStore.set('animation', false); + } + }); + }); +}); + +const preview = { + decorators: [ + (Story, context) => { + const story = Story(); + if (!moduleInitialized) { + const channel = addons.getChannel(); + (globalThis.requestIdleCallback || setTimeout)(() => { + channel.emit(FORCE_REMOUNT, { storyId: context.id }); + }); + } + return story; + }, + mswDecorator, + (Story, context) => { + return { + setup() { + return { + context, + popups: misskeyOS.popups, + }; + }, + template: + '' + + '', + }; + }, + ], + parameters: { + controls: { + exclude: /^__/, + }, + msw: { + handlers: commonHandlers, + }, + }, +} satisfies Preview; + +export default preview; diff --git a/packages/frontend/.storybook/tsconfig.json b/packages/frontend/.storybook/tsconfig.json new file mode 100644 index 000000000..2db2f1eab --- /dev/null +++ b/packages/frontend/.storybook/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "strict": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "checkJs": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react", + "jsxFactory": "h" + }, + "files": [ + "./changes.ts", + "./generate.tsx", + "./preload-locale.ts", + "./preload-theme.ts" + ] +} diff --git a/packages/frontend/@types/vue.d.ts b/packages/frontend/@types/vue.d.ts deleted file mode 100644 index 9c9c34ccc..000000000 --- a/packages/frontend/@types/vue.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -/// - -import type { $i } from '@/account'; -import type { defaultStore } from '@/store'; -import type { instance } from '@/instance'; -import type { i18n } from '@/i18n'; - -declare module 'vue' { - interface ComponentCustomProperties { - $i: typeof $i; - $store: typeof defaultStore; - $instance: typeof instance; - $t: typeof i18n['t']; - $ts: typeof i18n['ts']; - } -} diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e4c04f593..79fb626a9 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -4,20 +4,24 @@ "scripts": { "watch": "vite", "build": "vite build", + "storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'", + "build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build", + "chromatic": "chromatic", + "test": "vitest --run", + "test-and-coverage": "vitest --run --coverage", "typecheck": "vue-tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { - "@discordapp/twemoji": "14.0.2", + "@discordapp/twemoji": "14.1.2", "@rollup/plugin-alias": "4.0.3", "@rollup/plugin-json": "6.0.0", "@rollup/pluginutils": "5.0.2", - "@syuilo/aiscript": "0.12.4", - "@tabler/icons-webfont": "2.2.0", - "@vitejs/plugin-vue": "4.0.0", + "@syuilo/aiscript": "0.13.1", + "@tabler/icons-webfont": "2.12.0", + "@vitejs/plugin-vue": "4.1.0", "@vue/compiler-sfc": "3.2.47", - "autobind-decorator": "2.4.0", "autosize": "5.0.2", "blurhash": "2.0.5", "broadcast-channel": "4.20.2", @@ -27,72 +31,112 @@ "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", - "chartjs-plugin-zoom": "2.0.0", + "chartjs-plugin-zoom": "2.0.1", "compare-versions": "5.0.1", "cropperjs": "2.0.0-beta.2", "date-fns": "2.29.3", "escape-regexp": "0.0.1", "eventemitter3": "5.0.0", - "gsap": "3.11.4", + "gsap": "3.11.5", "idb-keyval": "6.2.0", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", "json5": "2.2.3", "matter-js": "0.19.0", "mfm-js": "0.23.3", - "misskey-js": "0.0.15", - "photoswipe": "5.3.6", + "misskey-js": "workspace:*", + "photoswipe": "5.3.7", "prismjs": "1.29.0", "punycode": "2.3.0", "querystring": "0.2.1", "rndstr": "1.0.0", - "rollup": "3.17.3", + "rollup": "3.20.2", "s-age": "1.1.2", "sanitize-html": "2.10.0", - "sass": "1.58.3", + "sass": "1.60.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.0", + "three": "0.151.3", "throttle-debounce": "5.0.0", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.2", - "tsconfig-paths": "4.1.2", + "tsc-alias": "1.8.5", + "tsconfig-paths": "4.2.0", "twemoji-parser": "14.0.0", - "typescript": "4.9.5", + "typescript": "5.0.3", "uuid": "9.0.0", "vanilla-tilt": "1.8.0", - "vite": "4.1.4", + "vite": "4.2.1", "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/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/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": "7.0.0", "@types/escape-regexp": "0.0.1", + "@types/estree": "1.0.0", "@types/gulp": "4.0.10", "@types/gulp-rename": "2.0.1", "@types/matter-js": "0.18.2", - "@types/node": "18.14.1", + "@types/micromatch": "3.1.1", + "@types/node": "18.15.11", "@types/punycode": "2.1.0", - "@types/sanitize-html": "2.8.0", + "@types/sanitize-html": "2.9.0", "@types/seedrandom": "3.0.5", + "@types/testing-library__jest-dom": "^5.14.5", "@types/throttle-debounce": "5.0.0", "@types/tinycolor2": "1.4.3", "@types/uuid": "9.0.1", "@types/websocket": "1.0.5", "@types/ws": "8.5.4", - "@typescript-eslint/eslint-plugin": "5.53.0", - "@typescript-eslint/parser": "5.53.0", + "@typescript-eslint/eslint-plugin": "5.57.1", + "@typescript-eslint/parser": "5.57.1", + "@vitest/coverage-c8": "^0.29.8", "@vue/runtime-core": "3.2.47", + "astring": "1.8.4", + "chokidar-cli": "3.0.0", + "chromatic": "6.17.3", "cross-env": "7.0.3", - "cypress": "12.7.0", - "eslint": "8.35.0", + "cypress": "12.9.0", + "eslint": "8.37.0", "eslint-plugin-import": "2.27.5", - "eslint-plugin-vue": "9.9.0", - "start-server-and-test": "1.15.4", - "vue-eslint-parser": "9.1.0", + "eslint-plugin-vue": "9.10.0", + "fast-glob": "3.2.12", + "happy-dom": "8.9.0", + "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-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", + "summaly": "github:misskey-dev/summaly", + "vite-plugin-turbosnap": "^1.0.1", + "vitest": "0.29.8", + "vitest-fetch-mock": "0.2.2", + "vue-eslint-parser": "9.1.1", "vue-tsc": "1.2.0" } } diff --git a/packages/frontend/public/mockServiceWorker.js b/packages/frontend/public/mockServiceWorker.js new file mode 100644 index 000000000..e915a1eb0 --- /dev/null +++ b/packages/frontend/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.1.0). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70' +const activeClientIds = new Set() + +self.addEventListener('install', function () { + self.skipWaiting() +}) + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('message', async function (event) { + const clientId = event.source.id + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: INTEGRITY_CHECKSUM, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }) + break + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +self.addEventListener('fetch', function (event) { + const { request } = event + const accept = request.headers.get('accept') || '' + + // Bypass server-sent events. + if (accept.includes('text/event-stream')) { + return + } + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === 'NetworkError') { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url, + ) + return + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}`, + ) + }), + ) +}) + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [channel.port2]) + }) +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs) + }) +} + +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) +} diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 610212b6e..9b104391d 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -1,4 +1,4 @@ -import { defineAsyncComponent, reactive } from 'vue'; +import { defineAsyncComponent, reactive, ref } from 'vue'; import * as misskey from 'misskey-js'; import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { i18n } from './i18n'; @@ -7,6 +7,7 @@ import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; +import { MenuButton } from './types/menu'; // TODO: 他のタブと永続化されたstateを同期 @@ -26,11 +27,11 @@ export function incNotesCount() { } export async function signout() { + if (!$i) return; + waiting(); miLocalStorage.removeItem('account'); - await removeAccount($i.id); - const accounts = await getAccounts(); //#region Remove service worker registration @@ -76,15 +77,19 @@ export async function addAccount(id: Account['id'], token: Account['token']) { } } -export async function removeAccount(id: Account['id']) { +export async function removeAccount(idOrToken: Account['id']) { const accounts = await getAccounts(); - accounts.splice(accounts.findIndex(x => x.id === id), 1); + const i = accounts.findIndex(x => x.id === idOrToken || x.token === idOrToken); + if (i !== -1) accounts.splice(i, 1); - if (accounts.length > 0) await set('accounts', accounts); - else await del('accounts'); + if (accounts.length > 0) { + await set('accounts', accounts); + } else { + await del('accounts'); + } } -function fetchAccount(token: string): Promise { +function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise { return new Promise((done, fail) => { // Fetch user window.fetch(`${apiUrl}/i`, { @@ -96,44 +101,94 @@ function fetchAccount(token: string): Promise { 'Content-Type': 'application/json', }, }) - .then(res => res.json()) - .then(res => { - if (res.error) { - if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { - showSuspendedDialog().then(() => { - signout(); - }); - } else { - alert({ + .then(res => new Promise }>((done2, fail2) => { + if (res.status >= 500 && res.status < 600) { + // サーバーエラー(5xx)の場合をrejectとする + // (認証エラーなど4xxはresolve) + return fail2(res); + } + res.json().then(done2, fail2); + })) + .then(async res => { + if (res.error) { + if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { + // SUSPENDED + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await showSuspendedDialog(); + } + } else if (res.error.id === 'e5b3b9f0-2b8f-4b9f-9c1f-8c5c1b2e1b1a') { + // USER_IS_DELETED + // アカウントが削除されている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ type: 'error', - title: i18n.ts.failedToFetchAccountInformation, - text: JSON.stringify(res.error), + title: i18n.ts.accountDeleted, + text: i18n.ts.accountDeletedDescription, + }); + } + } else if (res.error.id === 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14') { + // AUTHENTICATION_FAILED + // トークンが無効化されていたりアカウントが削除されたりしている + if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { + await alert({ + type: 'error', + title: i18n.ts.tokenRevoked, + text: i18n.ts.tokenRevokedDescription, }); } } else { - res.token = token; - done(res); + await alert({ + type: 'error', + title: i18n.ts.failedToFetchAccountInformation, + text: JSON.stringify(res.error), + }); } - }) - .catch(fail); + + // rejectかつ理由がtrueの場合、削除対象であることを示す + fail(true); + } else { + (res as Account).token = token; + done(res as Account); + } + }) + .catch(fail); }); } -export function updateAccount(accountData) { +export function updateAccount(accountData: Partial) { + if (!$i) return; for (const [key, value] of Object.entries(accountData)) { $i[key] = value; } miLocalStorage.setItem('account', JSON.stringify($i)); } -export function refreshAccount() { - return fetchAccount($i.token).then(updateAccount); +export async function refreshAccount() { + if (!$i) return; + return fetchAccount($i.token, $i.id) + .then(updateAccount, reason => { + if (reason === true) return signout(); + return; + }); } export async function login(token: Account['token'], redirect?: string) { - waiting(); + const showing = ref(true); + popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + success: false, + showing: showing, + }, {}, 'closed'); if (_DEV_) console.log('logging as token ', token); - const me = await fetchAccount(token); + const me = await fetchAccount(token, undefined, true) + .catch(reason => { + if (reason === true) { + // 削除対象の場合 + removeAccount(token); + } + + showing.value = false; + throw reason; + }); miLocalStorage.setItem('account', JSON.stringify(me)); document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う await addAccount(me.id, token); @@ -155,6 +210,8 @@ export async function openAccountMenu(opts: { active?: misskey.entities.UserDetailed['id']; onChoose?: (account: misskey.entities.UserDetailed) => void; }, ev: MouseEvent) { + if (!$i) return; + function showSigninDialog() { popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { @@ -175,8 +232,9 @@ export async function openAccountMenu(opts: { async function switchAccount(account: misskey.entities.UserDetailed) { const storedAccounts = await getAccounts(); - const token = storedAccounts.find(x => x.id === account.id).token; - switchAccountWithToken(token); + const found = storedAccounts.find(x => x.id === account.id); + if (found == null) return; + switchAccountWithToken(found.token); } function switchAccountWithToken(token: string) { @@ -188,7 +246,7 @@ export async function openAccountMenu(opts: { function createItem(account: misskey.entities.UserDetailed) { return { - type: 'user', + type: 'user' as const, user: account, active: opts.active != null ? opts.active === account.id : false, action: () => { @@ -201,22 +259,29 @@ export async function openAccountMenu(opts: { }; } - const accountItemPromises = storedAccounts.map(a => new Promise(res => { + const accountItemPromises = storedAccounts.map(a => new Promise | MenuButton>(res => { accountsPromise.then(accounts => { const account = accounts.find(x => x.id === a.id); - if (account == null) return res(null); + if (account == null) return res({ + type: 'button' as const, + text: a.id, + action: () => { + switchAccountWithToken(a.token); + }, + }); + res(createItem(account)); }); })); if (opts.withExtraOperation) { popupMenu([...[{ - type: 'link', + type: 'link' as const, text: i18n.ts.profile, to: `/@${ $i.username }`, avatar: $i, }, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, { - type: 'parent', + type: 'parent' as const, icon: 'ti ti-plus', text: i18n.ts.addAccount, children: [{ @@ -227,7 +292,7 @@ export async function openAccountMenu(opts: { action: () => { createAccount(); }, }], }, { - type: 'link', + type: 'link' as const, icon: 'ti ti-users', text: i18n.ts.manageAccounts, to: '/settings/accounts', diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts new file mode 100644 index 000000000..c95da64bb --- /dev/null +++ b/packages/frontend/src/cache.ts @@ -0,0 +1,6 @@ +import * as misskey from 'misskey-js'; +import { Cache } from '@/scripts/cache'; + +export const clipsCache = new Cache(Infinity); +export const rolesCache = new Cache(Infinity); +export const userListsCache = new Cache(Infinity); diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue new file mode 100644 index 000000000..fd472de6c --- /dev/null +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts new file mode 100644 index 000000000..05190aa26 --- /dev/null +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkAnalogClock from './MkAnalogClock.vue'; +export const Default = { + render(args) { + return { + components: { + MkAnalogClock, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + parameters: { + layout: 'fullscreen', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts new file mode 100644 index 000000000..e1c1c54d1 --- /dev/null +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -0,0 +1,30 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +/* eslint-disable import/no-duplicates */ +import { StoryObj } from '@storybook/vue3'; +import MkButton from './MkButton.vue'; +export const Default = { + render(args) { + return { + components: { + MkButton, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: 'Text', + }; + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkCaptcha.stories.impl.ts b/packages/frontend/src/components/MkCaptcha.stories.impl.ts new file mode 100644 index 000000000..6ac437a27 --- /dev/null +++ b/packages/frontend/src/components/MkCaptcha.stories.impl.ts @@ -0,0 +1,2 @@ +import MkCaptcha from './MkCaptcha.vue'; +void MkCaptcha; diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index c72cc2ab1..1875b507c 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -10,7 +10,8 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch } from 'vu import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; -type Captcha = { +// APIs provided by Captcha services +export type Captcha = { render(container: string | Node, options: { readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown; }): string; @@ -32,7 +33,7 @@ declare global { const props = defineProps<{ provider: CaptchaProvider; - sitekey: string; + sitekey: string | null; // null will show error on request modelValue?: string | null; }>(); diff --git a/packages/frontend/src/components/MkClipPreview.vue b/packages/frontend/src/components/MkClipPreview.vue new file mode 100644 index 000000000..c5fb71878 --- /dev/null +++ b/packages/frontend/src/components/MkClipPreview.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index 833fa9d38..1834224b8 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -14,10 +14,10 @@ @@ -35,6 +35,8 @@ diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index c768a086c..d36cc2d26 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -118,7 +118,7 @@ onMounted(() => { }); lightbox.init(); - + window.addEventListener('popstate', () => { if (lightbox.pswp && lightbox.pswp.isOpen === true) { lightbox.pswp.close(); @@ -239,5 +239,6 @@ const previewable = (file: misskey.entities.DriveFile): boolean => { max-height: 8em; overflow-y: auto; text-shadow: var(--bg) 0 0 10px, var(--bg) 0 0 3px, var(--bg) 0 0 3px; + white-space: pre-line; } diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 979c3eed2..e02a7af09 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -1,8 +1,8 @@ @@ -14,6 +14,7 @@ import { } from 'vue'; import tinycolor from 'tinycolor2'; import { host as localHost } from '@/config'; import { $i } from '@/account'; +import { defaultStore } from '@/store'; const props = defineProps<{ username: string; diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 9e3022896..e513a65a3 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -1,5 +1,5 @@