diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f8d9905ecd..31b6212cb5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,12 +4,10 @@ "service": "app", "workspaceFolder": "/workspace", "features": { - "ghcr.io/devcontainers-contrib/features/pnpm:2": { - "version": "8.9.2" - }, "ghcr.io/devcontainers/features/node:1": { - "version": "20.10.0" - } + "version": "20.12.2" + }, + "ghcr.io/devcontainers-contrib/features/corepack:1": {} }, "forwardPorts": [3000], "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index bcad3e6d85..729e1a9d2d 100755 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -4,6 +4,8 @@ set -xe sudo chown -R node /workspace git submodule update --init +corepack install +corepack enable pnpm config set store-dir /home/node/.local/share/pnpm/store pnpm install --frozen-lockfile cp .devcontainer/devcontainer.yml .config/default.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index c6b2a1611c..d42b58abc0 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,4 @@ # These are supported funding model platforms +github: [misskey-dev] patreon: syuilo diff --git a/.github/workflows/check-misskey-js-autogen.yml b/.github/workflows/check-misskey-js-autogen.yml index 8fad129115..39acad8bc3 100644 --- a/.github/workflows/check-misskey-js-autogen.yml +++ b/.github/workflows/check-misskey-js-autogen.yml @@ -5,29 +5,26 @@ on: branches: - master - develop + - improve-misskey-js-autogen-check paths: - packages/backend/** jobs: - check-misskey-js-autogen: + # pull_request_target safety: permissions: read-all, and there are no secrets used in this job + generate-misskey-js: runs-on: ubuntu-latest permissions: - pull-requests: write - - env: - api_json_name: "api-head.json" - + contents: read + if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }} steps: - name: checkout uses: actions/checkout@v4.1.1 with: submodules: true - ref: ${{ github.event.pull_request.head.sha }} + ref: refs/pull/${{ github.event.pull_request.number }}/merge - name: setup pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 + uses: pnpm/action-setup@v4 - name: setup node id: setup-node @@ -39,79 +36,81 @@ jobs: - name: install dependencies run: pnpm i --frozen-lockfile - - name: wait get-api-diff - uses: lewagon/wait-on-check-action@v1.3.3 + # generate api.json + - name: Copy Config + run: cp .config/example.yml .config/default.yml + - name: Build + run: pnpm build + - name: Generate API JSON + run: pnpm --filter backend generate-api-json + + # build misskey js + - name: Build misskey-js + run: |- + cp packages/backend/built/api.json packages/misskey-js/generator/api.json + pnpm run --filter misskey-js-type-generator generate + + # packages/misskey-js/generator/built/autogen + - name: Upload Generated + uses: actions/upload-artifact@v4 with: - ref: ${{ github.event.pull_request.head.sha }} - check-regexp: get-from-misskey .+ - repo-token: ${{ secrets.GITHUB_TOKEN }} - wait-interval: 30 + name: generated-misskey-js + path: packages/misskey-js/generator/built/autogen - - name: Download artifact - uses: actions/github-script@v7.0.1 + # pull_request_target safety: permissions: read-all, and there are no secrets used in this job + get-actual-misskey-js: + runs-on: ubuntu-latest + permissions: + contents: read + if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }} + steps: + - name: checkout + uses: actions/checkout@v4.1.1 with: - script: | - const fs = require('fs'); + submodules: true + ref: refs/pull/${{ github.event.pull_request.number }}/merge - const workflows = await github.rest.actions.listWorkflowRunsForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - head_sha: `${{ github.event.pull_request.head.sha }}` - }).then(x => x.data.workflow_runs); + - name: Upload From Merged + uses: actions/upload-artifact@v4 + with: + name: actual-misskey-js + path: packages/misskey-js/src/autogen - console.log(workflows.map(x => ({name: x.name, title: x.display_title}))); + # pull_request_target safety: nothing is cloned from repository + comment-misskey-js-autogen: + runs-on: ubuntu-latest + needs: [generate-misskey-js, get-actual-misskey-js] + permissions: + pull-requests: write + steps: + - name: download generated-misskey-js + uses: actions/download-artifact@v4 + with: + name: generated-misskey-js + path: misskey-js-generated - const run_id = workflows.find(x => x.name.includes("Get api.json from Misskey")).id; + - name: download actual-misskey-js + uses: actions/download-artifact@v4 + with: + name: actual-misskey-js + path: misskey-js-actual - let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: run_id, - }); + - name: check misskey-js changes + id: check-changes + run: | + diff -r -u --label=generated --label=on-tree ./misskey-js-generated ./misskey-js-actual > misskey-js.diff || true - let matchArtifacts = allArtifacts.data.artifacts.filter((artifact) => { - return artifact.name.startsWith("api-artifact-") || artifact.name == "api-artifact" - }); + if [ -s misskey-js.diff ]; then + echo "changes=true" >> $GITHUB_OUTPUT + else + echo "changes=false" >> $GITHUB_OUTPUT + fi - await Promise.all(matchArtifacts.map(async (artifact) => { - let download = await github.rest.actions.downloadArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: artifact.id, - archive_format: 'zip', - }); - await fs.promises.writeFile(`${process.env.GITHUB_WORKSPACE}/${artifact.name}.zip`, Buffer.from(download.data)); - })); - - - name: unzip artifacts - run: |- - find . -mindepth 1 -maxdepth 1 -type f -name '*.zip' -exec unzip {} -d . ';' - ls -la - - - name: get head checksum - run: |- - checksum=$(realpath head_checksum) - - cd packages/misskey-js/src - find autogen -type f -exec sh -c 'echo $(sed -E "s/^\s+\*\s+generatedAt:.+$//" {} | sha256sum | cut -d" " -f 1) {}' \; > $checksum - cd ../../.. - - - name: build autogen - run: |- - checksum=$(realpath ${api_json_name}_checksum) - mv $api_json_name packages/misskey-js/generator/api.json - - cd packages/misskey-js/generator - pnpm run generate - cd built - find autogen -type f -exec sh -c 'echo $(sed -E "s/^\s+\*\s+generatedAt:.+$//" {} | sha256sum | cut -d" " -f 1) {}' \; > $checksum - cd ../../../.. - - - name: check update for type definitions - run: diff head_checksum ${api_json_name}_checksum + - name: Print full diff + run: cat ./misskey-js.diff - name: send message - if: failure() + if: steps.check-changes.outputs.changes == 'true' uses: thollander/actions-comment-pull-request@v2 with: comment_tag: check-misskey-js-autogen @@ -125,7 +124,7 @@ jobs: ``` - name: send message - if: success() + if: steps.check-changes.outputs.changes == 'false' uses: thollander/actions-comment-pull-request@v2 with: comment_tag: check-misskey-js-autogen diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index e737b89b42..9b9c8f11c4 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: - node-version: [20.10.0] + node-version: [20.12.2] api-json-name: [api-base.json, api-head.json] include: - api-json-name: api-base.json @@ -32,10 +32,7 @@ jobs: ref: ${{ matrix.ref }} submodules: true - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 - run_install: false + uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4.0.2 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9b3f85fe1d..76616ec5a7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,10 +27,7 @@ jobs: with: fetch-depth: 0 submodules: true - - uses: pnpm/action-setup@v3 - with: - version: 8 - run_install: false + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4.0.2 with: node-version-file: '.node-version' @@ -54,10 +51,7 @@ jobs: with: fetch-depth: 0 submodules: true - - uses: pnpm/action-setup@v3 - with: - version: 7 - run_install: false + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4.0.2 with: node-version-file: '.node-version' @@ -80,10 +74,7 @@ jobs: with: fetch-depth: 0 submodules: true - - uses: pnpm/action-setup@v3 - with: - version: 7 - run_install: false + - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4.0.2 with: node-version-file: '.node-version' diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index 069534bd53..edfdab99e9 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -17,17 +17,14 @@ jobs: strategy: matrix: - node-version: [20.10.0] + node-version: [20.12.2] steps: - uses: actions/checkout@v4.1.1 with: submodules: true - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 - run_install: false + uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4.0.2 with: diff --git a/.github/workflows/release-with-dispatch.yml b/.github/workflows/release-with-dispatch.yml index 1a954739d9..bc6448cb37 100644 --- a/.github/workflows/release-with-dispatch.yml +++ b/.github/workflows/release-with-dispatch.yml @@ -61,6 +61,7 @@ jobs: - use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} + indent: ${{ vars.INDENT }} secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -75,6 +76,7 @@ jobs: pr_number: ${{ needs.get-pr.outputs.pr_number }} package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} + indent: ${{ vars.INDENT }} secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} @@ -115,6 +117,7 @@ jobs: # } package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} + indent: ${{ vars.INDENT }} secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release-with-ready.yml b/.github/workflows/release-with-ready.yml index b64ed20791..139503e563 100644 --- a/.github/workflows/release-with-ready.yml +++ b/.github/workflows/release-with-ready.yml @@ -33,6 +33,7 @@ jobs: pr_number: ${{ github.event.pull_request.number }} package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} + indent: ${{ vars.INDENT }} secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index ca82f4bcf3..c52883ffdd 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -34,10 +34,7 @@ jobs: echo "base=$(git rev-list --parents -n1 HEAD | cut -d" " -f2)" >> $GITHUB_OUTPUT git checkout $(git rev-list --parents -n1 HEAD | cut -d" " -f3) - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 - run_install: false + uses: pnpm/action-setup@v4 - name: Use Node.js 20.x uses: actions/setup-node@v4.0.2 with: diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index a803db4508..b1c54bb3e7 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -21,7 +21,7 @@ jobs: strategy: matrix: - node-version: [20.10.0] + node-version: [20.12.2] services: postgres: @@ -41,10 +41,7 @@ jobs: with: submodules: true - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 - run_install: false + uses: pnpm/action-setup@v4 - name: Install FFmpeg uses: FedericoCarboni/setup-ffmpeg@v3 - name: Use Node.js ${{ matrix.node-version }} @@ -73,7 +70,7 @@ jobs: strategy: matrix: - node-version: [20.10.0] + node-version: [20.12.2] services: postgres: @@ -93,10 +90,7 @@ jobs: with: submodules: true - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 - run_install: false + uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4.0.2 with: diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 1e020b7368..9d5053b82a 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -26,17 +26,14 @@ jobs: strategy: matrix: - node-version: [20.10.0] + node-version: [20.12.2] steps: - uses: actions/checkout@v4.1.1 with: submodules: true - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 - run_install: false + uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4.0.2 with: @@ -64,7 +61,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [20.10.0] + node-version: [20.12.2] browser: [chrome] services: @@ -91,10 +88,7 @@ jobs: #- uses: browser-actions/setup-firefox@latest # if: ${{ matrix.browser == 'firefox' }} - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 7 - run_install: false + uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4.0.2 with: diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index f73bd0b08f..2589d908b8 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: - node-version: [20.10.0] + node-version: [20.12.2] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 77af08b6fe..7f8db65293 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -16,17 +16,14 @@ jobs: strategy: matrix: - node-version: [20.10.0] + node-version: [20.12.2] steps: - uses: actions/checkout@v4.1.1 with: submodules: true - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 - run_install: false + uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4.0.2 with: diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index 36ed8d273f..24340e7d81 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -17,17 +17,14 @@ jobs: strategy: matrix: - node-version: [20.10.0] + node-version: [20.12.2] steps: - uses: actions/checkout@v4.1.1 with: submodules: true - name: Install pnpm - uses: pnpm/action-setup@v3 - with: - version: 8 - run_install: false + uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4.0.2 with: diff --git a/.node-version b/.node-version index d5a159609d..87834047a6 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.10.0 +20.12.2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 47e8e0cf19..a9944d4b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,22 @@ ### Note - コントロールパネル内にあるサマリープロキシの設定個所がセキュリティから全般へ変更となります。 +- 悪意のある第三者がリモートユーザーになりすましたアクティビティを受け取れてしまう問題を修正しました。詳しくは[GitHub security advisory](https://github.com/misskey-dev/misskey/security/advisories/GHSA-2vxv-pv3m-3wvj)をご覧ください。 ### General - Enhance: URLプレビューの有効化・無効化を設定できるように #13569 - Enhance: アンテナでBotによるノートを除外できるように (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/545) +- Enhance: クリップのノート数を表示するように +- Enhance: コンディショナルロールの条件として以下を新たに追加 (#13667) + - 猫ユーザーか + - botユーザーか + - サスペンド済みユーザーか + - 鍵アカウントユーザーか + - 「アカウントを見つけやすくする」が有効なユーザーか - Fix: Play作成時に設定した公開範囲が機能していない問題を修正 +- Fix: 正規化されていない状態のhashtagが連合されてきたhtmlに含まれているとhashtagが正しくhashtagに復元されない問題を修正 +- Fix: みつけるのアンケート欄にてチャンネルのアンケートが含まれてしまう問題を修正 ### Client - Feat: アップロードするファイルの名前をランダム文字列にできるように @@ -26,6 +36,11 @@ - Enhance: ノートについているリアクションの「もっと!」から、リアクションの一覧を表示できるように - Enhance: リプライにて引用がある場合テキストが空でもノートできるように - 引用したいノートのURLをコピーしリプライ投稿画面にペーストして添付することで達成できます +- Enhance: フォローするかどうかの確認ダイアログを出せるように +- Enhance: Playを手動でリロードできるように +- Enhance: 通報のコメント内のリンクをクリックした際、ウィンドウで開くように +- Enhance: `Ui:C:postForm` および `Ui:C:postFormButton` に `localOnly` と `visibility` を設定できるように +- Enhance: AiScriptを0.18.0にバージョンアップ - Fix: 一部のページ内リンクが正しく動作しない問題を修正 - Fix: 周年の実績が閏年を考慮しない問題を修正 - Fix: ローカルURLのプレビューポップアップが左上に表示される @@ -39,10 +54,19 @@ - Fix: CWのみの引用リノートが詳細ページで純粋なリノートとして誤って扱われてしまう問題を修正 - Fix: ノート詳細ページにおいてCW付き引用リノートのCWボタンのラベルに「引用」が含まれていない問題を修正 - Fix: ダイアログの入力で字数制限に違反していてもEnterキーが押せてしまう問題を修正 +- Fix: ダイレクト投稿の宛先が保存されない問題を修正 +- Fix: Playのページを離れたときに、Playが正常に初期化されない問題を修正 +- Fix: ページのOGP URLが間違っているのを修正 +- Fix: リバーシの対局を正しく共有できないことがある問題を修正 +- Fix: 通知をグループ化している際に、人数が正常に表示されないことがある問題を修正 +- Fix: 連合なしの状態の読み書きができない問題を修正 ### Server - Enhance: エンドポイント`antennas/update`の必須項目を`antennaId`のみに - Enhance: misskey-dev/summaly@5.1.0の取り込み(プレビュー生成処理の効率化) +- Enhance: ドライブのファイルがNSFWかどうか個別に連合されるように (#13756) + - 可能な場合、ノートの添付ファイルのセンシティブ判定がファイル単位になります +- Fix: リモートから配送されたアクティビティにJSON-LD compactionをかける - Fix: フォローリクエストを作成する際に既存のものは削除するように (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/440) - Fix: エンドポイント`notes/translate`のエラーを改善 @@ -51,6 +75,15 @@ - Fix: リプライのみの引用リノートと、CWのみの引用リノートが純粋なリノートとして誤って扱われてしまう問題を修正 - Fix: 登録にメール認証が必須になっている場合、登録されているメールアドレスを削除できないように (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/606) +- Fix: Add Cache-Control to Bull Board +- Fix: nginx経由で/files/にRangeリクエストされた場合に正しく応答できないのを修正 +- Fix: 一部のタイムラインのストリーミングでインスタンスミュートが効かない問題を修正 +- Fix: グローバルタイムラインで返信が表示されないことがある問題を修正 +- Fix: リノートをミュートしたユーザの投稿のリノートがミュートされる問題を修正 +- Fix: AP Link等は添付ファイル扱いしないようになど (#13754) +- Fix: FTTが有効かつsinceIdのみを指定した場合に帰って来るレスポンスが逆順である問題を修正 +- Fix: `/i/notifications`に `includeTypes`か`excludeTypes`を指定しているとき、通知が存在するのに空配列を返すことがある問題を修正 +- Fix: 複数idを指定する`users/show`が関係ないユーザを返すことがある問題を修正 ## 2024.3.1 diff --git a/Dockerfile b/Dockerfile index ee3a30a3c1..9fc2d611cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.4 -ARG NODE_VERSION=20.10.0-bullseye +ARG NODE_VERSION=20.12.2-bullseye # build assets & compile TypeScript diff --git a/locales/index.d.ts b/locales/index.d.ts index d6875c0868..779a5d2c3f 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -4944,6 +4944,18 @@ export interface Locale extends ILocale { * この設定をオフにすると、アップロード時にファイル名が自動でランダム文字列に置き換えられます。 */ "keepOriginalFilenameDescription": string; + /** + * 説明文はありません + */ + "noDescription": string; + /** + * フォローの際常に確認する + */ + "alwaysConfirmFollow": string; + /** + * お問い合わせ + */ + "inquiry": string; "_bubbleGame": { /** * 遊び方 @@ -6584,6 +6596,26 @@ export interface Locale extends ILocale { * リモートユーザー */ "isRemote": string; + /** + * 猫ユーザー + */ + "isCat": string; + /** + * botユーザー + */ + "isBot": string; + /** + * サスペンド済みユーザー + */ + "isSuspended": string; + /** + * 鍵アカウントユーザー + */ + "isLocked": string; + /** + * 「アカウントを見つけやすくする」が有効なユーザー + */ + "isExplorable": string; /** * アカウント作成から~以内 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 0b581a01e3..8f17215802 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1232,6 +1232,9 @@ launchApp: "アプリを起動" useNativeUIForVideoAudioPlayer: "動画・音声の再生にブラウザのUIを使用する" keepOriginalFilename: "オリジナルのファイル名を保持" keepOriginalFilenameDescription: "この設定をオフにすると、アップロード時にファイル名が自動でランダム文字列に置き換えられます。" +noDescription: "説明文はありません" +alwaysConfirmFollow: "フォローの際常に確認する" +inquiry: "お問い合わせ" _bubbleGame: howToPlay: "遊び方" @@ -1701,6 +1704,11 @@ _role: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" isRemote: "リモートユーザー" + isCat: "猫ユーザー" + isBot: "botユーザー" + isSuspended: "サスペンド済みユーザー" + isLocked: "鍵アカウントユーザー" + isExplorable: "「アカウントを見つけやすくする」が有効なユーザー" createdLessThan: "アカウント作成から~以内" createdMoreThan: "アカウント作成から~経過" followersLessThanOrEq: "フォロワー数が~以下" diff --git a/package.json b/package.json index 84d6db5124..23e0ea0ee5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@8.15.4", + "packageManager": "pnpm@9.0.6", "workspaces": [ "packages/frontend", "packages/backend", @@ -48,24 +48,24 @@ "lodash": "4.17.21" }, "dependencies": { - "cssnano": "6.0.5", + "cssnano": "6.1.2", "execa": "8.0.1", "fast-glob": "3.3.2", "ignore-walk": "6.0.4", "js-yaml": "4.1.0", - "postcss": "8.4.35", - "tar": "6.2.0", - "terser": "5.28.1", - "typescript": "5.3.3", - "esbuild": "0.19.11", - "glob": "10.3.10" + "postcss": "8.4.38", + "tar": "6.2.1", + "terser": "5.30.3", + "typescript": "5.4.5", + "esbuild": "0.20.2", + "glob": "10.3.12" }, "devDependencies": { - "@types/node": "^20.11.28", - "@typescript-eslint/eslint-plugin": "7.1.0", - "@typescript-eslint/parser": "7.1.0", + "@types/node": "20.12.7", + "@typescript-eslint/eslint-plugin": "7.7.1", + "@typescript-eslint/parser": "7.7.1", "cross-env": "7.0.3", - "cypress": "13.6.6", + "cypress": "13.7.3", "eslint": "8.57.0", "ncp": "2.0.0", "start-server-and-test": "2.0.3" diff --git a/packages/backend/assets/redoc.html b/packages/backend/assets/redoc.html index a9ebf662fc..2557b4532e 100644 --- a/packages/backend/assets/redoc.html +++ b/packages/backend/assets/redoc.html @@ -19,6 +19,6 @@ - + diff --git a/packages/backend/migration/1716129964060-ChannelIdDenormalizedForMiPoll.js b/packages/backend/migration/1716129964060-ChannelIdDenormalizedForMiPoll.js new file mode 100644 index 0000000000..f736378c04 --- /dev/null +++ b/packages/backend/migration/1716129964060-ChannelIdDenormalizedForMiPoll.js @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ChannelIdDenormalizedForMiPoll1716129964060 { + name = 'ChannelIdDenormalizedForMiPoll1716129964060' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "poll" ADD "channelId" character varying(32)`); + await queryRunner.query(`COMMENT ON COLUMN "poll"."channelId" IS '[Denormalized]'`); + await queryRunner.query(`CREATE INDEX "IDX_c1240fcc9675946ea5d6c2860e" ON "poll" ("channelId") `); + await queryRunner.query(`UPDATE "poll" SET "channelId" = "note"."channelId" FROM "note" WHERE "poll"."noteId" = "note"."id"`); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_c1240fcc9675946ea5d6c2860e"`); + await queryRunner.query(`COMMENT ON COLUMN "poll"."channelId" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "poll" DROP COLUMN "channelId"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 7f70ae0c97..23b3bfdb8b 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -12,9 +12,9 @@ "migrate": "pnpm typeorm migration:run -d ormconfig.js", "revert": "pnpm typeorm migration:revert -d ormconfig.js", "check:connect": "node ./scripts/check_connect.js", - "build": "swc src -d built -D", - "build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc", - "watch:swc": "swc src -d built -D -w", + "build": "swc src -d built -D --strip-leading-paths", + "build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths", + "watch:swc": "swc src -d built -D -w --strip-leading-paths", "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "watch": "node ./scripts/watch.mjs", "restart": "pnpm build && pnpm start", @@ -67,38 +67,39 @@ "dependencies": { "@aws-sdk/client-s3": "3.412.0", "@aws-sdk/lib-storage": "3.412.0", - "@bull-board/api": "5.14.2", - "@bull-board/fastify": "5.14.2", - "@bull-board/ui": "5.14.2", - "@discordapp/twemoji": "15.0.2", + "@bull-board/api": "5.17.0", + "@bull-board/fastify": "5.17.0", + "@bull-board/ui": "5.17.0", + "@discordapp/twemoji": "15.0.3", "@fastify/accepts": "4.3.0", "@fastify/cookie": "9.3.1", - "@fastify/cors": "8.5.0", - "@fastify/express": "2.3.0", - "@fastify/http-proxy": "9.3.0", - "@fastify/multipart": "8.1.0", - "@fastify/static": "6.12.0", - "@fastify/view": "8.2.0", + "@fastify/cors": "9.0.1", + "@fastify/express": "3.0.0", + "@fastify/http-proxy": "9.5.0", + "@fastify/multipart": "8.2.0", + "@fastify/static": "7.0.3", + "@fastify/view": "9.1.0", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.1.0", - "@nestjs/common": "10.3.3", - "@nestjs/core": "10.3.3", - "@nestjs/testing": "10.3.3", + "@napi-rs/canvas": "^0.1.52", + "@nestjs/common": "10.3.8", + "@nestjs/core": "10.3.8", + "@nestjs/testing": "10.3.8", "@peertube/http-signature": "1.7.0", - "@simplewebauthn/server": "9.0.3", + "@simplewebauthn/server": "10.0.0", "@sinonjs/fake-timers": "11.2.2", - "@smithy/node-http-handler": "2.1.10", - "@swc/cli": "0.1.63", - "@swc/core": "1.3.107", - "@twemoji/parser": "15.0.0", + "@smithy/node-http-handler": "2.5.0", + "@swc/cli": "0.3.12", + "@swc/core": "1.4.17", + "@twemoji/parser": "15.1.1", "accepts": "1.3.8", - "ajv": "8.12.0", - "archiver": "6.0.1", - "async-mutex": "0.4.1", + "ajv": "8.13.0", + "archiver": "7.0.1", + "async-mutex": "0.5.0", "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.2", - "bullmq": "5.4.0", + "bullmq": "5.7.8", "cacheable-lookup": "7.0.0", "cbor": "9.0.2", "chalk": "5.3.0", @@ -109,85 +110,84 @@ "content-disposition": "0.5.4", "date-fns": "2.30.0", "deep-email-validator": "0.1.21", - "fastify": "4.25.2", + "fastify": "4.26.2", "fastify-raw-body": "4.3.0", "feed": "4.2.2", "file-type": "19.0.0", "fluent-ffmpeg": "2.1.2", "form-data": "4.0.0", - "got": "14.2.0", - "happy-dom": "10.0.3", + "got": "14.2.1", + "happy-dom": "14.7.1", "hpagent": "1.2.0", "htmlescape": "1.1.1", - "http-link-header": "1.1.2", - "ioredis": "5.3.2", + "http-link-header": "1.1.3", + "ioredis": "5.4.1", "ip-cidr": "3.1.0", - "ipaddr.js": "2.1.0", + "ipaddr.js": "2.2.0", "is-svg": "5.0.0", "js-yaml": "4.1.0", - "jsdom": "23.2.0", + "jsdom": "24.0.0", "json5": "2.2.3", "jsonld": "8.3.2", "jsrsasign": "11.1.0", - "meilisearch": "0.37.0", + "meilisearch": "0.38.0", "mfm-js": "0.24.0", "microformats-parser": "2.0.2", "mime-types": "2.1.35", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "ms": "3.0.0-canary.1", - "nanoid": "5.0.6", + "nanoid": "5.0.7", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "nodemailer": "6.9.10", + "nodemailer": "6.9.13", "nsfwjs": "2.4.2", "oauth": "0.10.0", "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.2.2", + "otpauth": "9.2.3", "parse5": "7.1.2", - "pg": "8.11.3", + "pg": "8.11.5", "pkce-challenge": "4.1.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.2", "punycode": "2.3.1", - "pureimage": "0.3.17", "qrcode": "1.5.3", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.20.9", + "re2": "1.20.10", "redis-lock": "0.1.4", - "reflect-metadata": "0.2.1", + "reflect-metadata": "0.2.2", "rename": "1.0.4", "rss-parser": "3.13.0", "rxjs": "7.8.1", - "sanitize-html": "2.12.1", + "sanitize-html": "2.13.0", "secure-json-parse": "2.7.0", - "sharp": "0.33.2", + "sharp": "0.33.3", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.22.0", + "systeminformation": "5.22.7", "tinycolor2": "1.6.0", - "tmp": "0.2.2", + "tmp": "0.2.3", "tsc-alias": "1.8.8", "tsconfig-paths": "4.2.0", "typeorm": "0.3.20", - "typescript": "5.3.3", + "typescript": "5.4.5", "ulid": "2.3.0", "vary": "1.1.2", "web-push": "3.6.7", - "ws": "8.16.0", + "ws": "8.17.0", "xev": "3.0.2" }, "devDependencies": { "@jest/globals": "29.7.0", "@misskey-dev/eslint-plugin": "1.0.0", - "@nestjs/platform-express": "10.3.3", - "@simplewebauthn/types": "9.0.1", - "@swc/jest": "0.2.31", + "@nestjs/platform-express": "10.3.8", + "@simplewebauthn/types": "10.0.0", + "@swc/jest": "0.2.36", "@types/accepts": "1.3.7", "@types/archiver": "6.0.2", "@types/bcryptjs": "2.4.6", @@ -197,20 +197,20 @@ "@types/fluent-ffmpeg": "2.1.24", "@types/htmlescape": "^1.1.3", "@types/http-link-header": "1.0.5", - "@types/jest": "29.5.11", + "@types/jest": "29.5.12", "@types/js-yaml": "4.0.9", "@types/jsdom": "21.1.6", "@types/jsonld": "1.5.13", - "@types/jsrsasign": "10.5.12", + "@types/jsrsasign": "10.5.14", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "20.11.22", + "@types/node": "20.12.7", "@types/node-fetch": "3.0.3", - "@types/nodemailer": "6.4.14", + "@types/nodemailer": "6.4.15", "@types/oauth": "0.9.4", - "@types/oauth2orize": "1.11.3", + "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", - "@types/pg": "8.11.2", + "@types/pg": "8.11.5", "@types/pug": "2.0.10", "@types/punycode": "2.1.4", "@types/qrcode": "1.5.5", @@ -226,8 +226,8 @@ "@types/vary": "1.1.3", "@types/web-push": "3.6.3", "@types/ws": "8.5.10", - "@typescript-eslint/eslint-plugin": "7.1.0", - "@typescript-eslint/parser": "7.1.0", + "@typescript-eslint/eslint-plugin": "7.7.1", + "@typescript-eslint/parser": "7.7.1", "aws-sdk-client-mock": "3.0.1", "cross-env": "7.0.3", "eslint": "8.57.0", diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 2c27d33c06..5953155872 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -127,7 +127,7 @@ import { ApMfmService } from './activitypub/ApMfmService.js'; import { ApRendererService } from './activitypub/ApRendererService.js'; import { ApRequestService } from './activitypub/ApRequestService.js'; import { ApResolverService } from './activitypub/ApResolverService.js'; -import { LdSignatureService } from './activitypub/LdSignatureService.js'; +import { JsonLdService } from './activitypub/JsonLdService.js'; import { RemoteLoggerService } from './RemoteLoggerService.js'; import { RemoteUserResolveService } from './RemoteUserResolveService.js'; import { WebfingerService } from './WebfingerService.js'; @@ -266,7 +266,7 @@ const $ApMfmService: Provider = { provide: 'ApMfmService', useExisting: ApMfmSer const $ApRendererService: Provider = { provide: 'ApRendererService', useExisting: ApRendererService }; const $ApRequestService: Provider = { provide: 'ApRequestService', useExisting: ApRequestService }; const $ApResolverService: Provider = { provide: 'ApResolverService', useExisting: ApResolverService }; -const $LdSignatureService: Provider = { provide: 'LdSignatureService', useExisting: LdSignatureService }; +const $JsonLdService: Provider = { provide: 'JsonLdService', useExisting: JsonLdService }; const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useExisting: RemoteLoggerService }; const $RemoteUserResolveService: Provider = { provide: 'RemoteUserResolveService', useExisting: RemoteUserResolveService }; const $WebfingerService: Provider = { provide: 'WebfingerService', useExisting: WebfingerService }; @@ -406,7 +406,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, - LdSignatureService, + JsonLdService, RemoteLoggerService, RemoteUserResolveService, WebfingerService, @@ -542,7 +542,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRendererService, $ApRequestService, $ApResolverService, - $LdSignatureService, + $JsonLdService, $RemoteLoggerService, $RemoteUserResolveService, $WebfingerService, @@ -678,7 +678,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, - LdSignatureService, + JsonLdService, RemoteLoggerService, RemoteUserResolveService, WebfingerService, @@ -813,7 +813,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ApRendererService, $ApRequestService, $ApResolverService, - $LdSignatureService, + $JsonLdService, $RemoteLoggerService, $RemoteUserResolveService, $WebfingerService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index edb9335b6e..1c75566755 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -20,7 +20,7 @@ import { query } from '@/misc/prelude/url.js'; import type { Serialized } from '@/types.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; -const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; +const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; @Injectable() export class CustomEmojiService implements OnApplicationShutdown { diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 006433df7a..d5058f37c2 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -61,8 +61,8 @@ export class FanoutTimelineEndpointService { // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); - const shouldPrepend = ps.sinceId && !ps.untilId; - const idCompare: (a: string, b: string) => number = shouldPrepend ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1; + const ascending = ps.sinceId && !ps.untilId; + const idCompare: (a: string, b: string) => number = ascending ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1; const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); @@ -116,7 +116,7 @@ export class FanoutTimelineEndpointService { filter = (note) => { if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false; if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; - if (isRenote(note) && !isQuote(note) && isUserRelated(note, userIdsWhoMeMutingRenotes, ps.ignoreAuthorFromMute)) return false; + if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false; if (isInstanceMuted(note, userMutedInstances)) return false; return parentFilter(note); @@ -142,9 +142,7 @@ export class FanoutTimelineEndpointService { if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) { // 十分Redisからとれた - const result = redisTimeline.slice(0, ps.limit); - if (shouldPrepend) result.reverse(); - return result; + return redisTimeline.slice(0, ps.limit); } } @@ -152,8 +150,7 @@ export class FanoutTimelineEndpointService { const remainingToRead = ps.limit - redisTimeline.length; let dbUntil: string | null; let dbSince: string | null; - if (shouldPrepend) { - redisTimeline.reverse(); + if (ascending) { dbUntil = ps.untilId; dbSince = noteIds[noteIds.length - 1]; } else { @@ -161,7 +158,7 @@ export class FanoutTimelineEndpointService { dbSince = ps.sinceId; } const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead); - return shouldPrepend ? [...gotFromDb, ...redisTimeline] : [...redisTimeline, ...gotFromDb]; + return [...redisTimeline, ...gotFromDb]; } return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit); diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index c62ee5a642..9786f8b8bb 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -6,10 +6,11 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import * as parse5 from 'parse5'; -import { Window } from 'happy-dom'; +import { Window, XMLSerializer } from 'happy-dom'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; +import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; @@ -33,6 +34,8 @@ export class MfmService { // some AP servers like Pixelfed use br tags as well as newlines html = html.replace(/\r?\n/gi, '\n'); + const normalizedHashtagNames = hashtagNames == null ? undefined : new Set(hashtagNames.map(x => normalizeForSearch(x))); + const dom = parse5.parseFragment(html); let text = ''; @@ -85,7 +88,7 @@ export class MfmService { const href = node.attrs.find(x => x.name === 'href'); // ハッシュタグ - if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) { + if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { text += txt; // メンション } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { @@ -244,6 +247,8 @@ export class MfmService { const doc = window.document; + const body = doc.createElement('p'); + function appendChildren(children: mfm.MfmNode[], targetElement: any): void { if (children) { for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child); @@ -454,8 +459,8 @@ export class MfmService { }, }; - appendChildren(nodes, doc.body); + appendChildren(nodes, body); - return `

${doc.body.innerHTML}

`; + return new XMLSerializer().serializeToString(body); } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 32104fea90..e5580f36d1 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -473,6 +473,7 @@ export class NoteCreateService implements OnApplicationShutdown { noteVisibility: insert.visibility, userId: user.id, userHost: user.host, + channelId: insert.channelId, }); await transactionalEntityManager.insert(MiPoll, poll); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 09f3097114..70c537f9ab 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -205,45 +205,79 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { private evalCond(user: MiUser, roles: MiRole[], value: RoleCondFormulaValue): boolean { try { switch (value.type) { + // ~かつ~ case 'and': { return value.values.every(v => this.evalCond(user, roles, v)); } + // ~または~ case 'or': { return value.values.some(v => this.evalCond(user, roles, v)); } + // ~ではない case 'not': { return !this.evalCond(user, roles, value.value); } + // マニュアルロールがアサインされている case 'roleAssignedTo': { return roles.some(r => r.id === value.roleId); } + // ローカルユーザのみ case 'isLocal': { return this.userEntityService.isLocalUser(user); } + // リモートユーザのみ case 'isRemote': { return this.userEntityService.isRemoteUser(user); } + // サスペンド済みユーザである + case 'isSuspended': { + return user.isSuspended; + } + // 鍵アカウントユーザである + case 'isLocked': { + return user.isLocked; + } + // botユーザである + case 'isBot': { + return user.isBot; + } + // 猫である + case 'isCat': { + return user.isCat; + } + // 「ユーザを見つけやすくする」が有効なアカウント + case 'isExplorable': { + return user.isExplorable; + } + // ユーザが作成されてから指定期間経過した case 'createdLessThan': { return this.idService.parse(user.id).date.getTime() > (Date.now() - (value.sec * 1000)); } + // ユーザが作成されてから指定期間経っていない case 'createdMoreThan': { return this.idService.parse(user.id).date.getTime() < (Date.now() - (value.sec * 1000)); } + // フォロワー数が指定値以下 case 'followersLessThanOrEq': { return user.followersCount <= value.value; } + // フォロワー数が指定値以上 case 'followersMoreThanOrEq': { return user.followersCount >= value.value; } + // フォロー数が指定値以下 case 'followingLessThanOrEq': { return user.followingCount <= value.value; } + // フォロー数が指定値以上 case 'followingMoreThanOrEq': { return user.followingCount >= value.value; } + // ノート数が指定値以下 case 'notesLessThanOrEq': { return user.notesCount <= value.value; } + // ノート数が指定値以上 case 'notesMoreThanOrEq': { return user.notesCount >= value.value; } diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index 42fbed2110..ec9f4484a4 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -10,7 +10,7 @@ import { generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server'; -import { AttestationFormat, isoCBOR } from '@simplewebauthn/server/helpers'; +import { AttestationFormat, isoCBOR, isoUint8Array } from '@simplewebauthn/server/helpers'; import { DI } from '@/di-symbols.js'; import type { UserSecurityKeysRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -49,7 +49,7 @@ export class WebAuthnService { const instance = await this.metaService.fetch(); return { origin: this.config.url, - rpId: this.config.host, + rpId: this.config.hostname, rpName: instance.name ?? this.config.host, rpIcon: instance.iconUrl ?? undefined, }; @@ -65,13 +65,12 @@ export class WebAuthnService { const registrationOptions = await generateRegistrationOptions({ rpName: relyingParty.rpName, rpID: relyingParty.rpId, - userID: userId, + userID: isoUint8Array.fromUTF8String(userId), userName: userName, userDisplayName: userDisplayName, attestationType: 'indirect', - excludeCredentials: keys.map(key => ({ - id: Buffer.from(key.id, 'base64url'), - type: 'public-key', + excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{ + id: key.id, transports: key.transports ?? undefined, })), authenticatorSelection: { @@ -87,7 +86,7 @@ export class WebAuthnService { @bindThis public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{ - credentialID: Uint8Array; + credentialID: string; credentialPublicKey: Uint8Array; attestationObject: Uint8Array; fmt: AttestationFormat; @@ -144,6 +143,7 @@ export class WebAuthnService { @bindThis public async initiateAuthentication(userId: MiUser['id']): Promise { + const relyingParty = await this.getRelyingParty(); const keys = await this.userSecurityKeysRepository.findBy({ userId: userId, }); @@ -153,9 +153,9 @@ export class WebAuthnService { } const authenticationOptions = await generateAuthenticationOptions({ - allowCredentials: keys.map(key => ({ - id: Buffer.from(key.id, 'base64url'), - type: 'public-key', + rpID: relyingParty.rpId, + allowCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{ + id: key.id, transports: key.transports ?? undefined, })), userVerification: 'preferred', @@ -219,7 +219,7 @@ export class WebAuthnService { expectedOrigin: relyingParty.origin, expectedRPID: relyingParty.rpId, authenticator: { - credentialID: Buffer.from(key.id, 'base64url'), + credentialID: key.id, credentialPublicKey: Buffer.from(key.publicKey, 'base64url'), counter: key.counter, transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined, diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index d7fb977a99..4fc724b548 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -28,8 +28,9 @@ import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { IdService } from '@/core/IdService.js'; -import { LdSignatureService } from './LdSignatureService.js'; +import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; +import { CONTEXT } from './misc/contexts.js'; import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; @Injectable() @@ -56,7 +57,7 @@ export class ApRendererService { private customEmojiService: CustomEmojiService, private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, - private ldSignatureService: LdSignatureService, + private jsonLdService: JsonLdService, private userKeypairService: UserKeypairService, private apMfmService: ApMfmService, private mfmService: MfmService, @@ -166,6 +167,7 @@ export class ApRendererService { mediaType: file.webpublicType ?? file.type, url: this.driveFileEntityService.getPublicUrl(file), name: file.comment, + sensitive: file.isSensitive, }; } @@ -617,48 +619,16 @@ export class ApRendererService { x.id = `${this.config.url}/${randomUUID()}`; } - return Object.assign({ - '@context': [ - 'https://www.w3.org/ns/activitystreams', - 'https://w3id.org/security/v1', - { - Key: 'sec:Key', - // as non-standards - manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', - sensitive: 'as:sensitive', - Hashtag: 'as:Hashtag', - quoteUrl: 'as:quoteUrl', - // Mastodon - toot: 'http://joinmastodon.org/ns#', - Emoji: 'toot:Emoji', - featured: 'toot:featured', - discoverable: 'toot:discoverable', - // schema - schema: 'http://schema.org#', - PropertyValue: 'schema:PropertyValue', - value: 'schema:value', - // Misskey - misskey: 'https://misskey-hub.net/ns#', - '_misskey_content': 'misskey:_misskey_content', - '_misskey_quote': 'misskey:_misskey_quote', - '_misskey_reaction': 'misskey:_misskey_reaction', - '_misskey_votes': 'misskey:_misskey_votes', - '_misskey_summary': 'misskey:_misskey_summary', - 'isCat': 'misskey:isCat', - // vcard - vcard: 'http://www.w3.org/2006/vcard/ns#', - }, - ], - }, x as T & { id: string }); + return Object.assign({ '@context': CONTEXT }, x as T & { id: string }); } @bindThis public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise { 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`); + const jsonLd = this.jsonLdService.use(); + jsonLd.debug = false; + activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`); return activity; } diff --git a/packages/backend/src/core/activitypub/LdSignatureService.ts b/packages/backend/src/core/activitypub/JsonLdService.ts similarity index 83% rename from packages/backend/src/core/activitypub/LdSignatureService.ts rename to packages/backend/src/core/activitypub/JsonLdService.ts index 9de184336f..100d4fa19f 100644 --- a/packages/backend/src/core/activitypub/LdSignatureService.ts +++ b/packages/backend/src/core/activitypub/JsonLdService.ts @@ -7,14 +7,14 @@ import * as crypto from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; -import { CONTEXTS } from './misc/contexts.js'; +import { CONTEXT, PRELOADED_CONTEXTS } from './misc/contexts.js'; import { validateContentTypeSetAsJsonLD } from './misc/validator.js'; import type { JsonLdDocument } from 'jsonld'; -import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js'; +import type { JsonLd as JsonLdObject, RemoteDocument } from 'jsonld/jsonld-spec.js'; -// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 +// RsaSignature2017 implementation is based on https://github.com/transmute-industries/RsaSignature2017 -class LdSignature { +class JsonLd { public debug = false; public preLoad = true; public loderTimeout = 5000; @@ -89,10 +89,18 @@ class LdSignature { } @bindThis - public async normalize(data: JsonLdDocument): Promise { + public async compact(data: any, context: any = CONTEXT): Promise { const customLoader = this.getLoader(); // XXX: Importing jsonld dynamically since Jest frequently fails to import it statically // https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595 + return (await import('jsonld')).default.compact(data, context, { + documentLoader: customLoader, + }); + } + + @bindThis + public async normalize(data: JsonLdDocument): Promise { + const customLoader = this.getLoader(); return (await import('jsonld')).default.normalize(data, { documentLoader: customLoader, }); @@ -104,11 +112,11 @@ class LdSignature { if (!/^https?:\/\//.test(url)) throw new Error(`Invalid URL ${url}`); if (this.preLoad) { - if (url in CONTEXTS) { + if (url in PRELOADED_CONTEXTS) { if (this.debug) console.debug(`HIT: ${url}`); return { contextUrl: undefined, - document: CONTEXTS[url], + document: PRELOADED_CONTEXTS[url], documentUrl: url, }; } @@ -125,7 +133,7 @@ class LdSignature { } @bindThis - private async fetchDocument(url: string): Promise { + private async fetchDocument(url: string): Promise { const json = await this.httpRequestService.send( url, { @@ -146,7 +154,7 @@ class LdSignature { } }); - return json as JsonLd; + return json as JsonLdObject; } @bindThis @@ -158,14 +166,14 @@ class LdSignature { } @Injectable() -export class LdSignatureService { +export class JsonLdService { constructor( private httpRequestService: HttpRequestService, ) { } @bindThis - public use(): LdSignature { - return new LdSignature(this.httpRequestService); + public use(): JsonLd { + return new JsonLd(this.httpRequestService); } } diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index 88afdefcd3..feb8c42c56 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { JsonLd } from 'jsonld/jsonld-spec.js'; +import type { Context, JsonLd } from 'jsonld/jsonld-spec.js'; /* eslint:disable:quotemark indent */ const id_v1 = { @@ -526,7 +526,42 @@ const activitystreams = { }, } satisfies JsonLd; -export const CONTEXTS: Record = { +const context_iris = [ + 'https://www.w3.org/ns/activitystreams', + 'https://w3id.org/security/v1', +]; + +const extension_context_definition = { + Key: 'sec:Key', + // as non-standards + manuallyApprovesFollowers: 'as:manuallyApprovesFollowers', + sensitive: 'as:sensitive', + Hashtag: 'as:Hashtag', + quoteUrl: 'as:quoteUrl', + // Mastodon + toot: 'http://joinmastodon.org/ns#', + Emoji: 'toot:Emoji', + featured: 'toot:featured', + discoverable: 'toot:discoverable', + // schema + schema: 'http://schema.org#', + PropertyValue: 'schema:PropertyValue', + value: 'schema:value', + // Misskey + misskey: 'https://misskey-hub.net/ns#', + '_misskey_content': 'misskey:_misskey_content', + '_misskey_quote': 'misskey:_misskey_quote', + '_misskey_reaction': 'misskey:_misskey_reaction', + '_misskey_votes': 'misskey:_misskey_votes', + '_misskey_summary': 'misskey:_misskey_summary', + 'isCat': 'misskey:isCat', + // vcard + vcard: 'http://www.w3.org/2006/vcard/ns#', +} satisfies Context; + +export const CONTEXT: (string | Context)[] = [...context_iris, extension_context_definition]; + +export const PRELOADED_CONTEXTS: Record = { 'https://w3id.org/identity/v1': id_v1, 'https://w3id.org/security/v1': security_v1, 'https://www.w3.org/ns/activitystreams': activitystreams, diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 89b6ef23d0..3691967270 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -17,7 +17,7 @@ import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { ApResolverService } from '../ApResolverService.js'; import { ApLoggerService } from '../ApLoggerService.js'; -import type { IObject } from '../type.js'; +import { isDocument, type IObject } from '../type.js'; @Injectable() export class ApImageService { @@ -39,7 +39,7 @@ export class ApImageService { * Imageを作成します。 */ @bindThis - public async createImage(actor: MiRemoteUser, value: string | IObject): Promise { + public async createImage(actor: MiRemoteUser, value: string | IObject): Promise { // 投稿者が凍結されていたらスキップ if (actor.isSuspended) { throw new Error('actor has been suspended'); @@ -47,16 +47,18 @@ export class ApImageService { const image = await this.apResolverService.createResolver().resolve(value); + if (!isDocument(image)) return null; + if (image.url == null) { - throw new Error('invalid image: url not provided'); + return null; } if (typeof image.url !== 'string') { - throw new Error('invalid image: unexpected type of url: ' + JSON.stringify(image.url, null, 2)); + return null; } if (!checkHttps(image.url)) { - throw new Error('invalid image: unexpected schema of url: ' + image.url); + return null; } this.logger.info(`Creating the Image: ${image.url}`); @@ -86,12 +88,11 @@ export class ApImageService { /** * Imageを解決します。 * - * Misskeyに対象のImageが登録されていればそれを返し、そうでなければ - * リモートサーバーからフェッチしてMisskeyに登録しそれを返します。 + * ImageをリモートサーバーからフェッチしてMisskeyに登録しそれを返します。 */ @bindThis - public async resolveImage(actor: MiRemoteUser, value: string | IObject): Promise { - // TODO + public async resolveImage(actor: MiRemoteUser, value: string | IObject): Promise { + // TODO: Misskeyに対象のImageが登録されていればそれを返す // リモートサーバーからフェッチしてきて登録 return await this.createImage(actor, value); diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 4d64b08e15..4e361b57bc 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -4,7 +4,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/_.js'; @@ -209,15 +208,13 @@ export class ApNoteService { } // 添付ファイル - // TODO: attachmentは必ずしもImageではない - // TODO: attachmentは必ずしも配列ではない - const limit = promiseLimit(2); - const files = (await Promise.all(toArray(note.attachment).map(attach => ( - limit(() => this.apImageService.resolveImage(actor, { - ...attach, - sensitive: note.sensitive, // Noteがsensitiveなら添付もsensitiveにする - })) - )))); + const files: MiDriveFile[] = []; + + for (const attach of toArray(note.attachment)) { + attach.sensitive ??= note.sensitive; + const file = await this.apImageService.resolveImage(actor, attach); + if (file) files.push(file); + } // リプライ const reply: MiNote | null = note.inReplyTo diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index b43dddad61..09322888d5 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -25,6 +25,7 @@ export interface IObject { endTime?: Date; icon?: any; image?: any; + mediaType?: string; url?: ApObject | string; href?: string; tag?: IObject | IObject[]; @@ -240,14 +241,14 @@ export interface IKey extends IObject { } export interface IApDocument extends IObject { - type: 'Document'; - name: string | null; - mediaType: string; + type: 'Audio' | 'Document' | 'Image' | 'Page' | 'Video'; } -export interface IApImage extends IObject { +export const isDocument = (object: IObject): object is IApDocument => + ['Audio', 'Document', 'Image', 'Page', 'Video'].includes(getApType(object)); + +export interface IApImage extends IApDocument { type: 'Image'; - name: string | null; } export interface ICreate extends IActivity { diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index 26fcd6714d..ce49c3458c 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; +import type { ClipNotesRepository, ClipFavoritesRepository, ClipsRepository, MiUser } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { Packed } from '@/misc/json-schema.js'; import type { } from '@/models/Blocking.js'; @@ -20,6 +20,9 @@ export class ClipEntityService { @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, + @Inject(DI.clipNotesRepository) + private clipNotesRepository: ClipNotesRepository, + @Inject(DI.clipFavoritesRepository) private clipFavoritesRepository: ClipFavoritesRepository, @@ -47,6 +50,7 @@ export class ClipEntityService { isPublic: clip.isPublic, favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), isFavorited: meId ? await this.clipFavoritesRepository.exists({ where: { clipId: clip.id, userId: meId } }) : undefined, + notesCount: meId ? await this.clipNotesRepository.countBy({ clipId: clip.id }) : undefined, }); } diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index df2b27d709..b80a1ec206 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -249,20 +249,41 @@ export class UserEntityService implements OnModuleInit { ] = await Promise.all([ this.followingsRepository.findBy({ followerId: me }) .then(f => new Map(f.map(it => [it.followeeId, it]))), - this.followingsRepository.findBy({ followeeId: me }) - .then(it => it.map(it => it.followerId)), - this.followRequestsRepository.findBy({ followerId: me }) - .then(it => it.map(it => it.followeeId)), - this.followRequestsRepository.findBy({ followeeId: me }) - .then(it => it.map(it => it.followerId)), - this.blockingsRepository.findBy({ blockerId: me }) - .then(it => it.map(it => it.blockeeId)), - this.blockingsRepository.findBy({ blockeeId: me }) - .then(it => it.map(it => it.blockerId)), - this.mutingsRepository.findBy({ muterId: me }) - .then(it => it.map(it => it.muteeId)), - this.renoteMutingsRepository.findBy({ muterId: me }) - .then(it => it.map(it => it.muteeId)), + this.followingsRepository.createQueryBuilder('f') + .select('f.followerId') + .where('f.followeeId = :me', { me }) + .getRawMany<{ f_followerId: string }>() + .then(it => it.map(it => it.f_followerId)), + this.followRequestsRepository.createQueryBuilder('f') + .select('f.followeeId') + .where('f.followerId = :me', { me }) + .getRawMany<{ f_followeeId: string }>() + .then(it => it.map(it => it.f_followeeId)), + this.followRequestsRepository.createQueryBuilder('f') + .select('f.followerId') + .where('f.followeeId = :me', { me }) + .getRawMany<{ f_followerId: string }>() + .then(it => it.map(it => it.f_followerId)), + this.blockingsRepository.createQueryBuilder('b') + .select('b.blockeeId') + .where('b.blockerId = :me', { me }) + .getRawMany<{ b_blockeeId: string }>() + .then(it => it.map(it => it.b_blockeeId)), + this.blockingsRepository.createQueryBuilder('b') + .select('b.blockerId') + .where('b.blockeeId = :me', { me }) + .getRawMany<{ b_blockerId: string }>() + .then(it => it.map(it => it.b_blockerId)), + this.mutingsRepository.createQueryBuilder('m') + .select('m.muteeId') + .where('m.muterId = :me', { me }) + .getRawMany<{ m_muteeId: string }>() + .then(it => it.map(it => it.m_muteeId)), + this.renoteMutingsRepository.createQueryBuilder('m') + .select('m.muteeId') + .where('m.muterId = :me', { me }) + .getRawMany<{ m_muteeId: string }>() + .then(it => it.map(it => it.m_muteeId)), ]); return new Map( @@ -637,18 +658,17 @@ export class UserEntityService implements OnModuleInit { } const _userIds = _users.map(u => u.id); - // -- 特に前提条件のない値群を取得 - - const profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) }) - .then(profiles => new Map(profiles.map(p => [p.userId, p]))); - // -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得 + let profilesMap: Map = new Map(); let userRelations: Map = new Map(); let userMemos: Map = new Map(); let pinNotes: Map = new Map(); if (options?.schema !== 'UserLite') { + profilesMap = await this.userProfilesRepository.findBy({ userId: In(_userIds) }) + .then(profiles => new Map(profiles.map(p => [p.userId, p]))); + const meId = me ? me.id : null; if (meId) { userMemos = await this.userMemosRepository.findBy({ userId: meId }) diff --git a/packages/backend/src/misc/gen-identicon.ts b/packages/backend/src/misc/gen-identicon.ts index 62a8ab8ace..342e0f8602 100644 --- a/packages/backend/src/misc/gen-identicon.ts +++ b/packages/backend/src/misc/gen-identicon.ts @@ -8,9 +8,8 @@ * https://en.wikipedia.org/wiki/Identicon */ -import * as p from 'pureimage'; +import { createCanvas } from '@napi-rs/canvas'; import gen from 'random-seed'; -import type { WriteStream } from 'node:fs'; const size = 128; // px const n = 5; // resolution @@ -45,9 +44,9 @@ const sideN = Math.floor(n / 2); /** * Generate buffer of an identicon by seed */ -export function genIdenticon(seed: string, stream: WriteStream): Promise { +export async function genIdenticon(seed: string): Promise { const rand = gen.create(seed); - const canvas = p.make(size, size, undefined); + const canvas = createCanvas(size, size); const ctx = canvas.getContext('2d'); const bgColors = colors[rand(colors.length)]; @@ -101,5 +100,5 @@ export function genIdenticon(seed: string, stream: WriteStream): Promise { } } - return p.encodePNGToStream(canvas, stream); + return await canvas.encode('png'); } diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts index 5d48aba360..48f821806c 100644 --- a/packages/backend/src/misc/is-renote.ts +++ b/packages/backend/src/misc/is-renote.ts @@ -4,6 +4,7 @@ */ import type { MiNote } from '@/models/Note.js'; +import type { Packed } from '@/misc/json-schema.js'; type Renote = MiNote & { @@ -34,3 +35,33 @@ export function isQuote(note: Renote): note is Quote { note.hasPoll || note.fileIds.length > 0; } + +type PackedRenote = + Packed<'Note'> & { + renoteId: NonNullable['renoteId']> + }; + +type PackedQuote = + PackedRenote & ({ + text: NonNullable['text']> + } | { + cw: NonNullable['cw']> + } | { + replyId: NonNullable['replyId']> + } | { + poll: NonNullable['poll']> + } | { + fileIds: NonNullable['fileIds']> + }); + +export function isRenotePacked(note: Packed<'Note'>): note is PackedRenote { + return note.renoteId != null; +} + +export function isQuotePacked(note: PackedRenote): note is PackedQuote { + return note.text != null || + note.cw != null || + note.replyId != null || + note.poll != null || + (note.fileIds != null && note.fileIds.length > 0); +} diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 46b0bb2fab..41e5bfe9e4 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -48,6 +48,7 @@ import { packedRoleCondFormulaValueCreatedSchema, packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, packedRoleCondFormulaValueSchema, + packedRoleCondFormulaValueUserSettingBooleanSchema, } from '@/models/json-schema/role.js'; import { packedAdSchema } from '@/models/json-schema/ad.js'; import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js'; @@ -97,6 +98,7 @@ export const refs = { RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, RoleCondFormulaValueNot: packedRoleCondFormulaValueNot, RoleCondFormulaValueIsLocalOrRemote: packedRoleCondFormulaValueIsLocalOrRemoteSchema, + RoleCondFormulaValueUserSettingBooleanSchema: packedRoleCondFormulaValueUserSettingBooleanSchema, RoleCondFormulaValueAssignedRole: packedRoleCondFormulaValueAssignedRoleSchema, RoleCondFormulaValueCreated: packedRoleCondFormulaValueCreatedSchema, RoleCondFormulaFollowersOrFollowingOrNotes: packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, @@ -226,7 +228,7 @@ export type SchemaTypeDef

= p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : never ) : - p['items'] extends NonNullable ? SchemaTypeDef[] : + p['items'] extends NonNullable ? SchemaType[] : any[] ) : p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : diff --git a/packages/backend/src/models/Poll.ts b/packages/backend/src/models/Poll.ts index c2693dbb19..ca985c8b24 100644 --- a/packages/backend/src/models/Poll.ts +++ b/packages/backend/src/models/Poll.ts @@ -8,6 +8,7 @@ import { noteVisibilities } from '@/types.js'; import { id } from './util/id.js'; import { MiNote } from './Note.js'; import type { MiUser } from './User.js'; +import type { MiChannel } from "@/models/Channel.js"; @Entity('poll') export class MiPoll { @@ -58,6 +59,14 @@ export class MiPoll { comment: '[Denormalized]', }) public userHost: string | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]', + }) + public channelId: MiChannel['id'] | null; //#endregion constructor(data: Partial) { diff --git a/packages/backend/src/models/Role.ts b/packages/backend/src/models/Role.ts index 058abe3118..a173971b2c 100644 --- a/packages/backend/src/models/Role.ts +++ b/packages/backend/src/models/Role.ts @@ -6,69 +6,149 @@ import { Entity, Column, PrimaryColumn } from 'typeorm'; import { id } from './util/id.js'; +/** + * ~かつ~ + * 複数の条件を同時に満たす場合のみ成立とする + */ type CondFormulaValueAnd = { type: 'and'; values: RoleCondFormulaValue[]; }; +/** + * ~または~ + * 複数の条件のうち、いずれかを満たす場合のみ成立とする + */ type CondFormulaValueOr = { type: 'or'; values: RoleCondFormulaValue[]; }; +/** + * ~ではない + * 条件を満たさない場合のみ成立とする + */ type CondFormulaValueNot = { type: 'not'; value: RoleCondFormulaValue; }; +/** + * ローカルユーザーのみ成立とする + */ type CondFormulaValueIsLocal = { type: 'isLocal'; }; +/** + * リモートユーザーのみ成立とする + */ type CondFormulaValueIsRemote = { type: 'isRemote'; }; +/** + * 既に指定のマニュアルロールにアサインされている場合のみ成立とする + */ type CondFormulaValueRoleAssignedTo = { type: 'roleAssignedTo'; roleId: string; }; +/** + * サスペンド済みアカウントの場合のみ成立とする + */ +type CondFormulaValueIsSuspended = { + type: 'isSuspended'; +}; + +/** + * 鍵アカウントの場合のみ成立とする + */ +type CondFormulaValueIsLocked = { + type: 'isLocked'; +}; + +/** + * botアカウントの場合のみ成立とする + */ +type CondFormulaValueIsBot = { + type: 'isBot'; +}; + +/** + * 猫アカウントの場合のみ成立とする + */ +type CondFormulaValueIsCat = { + type: 'isCat'; +}; + +/** + * 「ユーザを見つけやすくする」が有効なアカウントの場合のみ成立とする + */ +type CondFormulaValueIsExplorable = { + type: 'isExplorable'; +}; + +/** + * ユーザが作成されてから指定期間経過した場合のみ成立とする + */ type CondFormulaValueCreatedLessThan = { type: 'createdLessThan'; sec: number; }; +/** + * ユーザが作成されてから指定期間経っていない場合のみ成立とする + */ type CondFormulaValueCreatedMoreThan = { type: 'createdMoreThan'; sec: number; }; +/** + * フォロワー数が指定値以下の場合のみ成立とする + */ type CondFormulaValueFollowersLessThanOrEq = { type: 'followersLessThanOrEq'; value: number; }; +/** + * フォロワー数が指定値以上の場合のみ成立とする + */ type CondFormulaValueFollowersMoreThanOrEq = { type: 'followersMoreThanOrEq'; value: number; }; +/** + * フォロー数が指定値以下の場合のみ成立とする + */ type CondFormulaValueFollowingLessThanOrEq = { type: 'followingLessThanOrEq'; value: number; }; +/** + * フォロー数が指定値以上の場合のみ成立とする + */ type CondFormulaValueFollowingMoreThanOrEq = { type: 'followingMoreThanOrEq'; value: number; }; +/** + * 投稿数が指定値以下の場合のみ成立とする + */ type CondFormulaValueNotesLessThanOrEq = { type: 'notesLessThanOrEq'; value: number; }; +/** + * 投稿数が指定値以上の場合のみ成立とする + */ type CondFormulaValueNotesMoreThanOrEq = { type: 'notesMoreThanOrEq'; value: number; @@ -80,6 +160,11 @@ export type RoleCondFormulaValue = { id: string } & ( CondFormulaValueNot | CondFormulaValueIsLocal | CondFormulaValueIsRemote | + CondFormulaValueIsSuspended | + CondFormulaValueIsLocked | + CondFormulaValueIsBot | + CondFormulaValueIsCat | + CondFormulaValueIsExplorable | CondFormulaValueRoleAssignedTo | CondFormulaValueCreatedLessThan | CondFormulaValueCreatedMoreThan | diff --git a/packages/backend/src/models/json-schema/clip.ts b/packages/backend/src/models/json-schema/clip.ts index ca4886c978..c4e7055cd8 100644 --- a/packages/backend/src/models/json-schema/clip.ts +++ b/packages/backend/src/models/json-schema/clip.ts @@ -52,5 +52,9 @@ export const packedClipSchema = { type: 'boolean', optional: true, nullable: false, }, + notesCount: { + type: 'integer', + optional: true, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index c770250503..d9987a70c3 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -57,6 +57,20 @@ export const packedRoleCondFormulaValueIsLocalOrRemoteSchema = { }, } as const; +export const packedRoleCondFormulaValueUserSettingBooleanSchema = { + type: 'object', + properties: { + id: { + type: 'string', optional: false, + }, + type: { + type: 'string', + nullable: false, optional: false, + enum: ['isSuspended', 'isLocked', 'isBot', 'isCat', 'isExplorable'], + }, + }, +} as const; + export const packedRoleCondFormulaValueAssignedRoleSchema = { type: 'object', properties: { @@ -135,6 +149,9 @@ export const packedRoleCondFormulaValueSchema = { { ref: 'RoleCondFormulaValueIsLocalOrRemote', }, + { + ref: 'RoleCondFormulaValueUserSettingBooleanSchema', + }, { ref: 'RoleCondFormulaValueAssignedRole', }, diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index 3addead058..1d05f4ade1 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -15,13 +15,14 @@ import InstanceChart from '@/core/chart/charts/instance.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js'; import FederationChart from '@/core/chart/charts/federation.js'; import { getApId } from '@/core/activitypub/type.js'; +import type { IActivity } from '@/core/activitypub/type.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; -import { LdSignatureService } from '@/core/activitypub/LdSignatureService.js'; +import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; @@ -38,7 +39,7 @@ export class InboxProcessorService { private apInboxService: ApInboxService, private federatedInstanceService: FederatedInstanceService, private fetchInstanceMetadataService: FetchInstanceMetadataService, - private ldSignatureService: LdSignatureService, + private jsonLdService: JsonLdService, private apPersonService: ApPersonService, private apDbResolverService: ApDbResolverService, private instanceChart: InstanceChart, @@ -52,7 +53,7 @@ export class InboxProcessorService { @bindThis public async process(job: Bull.Job): Promise { const signature = job.data.signature; // HTTP-signature - const activity = job.data.activity; + let activity = job.data.activity; //#region Log const info = Object.assign({}, activity); @@ -110,20 +111,21 @@ export class InboxProcessorService { // また、signatureのsignerは、activity.actorと一致する必要がある if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { // 一致しなくても、でもLD-Signatureがありそうならそっちも見る - if (activity.signature) { - if (activity.signature.type !== 'RsaSignature2017') { - throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${activity.signature.type}`); + const ldSignature = activity.signature; + if (ldSignature) { + if (ldSignature.type !== 'RsaSignature2017') { + throw new Bull.UnrecoverableError(`skip: unsupported LD-signature type ${ldSignature.type}`); } - // activity.signature.creator: https://example.oom/users/user#main-key + // ldSignature.creator: https://example.oom/users/user#main-key // みたいになっててUserを引っ張れば公開キーも入ることを期待する - if (activity.signature.creator) { - const candicate = activity.signature.creator.replace(/#.*/, ''); + if (ldSignature.creator) { + const candicate = ldSignature.creator.replace(/#.*/, ''); await this.apPersonService.resolvePerson(candicate).catch(() => null); } // keyIdからLD-Signatureのユーザーを取得 - authUser = await this.apDbResolverService.getAuthUserFromKeyId(activity.signature.creator); + authUser = await this.apDbResolverService.getAuthUserFromKeyId(ldSignature.creator); if (authUser == null) { throw new Bull.UnrecoverableError('skip: LD-Signatureのユーザーが取得できませんでした'); } @@ -132,13 +134,31 @@ export class InboxProcessorService { throw new Bull.UnrecoverableError('skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした'); } + const jsonLd = this.jsonLdService.use(); + // LD-Signature検証 - const ldSignature = this.ldSignatureService.use(); - const verified = await ldSignature.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); + const verified = await jsonLd.verifyRsaSignature2017(activity, authUser.key.keyPem).catch(() => false); if (!verified) { throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); } + // アクティビティを正規化 + delete activity.signature; + try { + activity = await jsonLd.compact(activity) as IActivity; + } catch (e) { + throw new Bull.UnrecoverableError(`skip: failed to compact activity: ${e}`); + } + // TODO: 元のアクティビティと非互換な形に正規化される場合は転送をスキップする + // https://github.com/mastodon/mastodon/blob/664b0ca/app/services/activitypub/process_collection_service.rb#L24-L29 + activity.signature = ldSignature; + + //#region Log + const compactedInfo = Object.assign({}, activity); + delete compactedInfo['@context']; + this.logger.debug(`compacted: ${JSON.stringify(compactedInfo, null, 2)}`); + //#endregion + // もう一度actorチェック if (authUser.user.uri !== activity.actor) { throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index ce7702143e..9db3aa1bfb 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -214,6 +214,8 @@ export class FileServerService { } reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); + reply.header('Content-Length', file.file.size); + reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition( 'inline', @@ -256,6 +258,7 @@ export class FileServerService { return fs.createReadStream(file.path); } else { reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); + reply.header('Content-Length', file.file.size); reply.header('Cache-Control', 'max-age=31536000, immutable'); reply.header('Content-Disposition', contentDisposition('inline', file.filename)); @@ -530,9 +533,7 @@ export class FileServerService { if (!file.storedInternal) { if (!(file.isLink && file.uri)) return '204'; const result = await this.downloadAndDetectTypeFromUrl(file.uri); - if (!file.size) { - file.size = (await fs.promises.stat(result.path)).size; - } + file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので return { ...result, url: file.uri, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 671dd31eb1..da17a88e03 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -18,7 +18,6 @@ import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; import { genIdenticon } from '@/misc/gen-identicon.js'; -import { createTemp } from '@/misc/create-temp.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; @@ -120,12 +119,20 @@ export class ServerService implements OnApplicationShutdown { return; } - const name = path.split('@')[0].replace(/\.webp$/i, ''); - const host = path.split('@')[1]?.replace(/\.webp$/i, ''); + const emojiPath = path.replace(/\.webp$/i, ''); + const pathChunks = emojiPath.split('@'); + + if (pathChunks.length > 2) { + reply.code(400); + return; + } + + const name = pathChunks.shift(); + const host = pathChunks.pop(); const emoji = await this.emojisRepository.findOneBy({ // `@.` is the spec of ReactionService.decodeReaction - host: (host == null || host === '.') ? IsNull() : host, + host: (host === undefined || host === '.') ? IsNull() : host, name: name, }); @@ -184,9 +191,7 @@ export class ServerService implements OnApplicationShutdown { reply.header('Cache-Control', 'public, max-age=86400'); if ((await this.metaService.fetch()).enableIdenticonGeneration) { - const [temp, cleanup] = await createTemp(); - await genIdenticon(request.params.x, fs.createWriteStream(temp)); - return fs.createReadStream(temp).on('close', () => cleanup()); + return await genIdenticon(request.params.x); } else { return reply.redirect('/static-assets/avatar.png'); } diff --git a/packages/backend/src/server/api/endpoints/fetch-rss.ts b/packages/backend/src/server/api/endpoints/fetch-rss.ts index 2085b06365..ba48b0119e 100644 --- a/packages/backend/src/server/api/endpoints/fetch-rss.ts +++ b/packages/backend/src/server/api/endpoints/fetch-rss.ts @@ -20,13 +20,188 @@ export const meta = { res: { type: 'object', properties: { + image: { + type: 'object', + optional: true, + properties: { + link: { + type: 'string', + optional: true, + }, + url: { + type: 'string', + optional: false, + }, + title: { + type: 'string', + optional: true, + }, + }, + }, + paginationLinks: { + type: 'object', + optional: true, + properties: { + self: { + type: 'string', + optional: true, + }, + first: { + type: 'string', + optional: true, + }, + next: { + type: 'string', + optional: true, + }, + last: { + type: 'string', + optional: true, + }, + prev: { + type: 'string', + optional: true, + }, + }, + }, + link: { + type: 'string', + optional: true, + }, + title: { + type: 'string', + optional: true, + }, items: { type: 'array', + optional: false, items: { type: 'object', + properties: { + link: { + type: 'string', + optional: true, + }, + guid: { + type: 'string', + optional: true, + }, + title: { + type: 'string', + optional: true, + }, + pubDate: { + type: 'string', + optional: true, + }, + creator: { + type: 'string', + optional: true, + }, + summary: { + type: 'string', + optional: true, + }, + content: { + type: 'string', + optional: true, + }, + isoDate: { + type: 'string', + optional: true, + }, + categories: { + type: 'array', + optional: true, + items: { + type: 'string', + }, + }, + contentSnippet: { + type: 'string', + optional: true, + }, + enclosure: { + type: 'object', + optional: true, + properties: { + url: { + type: 'string', + optional: false, + }, + length: { + type: 'number', + optional: true, + }, + type: { + type: 'string', + optional: true, + }, + }, + }, + }, }, - } - } + }, + feedUrl: { + type: 'string', + optional: true, + }, + description: { + type: 'string', + optional: true, + }, + itunes: { + type: 'object', + optional: true, + additionalProperties: true, + properties: { + image: { + type: 'string', + optional: true, + }, + owner: { + type: 'object', + optional: true, + properties: { + name: { + type: 'string', + optional: true, + }, + email: { + type: 'string', + optional: true, + }, + }, + }, + author: { + type: 'string', + optional: true, + }, + summary: { + type: 'string', + optional: true, + }, + explicit: { + type: 'string', + optional: true, + }, + categories: { + type: 'array', + optional: true, + items: { + type: 'string', + }, + }, + keywords: { + type: 'array', + optional: true, + items: { + type: 'string', + }, + }, + }, + }, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 5f738420f2..65eece5b97 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -96,10 +96,10 @@ export default class extends Endpoint { } const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential); + const keyId = keyInfo.credentialID; - const credentialId = Buffer.from(keyInfo.credentialID).toString('base64url'); await this.userSecurityKeysRepository.insert({ - id: credentialId, + id: keyId, userId: me.id, name: ps.name, publicKey: Buffer.from(keyInfo.credentialPublicKey).toString('base64url'), @@ -116,7 +116,7 @@ export default class extends Endpoint { })); return { - id: credentialId, + id: keyId, name: ps.name, }; }); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 320d9fdb00..2f619380e9 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -7,7 +7,7 @@ import { In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; +import { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; @@ -84,27 +84,51 @@ export default class extends Endpoint { // eslint- const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; - const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 - const notificationsRes = await this.redisClient.xrevrange( - `notificationTimeline:${me.id}`, - ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', - ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', - 'COUNT', limit); + let sinceTime = ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime().toString() : null; + let untilTime = ps.untilId ? this.idService.parse(ps.untilId).date.getTime().toString() : null; - if (notificationsRes.length === 0) { - return []; - } + let notifications: MiNotification[]; + for (;;) { + let notificationsRes: [id: string, fields: string[]][]; - let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[]; + // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 + if (sinceTime && !untilTime) { + notificationsRes = await this.redisClient.xrange( + `notificationTimeline:${me.id}`, + '(' + sinceTime, + '+', + 'COUNT', ps.limit); + } else { + notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${me.id}`, + untilTime ? '(' + untilTime : '+', + sinceTime ? '(' + sinceTime : '-', + 'COUNT', ps.limit); + } - if (includeTypes && includeTypes.length > 0) { - notifications = notifications.filter(notification => includeTypes.includes(notification.type)); - } else if (excludeTypes && excludeTypes.length > 0) { - notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); - } + if (notificationsRes.length === 0) { + return []; + } - if (notifications.length === 0) { - return []; + notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[]; + + if (includeTypes && includeTypes.length > 0) { + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); + } else if (excludeTypes && excludeTypes.length > 0) { + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); + } + + if (notifications.length !== 0) { + // 通知が1件以上ある場合は返す + break; + } + + // フィルタしたことで通知が0件になった場合、次のページを取得する + if (ps.sinceId && !ps.untilId) { + sinceTime = notificationsRes[notificationsRes.length - 1][0]; + } else { + untilTime = notificationsRes[notificationsRes.length - 1][0]; + } } // Mark all as read diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts index ba38573065..4fd6f8682d 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -32,6 +32,7 @@ export const paramDef = { properties: { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, + excludeChannels: { type: 'boolean', default: false }, }, required: [], } as const; @@ -86,6 +87,12 @@ export default class extends Endpoint { // eslint- query.setParameters(mutingQuery.getParameters()); //#endregion + //#region exclude channels + if (ps.excludeChannels) { + query.andWhere('poll.channelId IS NULL'); + } + //#endregion + const polls = await query .orderBy('poll.noteId', 'DESC') .limit(ps.limit) diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index bd81989cb9..26cfa921c5 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -110,9 +110,11 @@ export default class extends Endpoint { // eslint- }); // リクエストされた通りに並べ替え + // 順番は保持されるけど数は減ってる可能性がある const _users: MiUser[] = []; for (const id of ps.userIds) { - _users.push(users.find(x => x.id === id)!); + const user = users.find(x => x.id === id); + if (user != null) _users.push(user); } return await Promise.all(_users.map(u => this.userEntityService.pack(u, me, { diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 44a143538b..a267d27fba 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -4,6 +4,10 @@ */ import { bindThis } from '@/decorators.js'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import type { Packed } from '@/misc/json-schema.js'; import type Connection from './Connection.js'; /** @@ -54,6 +58,24 @@ export default abstract class Channel { return this.connection.subscriber; } + /* + * ミュートとブロックされてるを処理する + */ + protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean { + // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return true; + + // 流れてきたNoteがミュートしているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; + // 流れてきたNoteがブロックされているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true; + + // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの + if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + + return false; + } + constructor(id: string, connection: Connection) { this.id = id; this.connection = connection; diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 135d162e63..4a1d2dd109 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -4,7 +4,6 @@ */ import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; @@ -40,12 +39,7 @@ class AntennaChannel extends Channel { if (data.type === 'note') { const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (this.isNoteMutedOrBlocked(note)) 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 90ee1ecda5..140dd3dd9b 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -4,10 +4,10 @@ */ import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class ChannelChannel extends Channel { @@ -38,14 +38,9 @@ class ChannelChannel extends Channel { private async onNote(note: Packed<'Note'>) { if (note.channelId !== this.channelId) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; 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 723b89c908..17116258d8 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -4,14 +4,12 @@ */ 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/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class GlobalTimelineChannel extends Channel { @@ -52,26 +50,11 @@ class GlobalTimelineChannel extends Channel { if (note.visibility !== 'public') return; if (note.channelId != null) return; - // 関係ない返信は除外 - if (note.reply && !this.following[note.userId]?.withReplies) { - const reply = note.reply; - // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 - if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return; - } + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + if (this.isNoteMutedOrBlocked(note)) return; - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; - - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 377b1a0162..57bada5d9c 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -5,10 +5,10 @@ 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/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class HashtagChannel extends Channel { @@ -43,14 +43,9 @@ class HashtagChannel extends Channel { const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag)))); if (!matched) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; 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 f45bf8622e..878a3180cb 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -4,12 +4,10 @@ */ 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/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class HomeTimelineChannel extends Channel { @@ -51,9 +49,6 @@ class HomeTimelineChannel extends Channel { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances))) return; - if (note.visibility === 'followers') { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; } else if (note.visibility === 'specified') { @@ -72,7 +67,7 @@ class HomeTimelineChannel extends Channel { } // 純粋なリノート(引用リノートでないリノート)の場合 - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && note.poll == null) { + if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) { if (!this.withRenotes) return; if (note.renote.reply) { const reply = note.renote.reply; @@ -81,14 +76,9 @@ class HomeTimelineChannel extends Channel { } } - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; 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 d67da6f565..575d23d53c 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -4,14 +4,12 @@ */ 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/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class HybridTimelineChannel extends Channel { @@ -71,8 +69,7 @@ class HybridTimelineChannel extends Channel { if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return; } - // Ignore notes from instances the user has muted - if (isInstanceMuted(note, new Set(this.userProfile!.mutedInstances))) return; + if (this.isNoteMutedOrBlocked(note)) return; if (note.reply) { const reply = note.reply; @@ -85,14 +82,7 @@ class HybridTimelineChannel extends Channel { } } - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; - - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; if (this.user && note.renoteId && !note.text) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { 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 43d26124ef..442d08ae51 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -4,13 +4,12 @@ */ 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/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class LocalTimelineChannel extends Channel { @@ -61,16 +60,11 @@ class LocalTimelineChannel extends Channel { if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return; } - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index 80aab4b35e..6a4ad22460 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -4,8 +4,6 @@ */ import { Injectable } from '@nestjs/common'; -import { isUserRelated } from '@/misc/is-user-related.js'; -import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; @@ -46,12 +44,7 @@ class RoleTimelineChannel extends Channel { } if (note.visibility !== 'public') return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; - - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; + if (this.isNoteMutedOrBlocked(note)) return; this.send('note', note); } else { 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 f7bb106c03..14b30a157c 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -5,12 +5,11 @@ import { Inject, Injectable } from '@nestjs/common'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; -import { isUserRelated } from '@/misc/is-user-related.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'; -import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import Channel, { type MiChannelService } from '../channel.js'; class UserListChannel extends Channel { @@ -106,25 +105,17 @@ class UserListChannel extends Channel { } } - if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return; + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; - // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoMeMuting)) return; - // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isUserRelated(note, this.userIdsWhoBlockingMe)) return; + if (this.isNoteMutedOrBlocked(note)) return; - if (note.renote && !note.text && isUserRelated(note, this.userIdsWhoMeMutingRenotes)) return; - - if (this.user && note.renoteId && !note.text) { + if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; } } - // 流れてきたNoteがミュートしているインスタンスに関わるものだったら無視する - if (isInstanceMuted(note, this.userMutedInstances)) return; - this.connection.cacheNote(note); this.send('note', note); diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index b1af0c3df6..1394616752 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -199,9 +199,18 @@ export class ClientServerService { // Authenticate fastify.addHook('onRequest', async (request, reply) => { + if (request.routeOptions.url == null) { + reply.code(404).send('Not found'); + return; + } + // %71ueueとかでリクエストされたら困るため const url = decodeURI(request.routeOptions.url); if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) { + if (!url.startsWith(bullBoardPath + '/static/')) { + reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); + } + const token = request.cookies.token; if (token == null) { reply.code(401).send('Login required'); diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 123336809b..1d9146e22a 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=infoImageUrl) link(rel='prefetch' href=notFoundImageUrl) //- https://github.com/misskey-dev/misskey/issues/9842 - link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.44.0') + link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v3.3.0') link(rel='modulepreload' href=`/vite/${clientEntry.file}`) if !config.clientManifestExists diff --git a/packages/backend/src/server/web/views/page.pug b/packages/backend/src/server/web/views/page.pug index 08bb08ffe7..03c50eca8a 100644 --- a/packages/backend/src/server/web/views/page.pug +++ b/packages/backend/src/server/web/views/page.pug @@ -3,7 +3,7 @@ extends ./base block vars - const user = page.user; - const title = page.title; - - const url = `${config.url}/@${user.username}/${page.name}`; + - const url = `${config.url}/@${user.username}/pages/${page.name}`; block title = `${title} | ${instanceName}` diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index 9826068e48..1abbb4f044 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -63,6 +63,22 @@ describe('Renote Mute', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); }); + // #12956 + test('タイムラインにリノートミュートしているユーザーの通常ノートのリノートが含まれる', async () => { + const carolNote = await post(carol, { text: 'hi' }); + const bobRenote = await post(bob, { renoteId: carolNote.id }); + + // redisに追加されるのを待つ + await sleep(100); + + 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 === carolNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { const bobNote = await post(bob, { text: 'hi' }); @@ -86,4 +102,17 @@ describe('Renote Mute', () => { assert.strictEqual(fired, true); }); + + // #12956 + test('ストリームにリノートミュートしているユーザーの通常ノートのリノートが流れてくる', async () => { + const carolbNote = await post(carol, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: carolbNote.id }, bob), + msg => msg.type === 'note' && msg.body.userId === bob.id, + ); + + assert.strictEqual(fired, true); + }); }); diff --git a/packages/backend/test/e2e/streaming.ts b/packages/backend/test/e2e/streaming.ts index 26bb68ec6c..b0a70074c6 100644 --- a/packages/backend/test/e2e/streaming.ts +++ b/packages/backend/test/e2e/streaming.ts @@ -63,7 +63,7 @@ describe('Streaming', () => { takumiNote = await post(takumi, { text: 'piyo' }); // Follow: ayano => kyoko - await api('following/create', { userId: kyoko.id }, ayano); + await api('following/create', { userId: kyoko.id, withReplies: false }, ayano); // Follow: ayano => akari await follow(ayano, akari); @@ -509,6 +509,16 @@ describe('Streaming', () => { assert.strictEqual(fired, false); }); + + test('withReplies = falseでフォローしてる人によるリプライが流れてくる', async () => { + const fired = await waitFire( + ayano, 'globalTimeline', // ayano:Global + () => api('notes/create', { text: 'foo', replyId: kanakoNote.id }, kyoko), // kyoko posts + msg => msg.type === 'note' && msg.body.userId === kyoko.id, // wait kyoko + ); + + assert.strictEqual(fired, true); + }); }); describe('UserList Timeline', () => { diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts index f613fe9c7c..fd4a03413b 100644 --- a/packages/backend/test/unit/MfmService.ts +++ b/packages/backend/test/unit/MfmService.ts @@ -39,6 +39,12 @@ describe('MfmService', () => { const output = '

foo bar

'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); + + test('escape', () => { + const input = '```\n

Hello, world!

\n```'; + const output = '

<p>Hello, world!</p>

'; + assert.equal(mfmService.toHtml(mfm.parse(input)), output); + }); }); describe('fromHtml', () => { diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 19d03570e0..ec441735d7 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; @@ -20,6 +22,7 @@ import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { RoleCondFormulaValue } from '@/models/Role.js'; import { sleep } from '../utils.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -52,12 +55,26 @@ describe('RoleService', () => { id: genAidx(Date.now()), updatedAt: new Date(), lastUsedAt: new Date(), + name: '', description: '', ...data, }) .then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); } + function createConditionalRole(condFormula: RoleCondFormulaValue, data: Partial = {}) { + return createRole({ + name: `[conditional] ${condFormula.type}`, + target: 'conditional', + condFormula: condFormula, + ...data, + }); + } + + function aidx() { + return genAidx(Date.now()); + } + beforeEach(async () => { clock = lolex.install({ now: new Date(), @@ -73,6 +90,7 @@ describe('RoleService', () => { CacheService, IdService, GlobalEventService, + UserEntityService, { provide: NotificationService, useFactory: () => ({ @@ -209,79 +227,6 @@ describe('RoleService', () => { expect(result.driveCapacityMb).toBe(100); }); - test('conditional role', async () => { - const user1 = await createUser({ - id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)), - }); - const user2 = await createUser({ - id: genAidx(Date.now() - (1000 * 60 * 60 * 24 * 365)), - followersCount: 10, - }); - await createRole({ - name: 'a', - policies: { - canManageCustomEmojis: { - useDefault: false, - priority: 0, - value: true, - }, - }, - target: 'conditional', - condFormula: { - id: '232a4221-9816-49a6-a967-ae0fac52ec5e', - type: 'and', - values: [{ - id: '2a37ef43-2d93-4c4d-87f6-f2fdb7d9b530', - type: 'followersMoreThanOrEq', - value: 10, - }, { - id: '1bd67839-b126-4f92-bad0-4e285dab453b', - type: 'createdMoreThan', - sec: 60 * 60 * 24 * 7, - }], - }, - }); - - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); - - const user1Policies = await roleService.getUserPolicies(user1.id); - const user2Policies = await roleService.getUserPolicies(user2.id); - expect(user1Policies.canManageCustomEmojis).toBe(false); - expect(user2Policies.canManageCustomEmojis).toBe(true); - }); - - test('コンディショナルロール: マニュアルロールにアサイン済み', async () => { - const [user1, user2, role1] = await Promise.all([ - createUser(), - createUser(), - createRole({ - name: 'manual role', - }), - ]); - const role2 = await createRole({ - name: 'conditional role', - target: 'conditional', - condFormula: { - // idはバックエンドのロジックに必要ない? - id: 'bdc612bd-9d54-4675-ae83-0499c82ea670', - type: 'roleAssignedTo', - roleId: role1.id, - }, - }); - await roleService.assign(user2.id, role1.id); - - const [u1role, u2role] = await Promise.all([ - roleService.getUserRoles(user1.id), - roleService.getUserRoles(user2.id), - ]); - expect(u1role.some(r => r.id === role2.id)).toBe(false); - expect(u2role.some(r => r.id === role2.id)).toBe(true); - }); - test('expired role', async () => { const user = await createUser(); const role = await createRole({ @@ -320,6 +265,427 @@ describe('RoleService', () => { }); }); + describe('conditional role', () => { + test('~かつ~', async () => { + const [user1, user2, user3, user4] = await Promise.all([ + createUser({ isBot: true, isCat: false, isSuspended: false }), + createUser({ isBot: false, isCat: true, isSuspended: false }), + createUser({ isBot: true, isCat: true, isSuspended: false }), + createUser({ isBot: false, isCat: false, isSuspended: true }), + ]); + const role1 = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }); + const role2 = await createConditionalRole({ + id: aidx(), + type: 'isCat', + }); + const role3 = await createConditionalRole({ + id: aidx(), + type: 'isSuspended', + }); + const role4 = await createConditionalRole({ + id: aidx(), + type: 'and', + values: [role1.condFormula, role2.condFormula], + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + const actual4 = await roleService.getUserRoles(user4.id); + expect(actual1.some(r => r.id === role4.id)).toBe(false); + expect(actual2.some(r => r.id === role4.id)).toBe(false); + expect(actual3.some(r => r.id === role4.id)).toBe(true); + expect(actual4.some(r => r.id === role4.id)).toBe(false); + }); + + test('~または~', async () => { + const [user1, user2, user3, user4] = await Promise.all([ + createUser({ isBot: true, isCat: false, isSuspended: false }), + createUser({ isBot: false, isCat: true, isSuspended: false }), + createUser({ isBot: true, isCat: true, isSuspended: false }), + createUser({ isBot: false, isCat: false, isSuspended: true }), + ]); + const role1 = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }); + const role2 = await createConditionalRole({ + id: aidx(), + type: 'isCat', + }); + const role3 = await createConditionalRole({ + id: aidx(), + type: 'isSuspended', + }); + const role4 = await createConditionalRole({ + id: aidx(), + type: 'or', + values: [role1.condFormula, role2.condFormula], + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + const actual4 = await roleService.getUserRoles(user4.id); + expect(actual1.some(r => r.id === role4.id)).toBe(true); + expect(actual2.some(r => r.id === role4.id)).toBe(true); + expect(actual3.some(r => r.id === role4.id)).toBe(true); + expect(actual4.some(r => r.id === role4.id)).toBe(false); + }); + + test('~ではない', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ isBot: true, isCat: false, isSuspended: false }), + createUser({ isBot: false, isCat: true, isSuspended: false }), + createUser({ isBot: true, isCat: true, isSuspended: false }), + ]); + const role1 = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }); + const role2 = await createConditionalRole({ + id: aidx(), + type: 'isCat', + }); + const role4 = await createConditionalRole({ + id: aidx(), + type: 'not', + value: role1.condFormula, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role4.id)).toBe(false); + expect(actual2.some(r => r.id === role4.id)).toBe(true); + expect(actual3.some(r => r.id === role4.id)).toBe(false); + }); + + test('マニュアルロールにアサイン済み', async () => { + const [user1, user2, role1] = await Promise.all([ + createUser(), + createUser(), + createRole({ + name: 'manual role', + }), + ]); + const role2 = await createConditionalRole({ + id: aidx(), + type: 'roleAssignedTo', + roleId: role1.id, + }); + await roleService.assign(user2.id, role1.id); + + const [u1role, u2role] = await Promise.all([ + roleService.getUserRoles(user1.id), + roleService.getUserRoles(user2.id), + ]); + expect(u1role.some(r => r.id === role2.id)).toBe(false); + expect(u2role.some(r => r.id === role2.id)).toBe(true); + }); + + test('ローカルユーザのみ', async () => { + const [user1, user2] = await Promise.all([ + createUser({ host: null }), + createUser({ host: 'example.com' }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isLocal', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(false); + }); + + test('リモートユーザのみ', async () => { + const [user1, user2] = await Promise.all([ + createUser({ host: null }), + createUser({ host: 'example.com' }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isRemote', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('サスペンド済みユーザである', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isSuspended: false }), + createUser({ isSuspended: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isSuspended', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('鍵アカウントユーザである', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isLocked: false }), + createUser({ isLocked: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isLocked', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('botユーザである', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isBot: false }), + createUser({ isBot: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('猫である', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isCat: false }), + createUser({ isCat: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isCat', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('「ユーザを見つけやすくする」が有効なアカウント', async () => { + const [user1, user2] = await Promise.all([ + createUser({ isExplorable: false }), + createUser({ isExplorable: true }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'isExplorable', + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + }); + + test('ユーザが作成されてから指定期間経過した', async () => { + const base = new Date(); + base.setMinutes(base.getMinutes() - 5); + + const d1 = new Date(base); + const d2 = new Date(base); + const d3 = new Date(base); + d1.setSeconds(d1.getSeconds() - 1); + d3.setSeconds(d3.getSeconds() + 1); + + const [user1, user2, user3] = await Promise.all([ + // 4:59 + createUser({ id: genAidx(d1.getTime()) }), + // 5:00 + createUser({ id: genAidx(d2.getTime()) }), + // 5:01 + createUser({ id: genAidx(d3.getTime()) }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'createdLessThan', + // 5 minutes + sec: 300, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(false); + expect(actual3.some(r => r.id === role.id)).toBe(true); + }); + + test('ユーザが作成されてから指定期間経っていない', async () => { + const base = new Date(); + base.setMinutes(base.getMinutes() - 5); + + const d1 = new Date(base); + const d2 = new Date(base); + const d3 = new Date(base); + d1.setSeconds(d1.getSeconds() - 1); + d3.setSeconds(d3.getSeconds() + 1); + + const [user1, user2, user3] = await Promise.all([ + // 4:59 + createUser({ id: genAidx(d1.getTime()) }), + // 5:00 + createUser({ id: genAidx(d2.getTime()) }), + // 5:01 + createUser({ id: genAidx(d3.getTime()) }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'createdMoreThan', + // 5 minutes + sec: 300, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(false); + expect(actual3.some(r => r.id === role.id)).toBe(false); + }); + + test('フォロワー数が指定値以下', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ followersCount: 99 }), + createUser({ followersCount: 100 }), + createUser({ followersCount: 101 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'followersLessThanOrEq', + value: 100, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(false); + }); + + test('フォロワー数が指定値以下', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ followersCount: 99 }), + createUser({ followersCount: 100 }), + createUser({ followersCount: 101 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'followersMoreThanOrEq', + value: 100, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(true); + }); + + test('フォロー数が指定値以下', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ followingCount: 99 }), + createUser({ followingCount: 100 }), + createUser({ followingCount: 101 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'followingLessThanOrEq', + value: 100, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(false); + }); + + test('フォロー数が指定値以上', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ followingCount: 99 }), + createUser({ followingCount: 100 }), + createUser({ followingCount: 101 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'followingMoreThanOrEq', + value: 100, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(true); + }); + + test('ノート数が指定値以下', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ notesCount: 9 }), + createUser({ notesCount: 10 }), + createUser({ notesCount: 11 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'notesLessThanOrEq', + value: 10, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(true); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(false); + }); + + test('ノート数が指定値以上', async () => { + const [user1, user2, user3] = await Promise.all([ + createUser({ notesCount: 9 }), + createUser({ notesCount: 10 }), + createUser({ notesCount: 11 }), + ]); + const role = await createConditionalRole({ + id: aidx(), + type: 'notesMoreThanOrEq', + value: 10, + }); + + const actual1 = await roleService.getUserRoles(user1.id); + const actual2 = await roleService.getUserRoles(user2.id); + const actual3 = await roleService.getUserRoles(user3.id); + expect(actual1.some(r => r.id === role.id)).toBe(false); + expect(actual2.some(r => r.id === role.id)).toBe(true); + expect(actual3.some(r => r.id === role.id)).toBe(true); + }); + }); + describe('assign', () => { test('公開ロールの場合は通知される', async () => { const user = await createUser(); diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index b4b06b06bd..6962608106 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -13,11 +13,13 @@ import { ApImageService } from '@/core/activitypub/models/ApImageService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; +import { CONTEXT } from '@/core/activitypub/misc/contexts.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, IApDocument, ICollection, IPost } from '@/core/activitypub/type.js'; +import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js'; import { MiMeta, MiNote } from '@/models/_.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DownloadService } from '@/core/DownloadService.js'; @@ -88,6 +90,7 @@ describe('ActivityPub', () => { let noteService: ApNoteService; let personService: ApPersonService; let rendererService: ApRendererService; + let jsonLdService: JsonLdService; let resolver: MockResolver; const metaInitial = { @@ -128,6 +131,7 @@ describe('ActivityPub', () => { personService = app.get(ApPersonService); rendererService = app.get(ApRendererService); imageService = app.get(ApImageService); + jsonLdService = app.get(JsonLdService); resolver = new MockResolver(await app.resolve(LoggerService)); // Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error @@ -295,7 +299,7 @@ describe('ActivityPub', () => { await createRandomRemoteUser(resolver, personService), imageObject, ); - assert.ok(!driveFile.isLink); + assert.ok(driveFile && !driveFile.isLink); const sensitiveImageObject: IApDocument = { type: 'Document', @@ -308,7 +312,7 @@ describe('ActivityPub', () => { await createRandomRemoteUser(resolver, personService), sensitiveImageObject, ); - assert.ok(!sensitiveDriveFile.isLink); + assert.ok(sensitiveDriveFile && !sensitiveDriveFile.isLink); }); test('cacheRemoteFiles=false disables caching', async () => { @@ -324,7 +328,7 @@ describe('ActivityPub', () => { await createRandomRemoteUser(resolver, personService), imageObject, ); - assert.ok(driveFile.isLink); + assert.ok(driveFile && driveFile.isLink); const sensitiveImageObject: IApDocument = { type: 'Document', @@ -337,7 +341,7 @@ describe('ActivityPub', () => { await createRandomRemoteUser(resolver, personService), sensitiveImageObject, ); - assert.ok(sensitiveDriveFile.isLink); + assert.ok(sensitiveDriveFile && sensitiveDriveFile.isLink); }); test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => { @@ -353,7 +357,7 @@ describe('ActivityPub', () => { await createRandomRemoteUser(resolver, personService), imageObject, ); - assert.ok(!driveFile.isLink); + assert.ok(driveFile && !driveFile.isLink); const sensitiveImageObject: IApDocument = { type: 'Document', @@ -366,7 +370,57 @@ describe('ActivityPub', () => { await createRandomRemoteUser(resolver, personService), sensitiveImageObject, ); - assert.ok(sensitiveDriveFile.isLink); + assert.ok(sensitiveDriveFile && sensitiveDriveFile.isLink); + }); + + test('Link is not an attachment files', async () => { + const linkObject: IObject = { + type: 'Link', + href: 'https://example.com/', + }; + const driveFile = await imageService.createImage( + await createRandomRemoteUser(resolver, personService), + linkObject, + ); + assert.strictEqual(driveFile, null); + }); + }); + + describe('JSON-LD', () =>{ + test('Compaction', async () => { + const jsonLd = jsonLdService.use(); + + const object = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + _misskey_quote: 'https://misskey-hub.net/ns#_misskey_quote', + unknown: 'https://example.org/ns#unknown', + undefined: null, + }, + ], + id: 'https://example.com/notes/42', + type: 'Note', + attributedTo: 'https://example.com/users/1', + to: ['https://www.w3.org/ns/activitystreams#Public'], + content: 'test test foo', + _misskey_quote: 'https://example.com/notes/1', + unknown: 'test test bar', + undefined: 'test test baz', + }; + const compacted = await jsonLd.compact(object); + + assert.deepStrictEqual(compacted, { + '@context': CONTEXT, + id: 'https://example.com/notes/42', + type: 'Note', + attributedTo: 'https://example.com/users/1', + to: 'as:Public', + content: 'test test foo', + _misskey_quote: 'https://example.com/notes/1', + 'https://example.org/ns#unknown': 'test test bar', + // undefined: 'test test baz', + }); }); }); }); diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 48c9e0261d..3a24ccb248 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -27,7 +27,7 @@ export function galleryPost(isSensitive = false) { id: 'somepostid', createdAt: '2016-12-28T22:49:51.000Z', updatedAt: '2016-12-28T22:49:51.000Z', - userid: 'someuserid', + userId: 'someuserid', user: userDetailed(), title: 'Some post title', description: 'Some post description', @@ -75,9 +75,8 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi 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', avatarDecorations: [], - emojis: [], + 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', @@ -118,11 +117,16 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi publicReactions: false, securityKeys: false, twoFactorEnabled: false, + usePasswordLessLogin: false, twoFactorBackupCodesStock: 'none', updatedAt: null, + lastFetchedAt: null, uri: null, url: null, + movedTo: null, + alsoKnownAs: null, notify: 'none', + memo: null }; } diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 1e925aede6..d74c83a500 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -82,23 +82,16 @@ function h( 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]; - }; +declare namespace h.JSX { + type Element = estree.Node; + 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): Promise { @@ -388,6 +381,7 @@ function toStories(component: string): Promise { '/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' + '/* eslint-disable import/no-default-export */\n' + '/* eslint-disable import/no-duplicates */\n' + + '/* eslint-disable import/order */\n' + generate(program, { generator }) + (hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''), { diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts index 0a87488573..d3822942cd 100644 --- a/packages/frontend/.storybook/main.ts +++ b/packages/frontend/.storybook/main.ts @@ -34,7 +34,7 @@ const config = { disableTelemetry: true, }, async viteFinal(config) { - const replacePluginForIsChromatic = config.plugins?.findIndex((plugin) => plugin && (plugin as Partial)?.name === 'replace') ?? -1; + const replacePluginForIsChromatic = config.plugins?.findIndex((plugin: Plugin) => plugin && plugin.name === 'replace') ?? -1; if (~replacePluginForIsChromatic) { config.plugins?.splice(replacePluginForIsChromatic, 1); } diff --git a/packages/frontend/.storybook/mocks.ts b/packages/frontend/.storybook/mocks.ts index 817b0125e7..29cb112ccb 100644 --- a/packages/frontend/.storybook/mocks.ts +++ b/packages/frontend/.storybook/mocks.ts @@ -6,7 +6,8 @@ import { type SharedOptions, http, HttpResponse } 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)) { + const url = new URL(req.url); + if (url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(url.pathname)) { return } print.warning() diff --git a/packages/frontend/.storybook/preview-head.html b/packages/frontend/.storybook/preview-head.html index e50c488243..ae42fd49bc 100644 --- a/packages/frontend/.storybook/preview-head.html +++ b/packages/frontend/.storybook/preview-head.html @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +