diff --git a/.config/docker_example.env b/.config/docker_example.env index 4fe8e76b784b..c61248da2e46 100644 --- a/.config/docker_example.env +++ b/.config/docker_example.env @@ -1,5 +1,11 @@ +# misskey settings +# MISSKEY_URL=https://example.tld/ + # db settings POSTGRES_PASSWORD=example-misskey-pass +# DATABASE_PASSWORD=${POSTGRES_PASSWORD} POSTGRES_USER=example-misskey-user +# DATABASE_USER=${POSTGRES_USER} POSTGRES_DB=misskey +# DATABASE_DB=${POSTGRES_DB} DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}" diff --git a/.config/docker_example.yml b/.config/docker_example.yml index acd169bf436e..d347882d1a91 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -6,6 +6,7 @@ #───┘ URL └───────────────────────────────────────────────────── # Final accessible URL seen by a user. +# You can set url from an environment variable instead. url: https://example.tld/ # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE @@ -38,9 +39,11 @@ db: port: 5432 # Database name + # You can set db from an environment variable instead. db: misskey # Auth + # You can set user and pass from environment variables instead. user: example-misskey-user pass: example-misskey-pass @@ -106,7 +109,7 @@ redis: # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── -# You can set scope to local (default value) or global +# You can set scope to local (default value) or global # (include notes from remote). #meilisearch: @@ -136,6 +139,21 @@ redis: id: 'aidx' +# ┌────────────────┐ +#───┘ Error tracking └────────────────────────────────────────── + +# Sentry is available for error tracking. +# See the Sentry documentation for more details on options. + +#sentryForBackend: +# enableNodeProfiling: true +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + +#sentryForFrontend: +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + # ┌─────────────────────┐ #───┘ Other configuration └───────────────────────────────────── @@ -185,7 +203,7 @@ proxyRemoteFiles: true signToActivityPubGet: true # For security reasons, uploading attachments from the intranet is prohibited, -# but exceptions can be made from the following settings. Default value is "undefined". +# but exceptions can be made from the following settings. Default value is "undefined". # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). #allowedPrivateNetworks: [ # '127.0.0.1/32' diff --git a/.config/example.yml b/.config/example.yml index b0b7f140593d..b11cbd137328 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -205,6 +205,21 @@ redis: id: 'aidx' +# ┌────────────────┐ +#───┘ Error tracking └────────────────────────────────────────── + +# Sentry is available for error tracking. +# See the Sentry documentation for more details on options. + +#sentryForBackend: +# enableNodeProfiling: true +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + +#sentryForFrontend: +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + # ┌─────────────────────┐ #───┘ Other configuration └───────────────────────────────────── diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/compose.yml similarity index 93% rename from .devcontainer/docker-compose.yml rename to .devcontainer/compose.yml index 2809cd2ca469..d02d2a8f4a89 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: app: build: @@ -8,6 +6,7 @@ services: volumes: - ../:/workspace:cached + - node_modules:/workspace/node_modules command: sleep infinity @@ -46,6 +45,7 @@ services: volumes: postgres-data: redis-data: + node_modules: networks: internal_network: diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 31b6212cb5c4..7ea23e314ea4 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Misskey", - "dockerComposeFile": "docker-compose.yml", + "dockerComposeFile": "compose.yml", "service": "app", "workspaceFolder": "/workspace", "features": { @@ -10,7 +10,7 @@ "ghcr.io/devcontainers-contrib/features/corepack:1": {} }, "forwardPorts": [3000], - "postCreateCommand": "sudo chmod 755 .devcontainer/init.sh && .devcontainer/init.sh", + "postCreateCommand": "/bin/bash .devcontainer/init.sh", "customizations": { "vscode": { "extensions": [ diff --git a/.devcontainer/devcontainer.yml b/.devcontainer/devcontainer.yml index 7ea09294692a..beefcfd0a2d5 100644 --- a/.devcontainer/devcontainer.yml +++ b/.devcontainer/devcontainer.yml @@ -132,6 +132,21 @@ redis: id: 'aidx' +# ┌────────────────┐ +#───┘ Error tracking └────────────────────────────────────────── + +# Sentry is available for error tracking. +# See the Sentry documentation for more details on options. + +#sentryForBackend: +# enableNodeProfiling: true +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + +#sentryForFrontend: +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + # ┌─────────────────────┐ #───┘ Other configuration └───────────────────────────────────── diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index 729e1a9d2d9d..55fb1e6fa687 100755 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -2,7 +2,8 @@ set -xe -sudo chown -R node /workspace +sudo chown node node_modules +git config --global --add safe.directory /workspace git submodule update --init corepack install corepack enable diff --git a/.dockerignore b/.dockerignore index 1de0c7982bcd..7dbb06e1d0e6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,7 +7,7 @@ Dockerfile build/ built/ db/ -docker-compose.yml +.devcontainer/compose.yml node_modules/ packages/*/node_modules redis/ @@ -28,4 +28,4 @@ fluent-emojis/ .idea/ packages/*/.vscode/ -packages/backend/test/docker-compose.yml +packages/backend/test/compose.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index d42b58abc09c..000000000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,4 +0,0 @@ -# These are supported funding model platforms - -github: [misskey-dev] -patreon: syuilo diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index e8b65dc3b9fc..5acad83336d3 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,3 +2,7 @@ contact_links: - name: 💬 Misskey official Discord url: https://discord.gg/Wp8gVStHW3 about: Chat freely about Misskey + # 仮 + - name: 💬 Start discussion + url: https://github.com/misskey-dev/misskey/discussions + about: The official forum to join conversation and ask question diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 1b7b68b14f05..e7db18316cb3 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -4,10 +4,11 @@ on: push: paths: - packages/misskey-js/** + - .github/workflows/api-misskey-js.yml pull_request: paths: - packages/misskey-js/** - + - .github/workflows/api-misskey-js.yml jobs: report: @@ -20,7 +21,7 @@ jobs: - run: corepack enable - name: Setup Node.js - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index f254af0d1fd0..d4e99f966ef0 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout head uses: actions/checkout@v4.1.1 - name: Setup Node.js - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' diff --git a/.github/workflows/check-misskey-js-autogen.yml b/.github/workflows/check-misskey-js-autogen.yml index 39acad8bc348..3a2a2d5f8dd7 100644 --- a/.github/workflows/check-misskey-js-autogen.yml +++ b/.github/workflows/check-misskey-js-autogen.yml @@ -28,7 +28,7 @@ jobs: - name: setup node id: setup-node - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: pnpm diff --git a/.github/workflows/check-misskey-js-version.yml b/.github/workflows/check-misskey-js-version.yml index 325a893605b3..99c29ac974cf 100644 --- a/.github/workflows/check-misskey-js-version.yml +++ b/.github/workflows/check-misskey-js-version.yml @@ -6,12 +6,13 @@ on: paths: - packages/misskey-js/package.json - package.json + - .github/workflows/check-misskey-js-version.yml pull_request: branches: [ develop ] paths: - packages/misskey-js/package.json - package.json - + - .github/workflows/check-misskey-js-version.yml jobs: check-version: # ルートの package.json と packages/misskey-js/package.json のバージョンが一致しているかを確認する diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml index cb84849580ce..ac2b1b4d358a 100644 --- a/.github/workflows/docker-develop.yml +++ b/.github/workflows/docker-develop.yml @@ -37,7 +37,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push by digest id: build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 23c1bdbc1686..db899ba386be 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -48,7 +48,7 @@ jobs: password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and Push to Docker Hub id: build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: true diff --git a/.github/workflows/dockle.yml b/.github/workflows/dockle.yml index eee7a78fedfa..c3dba4213d31 100644 --- a/.github/workflows/dockle.yml +++ b/.github/workflows/dockle.yml @@ -13,14 +13,16 @@ jobs: runs-on: ubuntu-latest env: DOCKER_CONTENT_TRUST: 1 + DOCKLE_VERSION: 0.4.14 steps: - uses: actions/checkout@v4.1.1 - - run: | - curl -L -o dockle.deb "https://github.com/goodwithtech/dockle/releases/download/v0.4.10/dockle_0.4.10_Linux-64bit.deb" + - name: Download and install dockle v${{ env.DOCKLE_VERSION }} + run: | + curl -L -o dockle.deb "https://github.com/goodwithtech/dockle/releases/download/v${DOCKLE_VERSION}/dockle_${DOCKLE_VERSION}_Linux-64bit.deb" sudo dpkg -i dockle.deb - run: | cp .config/docker_example.env .config/docker.env - cp ./docker-compose_example.yml ./docker-compose.yml + cp ./compose_example.yml ./compose.yml - run: | docker compose up -d web docker tag "$(docker compose images web | awk 'OFS=":" {print $4}' | tail -n +2)" misskey-web:latest diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 9b9c8f11c413..4afafabf2ead 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -9,7 +9,7 @@ on: paths: - packages/backend/** - .github/workflows/get-api-diff.yml - + - .github/workflows/get-api-diff.yml jobs: get-from-misskey: runs-on: ubuntu-latest @@ -34,7 +34,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 76616ec5a71a..c21fc951239f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,15 +10,16 @@ on: - packages/frontend/** - packages/sw/** - packages/misskey-js/** - - packages/shared/.eslintrc.js + - packages/shared/eslint.config.js + - .github/workflows/lint.yml pull_request: paths: - packages/backend/** - packages/frontend/** - packages/sw/** - packages/misskey-js/** - - packages/shared/.eslintrc.js - + - packages/shared/eslint.config.js + - .github/workflows/lint.yml jobs: pnpm_install: runs-on: ubuntu-latest @@ -28,7 +29,7 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' @@ -39,6 +40,8 @@ jobs: needs: [pnpm_install] runs-on: ubuntu-latest continue-on-error: true + env: + eslint-cache-version: v1 strategy: matrix: workspace: @@ -52,13 +55,20 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' - run: corepack enable - run: pnpm i --frozen-lockfile - - run: pnpm --filter ${{ matrix.workspace }} run eslint + - name: Restore eslint cache + uses: actions/cache@v4.0.2 + with: + path: node_modules/.cache/eslint + key: eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + eslint-${{ env.eslint-cache-version }}-${{ hashFiles('/pnpm-lock.yaml') }}- + - run: pnpm --filter ${{ matrix.workspace }} run eslint --cache --cache-location node_modules/.cache/eslint --cache-strategy content typecheck: needs: [pnpm_install] @@ -75,7 +85,7 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index de2247e77277..95251bfe31e0 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -4,10 +4,11 @@ on: push: paths: - locales/** + - .github/workflows/locale.yml pull_request: paths: - locales/** - + - .github/workflows/locale.yml jobs: locale_verify: runs-on: ubuntu-latest @@ -18,7 +19,7 @@ jobs: fetch-depth: 0 submodules: true - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.2 + - uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index edfdab99e930..22c04ff29711 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -26,7 +26,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/release-edit-with-push.yml b/.github/workflows/release-edit-with-push.yml index 944b98eb7cc2..f86c1948f8b0 100644 --- a/.github/workflows/release-edit-with-push.yml +++ b/.github/workflows/release-edit-with-push.yml @@ -3,10 +3,10 @@ name: "Release Manager: sync changelog with PR" on: push: branches: - - release/** + - develop paths: - 'CHANGELOG.md' - + # - .github/workflows/release-edit-with-push.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -20,21 +20,29 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - # headがrelease/かつopenのPRを1つ取得 + # headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得 - name: Get PR run: | - echo "pr_number=$(gh pr list --limit 1 --head "${{ github.ref_name }}" --json number --jq '.[] | .number')" >> $GITHUB_OUTPUT + echo "pr_number=$(gh pr list --limit 1 --search "head:$GITHUB_REF_NAME base:$STABLE_BRANCH is:open" --json number --jq '.[] | .number')" >> $GITHUB_OUTPUT id: get_pr + env: + STABLE_BRANCH: ${{ vars.STABLE_BRANCH }} - name: Get target version - uses: misskey-dev/release-manager-actions/.github/actions/get-target-version@v1 + if: steps.get_pr.outputs.pr_number != '' + uses: misskey-dev/release-manager-actions/.github/actions/get-target-version@v2 id: v # CHANGELOG.mdの内容を取得 - name: Get changelog - uses: misskey-dev/release-manager-actions/.github/actions/get-changelog@v1 + if: steps.get_pr.outputs.pr_number != '' + uses: misskey-dev/release-manager-actions/.github/actions/get-changelog@v2 with: version: ${{ steps.v.outputs.target_version }} id: changelog # PRのnotesを更新 - name: Update PR + if: steps.get_pr.outputs.pr_number != '' run: | - gh pr edit ${{ steps.get_pr.outputs.pr_number }} --body "${{ steps.changelog.outputs.changelog }}" + gh pr edit "$PR_NUMBER" --body "$CHANGELOG" + env: + PR_NUMBER: ${{ steps.get_pr.outputs.pr_number }} + CHANGELOG: ${{ steps.changelog.outputs.changelog }} diff --git a/.github/workflows/release-with-dispatch.yml b/.github/workflows/release-with-dispatch.yml index bc6448cb37d8..0936bc0ae8b9 100644 --- a/.github/workflows/release-with-dispatch.yml +++ b/.github/workflows/release-with-dispatch.yml @@ -33,18 +33,21 @@ jobs: pr_number: ${{ steps.get_pr.outputs.pr_number }} steps: - uses: actions/checkout@v4 - # headがrelease/かつopenのPRを1つ取得 + # headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得 - name: Get PRs run: | - echo "pr_number=$(gh pr list --limit 1 --search "head:release/ is:open" --json number --jq '.[] | .number')" >> $GITHUB_OUTPUT + echo "pr_number=$(gh pr list --limit 1 --search "head:$GITHUB_REF_NAME base:$STABLE_BRANCH is:open" --json number --jq '.[] | .number')" >> $GITHUB_OUTPUT id: get_pr + env: + STABLE_BRANCH: ${{ vars.STABLE_BRANCH }} merge: - uses: misskey-dev/release-manager-actions/.github/workflows/merge.yml@v1 + uses: misskey-dev/release-manager-actions/.github/workflows/merge.yml@v2 needs: get-pr if: ${{ needs.get-pr.outputs.pr_number != '' && inputs.merge == true }} with: pr_number: ${{ needs.get-pr.outputs.pr_number }} + user: 'github-actions[bot]' package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} # Text to prepend to the changelog # The first line must be `## Unreleased` @@ -65,15 +68,14 @@ jobs: secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - RULESET_EDIT_APP_ID: ${{ secrets.RULESET_EDIT_APP_ID }} - RULESET_EDIT_APP_PRIVATE_KEY: ${{ secrets.RULESET_EDIT_APP_PRIVATE_KEY }} create-prerelease: - uses: misskey-dev/release-manager-actions/.github/workflows/create-prerelease.yml@v1 + uses: misskey-dev/release-manager-actions/.github/workflows/create-prerelease.yml@v2 needs: get-pr if: ${{ needs.get-pr.outputs.pr_number != '' && inputs.merge != true }} with: pr_number: ${{ needs.get-pr.outputs.pr_number }} + user: 'github-actions[bot]' package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} indent: ${{ vars.INDENT }} @@ -82,10 +84,11 @@ jobs: RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} create-target: - uses: misskey-dev/release-manager-actions/.github/workflows/create-target.yml@v1 + uses: misskey-dev/release-manager-actions/.github/workflows/create-target.yml@v2 needs: get-pr if: ${{ needs.get-pr.outputs.pr_number == '' }} with: + user: 'github-actions[bot]' # The script for version increment. # process.env.CURRENT_VERSION: The current version. # @@ -118,8 +121,7 @@ jobs: package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} indent: ${{ vars.INDENT }} + stable_branch: ${{ vars.STABLE_BRANCH }} secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} - RULESET_EDIT_APP_ID: ${{ secrets.RULESET_EDIT_APP_ID }} - RULESET_EDIT_APP_PRIVATE_KEY: ${{ secrets.RULESET_EDIT_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release-with-ready.yml b/.github/workflows/release-with-ready.yml index 139503e563e8..79b6ade01285 100644 --- a/.github/workflows/release-with-ready.yml +++ b/.github/workflows/release-with-ready.yml @@ -16,21 +16,26 @@ jobs: check: runs-on: ubuntu-latest outputs: - ref: ${{ steps.get_pr.outputs.ref }} + head: ${{ steps.get_pr.outputs.head }} + base: ${{ steps.get_pr.outputs.base }} steps: - uses: actions/checkout@v4 # PR情報を取得 - name: Get PR run: | - pr_json=$(gh pr view ${{ github.event.pull_request.number }} --json isDraft,headRefName) - echo "ref=$(echo $pr_json | jq -r '.headRefName')" >> $GITHUB_OUTPUT + pr_json=$(gh pr view "$PR_NUMBER" --json isDraft,headRefName,baseRefName) + echo "head=$(echo $pr_json | jq -r '.headRefName')" >> $GITHUB_OUTPUT + echo "base=$(echo $pr_json | jq -r '.baseRefName')" >> $GITHUB_OUTPUT id: get_pr + env: + PR_NUMBER: ${{ github.event.pull_request.number }} release: - uses: misskey-dev/release-manager-actions/.github/workflows/create-prerelease.yml@v1 + uses: misskey-dev/release-manager-actions/.github/workflows/create-prerelease.yml@v2 needs: check - if: startsWith(needs.check.outputs.ref, 'release/') + if: needs.check.outputs.head == github.event.repository.default_branch && needs.check.outputs.base == vars.STABLE_BRANCH with: pr_number: ${{ github.event.pull_request.number }} + user: 'github-actions[bot]' package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} indent: ${{ vars.INDENT }} diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index c52883ffdde9..68452aacaf88 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -36,7 +36,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js 20.x - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version-file: '.node-version' cache: 'pnpm' @@ -88,7 +88,7 @@ jobs: if [ "$BRANCH" = "misskey-dev:$HEAD_REF" ]; then BRANCH="$HEAD_REF" fi - pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static --branch-name $BRANCH $(echo "$CHROMATIC_PARAMETER") + pnpm --filter frontend chromatic --exit-once-uploaded -d storybook-static --branch-name "$BRANCH" $(echo "$CHROMATIC_PARAMETER") env: HEAD_REF: ${{ github.event.pull_request.head.ref }} CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index b1c54bb3e77e..bfb79ef09013 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -9,12 +9,13 @@ on: - packages/backend/** # for permissions - packages/misskey-js/** + - .github/workflows/test-backend.yml pull_request: paths: - packages/backend/** # for permissions - packages/misskey-js/** - + - .github/workflows/test-backend.yml jobs: unit: runs-on: ubuntu-latest @@ -45,7 +46,7 @@ jobs: - name: Install FFmpeg uses: FedericoCarboni/setup-ffmpeg@v3 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -92,7 +93,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 9d5053b82aa8..c17a9fd3877a 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -11,7 +11,7 @@ on: - packages/misskey-js/** # for e2e - packages/backend/** - + - .github/workflows/test-frontend.yml pull_request: paths: - packages/frontend/** @@ -19,7 +19,7 @@ on: - packages/misskey-js/** # for e2e - packages/backend/** - + - .github/workflows/test-frontend.yml jobs: vitest: runs-on: ubuntu-latest @@ -35,7 +35,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -90,7 +90,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 2589d908b8d4..6ee67e8735bf 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -8,11 +8,12 @@ on: branches: [ develop ] paths: - packages/misskey-js/** + - .github/workflows/test-misskey-js.yml pull_request: branches: [ develop ] paths: - packages/misskey-js/** - + - .github/workflows/test-misskey-js.yml jobs: test: @@ -30,7 +31,7 @@ jobs: - run: corepack enable - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 7f8db652938f..18d02ec030ee 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -25,7 +25,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index 24340e7d81f2..90f2929a2566 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -7,10 +7,11 @@ on: - develop paths: - packages/backend/** + - .github/workflows/validate-api-json.yml pull_request: paths: - packages/backend/** - + - .github/workflows/validate-api-json.yml jobs: validate-api-json: runs-on: ubuntu-latest @@ -26,7 +27,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.2 + uses: actions/setup-node@v4.0.3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' diff --git a/.gitignore b/.gitignore index bdc14fea0aba..3466984cf648 100644 --- a/.gitignore +++ b/.gitignore @@ -35,8 +35,8 @@ coverage !/.config/example.yml !/.config/docker_example.yml !/.config/docker_example.env -docker-compose.yml -!/.devcontainer/docker-compose.yml +.devcontainer/compose.yml +!/.devcontainer/compose.yml # misskey /build diff --git a/CHANGELOG.md b/CHANGELOG.md index 681903a1f2ba..14cae5d3de8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,75 @@ ## Unreleased +### Note +- デッキUIの新着ノートをサウンドで通知する機能の追加(v2024.5.0)に伴い、以前から動作しなくなっていたクライアント設定内の「アンテナ受信」「チャンネル通知」サウンドを削除しました。 + +### General +- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 +- Feat: ユーザーのアイコン/バナーの変更可否をロールで設定可能に + - 変更不可となっていても、設定済みのものを解除してデフォルト画像に戻すことは出来ます +- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 +- Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 +- Fix: デフォルトテーマに無効なテーマコードを入力するとUIが使用できなくなる問題を修正 +- Enhance: Allow negative delay for MFM animation elements (`tada`, `jelly`, `twitch`, `shake`, `spin`, `jump`, `bounce`, `rainbow`) + +### Client +- Enhance: 内蔵APIドキュメントのデザイン・パフォーマンスを改善 +- Enhance: 非ログイン時に他サーバーに遷移するアクションを追加 +- Enhance: 非ログイン時のハイライトTLのデザインを改善 +- Enhance: フロントエンドのアクセシビリティ改善 + (Based on https://github.com/taiyme/misskey/pull/226) +- Enhance: サーバー情報ページ・お問い合わせページを改善 + (Cherry-picked from https://github.com/taiyme/misskey/pull/238) +- Fix: `/about#federation` ページなどで各インスタンスのチャートが表示されなくなっていた問題を修正 +- Fix: ユーザーページの追加情報のラベルを投稿者のサーバーの絵文字で表示する (#13968) +- Fix: リバーシの対局を正しく共有できないことがある問題を修正 +- Fix: コントロールパネルでベースロールのポリシーを編集してもUI上では変更が反映されない問題を修正 +- Fix: アンテナの編集画面のボタンに隙間を追加 +- Fix: テーマプレビューが見れない問題を修正 +- Fix: ショートカットキーが連打できる問題を修正 + (Cherry-picked from https://github.com/taiyme/misskey/pull/234) + +### Server +- Feat: レートリミット制限に引っかかったときに`Retry-After`ヘッダーを返すように (#13949) +- Enhance: エンドポイント`clips/update`の必須項目を`clipId`のみに +- Enhance: エンドポイント`admin/roles/update`の必須項目を`roleId`のみに +- Enhance: エンドポイント`pages/update`の必須項目を`pageId`のみに +- Enhance: エンドポイント`gallery/posts/update`の必須項目を`postId`のみに +- Enhance: エンドポイント`i/webhook/update`の必須項目を`webhookId`のみに +- Enhance: エンドポイント`admin/ad/update`の必須項目を`id`のみに +- Enhance: `default.yml`内の`url`, `db.db`, `db.user`, `db.pass`を環境変数から読み込めるように +- Fix: チャート生成時にinstance.suspentionStateに置き換えられたinstance.isSuspendedが参照されてしまう問題を修正 +- Fix: ユーザーのフィードページのMFMをHTMLに展開するように (#14006) +- Fix: アンテナ・クリップ・リスト・ウェブフックがロールポリシーの上限より一つ多く作れてしまうのを修正 (#14036) +- Fix: notRespondingSinceが実装される前に不通になったインスタンスが自動的に配信停止にならない (#14059) +- Fix: FTT有効時、タイムライン用エンドポイントで`sinceId`にキャッシュ内最古のものより古いものを指定した場合に正しく結果が返ってこない問題を修正 +- Fix: 自分以外のクリップ内のノート個数が見えることがあるのを修正 +- Fix: 空文字列のリアクションはフォールバックされるように +- Fix: リノートにリアクションできないように +- Fix: ユーザー名の前後に空白文字列がある場合は省略するように +- Fix: プロフィール編集時に名前を空白文字列のみにできる問題を修正 +- Fix: ユーザ名のサジェスト時に表示される内容と順番を調整(以下の順番になります) #14149 + 1. フォロー中かつアクティブなユーザ + 2. フォロー中かつ非アクティブなユーザ + 3. フォローしていないアクティブなユーザ + 4. フォローしていない非アクティブなユーザ +- Fix: 一般ユーザーから見たユーザーのバッジの一覧に公開されていないものが含まれることがある問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/652) +- Fix: ユーザーのリアクション一覧でミュート/ブロックが機能していなかった問題を修正 + +### Misskey.js +- Feat: `/drive/files/create` のリクエストに対応(`multipart/form-data`に対応) +- Feat: `/admin/role/create` のロールポリシーの型を修正 + +## 2024.5.0 + ### Note - コントロールパネル内にあるサマリープロキシの設定個所がセキュリティから全般へ変更となります。 - 悪意のある第三者がリモートユーザーになりすましたアクティビティを受け取れてしまう問題を修正しました。詳しくは[GitHub security advisory](https://github.com/misskey-dev/misskey/security/advisories/GHSA-2vxv-pv3m-3wvj)をご覧ください。 - 管理者向け権限 `read:admin:show-users` は `read:admin:show-user` に統合されました。必要に応じてAPIトークンを再発行してください。 ### General +- Feat: エラートラッキングにSentryを使用できるようになりました - Enhance: URLプレビューの有効化・無効化を設定できるように #13569 - Enhance: アンテナでBotによるノートを除外できるように (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/545) @@ -19,12 +83,15 @@ - Enhance: Goneを出さずに終了したサーバーへの配信停止を自動的に行うように - もしそのようなサーバーからから配信が届いた場合には自動的に配信を再開します - Enhance: 配信停止の理由を表示するように +- Enhance: サーバーのお問い合わせ先URLを設定できるようになりました - Fix: Play作成時に設定した公開範囲が機能していない問題を修正 - Fix: 正規化されていない状態のhashtagが連合されてきたhtmlに含まれているとhashtagが正しくhashtagに復元されない問題を修正 - Fix: みつけるのアンケート欄にてチャンネルのアンケートが含まれてしまう問題を修正 ### Client - Feat: アップロードするファイルの名前をランダム文字列にできるように +- Feat: 個別のお知らせにリンクで飛べるように + (Based on https://github.com/MisskeyIO/misskey/pull/639) - Enhance: 自分のノートの添付ファイルから直接ファイルの詳細ページに飛べるように - Enhance: 広告がMisskeyと同一ドメインの場合はRouterで遷移するように - Enhance: リアクション・いいねの総数を表示するように @@ -47,6 +114,9 @@ - Enhance: AiScriptを0.18.0にバージョンアップ - Enhance: 通常のノートでも、お気に入りに登録したチャンネルにリノートできるように - Enhance: 長いテキストをペーストした際にテキストファイルとして添付するかどうかを選択できるように +- Enhance: 新着ノートをサウンドで通知する機能をdeck UIに追加しました +- Enhance: コントロールパネルのクイックアクションからファイルを照会できるように +- Enhance: コントロールパネルのクイックアクションから通常の照会を行えるように - Fix: 一部のページ内リンクが正しく動作しない問題を修正 - Fix: 周年の実績が閏年を考慮しない問題を修正 - Fix: ローカルURLのプレビューポップアップが左上に表示される @@ -67,6 +137,7 @@ - Fix: 通知をグループ化している際に、人数が正常に表示されないことがある問題を修正 - Fix: 連合なしの状態の読み書きができない問題を修正 - Fix: `/share` で日本語等を含むurlがurlエンコードされない問題を修正 +- Fix: ファイルを5つ以上添付してもテキストがないとノートが折りたたまれない問題を修正 ### Server - Enhance: エンドポイント`antennas/update`の必須項目を`antennaId`のみに @@ -92,6 +163,8 @@ - Fix: `/i/notifications`に `includeTypes`か`excludeTypes`を指定しているとき、通知が存在するのに空配列を返すことがある問題を修正 - Fix: 複数idを指定する`users/show`が関係ないユーザを返すことがある問題を修正 - Fix: `/tags` と `/user-tags` が検索エンジンにインデックスされないように +- Fix: もともとセンシティブではないと連合されていたファイルがセンシティブとして連合された場合にセンシティブとしてそのファイルを扱うように + - センシティブとして連合したファイルは非センシティブとして連合されてもセンシティブとして扱われます ## 2024.3.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dcb625626d6f..b718f3703f87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,7 +165,7 @@ cp .github/misskey/test.yml .config/ ``` Prepare DB/Redis for testing. ``` -docker compose -f packages/backend/test/docker-compose.yml up +docker compose -f packages/backend/test/compose.yml up ``` Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`. diff --git a/Dockerfile b/Dockerfile index 9fc2d611cd82..d6ca6b8cdffb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -82,6 +82,10 @@ RUN apt-get update \ USER misskey WORKDIR /misskey +# add package.json to add pnpm +COPY --chown=misskey:misskey ./package.json ./package.json +RUN corepack install + COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules diff --git a/README.md b/README.md index 24013a7bd817..92e8fef6396e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ ## Thanks +Sentry + +Thanks to [Sentry](https://sentry.io/) for providing the error tracking platform that helps us catch unexpected errors. + Chromatic Thanks to [Chromatic](https://www.chromatic.com/) for providing the visual testing platform that helps us review UI changes and catch visual regressions. diff --git a/chart/files/default.yml b/chart/files/default.yml index 4cc291e80a77..f98b8ebfee04 100644 --- a/chart/files/default.yml +++ b/chart/files/default.yml @@ -152,6 +152,22 @@ redis: # ID SETTINGS AFTER THAT! id: "aidx" + +# ┌────────────────┐ +#───┘ Error tracking └────────────────────────────────────────── + +# Sentry is available for error tracking. +# See the Sentry documentation for more details on options. + +#sentryForBackend: +# enableNodeProfiling: true +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + +#sentryForFrontend: +# options: +# dsn: 'https://examplePublicKey@o0.ingest.sentry.io/0' + # ┌─────────────────────┐ #───┘ Other configuration └───────────────────────────────────── diff --git a/docker-compose.local-db.yml b/compose.local-db.yml similarity index 98% rename from docker-compose.local-db.yml rename to compose.local-db.yml index 16ba4b49e179..3835cb23dbad 100644 --- a/docker-compose.local-db.yml +++ b/compose.local-db.yml @@ -1,5 +1,3 @@ -version: "3" - # このconfigは、 dockerでMisskey本体を起動せず、 redisとpostgresql などだけを起動します services: diff --git a/docker-compose_example.yml b/compose_example.yml similarity index 97% rename from docker-compose_example.yml rename to compose_example.yml index 5cebbe416467..336bd814a793 100644 --- a/docker-compose_example.yml +++ b/compose_example.yml @@ -1,5 +1,3 @@ -version: "3" - services: web: build: . @@ -19,6 +17,8 @@ services: networks: - internal_network - external_network + # env_file: + # - .config/docker.env volumes: - ./files:/misskey/files - ./.config:/misskey/.config:ro diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 88707fe1118e..955d672c1d05 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1016,6 +1016,8 @@ sourceCode: "الشفرة المصدرية" flip: "اقلب" lastNDays: "آخر {n} أيام" surrender: "ألغِ" +_delivery: + stop: "مُعلّق" _initialAccountSetting: accountCreated: "نجح إنشاء حسابك!" letsStartAccountSetup: "إذا كنت جديدًا لنعدّ حسابك الشخصي." diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index dc5d315aed9f..abcf07da831e 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -857,6 +857,10 @@ replies: "জবাব" renotes: "রিনোট" sourceCode: "সোর্স কোড" flip: "উল্টান" +_delivery: + stop: "স্থগিত করা হয়েছে" + _type: + none: "প্রকাশ করা হচ্ছে" _role: priority: "অগ্রাধিকার" _priority: diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index d035555c73af..0345ee0326e9 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -1224,6 +1224,10 @@ gameRetry: "Torna a provar" notUsePleaseLeaveBlank: "Si no voleu usar-ho, deixeu-ho en blanc" useTotp: "Usa una contrasenya d'un sol ús" useBackupCode: "Usa un codi de recuperació" +_delivery: + stop: "Suspés" + _type: + none: "S'està publicant" _bubbleGame: howToPlay: "Com es juga" _howToPlay: @@ -2001,7 +2005,6 @@ _permissions: "read:admin:server-info": "Veure informació del servidor" "read:admin:show-moderation-log": "Veure registre de moderació " "read:admin:show-user": "Veure informació privada de l'usuari " - "read:admin:show-users": "Veure informació privada de l'usuari " "write:admin:suspend-user": "Suspendre usuari" "write:admin:unset-user-avatar": "Esborrar avatar d'usuari " "write:admin:unset-user-banner": "Esborrar bàner de l'usuari " diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index cff533976ec6..c8a0b0cb2804 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -1099,6 +1099,10 @@ sourceCode: "Zdrojový kód" flip: "Otočit" lastNDays: "Posledních {n} dnů" surrender: "Zrušit" +_delivery: + stop: "Suspendováno" + _type: + none: "Publikuji" _initialAccountSetting: accountCreated: "Váš účet byl úspěšně vytvořen!" letsStartAccountSetup: "Pro začátek si nastavte svůj profil." diff --git a/locales/da-DK.yml b/locales/da-DK.yml index 08c15ed092fc..5eb7a5a5f417 100644 --- a/locales/da-DK.yml +++ b/locales/da-DK.yml @@ -1,2 +1,4 @@ --- _lang_: "Dansk" +headlineMisskey: "" +introMisskey: "ようこそ!Misskeyは、オープンソースの分散型マイクロブログサービスです。\n「ノート」を作成して、いま起こっていることを共有したり、あなたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加することもできます👍\n新しい世界を探検しよう🚀" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 3e1c40512e77..9e42e0125257 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -1185,6 +1185,10 @@ addMfmFunction: "MFM hinzufügen" sfx: "Soundeffekte" lastNDays: "Letzten {n} Tage" surrender: "Abbrechen" +_delivery: + stop: "Gesperrt" + _type: + none: "Wird veröffentlicht" _announcement: forExistingUsers: "Nur für existierende Nutzer" forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt." diff --git a/locales/en-US.yml b/locales/en-US.yml index 10e9fd778e70..c20a1ac7d8ef 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -108,11 +108,14 @@ enterEmoji: "Enter an emoji" renote: "Renote" unrenote: "Remove renote" renoted: "Renoted." +renotedToX: "Renote from {name} users。" cantRenote: "This post can't be renoted." cantReRenote: "A renote can't be renoted." quote: "Quote" inChannelRenote: "Channel-only Renote" inChannelQuote: "Channel-only Quote" +renoteToChannel: "Renote to channel" +renoteToOtherChannel: "Renote to other channel" pinnedNote: "Pinned note" pinned: "Pin to profile" you: "You" @@ -468,6 +471,7 @@ retype: "Enter again" noteOf: "Note by {user}" quoteAttached: "Quote" quoteQuestion: "Append as quote?" +attachAsFileQuestion: "The text in clipboard is long. Would you want to attach it as text file?" noMessagesYet: "No messages yet" newMessageExists: "There are new messages" onlyOneFileCanBeAttached: "You can only attach one file to a message" @@ -1235,6 +1239,15 @@ keepOriginalFilenameDescription: "If you turn off this setting, files names will noDescription: "There is not the explanation" alwaysConfirmFollow: "Always confirm when following" inquiry: "Contact" +_delivery: + status: "Delivery status" + stop: "Suspended" + resume: "Delivery resume" + _type: + none: "Publishing" + manuallySuspended: "Manually suspended" + goneSuspended: "Server is suspended due to server deletion" + autoSuspendedForNotResponding: "Server is suspended due to no responding" _bubbleGame: howToPlay: "How to play" hold: "Hold" @@ -2032,7 +2045,6 @@ _permissions: "read:admin:server-info": "View server info" "read:admin:show-moderation-log": "View moderation log" "read:admin:show-user": "View private user info" - "read:admin:show-users": "View private user info" "write:admin:suspend-user": "Suspend user" "write:admin:unset-user-avatar": "Remove user avatar" "write:admin:unset-user-banner": "Remove user banner" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 2e05364c312f..5c8249ded50f 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -1233,6 +1233,10 @@ useNativeUIForVideoAudioPlayer: "Usar la interfaz del navegador cuando se reprod keepOriginalFilename: "Mantener el nombre original del archivo" noDescription: "No hay descripción" alwaysConfirmFollow: "Confirmar siempre cuando se sigue a alguien" +_delivery: + stop: "Suspendido" + _type: + none: "Publicando" _bubbleGame: howToPlay: "Cómo jugar" hold: "Mantener" @@ -2029,7 +2033,6 @@ _permissions: "read:admin:server-info": "Ver información del servidor" "read:admin:show-moderation-log": "Ver log de moderación" "read:admin:show-user": "Ver información privada de usuario" - "read:admin:show-users": "Ver información privada de usuario" "write:admin:suspend-user": "Suspender cuentas de usuario" "write:admin:unset-user-avatar": "Quitar avatares de usuario" "write:admin:unset-user-banner": "Quitar banner de usuarios" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index 58a11a5cc4ea..8d66c3d37572 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -1224,6 +1224,10 @@ enableHorizontalSwipe: "Glisser pour changer d'onglet" loading: "Chargement en cours" surrender: "Annuler" gameRetry: "Réessayer" +_delivery: + stop: "Suspendu·e" + _type: + none: "Publié" _bubbleGame: howToPlay: "Comment jouer" hold: "Réserver" diff --git a/locales/id-ID.yml b/locales/id-ID.yml index f8e645d63b57..7f509afa501a 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -108,11 +108,14 @@ enterEmoji: "Masukkan emoji" renote: "Renote" unrenote: "Hapus renote" renoted: "Telah direnote" +renotedToX: "{name} telah merenote" cantRenote: "Postingan ini tidak dapat direnote" cantReRenote: "Renote tidak dapat direnote" quote: "Kutip" inChannelRenote: "Hanya renote dalam kanal" inChannelQuote: "Hanya kutip dalam kanal" +renoteToChannel: "Renote ke kanal" +renoteToOtherChannel: "Renote ke kanal lainnya" pinnedNote: "Catatan yang disematkan" pinned: "Sematkan ke profil" you: "Kamu" @@ -468,6 +471,7 @@ retype: "Masukkan ulang" noteOf: "Catatan milik {user}" quoteAttached: "Dikutip" quoteQuestion: "Apakah kamu ingin menambahkan kutipan?" +attachAsFileQuestion: "Teks dalam papan klip terlalu panjang. Apakah kamu ingin melampirkannya sebagai berkas teks?" noMessagesYet: "Tidak ada pesan" newMessageExists: "Kamu mendapatkan pesan baru" onlyOneFileCanBeAttached: "Kamu hanya dapat melampirkan satu berkas ke dalam pesan" @@ -1235,6 +1239,15 @@ keepOriginalFilenameDescription: "Apabila pengaturan ini dimatikan, nama berkas noDescription: "Tidak ada deskripsi" alwaysConfirmFollow: "Selalu konfirmasi ketika mengikuti" inquiry: "Hubungi kami" +_delivery: + status: "Status pengiriman" + stop: "Ditangguhkan" + resume: "Lanjutkan pengiriman" + _type: + none: "Sedang menyiarkan langsung" + manuallySuspended: "Ditangguhkan manual" + goneSuspended: "Sedang ditangguhkan untuk penghapusan peladen" + autoSuspendedForNotResponding: "Sedang ditangguhkan karena peladen tidak menjawab" _bubbleGame: howToPlay: "Cara bermain" hold: "Tahan" @@ -2032,7 +2045,6 @@ _permissions: "read:admin:server-info": "Lihat informasi peladen" "read:admin:show-moderation-log": "Lihat log moderasi" "read:admin:show-user": "Lihat informasi pengguna privat" - "read:admin:show-users": "Lihat informasi pengguna privat" "write:admin:suspend-user": "Tangguhkan pengguna" "write:admin:unset-user-avatar": "Hapus avatar pengguna" "write:admin:unset-user-banner": "Hapus banner pengguna" diff --git a/locales/index.d.ts b/locales/index.d.ts index 4c8d4c5c96df..35aa07305b67 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -736,6 +736,22 @@ export interface Locale extends ILocale { * リモートで表示 */ "showOnRemote": string; + /** + * リモートで続行 + */ + "continueOnRemote": string; + /** + * Misskey Hubからサーバーを選択 + */ + "chooseServerOnMisskeyHub": string; + /** + * サーバーのドメインを直接指定 + */ + "specifyServerHost": string; + /** + * ドメインを入力してください + */ + "inputHostName": string; /** * 全般 */ @@ -1292,6 +1308,10 @@ export interface Locale extends ILocale { * フォルダーを選択 */ "selectFolders": string; + /** + * ファイルが選択されていません + */ + "fileNotSelected": string; /** * ファイル名を変更 */ @@ -1929,9 +1949,13 @@ export interface Locale extends ILocale { */ "onlyOneFileCanBeAttached": string; /** - * 続行する前に、サインアップまたはサインインが必要です + * 続行する前に、登録またはログインが必要です */ "signinRequired": string; + /** + * 続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります + */ + "signinOrContinueOnRemote": string; /** * 招待 */ @@ -3372,6 +3396,10 @@ export interface Locale extends ILocale { * 管理者情報が設定されていません。 */ "noMaintainerInformationWarning": string; + /** + * 問い合わせ先URLが設定されていません。 + */ + "noInquiryUrlWarning": string; /** * Botプロテクションが設定されていません。 */ @@ -4125,9 +4153,13 @@ export interface Locale extends ILocale { */ "thisPostMayBeAnnoyingIgnore": string; /** - * 見たことのあるリノートを省略して表示 + * リノートのスマート省略 */ "collapseRenotes": string; + /** + * リアクションやリノートをしたことがあるノートをたたんで表示します。 + */ + "collapseRenotesDescription": string; /** * サーバー内部エラー */ @@ -4984,6 +5016,10 @@ export interface Locale extends ILocale { * お問い合わせ */ "inquiry": string; + /** + * もう一度お試しください。 + */ + "tryAgain": string; "_delivery": { /** * 配信状態 @@ -5475,6 +5511,14 @@ export interface Locale extends ILocale { * 有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。 */ "fanoutTimelineDbFallbackDescription": string; + /** + * 問い合わせ先URL + */ + "inquiryUrl": string; + /** + * サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。 + */ + "inquiryUrlDescription": string; }; "_accountMigration": { /** @@ -6586,6 +6630,10 @@ export interface Locale extends ILocale { * ファイルにNSFWを常に付与 */ "alwaysMarkNsfw": string; + /** + * アイコンとバナーの更新を許可 + */ + "canUpdateBioMedia": string; /** * ノートのピン留めの最大数 */ @@ -7507,14 +7555,6 @@ export interface Locale extends ILocale { * 通知 */ "notification": string; - /** - * アンテナ受信 - */ - "antenna": string; - /** - * チャンネル通知 - */ - "channel": string; /** * リアクション選択時 */ @@ -9151,6 +9191,10 @@ export interface Locale extends ILocale { * カラムを追加 */ "addColumn": string; + /** + * 新着ノート通知の設定 + */ + "newNoteNotificationSettings": string; /** * カラムの設定 */ @@ -9293,6 +9337,10 @@ export interface Locale extends ILocale { * Webhookを作成 */ "createWebhook": string; + /** + * Webhookを編集 + */ + "modifyWebhook": string; /** * 名前 */ @@ -9339,6 +9387,72 @@ export interface Locale extends ILocale { */ "mention": string; }; + "_systemEvents": { + /** + * ユーザーから通報があったとき + */ + "abuseReport": string; + /** + * ユーザーからの通報を処理したとき + */ + "abuseReportResolved": string; + }; + /** + * Webhookを削除しますか? + */ + "deleteConfirm": string; + }; + "_abuseReport": { + "_notificationRecipient": { + /** + * 通報の通知先を追加 + */ + "createRecipient": string; + /** + * 通報の通知先を編集 + */ + "modifyRecipient": string; + /** + * 通知先の種類 + */ + "recipientType": string; + "_recipientType": { + /** + * メール + */ + "mail": string; + /** + * Webhook + */ + "webhook": string; + "_captions": { + /** + * モデレーター権限を持つユーザーのメールアドレスに通知を送ります(通報を受けた時のみ) + */ + "mail": string; + /** + * 指定したSystemWebhookに通知を送ります(通報を受けた時と通報を解決した時にそれぞれ発信) + */ + "webhook": string; + }; + }; + /** + * キーワード + */ + "keywords": string; + /** + * 通知先ユーザー + */ + "notifiedUser": string; + /** + * 使用するWebhook + */ + "notifiedWebhook": string; + /** + * 通知先を削除しますか? + */ + "deleteConfirm": string; + }; }; "_moderationLogTypes": { /** @@ -9485,6 +9599,30 @@ export interface Locale extends ILocale { * ユーザーのバナーを解除 */ "unsetUserBanner": string; + /** + * SystemWebhookを作成 + */ + "createSystemWebhook": string; + /** + * SystemWebhookを更新 + */ + "updateSystemWebhook": string; + /** + * SystemWebhookを削除 + */ + "deleteSystemWebhook": string; + /** + * 通報の通知先を作成 + */ + "createAbuseReportNotificationRecipient": string; + /** + * 通報の通知先を更新 + */ + "updateAbuseReportNotificationRecipient": string; + /** + * 通報の通知先を削除 + */ + "deleteAbuseReportNotificationRecipient": string; }; "_fileViewer": { /** @@ -9655,7 +9793,7 @@ export interface Locale extends ILocale { "_dataSaver": { "_media": { /** - * メディアの読み込み + * メディアの読み込みを無効化 */ "title": string; /** @@ -9665,7 +9803,7 @@ export interface Locale extends ILocale { }; "_avatar": { /** - * アイコン画像 + * アイコン画像のアニメーションを無効化 */ "title": string; /** @@ -9675,7 +9813,7 @@ export interface Locale extends ILocale { }; "_urlPreview": { /** - * URLプレビューのサムネイル + * URLプレビューのサムネイルを非表示 */ "title": string; /** @@ -9685,7 +9823,7 @@ export interface Locale extends ILocale { }; "_code": { /** - * コードハイライト + * コードハイライトを非表示 */ "title": string; /** diff --git a/locales/index.js b/locales/index.js index 650e55233776..c2738884eb34 100644 --- a/locales/index.js +++ b/locales/index.js @@ -52,7 +52,11 @@ const primaries = { const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); export function build() { - const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {}); + // vitestの挙動を調整するため、一度ローカル変数化する必要がある + // https://github.com/vitest-dev/vitest/issues/3988#issuecomment-1686599577 + // https://github.com/misskey-dev/misskey/pull/14057#issuecomment-2192833785 + const metaUrl = import.meta.url; + const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, metaUrl), 'utf-8'))) || {}, a), {}); // 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す const removeEmpty = (obj) => { diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 0a250a2e289e..1d12a62ccadc 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -1233,6 +1233,10 @@ useNativeUIForVideoAudioPlayer: "Riprodurre audio/video usando le funzionalità keepOriginalFilename: "Mantieni il nome file originale" keepOriginalFilenameDescription: "Disattivandola, i file verranno caricati usando nomi casuali." noDescription: "Manca la descrizione" +_delivery: + stop: "Sospensione" + _type: + none: "Pubblicazione" _bubbleGame: howToPlay: "Come giocare" hold: "Tieni" @@ -2025,7 +2029,6 @@ _permissions: "read:admin:server-info": "Vedere le informazioni sul server" "read:admin:show-moderation-log": "Vedere lo storico di moderazione" "read:admin:show-user": "Vedere le informazioni private degli account utente" - "read:admin:show-users": "Vedere le informazioni private degli account utente" "write:admin:suspend-user": "Sospendere i profili" "write:admin:unset-user-avatar": "Rimuovere la foto profilo dai profili" "write:admin:unset-user-banner": "Rimuovere l'immagine testata dai profili" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 7a12abba5721..6902e21530b1 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -180,6 +180,10 @@ addAccount: "アカウントを追加" reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗しました" showOnRemote: "リモートで表示" +continueOnRemote: "リモートで続行" +chooseServerOnMisskeyHub: "Misskey Hubからサーバーを選択" +specifyServerHost: "サーバーのドメインを直接指定" +inputHostName: "ドメインを入力してください" general: "全般" wallpaper: "壁紙" setWallpaper: "壁紙を設定" @@ -319,6 +323,7 @@ selectFile: "ファイルを選択" selectFiles: "ファイルを選択" selectFolder: "フォルダーを選択" selectFolders: "フォルダーを選択" +fileNotSelected: "ファイルが選択されていません" renameFile: "ファイル名を変更" folderName: "フォルダー名" createFolder: "フォルダーを作成" @@ -478,7 +483,8 @@ attachAsFileQuestion: "クリップボードのテキストが長いです。テ noMessagesYet: "まだチャットはありません" newMessageExists: "新しいメッセージがあります" onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" -signinRequired: "続行する前に、サインアップまたはサインインが必要です" +signinRequired: "続行する前に、登録またはログインが必要です" +signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります" invitations: "招待" invitationCode: "招待コード" checking: "確認しています" @@ -839,6 +845,7 @@ administration: "管理" accounts: "アカウント" switch: "切り替え" noMaintainerInformationWarning: "管理者情報が設定されていません。" +noInquiryUrlWarning: "問い合わせ先URLが設定されていません。" noBotProtectionWarning: "Botプロテクションが設定されていません。" configure: "設定する" postToGallery: "ギャラリーへ投稿" @@ -1027,7 +1034,8 @@ thisPostMayBeAnnoying: "この投稿は迷惑になる可能性があります thisPostMayBeAnnoyingHome: "ホームに投稿" thisPostMayBeAnnoyingCancel: "やめる" thisPostMayBeAnnoyingIgnore: "このまま投稿" -collapseRenotes: "見たことのあるリノートを省略して表示" +collapseRenotes: "リノートのスマート省略" +collapseRenotesDescription: "リアクションやリノートをしたことがあるノートをたたんで表示します。" internalServerError: "サーバー内部エラー" internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。" copyErrorInfo: "エラー情報をコピー" @@ -1242,6 +1250,7 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ noDescription: "説明文はありません" alwaysConfirmFollow: "フォローの際常に確認する" inquiry: "お問い合わせ" +tryAgain: "もう一度お試しください。" _delivery: status: "配信状態" @@ -1384,6 +1393,8 @@ _serverSettings: fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。" fanoutTimelineDbFallback: "データベースへのフォールバック" fanoutTimelineDbFallbackDescription: "有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。" + inquiryUrl: "問い合わせ先URL" + inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" @@ -1703,6 +1714,7 @@ _role: canManageAvatarDecorations: "アバターデコレーションの管理" driveCapacity: "ドライブ容量" alwaysMarkNsfw: "ファイルにNSFWを常に付与" + canUpdateBioMedia: "アイコンとバナーの更新を許可" pinMax: "ノートのピン留めの最大数" antennaMax: "アンテナの作成可能数" wordMuteMax: "ワードミュートの最大文字数" @@ -1969,8 +1981,6 @@ _sfx: note: "ノート" noteMy: "ノート(自分)" notification: "通知" - antenna: "アンテナ受信" - channel: "チャンネル通知" reaction: "リアクション選択時" _soundSettings: @@ -2422,6 +2432,7 @@ _deck: alwaysShowMainColumn: "常にメインカラムを表示" columnAlign: "カラムの寄せ" addColumn: "カラムを追加" + newNoteNotificationSettings: "新着ノート通知の設定" configureColumn: "カラムの設定" swapLeft: "左に移動" swapRight: "右に移動" @@ -2465,6 +2476,7 @@ _drivecleaner: _webhookSettings: createWebhook: "Webhookを作成" + modifyWebhook: "Webhookを編集" name: "名前" secret: "シークレット" events: "Webhookを実行するタイミング" @@ -2477,6 +2489,26 @@ _webhookSettings: renote: "Renoteされたとき" reaction: "リアクションがあったとき" mention: "メンションされたとき" + _systemEvents: + abuseReport: "ユーザーから通報があったとき" + abuseReportResolved: "ユーザーからの通報を処理したとき" + deleteConfirm: "Webhookを削除しますか?" + +_abuseReport: + _notificationRecipient: + createRecipient: "通報の通知先を追加" + modifyRecipient: "通報の通知先を編集" + recipientType: "通知先の種類" + _recipientType: + mail: "メール" + webhook: "Webhook" + _captions: + mail: "モデレーター権限を持つユーザーのメールアドレスに通知を送ります(通報を受けた時のみ)" + webhook: "指定したSystemWebhookに通知を送ります(通報を受けた時と通報を解決した時にそれぞれ発信)" + keywords: "キーワード" + notifiedUser: "通知先ユーザー" + notifiedWebhook: "使用するWebhook" + deleteConfirm: "通知先を削除しますか?" _moderationLogTypes: createRole: "ロールを作成" @@ -2515,6 +2547,12 @@ _moderationLogTypes: deleteAvatarDecoration: "アイコンデコレーションを削除" unsetUserAvatar: "ユーザーのアイコンを解除" unsetUserBanner: "ユーザーのバナーを解除" + createSystemWebhook: "SystemWebhookを作成" + updateSystemWebhook: "SystemWebhookを更新" + deleteSystemWebhook: "SystemWebhookを削除" + createAbuseReportNotificationRecipient: "通報の通知先を作成" + updateAbuseReportNotificationRecipient: "通報の通知先を更新" + deleteAbuseReportNotificationRecipient: "通報の通知先を削除" _fileViewer: title: "ファイルの詳細" @@ -2569,16 +2607,16 @@ _externalResourceInstaller: _dataSaver: _media: - title: "メディアの読み込み" + title: "メディアの読み込みを無効化" description: "画像・動画が自動で読み込まれるのを防止します。隠れている画像・動画はタップすると読み込まれます。" _avatar: - title: "アイコン画像" + title: "アイコン画像のアニメーションを無効化" description: "アイコン画像のアニメーションが停止します。アニメーション画像は通常の画像よりファイルサイズが大きいことがあるので、データ通信量をさらに削減できます。" _urlPreview: - title: "URLプレビューのサムネイル" + title: "URLプレビューのサムネイルを非表示" description: "URLプレビューのサムネイル画像が読み込まれなくなります。" _code: - title: "コードハイライト" + title: "コードハイライトを非表示" description: "MFMなどでコードハイライト記法が使われている場合、タップするまで読み込まれなくなります。コードハイライトではハイライトする言語ごとにその定義ファイルを読み込む必要がありますが、それらが自動で読み込まれなくなるため、通信量の削減が見込めます。" _hemisphere: diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index e6a23a34d7e2..7a33968e9e37 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -1235,6 +1235,10 @@ keepOriginalFilenameDescription: "この設定をオフにすると、アップ noDescription: "説明文はあらへんで" alwaysConfirmFollow: "フォローの際常に確認する" inquiry: "問い合わせ" +_delivery: + stop: "配信せぇへん" + _type: + none: "配信しとる" _bubbleGame: howToPlay: "遊び方" hold: "ホールド" @@ -2032,7 +2036,6 @@ _permissions: "read:admin:server-info": "サーバーの情報見る" "read:admin:show-moderation-log": "モデレーションログ見る" "read:admin:show-user": "ユーザーのプライベートな情報見る" - "read:admin:show-users": "ユーザーのプライベートな情報見る" "write:admin:suspend-user": "ユーザーを凍結" "write:admin:unset-user-avatar": "ユーザーのアバターを削除" "write:admin:unset-user-banner": "ユーザーのバナーを削除" diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index c80a4d399752..9466aff01f41 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -649,6 +649,10 @@ replies: "답하기" renotes: "리노트" attach: "옇기" surrender: "아이예" +_delivery: + stop: "고만 보내예" + _type: + none: "보내고 잇어예" _initialAccountSetting: startTutorial: "길라잡이 하기" _initialTutorial: diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index fc3a64acab1d..294a5a1520cd 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1230,6 +1230,10 @@ useTotp: "일회용 비밀번호 사용" useBackupCode: "백업 코드 사용" launchApp: "앱 실행" useNativeUIForVideoAudioPlayer: "브라우저 UI에서 미디어 재생" +_delivery: + stop: "정지됨" + _type: + none: "배포 중" _bubbleGame: howToPlay: "설명" hold: "홀드" @@ -2021,7 +2025,6 @@ _permissions: "read:admin:server-info": "서버 정보 보기" "read:admin:show-moderation-log": "조정 기록 보기" "read:admin:show-user": "사용자 개인정보 보기" - "read:admin:show-users": "사용자 개인정보 보기" "write:admin:suspend-user": "사용자 정지하기" "write:admin:unset-user-avatar": "사용자 아바타 삭제하기" "write:admin:unset-user-banner": "사용자 배너 삭제하기" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index fa4b3b6f9a95..087bac374524 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -395,6 +395,10 @@ searchByGoogle: "ຄົ້ນຫາ" file: "ໄຟລ໌" replies: "ຕອບ​ໄປ​ທີ" renotes: "Renote" +_delivery: + stop: "ໂຈະ" + _type: + none: "ການ​ພິມ​ເຜີຍ​ແຜ່" _role: _priority: middle: "ປານກາງ" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index e33b978bc857..eb48cf72da02 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -429,6 +429,10 @@ loggedInAsBot: "Momenteel als bot ingelogd" icon: "Avatar" replies: "Antwoord" renotes: "Herdelen" +_delivery: + stop: "Opgeschort" + _type: + none: "Publiceren" _email: _follow: title: "volgde jou" diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 475f93267be8..2b4c9b77761f 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -464,6 +464,8 @@ icon: "Avatar" replies: "Svar" renotes: "Renote" surrender: "Avbryt" +_delivery: + stop: "Suspendert" _initialAccountSetting: theseSettingsCanEditLater: "Du kan endre disse innstillingene senere." _achievements: diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 2183aa3022bc..9d75f7a9d76a 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1023,6 +1023,10 @@ flip: "Odwróć" lastNDays: "W ciągu ostatnich {n} dni" surrender: "Odrzuć" gameRetry: "Spróbuj ponownie" +_delivery: + stop: "Zawieszono" + _type: + none: "Publikowanie" _bubbleGame: _score: score: "Wynik" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index e00f5750ddb5..cfc576b6e119 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -1012,6 +1012,10 @@ keepScreenOn: "Manter a tela do dispositivo sempre ligada" flip: "Inversão" lastNDays: "Últimos {n} dias" surrender: "Cancelar" +_delivery: + stop: "Suspenso" + _type: + none: "Publicando" _initialAccountSetting: followUsers: "Siga usuários que lhe interessam para criar a sua linha do tempo." _serverSettings: diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index 695eb2501fe6..328d34405e30 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -651,6 +651,10 @@ show: "Arată" icon: "Avatar" replies: "Răspunde" renotes: "Re-notează" +_delivery: + stop: "Suspendat" + _type: + none: "Publicare" _role: _priority: middle: "Mediu" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 66e032f16f82..71f5cad601b5 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -1099,6 +1099,10 @@ flip: "Переворот" code: "Код" lastNDays: "Последние {n} сут" surrender: "Этот пост не может быть отменен." +_delivery: + stop: "Заморожено" + _type: + none: "Публикация" _initialAccountSetting: accountCreated: "Аккаунт успешно создан!" letsStartAccountSetup: "Давайте настроим вашу учётную запись." diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 0978701e5557..52f6bf142cac 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -922,6 +922,10 @@ renotes: "Preposlať" sourceCode: "Zdrojový kód" flip: "Preklopiť" lastNDays: "Posledných {n} dní" +_delivery: + stop: "Zmrazené" + _type: + none: "Zverejňovanie" _role: priority: "Priorita" _priority: diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 62bc71a13df2..089dc3949f8a 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -488,6 +488,10 @@ dataSaver: "Databesparing" icon: "Profilbild" replies: "Svara" renotes: "Omnotera" +_delivery: + stop: "Suspenderad" + _type: + none: "Publiceras" _achievements: _types: _open3windows: diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 020b95485475..ab09ac4d5a45 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -1235,6 +1235,10 @@ keepOriginalFilenameDescription: "หากปิดการตั้งค่ noDescription: "ไม่มีข้อความอธิบาย" alwaysConfirmFollow: "แสดงข้อความยืนยันเมื่อกดติดตาม" inquiry: "ติดต่อเรา" +_delivery: + stop: "ถูกระงับ" + _type: + none: "กำลังเผยแพร่" _bubbleGame: howToPlay: "วิธีเล่น" hold: "หยุดชั่วคราว" @@ -2032,7 +2036,6 @@ _permissions: "read:admin:server-info": "ดูข้อมูลเซิร์ฟเวอร์" "read:admin:show-moderation-log": "ดูปูมการแก้ไข" "read:admin:show-user": "ดูข้อมูลส่วนตัวของผู้ใช้" - "read:admin:show-users": "ดูข้อมูลส่วนตัวของผู้ใช้" "write:admin:suspend-user": "ระงับผู้ใช้" "write:admin:unset-user-avatar": "ลบอวตารผู้ใช้" "write:admin:unset-user-banner": "ลบแบนเนอร์ผู้ใช้" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index 0793592d3402..cf6729a81d8e 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -378,6 +378,10 @@ addMemo: "Kısa not ekle" icon: "Avatar" replies: "yanıt" renotes: "vazgeçme" +_delivery: + stop: "Askıya alınmış" + _type: + none: "Paylaşım" _accountDelete: started: "Silme işlemi başlatıldı" _email: diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 0ce5dc12028d..661ecf19d7d9 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -914,6 +914,10 @@ renotes: "Поширити" sourceCode: "Вихідний код" flip: "Перевернути" lastNDays: "Останні {n} днів" +_delivery: + stop: "Призупинено" + _type: + none: "Публікація" _achievements: earnedAt: "Відкрито" _types: diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index 809e78549295..4a930626f4f8 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -846,6 +846,10 @@ icon: "Avatar" replies: "Javob berish" renotes: "Qayta qayd etish" flip: "Teskari" +_delivery: + stop: "To'xtatilgan" + _type: + none: "Yuborilmoqda" _achievements: _types: _viewInstanceChart: diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index d9c21d29ad4c..acc2e0c6a99e 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1118,6 +1118,10 @@ pullDownToRefresh: "Kéo xuống để làm mới" cwNotationRequired: "Nếu \"Ẩn nội dung\" được bật thì cần phải có chú thích." lastNDays: "{n} ngày trước" surrender: "Từ chối" +_delivery: + stop: "Đã vô hiệu hóa" + _type: + none: "Đang đăng" _announcement: forExistingUsers: "Chỉ những người dùng đã tồn tại" forExistingUsersDescription: "Nếu được bật, thông báo này sẽ chỉ hiển thị với những người dùng đã tồn tại vào lúc thông báo được tạo. Nếu tắt đi, những tài khoản mới đăng ký sau khi thông báo được đăng lên cũng sẽ thấy nó." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 17164dfe988c..f92d997b5a3a 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -316,6 +316,7 @@ selectFile: "选择文件" selectFiles: "选择文件" selectFolder: "选择文件夹" selectFolders: "选择多个文件夹" +fileNotSelected: "未选择文件" renameFile: "重命名文件" folderName: "文件夹名称" createFolder: "创建文件夹" @@ -471,6 +472,7 @@ retype: "重新输入" noteOf: "{user} 的帖子" quoteAttached: "已引用" quoteQuestion: "是否引用此链接内容?" +attachAsFileQuestion: "剪贴板内的文字过长。要转换为文本文件并添加吗?" noMessagesYet: "现在没有新的聊天" newMessageExists: "新信息" onlyOneFileCanBeAttached: "只能添加一个附件" @@ -1024,6 +1026,7 @@ thisPostMayBeAnnoyingHome: "发到首页" thisPostMayBeAnnoyingCancel: "取消" thisPostMayBeAnnoyingIgnore: "就这样发布" collapseRenotes: "省略显示已经看过的转发内容" +collapseRenotesDescription: "将回应过或转贴过的贴子折叠表示。" internalServerError: "内部服务器错误" internalServerErrorDescription: "内部服务器发生了预期外的错误" copyErrorInfo: "复制错误信息" @@ -1238,6 +1241,15 @@ keepOriginalFilenameDescription: "若关闭此设置,上传文件时文件名 noDescription: "没有描述" alwaysConfirmFollow: "总是确认关注" inquiry: "联系我们" +_delivery: + status: "投递状态" + stop: "停止投递" + resume: "继续投递" + _type: + none: "投递中" + manuallySuspended: "手动停止中" + goneSuspended: "因服务器被删除而停止" + autoSuspendedForNotResponding: "因服务器无应答而停止" _bubbleGame: howToPlay: "游戏说明" hold: "抓住" @@ -1696,8 +1708,10 @@ _role: roleAssignedTo: "已分配给手动角色" isLocal: "是本地用户" isRemote: "是远程用户" + isCat: "猫猫用户" isBot: "机器人用户" isSuspended: "停用的用户" + isLocked: "锁推用户" isExplorable: "启用“使账号可见”的用户" createdLessThan: "账户创建时间少于" createdMoreThan: "账户创建时间超过" @@ -2032,7 +2046,6 @@ _permissions: "read:admin:server-info": "查看服务器信息" "read:admin:show-moderation-log": "查看管理日志" "read:admin:show-user": "查看用户的非公开信息" - "read:admin:show-users": "查看用户的非公开信息" "write:admin:suspend-user": "冻结用户" "write:admin:unset-user-avatar": "删除用户头像" "write:admin:unset-user-banner": "删除用户横幅" @@ -2346,6 +2359,7 @@ _deck: alwaysShowMainColumn: "总是显示主列" columnAlign: "列对齐" addColumn: "添加列" + newNoteNotificationSettings: "新帖子通知设定" configureColumn: "列设置" swapLeft: "向左移动" swapRight: "向右移动" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 8cde13052f35..aac3f7662c7e 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -108,11 +108,14 @@ enterEmoji: "輸入表情符號" renote: "轉發" unrenote: "取消轉發" renoted: "轉發成功。" +renotedToX: "轉發給 {name} 了。" cantRenote: "無法轉發此貼文。" cantReRenote: "無法轉發之前已經轉發過的內容。" quote: "引用" inChannelRenote: "在頻道內轉發" inChannelQuote: "在頻道內引用" +renoteToChannel: "轉發至頻道" +renoteToOtherChannel: "轉發至其他頻道" pinnedNote: "已置頂的貼文" pinned: "置頂" you: "您" @@ -169,7 +172,7 @@ cacheRemoteSensitiveFilesDescription: "若停用這個設定,則不會快取 flagAsBot: "此使用者是機器人" flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整 Misskey 內部系統將本帳戶識別為機器人。" flagAsCat: "此帳戶是一隻貓,喵~~~!!!" -flagAsCatDescription: "如果想將本帳戶標示為一隻貓,請開啟此標示" +flagAsCatDescription: "喵喵喵??" flagShowTimelineReplies: "在時間軸上顯示貼文的回覆" flagShowTimelineRepliesDescription: "啟用後,時間軸除了顯示使用者的貼文以外,還會顯示使用者對其他貼文的回覆。" autoAcceptFollowed: "自動允許來自追隨中使用者的追隨請求" @@ -313,6 +316,7 @@ selectFile: "選擇檔案" selectFiles: "選擇檔案" selectFolder: "選擇資料夾" selectFolders: "選擇資料夾" +fileNotSelected: "尚未選擇檔案" renameFile: "重新命名檔案" folderName: "資料夾名稱" createFolder: "新增資料夾" @@ -366,7 +370,7 @@ enableRegistration: "開放新使用者註冊" invite: "邀請" driveCapacityPerLocalAccount: "每個本地使用者的雲端硬碟容量" driveCapacityPerRemoteAccount: "每個非本地用戶的雲端空間大小" -inMb: "以Mbps為單位" +inMb: "以 MB 為單位" bannerUrl: "橫幅圖片URL" backgroundImageUrl: "背景圖片的來源網址 " basicInfo: "基本資訊" @@ -378,12 +382,12 @@ pinnedClipId: "置頂的摘錄ID" pinnedNotes: "已置頂的貼文" hcaptcha: "hCaptcha" enableHcaptcha: "啟用 hCaptcha" -hcaptchaSiteKey: "網站金鑰" -hcaptchaSecretKey: "金鑰" +hcaptchaSiteKey: "hcaptchaSiteKey" +hcaptchaSecretKey: "hcaptchaSecretKey" mcaptcha: "mCaptcha" enableMcaptcha: "啟用 mCaptcha" mcaptchaSiteKey: "網站金鑰" -mcaptchaSecretKey: "金鑰" +mcaptchaSecretKey: "私密金鑰" mcaptchaInstanceUrl: "mCaptcha 的實例網址" recaptcha: "reCAPTCHA" enableRecaptcha: "啟用 reCAPTCHA" @@ -391,8 +395,8 @@ recaptchaSiteKey: "網站金鑰" recaptchaSecretKey: "金鑰" turnstile: "Turnstile" enableTurnstile: "啟用 Turnstile" -turnstileSiteKey: "網站金鑰" -turnstileSecretKey: "金鑰" +turnstileSiteKey: "turnstileSiteKey" +turnstileSecretKey: "turnstileSecretKey" avoidMultiCaptchaConfirm: "使用多種驗證方式可能會造成干擾,您要關閉其他驗證方式嗎?您可以按「取消」保留多種驗證方式。" antennas: "天線" manageAntennas: "管理天線" @@ -464,10 +468,11 @@ title: "標題" text: "文字" enable: "啟用" next: "下一步" -retype: "再次輸入" +retype: "重新輸入" noteOf: "{user}的貼文" quoteAttached: "引用" quoteQuestion: "是否要引用?" +attachAsFileQuestion: "剪貼簿的文字較長。請問是否要將其以文字檔的方式附加呢?" noMessagesYet: "沒有訊息" newMessageExists: "有新的訊息" onlyOneFileCanBeAttached: "只能加入一個附件" @@ -602,7 +607,7 @@ addItem: "新增項目" rearrange: "排序方式" relays: "中繼器" addRelay: "新增中繼器" -inboxUrl: "收件夾URL" +inboxUrl: "收件夾 URL" addedRelays: "已加入的中繼器" serviceworkerInfo: "如要使用推播通知,需要啟用此選項並設定金鑰。" deletedNote: "已刪除的貼文" @@ -791,7 +796,7 @@ newVersionOfClientAvailable: "新版本的客戶端可用。" usageAmount: "使用量" capacity: "容量" inUse: "已使用" -editCode: "編輯代碼" +editCode: "編輯程式碼" apply: "套用" receiveAnnouncementFromInstance: "接收來自伺服器的通知" emailNotification: "郵件通知" @@ -1021,6 +1026,7 @@ thisPostMayBeAnnoyingHome: "發佈到首頁" thisPostMayBeAnnoyingCancel: "退出" thisPostMayBeAnnoyingIgnore: "直接發佈貼文" collapseRenotes: "省略顯示已看過的轉發貼文" +collapseRenotesDescription: "將已做過反應和轉發的貼文折疊顯示。" internalServerError: "內部伺服器錯誤" internalServerErrorDescription: "內部伺服器出現意外錯誤。" copyErrorInfo: "複製錯誤資訊" @@ -1062,7 +1068,7 @@ enableChartsForFederatedInstances: "生成遠端伺服器的圖表" showClipButtonInNoteFooter: "新增摘錄按鈕至貼文" reactionsDisplaySize: "反應的顯示尺寸" limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。" -noteIdOrUrl: "貼文ID或URL" +noteIdOrUrl: "貼文 ID 或 URL" video: "影片" videos: "影片" audio: "音效" @@ -1077,7 +1083,7 @@ addMemo: "新增備註" editMemo: "編輯備註" reactionsList: "反應列表" renotesList: "轉發貼文列表" -notificationDisplay: "通知的顯示" +notificationDisplay: "通知" leftTop: "左上" rightTop: "右上" leftBottom: "左下" @@ -1179,15 +1185,15 @@ repositoryUrlOrTarballRequired: "如果儲存庫不是公開的,則必須提 feedback: "意見回饋" feedbackUrl: "意見回饋 URL" impressum: "營運者資訊" -impressumUrl: "營運者資訊網址" +impressumUrl: "營運者資訊 URL" impressumDescription: "在德國與部份地區必須要明確顯示營運者資訊。" privacyPolicy: "隱私政策" -privacyPolicyUrl: "隱私政策網址" +privacyPolicyUrl: "隱私政策 URL" tosAndPrivacyPolicy: "服務條款和隱私政策" avatarDecorations: "頭像裝飾" attach: "裝上" detach: "取下" -detachAll: "移除所有裝飾" +detachAll: "全部移除" angle: "角度" flip: "翻轉" showAvatarDecorations: "顯示頭像裝飾" @@ -1205,7 +1211,7 @@ remainingN: "剩餘:{n}" overwriteContentConfirm: "確定要覆蓋目前的內容嗎?" seasonalScreenEffect: "隨季節變換畫面的呈現" decorate: "設置頭像裝飾" -addMfmFunction: "插入MFM功能語法" +addMfmFunction: "插入 MFM 功能語法" enableQuickAddMfmFunction: "顯示高級 MFM 選擇器" bubbleGame: "氣泡遊戲" sfx: "音效" @@ -1225,16 +1231,25 @@ enableHorizontalSwipe: "滑動切換時間軸" loading: "載入中" surrender: "退出" gameRetry: "再試一次" -notUsePleaseLeaveBlank: "如不使用,請留空" +notUsePleaseLeaveBlank: "如果不使用的話請留白" useTotp: "使用一次性密碼" useBackupCode: "使用備用驗證碼" -launchApp: "啟動 App" +launchApp: "啟動 APP" useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊" keepOriginalFilename: "保留原始檔名" keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。" noDescription: "沒有說明文字" alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息" inquiry: "聯絡我們" +_delivery: + status: "傳送狀態" + stop: "停止傳送" + resume: "恢復傳送" + _type: + none: "直播中" + manuallySuspended: "手動暫停中" + goneSuspended: "因為伺服器刪除所以暫停中" + autoSuspendedForNotResponding: "因為伺服器沒有回應所以暫停中" _bubbleGame: howToPlay: "玩法說明" hold: "保留" @@ -1243,7 +1258,7 @@ _bubbleGame: scoreYen: "賺取的金額" highScore: "最高分" maxChain: "最大結合數" - yen: "{yen} 日圓" + yen: "{yen}円" estimatedQty: "{qty}個" scoreSweets: "飯糰 {onigiriQtyWithUnit}" _howToPlay: @@ -1271,7 +1286,7 @@ _initialAccountSetting: privacySetting: "隱私設定" theseSettingsCanEditLater: "這裡的設定可以在之後變更。" youCanEditMoreSettingsInSettingsPageLater: "除此之外,還可以在「設定」頁面進行各種設定。之後請確認看看。" - followUsers: "為了構築時間軸,試著追蹤您感興趣的使用者吧。" + followUsers: "為了構築時間軸,試著追隨您感興趣的使用者吧。" pushNotificationDescription: "啟用推送通知,就可以在設備上接收{name}的通知。" initialAccountSettingCompleted: "初始設定完成了!" haveFun: "盡情享受{name}吧!" @@ -1326,7 +1341,7 @@ _initialTutorial: title: "隱藏內容(CW)" description: "將顯示「註釋」中寫入的內容而不是本文。按一下「顯示內容」以顯示本文。" _exampleNote: - cw: "美食恐怖主義注意" + cw: "注意消夜文" note: "我吃了一個巧克力甜甜圈🍩😋" useCases: "伺服器的服務條款可能會規範特定的貼文需要使用隱藏內容,除此之外也會用在隱藏劇情洩漏與敏感內容的貼文。" _howToMakeAttachmentsSensitive: @@ -1351,7 +1366,7 @@ _serverRules: _serverSettings: iconUrl: "圖示的 URL" appIconDescription: "指定顯示 {host} 為應用程式時的圖示。" - appIconUsageExample: "例如:漸進式網路應用程式(PWA)、於手機桌面新增書籤" + appIconUsageExample: "例如:PWA 或是在手機桌面作為書籤等" appIconStyleRecommendation: "因為可能會裁剪成圓形或圓角,所以建議用單色填滿邊框及背景。" appIconResolutionMustBe: "解析度必須為 {resolution}。" manifestJsonOverride: "覆寫 manifest.json" @@ -1360,6 +1375,8 @@ _serverSettings: fanoutTimelineDescription: "如果啟用的話,檢索各個時間軸的性能會顯著提昇,資料庫的負荷也會減少。不過,Redis 的記憶體使用量會增加。如果伺服器的記憶體容量比較少或者運行不穩定,可以停用。" fanoutTimelineDbFallback: "資料庫的回退" fanoutTimelineDbFallbackDescription: "若啟用,在時間軸沒有快取的情況下將執行回退處理以額外查詢資料庫。若停用,可以透過不執行回退處理來進一步減少伺服器的負荷,但會限制可取得的時間軸範圍。" + inquiryUrl: "聯絡表單網址" + inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址或包含運營者聯絡資訊網頁的網址。" _accountMigration: moveFrom: "從其他帳戶遷移到這個帳戶" moveFromSub: "為另一個帳戶建立別名" @@ -1559,7 +1576,7 @@ _achievements: _postedAt0min0sec: title: "報時" description: "在零分零秒發佈貼文" - flavor: "啵、啵、啵、嗶ーー" + flavor: "啵.啵.啵.嗶ー" _selfQuote: title: "自我引用" description: "引用了自己的貼文" @@ -1694,8 +1711,8 @@ _role: roleAssignedTo: "手動指派角色完成" isLocal: "本地使用者" isRemote: "遠端使用者" - isCat: "使用者是貓" - isBot: "使用者是機器人" + isCat: "貓使用者" + isBot: "機器人使用者" isSuspended: "被停權的使用者" isLocked: "上鎖的使用者" isExplorable: "開啟了「使您的帳戶更容易被找到」功能的使用者" @@ -1857,7 +1874,7 @@ _theme: invalid: "佈景主題格式錯誤" make: "製作佈景主題" base: "基於" - addConstant: "添加常數" + addConstant: "新增常數" constant: "常數" defaultValue: "預設值" color: "顏色" @@ -1932,22 +1949,22 @@ _soundSettings: _ago: future: "未來" justNow: "剛剛" - secondsAgo: "{n} 秒前" - minutesAgo: "{n} 分鐘前 " - hoursAgo: "{n} 小時前" - daysAgo: "{n} 天前" - weeksAgo: "{n} 週前" - monthsAgo: "{n} 個月前" - yearsAgo: "{n} 年前" + secondsAgo: "{n}秒前" + minutesAgo: "{n}分鐘前" + hoursAgo: "{n}小時前" + daysAgo: "{n}天前" + weeksAgo: "{n}周前" + monthsAgo: "{n}個月前" + yearsAgo: "{n}年前" invalid: "無" _timeIn: - seconds: "{n} 秒後" - minutes: "{n} 分後" - hours: "{n} 小時後" - days: "{n} 日後" - weeks: "{n} 週後" - months: "{n} 個月後" - years: "{n} 年後" + seconds: "{n}秒後" + minutes: "{n}分鐘後" + hours: "{n}小時後" + days: "{n}天後" + weeks: "{n}周後" + months: "{n}個月後" + years: "{n}年後" _time: second: "秒" minute: "分鐘" @@ -2032,7 +2049,6 @@ _permissions: "read:admin:server-info": "查看伺服器的資訊" "read:admin:show-moderation-log": "查看審查紀錄" "read:admin:show-user": "查看使用者的私密資訊" - "read:admin:show-users": "查看使用者的私密資訊" "write:admin:suspend-user": "凍結使用者" "write:admin:unset-user-avatar": "刪除使用者的頭像" "write:admin:unset-user-banner": "刪除使用者的橫幅" @@ -2085,13 +2101,13 @@ _antennaSources: userList: "來自特定清單中的貼文" userBlacklist: "除指定使用者外的所有貼文" _weekday: - sunday: "週日" - monday: "週一" - tuesday: "週二" - wednesday: "週三" - thursday: "週四" - friday: "週五" - saturday: "週六" + sunday: "星期天" + monday: "星期一" + tuesday: "星期二" + wednesday: "星期三" + thursday: "星期四" + friday: "星期五" + saturday: "星期六" _widgets: profile: "個人檔案" instanceInfo: "伺服器資訊" @@ -2140,7 +2156,7 @@ _poll: deadlineDate: "截止日期" deadlineTime: "小時" duration: "時長" - votesCount: "{n} 票" + votesCount: "{n}票" totalVotes: "合計 {n} 票" vote: "投票" showResult: "顯示結果" @@ -2173,7 +2189,7 @@ _postForm: e: "寫些什麼吧……" f: "靜待發文……" _profile: - name: "名稱" + name: "名字" username: "使用者名稱" description: "關於我" youCanIncludeHashtags: "你也可以在「關於我」中加上 #tag" @@ -2231,10 +2247,10 @@ _timelines: _play: new: "新增 Play" edit: "編輯 Play" - created: "已新增Play " - updated: "已更新Play " + created: "已新增 Play " + updated: "已更新 Play " deleted: "已刪除 Play" - pageSetting: "Play設定" + pageSetting: "Play 設定" editThisPage: "編輯此 Play" viewSource: "檢視原始碼" my: "自己的 Play" @@ -2247,7 +2263,7 @@ _play: _pages: newPage: "建立頁面" editPage: "編輯頁面" - readPage: "正檢視原始碼" + readPage: "正在檢視原始碼" created: "頁面已建立" updated: "頁面已更新" deleted: "頁面已被刪除" @@ -2274,7 +2290,7 @@ _pages: hideTitleWhenPinned: "被置頂於個人資料時隱藏頁面標題" font: "字型" fontSerif: "襯線體" - fontSansSerif: "無襯線體" + fontSansSerif: "黑體" eyeCatchingImageSet: "設定封面影像" eyeCatchingImageRemove: "刪除封面影像" chooseBlock: "新增方塊" @@ -2346,6 +2362,7 @@ _deck: alwaysShowMainColumn: "總是顯示主欄" columnAlign: "對齊欄位" addColumn: "新增欄位" + newNoteNotificationSettings: "新貼文通知的設定" configureColumn: "欄位的設定" swapLeft: "向左移動" swapRight: "向右移動" @@ -2384,7 +2401,7 @@ _drivecleaner: orderByCreatedAtAsc: "按新增日期降序排列" _webhookSettings: createWebhook: "建立 Webhook" - name: "名稱" + name: "名字" secret: "密鑰" events: "何時運行 Webhook" active: "已啟用" diff --git a/package.json b/package.json index 23e0ea0ee5b9..bf8415d21217 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "2024.3.1", + "version": "2024.5.0", "codename": "nasubi", "repository": { "type": "git", @@ -55,20 +55,22 @@ "js-yaml": "4.1.0", "postcss": "8.4.38", "tar": "6.2.1", - "terser": "5.30.3", - "typescript": "5.4.5", - "esbuild": "0.20.2", + "terser": "5.31.1", + "typescript": "5.5.3", + "esbuild": "0.22.0", "glob": "10.3.12" }, "devDependencies": { - "@types/node": "20.12.7", - "@typescript-eslint/eslint-plugin": "7.7.1", - "@typescript-eslint/parser": "7.7.1", + "@misskey-dev/eslint-plugin": "2.0.2", + "@types/node": "20.14.9", + "@typescript-eslint/eslint-plugin": "7.15.0", + "@typescript-eslint/parser": "7.15.0", "cross-env": "7.0.3", - "cypress": "13.7.3", - "eslint": "8.57.0", + "cypress": "13.13.0", + "eslint": "9.6.0", + "globals": "15.7.0", "ncp": "2.0.0", - "start-server-and-test": "2.0.3" + "start-server-and-test": "2.0.4" }, "optionalDependencies": { "@tensorflow/tfjs-core": "4.4.0" diff --git a/packages/backend/.eslintignore b/packages/backend/.eslintignore deleted file mode 100644 index 790eb90145b4..000000000000 --- a/packages/backend/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -/built -/.eslintrc.js -/@types/**/* diff --git a/packages/backend/.eslintrc.cjs b/packages/backend/.eslintrc.cjs deleted file mode 100644 index f9fe4814e671..000000000000 --- a/packages/backend/.eslintrc.cjs +++ /dev/null @@ -1,32 +0,0 @@ -module.exports = { - parserOptions: { - tsconfigRootDir: __dirname, - project: ['./tsconfig.json', './test/tsconfig.json'], - }, - extends: [ - '../shared/.eslintrc.js', - ], - rules: { - 'import/order': ['warn', { - 'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], - 'pathGroups': [ - { - 'pattern': '@/**', - 'group': 'external', - 'position': 'after' - } - ], - }], - 'no-restricted-globals': [ - 'error', - { - 'name': '__dirname', - 'message': 'Not in ESModule. Use `import.meta.url` instead.' - }, - { - 'name': '__filename', - 'message': 'Not in ESModule. Use `import.meta.url` instead.' - } - ] - }, -}; diff --git a/packages/backend/assets/api-doc.html b/packages/backend/assets/api-doc.html new file mode 100644 index 000000000000..19e0349d47d9 --- /dev/null +++ b/packages/backend/assets/api-doc.html @@ -0,0 +1,20 @@ + + + + Misskey API + + + + + + + + + diff --git a/packages/backend/assets/redoc.html b/packages/backend/assets/redoc.html deleted file mode 100644 index 2557b4532ec3..000000000000 --- a/packages/backend/assets/redoc.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - Misskey API - - - - - - - - - - - - - diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js new file mode 100644 index 000000000000..318b7fd340f3 --- /dev/null +++ b/packages/backend/eslint.config.js @@ -0,0 +1,46 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../shared/eslint.config.js'; + +export default [ + ...sharedConfig, + { + ignores: ['**/node_modules', 'built', '@types/**/*'], + }, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json', './test/tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + 'import/order': ['warn', { + groups: [ + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index', + 'object', + 'type', + ], + pathGroups: [{ + pattern: '@/**', + group: 'external', + position: 'after', + }], + }], + 'no-restricted-globals': ['error', { + name: '__dirname', + message: 'Not in ESModule. Use `import.meta.url` instead.', + }, { + name: '__filename', + message: 'Not in ESModule. Use `import.meta.url` instead.', + }], + }, + }, +]; diff --git a/packages/backend/migration/1713656541000-abuse-report-notification.js b/packages/backend/migration/1713656541000-abuse-report-notification.js new file mode 100644 index 000000000000..4a754f81e2f5 --- /dev/null +++ b/packages/backend/migration/1713656541000-abuse-report-notification.js @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AbuseReportNotification1713656541000 { + name = 'AbuseReportNotification1713656541000' + + async up(queryRunner) { + await queryRunner.query(` + CREATE TABLE "system_webhook" ( + "id" varchar(32) NOT NULL, + "isActive" boolean NOT NULL DEFAULT true, + "updatedAt" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + "latestSentAt" timestamp with time zone NULL DEFAULT NULL, + "latestStatus" integer NULL DEFAULT NULL, + "name" varchar(255) NOT NULL, + "on" varchar(128) [] NOT NULL DEFAULT '{}'::character varying[], + "url" varchar(1024) NOT NULL, + "secret" varchar(1024) NOT NULL, + CONSTRAINT "PK_system_webhook_id" PRIMARY KEY ("id") + ); + CREATE INDEX "IDX_system_webhook_isActive" ON "system_webhook" ("isActive"); + CREATE INDEX "IDX_system_webhook_on" ON "system_webhook" USING gin ("on"); + + CREATE TABLE "abuse_report_notification_recipient" ( + "id" varchar(32) NOT NULL, + "isActive" boolean NOT NULL DEFAULT true, + "updatedAt" timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" varchar(255) NOT NULL, + "method" varchar(64) NOT NULL, + "userId" varchar(32) NULL DEFAULT NULL, + "systemWebhookId" varchar(32) NULL DEFAULT NULL, + CONSTRAINT "PK_abuse_report_notification_recipient_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_abuse_report_notification_recipient_userId1" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_abuse_report_notification_recipient_userId2" FOREIGN KEY ("userId") REFERENCES "user_profile"("userId") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_abuse_report_notification_recipient_systemWebhookId" FOREIGN KEY ("systemWebhookId") REFERENCES "system_webhook"("id") ON DELETE CASCADE ON UPDATE NO ACTION + ); + CREATE INDEX "IDX_abuse_report_notification_recipient_isActive" ON "abuse_report_notification_recipient" ("isActive"); + CREATE INDEX "IDX_abuse_report_notification_recipient_method" ON "abuse_report_notification_recipient" ("method"); + CREATE INDEX "IDX_abuse_report_notification_recipient_userId" ON "abuse_report_notification_recipient" ("userId"); + CREATE INDEX "IDX_abuse_report_notification_recipient_systemWebhookId" ON "abuse_report_notification_recipient" ("systemWebhookId"); + `); + } + + async down(queryRunner) { + await queryRunner.query(` + ALTER TABLE "abuse_report_notification_recipient" DROP CONSTRAINT "FK_abuse_report_notification_recipient_userId1"; + ALTER TABLE "abuse_report_notification_recipient" DROP CONSTRAINT "FK_abuse_report_notification_recipient_userId2"; + ALTER TABLE "abuse_report_notification_recipient" DROP CONSTRAINT "FK_abuse_report_notification_recipient_systemWebhookId"; + DROP INDEX "IDX_abuse_report_notification_recipient_isActive"; + DROP INDEX "IDX_abuse_report_notification_recipient_method"; + DROP INDEX "IDX_abuse_report_notification_recipient_userId"; + DROP INDEX "IDX_abuse_report_notification_recipient_systemWebhookId"; + DROP TABLE "abuse_report_notification_recipient"; + + DROP INDEX "IDX_system_webhook_isActive"; + DROP INDEX "IDX_system_webhook_on"; + DROP TABLE "system_webhook"; + `); + } +} diff --git a/packages/backend/migration/1716450883149-RemoveAntennaNotify.js b/packages/backend/migration/1716450883149-RemoveAntennaNotify.js new file mode 100644 index 000000000000..b5a2441855d2 --- /dev/null +++ b/packages/backend/migration/1716450883149-RemoveAntennaNotify.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RemoveAntennaNotify1716450883149 { + name = 'RemoveAntennaNotify1716450883149' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "notify"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "antenna" ADD "notify" boolean NOT NULL`); + } +} diff --git a/packages/backend/migration/1717117195275-inquiryUrl.js b/packages/backend/migration/1717117195275-inquiryUrl.js new file mode 100644 index 000000000000..29ca31af1403 --- /dev/null +++ b/packages/backend/migration/1717117195275-inquiryUrl.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class InquiryUrl1717117195275 { + name = 'InquiryUrl1717117195275' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "inquiryUrl" character varying(1024)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "inquiryUrl"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 8e29252d759c..22fdc5cf1681 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "engines": { - "node": ">=20.10.0" + "node": "^20.10.0 || ^22.0.0" }, "scripts": { "start": "node ./built/boot/entry.js", @@ -65,41 +65,43 @@ "utf-8-validate": "6.0.3" }, "dependencies": { - "@aws-sdk/client-s3": "3.412.0", - "@aws-sdk/lib-storage": "3.412.0", - "@bull-board/api": "5.17.0", - "@bull-board/fastify": "5.17.0", - "@bull-board/ui": "5.17.0", + "@aws-sdk/client-s3": "3.600.0", + "@aws-sdk/lib-storage": "3.600.0", + "@bull-board/api": "5.20.5", + "@bull-board/fastify": "5.20.5", + "@bull-board/ui": "5.20.5", "@discordapp/twemoji": "15.0.3", "@fastify/accepts": "4.3.0", "@fastify/cookie": "9.3.1", "@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/multipart": "8.3.0", + "@fastify/static": "7.0.4", "@fastify/view": "9.1.0", "@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/summaly": "5.1.0", - "@napi-rs/canvas": "^0.1.52", - "@nestjs/common": "10.3.8", - "@nestjs/core": "10.3.8", - "@nestjs/testing": "10.3.8", + "@napi-rs/canvas": "^0.1.53", + "@nestjs/common": "10.3.10", + "@nestjs/core": "10.3.10", + "@nestjs/testing": "10.3.10", "@peertube/http-signature": "1.7.0", + "@sentry/node": "8.13.0", + "@sentry/profiling-node": "8.13.0", "@simplewebauthn/server": "10.0.0", "@sinonjs/fake-timers": "11.2.2", "@smithy/node-http-handler": "2.5.0", "@swc/cli": "0.3.12", - "@swc/core": "1.4.17", + "@swc/core": "1.6.6", "@twemoji/parser": "15.1.1", "accepts": "1.3.8", - "ajv": "8.13.0", + "ajv": "8.16.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.7.8", + "bullmq": "5.8.3", "cacheable-lookup": "7.0.0", "cbor": "9.0.2", "chalk": "5.3.0", @@ -110,27 +112,27 @@ "content-disposition": "0.5.4", "date-fns": "2.30.0", "deep-email-validator": "0.1.21", - "fastify": "4.26.2", + "fastify": "4.28.1", "fastify-raw-body": "4.3.0", "feed": "4.2.2", "file-type": "19.0.0", - "fluent-ffmpeg": "2.1.2", + "fluent-ffmpeg": "2.1.3", "form-data": "4.0.0", - "got": "14.2.1", + "got": "14.4.1", "happy-dom": "10.0.3", "hpagent": "1.2.0", "htmlescape": "1.1.1", "http-link-header": "1.1.3", "ioredis": "5.4.1", - "ip-cidr": "3.1.0", + "ip-cidr": "4.0.1", "ipaddr.js": "2.2.0", - "is-svg": "5.0.0", + "is-svg": "5.0.1", "js-yaml": "4.1.0", - "jsdom": "24.0.0", + "jsdom": "24.1.0", "json5": "2.2.3", "jsonld": "8.3.2", "jsrsasign": "11.1.0", - "meilisearch": "0.38.0", + "meilisearch": "0.41.0", "mfm-js": "0.24.0", "microformats-parser": "2.0.2", "mime-types": "2.1.35", @@ -140,24 +142,24 @@ "nanoid": "5.0.7", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "nodemailer": "6.9.13", + "nodemailer": "6.9.14", "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.3", + "otpauth": "9.3.1", "parse5": "7.1.2", - "pg": "8.11.5", + "pg": "8.12.0", "pkce-challenge": "4.1.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", - "pug": "3.0.2", + "pug": "3.0.3", "punycode": "2.3.1", "qrcode": "1.5.3", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.20.10", + "re2": "1.21.3", "redis-lock": "0.1.4", "reflect-metadata": "0.2.2", "rename": "1.0.4", @@ -165,27 +167,26 @@ "rxjs": "7.8.1", "sanitize-html": "2.13.0", "secure-json-parse": "2.7.0", - "sharp": "0.33.3", + "sharp": "0.33.4", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.22.7", + "systeminformation": "5.22.11", "tinycolor2": "1.6.0", "tmp": "0.2.3", - "tsc-alias": "1.8.8", + "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", "typeorm": "0.3.20", - "typescript": "5.4.5", + "typescript": "5.5.3", "ulid": "2.3.0", "vary": "1.1.2", "web-push": "3.6.7", - "ws": "8.17.0", + "ws": "8.17.1", "xev": "3.0.2" }, "devDependencies": { "@jest/globals": "29.7.0", - "@misskey-dev/eslint-plugin": "1.0.0", - "@nestjs/platform-express": "10.3.8", + "@nestjs/platform-express": "10.3.10", "@simplewebauthn/types": "10.0.0", "@swc/jest": "0.2.36", "@types/accepts": "1.3.7", @@ -195,22 +196,21 @@ "@types/color-convert": "2.0.3", "@types/content-disposition": "0.5.8", "@types/fluent-ffmpeg": "2.1.24", - "@types/htmlescape": "^1.1.3", - "@types/http-link-header": "1.0.5", + "@types/htmlescape": "1.1.3", + "@types/http-link-header": "1.0.7", "@types/jest": "29.5.12", "@types/js-yaml": "4.0.9", - "@types/jsdom": "21.1.6", - "@types/jsonld": "1.5.13", + "@types/jsdom": "21.1.7", + "@types/jsonld": "1.5.14", "@types/jsrsasign": "10.5.14", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "20.12.7", - "@types/node-fetch": "3.0.3", + "@types/node": "20.14.9", "@types/nodemailer": "6.4.15", - "@types/oauth": "0.9.4", + "@types/oauth": "0.9.5", "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", - "@types/pg": "8.11.5", + "@types/pg": "8.11.6", "@types/pug": "2.0.10", "@types/punycode": "2.1.4", "@types/qrcode": "1.5.5", @@ -226,18 +226,17 @@ "@types/vary": "1.1.3", "@types/web-push": "3.6.3", "@types/ws": "8.5.10", - "@typescript-eslint/eslint-plugin": "7.7.1", - "@typescript-eslint/parser": "7.7.1", - "aws-sdk-client-mock": "3.0.1", + "@typescript-eslint/eslint-plugin": "7.15.0", + "@typescript-eslint/parser": "7.15.0", + "aws-sdk-client-mock": "4.0.1", "cross-env": "7.0.3", - "eslint": "8.57.0", "eslint-plugin-import": "2.29.1", - "execa": "8.0.1", - "fkill": "^9.0.0", + "execa": "9.2.0", + "fkill": "9.0.0", "jest": "29.7.0", "jest-mock": "29.7.0", - "nodemon": "3.1.0", + "nodemon": "3.1.4", "pid-port": "1.0.0", - "simple-oauth2": "5.0.0" + "simple-oauth2": "5.0.1" } } diff --git a/packages/backend/scripts/dev.mjs b/packages/backend/scripts/dev.mjs index 2d0de0f91666..a3e0558abd97 100644 --- a/packages/backend/scripts/dev.mjs +++ b/packages/backend/scripts/dev.mjs @@ -30,6 +30,7 @@ function execStart() { async function killProc() { if (backendProcess) { + backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す backendProcess.kill(); await new Promise(resolve => backendProcess.on('exit', resolve)); backendProcess = undefined; @@ -46,6 +47,7 @@ async function killProc() { ], { stdio: [process.stdin, process.stdout, process.stderr, 'ipc'], + serialization: "json", }) .on('message', async (message) => { if (message.type === 'exit') { diff --git a/packages/backend/src/NestLogger.ts b/packages/backend/src/NestLogger.ts index 80f1f7a024d6..d0be19664f31 100644 --- a/packages/backend/src/NestLogger.ts +++ b/packages/backend/src/NestLogger.ts @@ -7,7 +7,7 @@ import { LoggerService } from '@nestjs/common'; import Logger from '@/logger.js'; const logger = new Logger('core', 'cyan'); -const nestLogger = logger.createSubLogger('nest', 'green', false); +const nestLogger = logger.createSubLogger('nest', 'green'); export class NestLogger implements LoggerService { /** diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index 04c6ca9723cb..25375c3015f7 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -25,7 +25,7 @@ Error.stackTraceLimit = Infinity; EventEmitter.defaultMaxListeners = 128; const logger = new Logger('core', 'cyan'); -const clusterLogger = logger.createSubLogger('cluster', 'orange', false); +const clusterLogger = logger.createSubLogger('cluster', 'orange'); const ev = new Xev(); //#region Events diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 30f9477ccf81..4bc5c799cfb9 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -10,6 +10,8 @@ import * as os from 'node:os'; import cluster from 'node:cluster'; import chalk from 'chalk'; import chalkTemplate from 'chalk-template'; +import * as Sentry from '@sentry/node'; +import { nodeProfilingIntegration } from '@sentry/profiling-node'; import Logger from '@/logger.js'; import { loadConfig } from '@/config.js'; import type { Config } from '@/config.js'; @@ -23,7 +25,7 @@ const _dirname = dirname(_filename); const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8')); const logger = new Logger('core', 'cyan'); -const bootLogger = logger.createSubLogger('boot', 'magenta', false); +const bootLogger = logger.createSubLogger('boot', 'magenta'); const themeColor = chalk.hex('#86b300'); @@ -71,6 +73,24 @@ export async function masterMain() { bootLogger.succ('Misskey initialized'); + if (config.sentryForBackend) { + Sentry.init({ + integrations: [ + ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), + ], + + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + + // Set sampling rate for profiling - this is relative to tracesSampleRate + profilesSampleRate: 1.0, + + maxBreadcrumbs: 0, + + ...config.sentryForBackend.options, + }); + } + if (envOption.disableClustering) { if (envOption.onlyServer) { await server(); diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index d4a7cd56e5ce..5d4a15b29f02 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -4,13 +4,36 @@ */ import cluster from 'node:cluster'; +import * as Sentry from '@sentry/node'; +import { nodeProfilingIntegration } from '@sentry/profiling-node'; import { envOption } from '@/env.js'; +import { loadConfig } from '@/config.js'; import { jobQueue, server } from './common.js'; /** * Init worker process */ export async function workerMain() { + const config = loadConfig(); + + if (config.sentryForBackend) { + Sentry.init({ + integrations: [ + ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), + ], + + // Performance Monitoring + tracesSampleRate: 1.0, // Capture 100% of the transactions + + // Set sampling rate for profiling - this is relative to tracesSampleRate + profilesSampleRate: 1.0, + + maxBreadcrumbs: 0, + + ...config.sentryForBackend.options, + }); + } + if (envOption.onlyServer) { await server(); } else if (envOption.onlyQueue) { diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 0ca1fa55c1f8..3e5a1e81cd70 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -7,6 +7,7 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import * as yaml from 'js-yaml'; +import * as Sentry from '@sentry/node'; import type { RedisOptions } from 'ioredis'; type RedisOptionsSource = Partial & { @@ -22,7 +23,7 @@ type RedisOptionsSource = Partial & { * 設定ファイルの型 */ type Source = { - url: string; + url?: string; port?: number; socket?: string; chmodSocket?: string; @@ -30,9 +31,9 @@ type Source = { db: { host: string; port: number; - db: string; - user: string; - pass: string; + db?: string; + user?: string; + pass?: string; disableCache?: boolean; extra?: { [x: string]: string }; }; @@ -56,6 +57,8 @@ type Source = { index: string; scope?: 'local' | 'global' | string[]; }; + sentryForBackend?: { options: Partial; enableNodeProfiling: boolean; }; + sentryForFrontend?: { options: Partial }; publishTarballInsteadOfProvideRepositoryUrl?: boolean; @@ -166,6 +169,8 @@ export type Config = { redisForPubsub: RedisOptions & RedisOptionsSource; redisForJobQueue: RedisOptions & RedisOptionsSource; redisForTimelines: RedisOptions & RedisOptionsSource; + sentryForBackend: { options: Partial; enableNodeProfiling: boolean; } | undefined; + sentryForFrontend: { options: Partial } | undefined; perChannelMaxNoteCacheCount: number; perUserNotificationsMaxCount: number; deactivateAntennaThreshold: number; @@ -197,13 +202,17 @@ export function loadConfig(): Config { : { 'src/_boot_.ts': { file: 'src/_boot_.ts' } }; const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; - const url = tryCreateUrl(config.url); + const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? ''); const version = meta.version; const host = url.host; const hostname = url.hostname; const scheme = url.protocol.replace(/:$/, ''); const wsScheme = scheme.replace('http', 'ws'); + const dbDb = config.db.db ?? process.env.DATABASE_DB ?? ''; + const dbUser = config.db.user ?? process.env.DATABASE_USER ?? ''; + const dbPass = config.db.pass ?? process.env.DATABASE_PASSWORD ?? ''; + const externalMediaProxy = config.mediaProxy ? config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy : null; @@ -226,7 +235,7 @@ export function loadConfig(): Config { apiUrl: `${scheme}://${host}/api`, authUrl: `${scheme}://${host}/auth`, driveUrl: `${scheme}://${host}/files`, - db: config.db, + db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass }, dbReplications: config.dbReplications, dbSlaves: config.dbSlaves, meilisearch: config.meilisearch, @@ -234,6 +243,8 @@ export function loadConfig(): Config { redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis, redisForTimelines: config.redisForTimelines ? convertRedisOptions(config.redisForTimelines, host) : redis, + sentryForBackend: config.sentryForBackend, + sentryForFrontend: config.sentryForFrontend, id: config.id, proxy: config.proxy, proxySmtp: config.proxySmtp, @@ -252,7 +263,7 @@ export function loadConfig(): Config { deliverJobMaxAttempts: config.deliverJobMaxAttempts, inboxJobMaxAttempts: config.inboxJobMaxAttempts, proxyRemoteFiles: config.proxyRemoteFiles, - signToActivityPubGet: config.signToActivityPubGet, + signToActivityPubGet: config.signToActivityPubGet ?? true, mediaProxy: externalMediaProxy ?? internalMediaProxy, externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy, videoThumbnailGenerator: config.videoThumbnailGenerator ? diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index a238f4973a95..4dc689238bf2 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +// dummy export const MAX_NOTE_TEXT_LENGTH = 3000; export const USER_ONLINE_THRESHOLD = 1000 * 60 * 10; // 10min diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts new file mode 100644 index 000000000000..42e5931212f7 --- /dev/null +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -0,0 +1,405 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable, type OnApplicationShutdown } from '@nestjs/common'; +import { Brackets, In, IsNull, Not } from 'typeorm'; +import * as Redis from 'ioredis'; +import sanitizeHtml from 'sanitize-html'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import type { + AbuseReportNotificationRecipientRepository, + MiAbuseReportNotificationRecipient, + MiAbuseUserReport, + MiUser, +} from '@/models/_.js'; +import { EmailService } from '@/core/EmailService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { IdService } from './IdService.js'; + +@Injectable() +export class AbuseReportNotificationService implements OnApplicationShutdown { + constructor( + @Inject(DI.abuseReportNotificationRecipientRepository) + private abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + private idService: IdService, + private roleService: RoleService, + private systemWebhookService: SystemWebhookService, + private emailService: EmailService, + private metaService: MetaService, + private moderationLogService: ModerationLogService, + private globalEventService: GlobalEventService, + ) { + this.redisForSub.on('message', this.onMessage); + } + + /** + * 管理者用Redisイベントを用いて{@link abuseReports}の内容を管理者各位に通知する. + * 通知先ユーザは{@link RoleService.getModeratorIds}の取得結果に依る. + * + * @see RoleService.getModeratorIds + * @see GlobalEventService.publishAdminStream + */ + @bindThis + public async notifyAdminStream(abuseReports: MiAbuseUserReport[]) { + if (abuseReports.length <= 0) { + return; + } + + const moderatorIds = await this.roleService.getModeratorIds(true, true); + + for (const moderatorId of moderatorIds) { + for (const abuseReport of abuseReports) { + this.globalEventService.publishAdminStream( + moderatorId, + 'newAbuseUserReport', + { + id: abuseReport.id, + targetUserId: abuseReport.targetUserId, + reporterId: abuseReport.reporterId, + comment: abuseReport.comment, + }, + ); + } + } + } + + /** + * Mailを用いて{@link abuseReports}の内容を管理者各位に通知する. + * メールアドレスの送信先は以下の通り. + * - モデレータ権限所有者ユーザ(設定画面からメールアドレスの設定を行っているユーザに限る) + * - metaテーブルに設定されているメールアドレス + * + * @see EmailService.sendEmail + */ + @bindThis + public async notifyMail(abuseReports: MiAbuseUserReport[]) { + if (abuseReports.length <= 0) { + return; + } + + const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it + .filter(it => it.isActive && it.userProfile?.emailVerified) + .map(it => it.userProfile?.email) + .filter(x => x != null), + ); + + // 送信先の鮮度を保つため、毎回取得する + const meta = await this.metaService.fetch(true); + recipientEMailAddresses.push( + ...(meta.email ? [meta.email] : []), + ); + + if (recipientEMailAddresses.length <= 0) { + return; + } + + for (const mailAddress of recipientEMailAddresses) { + await Promise.all( + abuseReports.map(it => { + // TODO: 送信処理はJobQueue化したい + return this.emailService.sendEmail( + mailAddress, + 'New Abuse Report', + sanitizeHtml(it.comment), + sanitizeHtml(it.comment), + ); + }), + ); + } + } + + /** + * SystemWebhookを用いて{@link abuseReports}の内容を管理者各位に通知する. + * ここではJobQueueへのエンキューのみを行うため、即時実行されない. + * + * @see SystemWebhookService.enqueueSystemWebhook + */ + @bindThis + public async notifySystemWebhook( + abuseReports: MiAbuseUserReport[], + type: 'abuseReport' | 'abuseReportResolved', + ) { + if (abuseReports.length <= 0) { + return; + } + + const recipientWebhookIds = await this.fetchWebhookRecipients() + .then(it => it + .filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook') + .map(it => it.systemWebhookId) + .filter(x => x != null)); + for (const webhookId of recipientWebhookIds) { + await Promise.all( + abuseReports.map(it => { + return this.systemWebhookService.enqueueSystemWebhook( + webhookId, + type, + it, + ); + }), + ); + } + } + + /** + * 通報の通知先一覧を取得する. + * + * @param {Object} [params] クエリの取得条件 + * @param {Object} [params.method] 取得する通知先の通知方法 + * @param {Object} [opts] 動作時の詳細なオプション + * @param {boolean} [opts.removeUnauthorized] 副作用としてモデレータ権限を持たない送信先ユーザをDBから削除するかどうか(default: true) + * @param {boolean} [opts.joinUser] 通知先のユーザ情報をJOINするかどうか(default: false) + * @param {boolean} [opts.joinSystemWebhook] 通知先のSystemWebhook情報をJOINするかどうか(default: false) + * @see removeUnauthorizedRecipientUsers + */ + @bindThis + public async fetchRecipients( + params?: { + ids?: MiAbuseReportNotificationRecipient['id'][], + method?: RecipientMethod[], + }, + opts?: { + removeUnauthorized?: boolean, + joinUser?: boolean, + joinSystemWebhook?: boolean, + }, + ): Promise { + const query = this.abuseReportNotificationRecipientRepository.createQueryBuilder('recipient'); + + if (opts?.joinUser) { + query.innerJoinAndSelect('user', 'user', 'recipient.userId = user.id'); + query.innerJoinAndSelect('recipient.userProfile', 'userProfile'); + } + + if (opts?.joinSystemWebhook) { + query.innerJoinAndSelect('recipient.systemWebhook', 'systemWebhook'); + } + + if (params?.ids) { + query.andWhere({ id: In(params.ids) }); + } + + if (params?.method) { + query.andWhere(new Brackets(qb => { + if (params.method?.includes('email')) { + qb.orWhere({ method: 'email', userId: Not(IsNull()) }); + } + if (params.method?.includes('webhook')) { + qb.orWhere({ method: 'webhook', userId: IsNull() }); + } + })); + } + + const recipients = await query.getMany(); + if (recipients.length <= 0) { + return []; + } + + // アサイン有効期限切れはイベントで拾えないので、このタイミングでチェック及び削除(オプション) + return (opts?.removeUnauthorized ?? true) + ? await this.removeUnauthorizedRecipientUsers(recipients) + : recipients; + } + + /** + * EMailの通知先一覧を取得する. + * リレーション先の{@link MiUser}および{@link MiUserProfile}も同時に取得する. + * + * @param {Object} [opts] + * @param {boolean} [opts.removeUnauthorized] 副作用としてモデレータ権限を持たない送信先ユーザをDBから削除するかどうか(default: true) + * @see removeUnauthorizedRecipientUsers + */ + @bindThis + public async fetchEMailRecipients(opts?: { + removeUnauthorized?: boolean + }): Promise { + return this.fetchRecipients({ method: ['email'] }, { joinUser: true, ...opts }); + } + + /** + * Webhookの通知先一覧を取得する. + * リレーション先の{@link MiSystemWebhook}も同時に取得する. + */ + @bindThis + public fetchWebhookRecipients(): Promise { + return this.fetchRecipients({ method: ['webhook'] }, { joinSystemWebhook: true }); + } + + /** + * 通知先を作成する. + */ + @bindThis + public async createRecipient( + params: { + isActive: MiAbuseReportNotificationRecipient['isActive']; + name: MiAbuseReportNotificationRecipient['name']; + method: MiAbuseReportNotificationRecipient['method']; + userId: MiAbuseReportNotificationRecipient['userId']; + systemWebhookId: MiAbuseReportNotificationRecipient['systemWebhookId']; + }, + updater: MiUser, + ): Promise { + const id = this.idService.gen(); + await this.abuseReportNotificationRecipientRepository.insert({ + ...params, + id, + }); + + const created = await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: id }); + + this.moderationLogService + .log(updater, 'createAbuseReportNotificationRecipient', { + recipientId: id, + recipient: created, + }) + .then(); + + return created; + } + + /** + * 通知先を更新する. + */ + @bindThis + public async updateRecipient( + params: { + id: MiAbuseReportNotificationRecipient['id']; + isActive: MiAbuseReportNotificationRecipient['isActive']; + name: MiAbuseReportNotificationRecipient['name']; + method: MiAbuseReportNotificationRecipient['method']; + userId: MiAbuseReportNotificationRecipient['userId']; + systemWebhookId: MiAbuseReportNotificationRecipient['systemWebhookId']; + }, + updater: MiUser, + ): Promise { + const beforeEntity = await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: params.id }); + + await this.abuseReportNotificationRecipientRepository.update(params.id, { + isActive: params.isActive, + updatedAt: new Date(), + name: params.name, + method: params.method, + userId: params.userId, + systemWebhookId: params.systemWebhookId, + }); + + const afterEntity = await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: params.id }); + + this.moderationLogService + .log(updater, 'updateAbuseReportNotificationRecipient', { + recipientId: params.id, + before: beforeEntity, + after: afterEntity, + }) + .then(); + + return afterEntity; + } + + /** + * 通知先を削除する. + */ + @bindThis + public async deleteRecipient( + id: MiAbuseReportNotificationRecipient['id'], + updater: MiUser, + ) { + const entity = await this.abuseReportNotificationRecipientRepository.findBy({ id }); + + await this.abuseReportNotificationRecipientRepository.delete(id); + + this.moderationLogService + .log(updater, 'deleteAbuseReportNotificationRecipient', { + recipientId: id, + recipient: entity, + }) + .then(); + } + + /** + * モデレータ権限を持たない(*1)通知先ユーザを削除する. + * + * *1: 以下の両方を満たすものの事を言う + * - 通知先にユーザIDが設定されている + * - 付与ロールにモデレータ権限がない or アサインの有効期限が切れている + * + * @param recipients 通知先一覧の配列 + * @returns {@lisk recipients}からモデレータ権限を持たない通知先を削除した配列 + */ + @bindThis + private async removeUnauthorizedRecipientUsers(recipients: MiAbuseReportNotificationRecipient[]): Promise { + const userRecipients = recipients.filter(it => it.userId !== null); + const recipientUserIds = new Set(userRecipients.map(it => it.userId).filter(x => x != null)); + if (recipientUserIds.size <= 0) { + // ユーザが通知先として設定されていない場合、この関数での処理を行うべきレコードが無い + return recipients; + } + + // モデレータ権限の有無で通知先設定を振り分ける + const authorizedUserIds = await this.roleService.getModeratorIds(true, true); + const authorizedUserRecipients = Array.of(); + const unauthorizedUserRecipients = Array.of(); + for (const recipient of userRecipients) { + // eslint-disable-next-line + if (authorizedUserIds.includes(recipient.userId!)) { + authorizedUserRecipients.push(recipient); + } else { + unauthorizedUserRecipients.push(recipient); + } + } + + // モデレータ権限を持たない通知先をDBから削除する + if (unauthorizedUserRecipients.length > 0) { + await this.abuseReportNotificationRecipientRepository.delete(unauthorizedUserRecipients.map(it => it.id)); + } + const nonUserRecipients = recipients.filter(it => it.userId === null); + return [...nonUserRecipients, ...authorizedUserRecipients].sort((a, b) => a.id.localeCompare(b.id)); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + if (obj.channel !== 'internal') { + return; + } + + const { type } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'roleUpdated': + case 'roleDeleted': + case 'userRoleUnassigned': { + // 場合によってはキャッシュ更新よりも先にここが呼ばれてしまう可能性があるのでnextTickで遅延実行 + process.nextTick(async () => { + const recipients = await this.abuseReportNotificationRecipientRepository.findBy({ + userId: Not(IsNull()), + }); + await this.removeUnauthorizedRecipientUsers(recipients); + }); + break; + } + default: { + break; + } + } + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts new file mode 100644 index 000000000000..69c51509ba40 --- /dev/null +++ b/packages/backend/src/core/AbuseReportService.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import type { AbuseUserReportsRepository, MiAbuseUserReport, MiUser, UsersRepository } from '@/models/_.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { InstanceActorService } from '@/core/InstanceActorService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { IdService } from './IdService.js'; + +@Injectable() +export class AbuseReportService { + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private idService: IdService, + private abuseReportNotificationService: AbuseReportNotificationService, + private queueService: QueueService, + private instanceActorService: InstanceActorService, + private apRendererService: ApRendererService, + private moderationLogService: ModerationLogService, + ) { + } + + /** + * ユーザからの通報をDBに記録し、その内容を下記の手段で管理者各位に通知する. + * - 管理者用Redisイベント + * - EMail(モデレータ権限所有者ユーザ+metaテーブルに設定されているメールアドレス) + * - SystemWebhook + * + * @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える + * @see AbuseReportNotificationService.notify + */ + @bindThis + public async report(params: { + targetUserId: MiAbuseUserReport['targetUserId'], + targetUserHost: MiAbuseUserReport['targetUserHost'], + reporterId: MiAbuseUserReport['reporterId'], + reporterHost: MiAbuseUserReport['reporterHost'], + comment: string, + }[]) { + const entities = params.map(param => { + return { + id: this.idService.gen(), + targetUserId: param.targetUserId, + targetUserHost: param.targetUserHost, + reporterId: param.reporterId, + reporterHost: param.reporterHost, + comment: param.comment, + }; + }); + + const reports = Array.of(); + for (const entity of entities) { + const report = await this.abuseUserReportsRepository.insertOne(entity); + reports.push(report); + } + + return Promise.all([ + this.abuseReportNotificationService.notifyAdminStream(reports), + this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReport'), + this.abuseReportNotificationService.notifyMail(reports), + ]); + } + + /** + * 通報を解決し、その内容を下記の手段で管理者各位に通知する. + * - SystemWebhook + * + * @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える + * @param operator 通報を処理したユーザ + * @see AbuseReportNotificationService.notify + */ + @bindThis + public async resolve( + params: { + reportId: string; + forward: boolean; + }[], + operator: MiUser, + ) { + const paramsMap = new Map(params.map(it => [it.reportId, it])); + const reports = await this.abuseUserReportsRepository.findBy({ + id: In(params.map(it => it.reportId)), + }); + + for (const report of reports) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const ps = paramsMap.get(report.id)!; + + await this.abuseUserReportsRepository.update(report.id, { + resolved: true, + assigneeId: operator.id, + forwarded: ps.forward && report.targetUserHost !== null, + }); + + if (ps.forward && report.targetUserHost != null) { + const actor = await this.instanceActorService.getInstanceActor(); + const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); + + // eslint-disable-next-line + const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment); + const contextAssignedFlag = this.apRendererService.addContext(flag); + this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false); + } + + this.moderationLogService + .log(operator, 'resolveAbuseReport', { + reportId: report.id, + report: report, + forwarded: ps.forward && report.targetUserHost !== null, + }) + .then(); + } + + return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) }) + .then(reports => this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReportResolved')); + } +} diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index b298a7092983..40a9db01c057 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -4,13 +4,14 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { Brackets } from 'typeorm'; +import { Brackets, EntityNotFoundError } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { Packed } from '@/misc/json-schema.js'; import { IdService } from '@/core/IdService.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -29,6 +30,7 @@ export class AnnouncementService { private idService: IdService, private globalEventService: GlobalEventService, private moderationLogService: ModerationLogService, + private announcementEntityService: AnnouncementEntityService, ) { } @@ -65,7 +67,7 @@ export class AnnouncementService { @bindThis public async create(values: Partial, moderator?: MiUser): Promise<{ raw: MiAnnouncement; packed: Packed<'Announcement'> }> { - const announcement = await this.announcementsRepository.insert({ + const announcement = await this.announcementsRepository.insertOne({ id: this.idService.gen(), updatedAt: null, title: values.title, @@ -77,9 +79,9 @@ export class AnnouncementService { silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, userId: values.userId, - }).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0])); + }); - const packed = (await this.packMany([announcement]))[0]; + const packed = await this.announcementEntityService.pack(announcement); if (values.userId) { this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { @@ -177,6 +179,24 @@ export class AnnouncementService { } } + @bindThis + public async getAnnouncement(announcementId: MiAnnouncement['id'], me: MiUser | null): Promise> { + const announcement = await this.announcementsRepository.findOneByOrFail({ id: announcementId }); + if (me) { + if (announcement.userId && announcement.userId !== me.id) { + throw new EntityNotFoundError(this.announcementsRepository.metadata.target, { id: announcementId }); + } + + const read = await this.announcementReadsRepository.findOneBy({ + announcementId: announcement.id, + userId: me.id, + }); + return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me); + } else { + return this.announcementEntityService.pack(announcement, null); + } + } + @bindThis public async read(user: MiUser, announcementId: MiAnnouncement['id']): Promise { try { @@ -193,29 +213,4 @@ export class AnnouncementService { this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); } } - - @bindThis - public async packMany( - announcements: MiAnnouncement[], - me?: { id: MiUser['id'] } | null | undefined, - options?: { - reads?: MiAnnouncementRead[]; - }, - ): Promise[]> { - const reads = me ? (options?.reads ?? await this.getReads(me.id)) : []; - return announcements.map(announcement => ({ - id: announcement.id, - createdAt: this.idService.parse(announcement.id).date.toISOString(), - updatedAt: announcement.updatedAt?.toISOString() ?? null, - text: announcement.text, - title: announcement.title, - imageUrl: announcement.imageUrl, - icon: announcement.icon, - display: announcement.display, - needConfirmationToRead: announcement.needConfirmationToRead, - silence: announcement.silence, - forYou: announcement.userId === me?.id, - isRead: reads.some(read => read.announcementId === announcement.id), - })); - } } diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 21e31d79a429..8b54bbe01241 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -55,10 +55,10 @@ export class AvatarDecorationService implements OnApplicationShutdown { @bindThis public async create(options: Partial, moderator?: MiUser): Promise { - const created = await this.avatarDecorationsRepository.insert({ + const created = await this.avatarDecorationsRepository.insertOne({ id: this.idService.gen(), ...options, - }).then(x => this.avatarDecorationsRepository.findOneByOrFail(x.identifiers[0])); + }); this.globalEventService.publishInternalEvent('avatarDecorationCreated', created); diff --git a/packages/backend/src/core/ClipService.ts b/packages/backend/src/core/ClipService.ts index bb8be26ce6c5..929a9db0645a 100644 --- a/packages/backend/src/core/ClipService.ts +++ b/packages/backend/src/core/ClipService.ts @@ -41,17 +41,17 @@ export class ClipService { const currentCount = await this.clipsRepository.countBy({ userId: me.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).clipLimit) { throw new ClipService.TooManyClipsError(); } - const clip = await this.clipsRepository.insert({ + const clip = await this.clipsRepository.insertOne({ id: this.idService.gen(), userId: me.id, name: name, isPublic: isPublic, description: description, - }).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0])); + }); return clip; } @@ -102,7 +102,7 @@ export class ClipService { const currentCount = await this.clipNotesRepository.countBy({ clipId: clip.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) { throw new ClipService.TooManyClipNotesError(); } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 595315587241..0208540afa7b 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -5,6 +5,14 @@ import { Module } from '@nestjs/common'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { UserSearchService } from '@/core/UserSearchService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -56,7 +64,7 @@ import { UserMutingService } from './UserMutingService.js'; import { UserSuspendService } from './UserSuspendService.js'; import { UserAuthService } from './UserAuthService.js'; import { VideoProcessingService } from './VideoProcessingService.js'; -import { WebhookService } from './WebhookService.js'; +import { UserWebhookService } from './UserWebhookService.js'; import { ProxyAccountService } from './ProxyAccountService.js'; import { UtilityService } from './UtilityService.js'; import { FileInfoService } from './FileInfoService.js'; @@ -84,6 +92,7 @@ import ApRequestChart from './chart/charts/ap-request.js'; import { ChartManagementService } from './chart/ChartManagementService.js'; import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js'; +import { AnnouncementEntityService } from './entities/AnnouncementEntityService.js'; import { AntennaEntityService } from './entities/AntennaEntityService.js'; import { AppEntityService } from './entities/AppEntityService.js'; import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; @@ -143,6 +152,8 @@ import type { Provider } from '@nestjs/common'; //#region 文字列ベースでのinjection用(循環参照対応のため) const $LoggerService: Provider = { provide: 'LoggerService', useExisting: LoggerService }; +const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisting: AbuseReportService }; +const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService }; const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService }; const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService }; const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; @@ -192,10 +203,12 @@ const $UserFollowingService: Provider = { provide: 'UserFollowingService', useEx const $UserKeypairService: Provider = { provide: 'UserKeypairService', useExisting: UserKeypairService }; const $UserListService: Provider = { provide: 'UserListService', useExisting: UserListService }; const $UserMutingService: Provider = { provide: 'UserMutingService', useExisting: UserMutingService }; +const $UserSearchService: Provider = { provide: 'UserSearchService', useExisting: UserSearchService }; const $UserSuspendService: Provider = { provide: 'UserSuspendService', useExisting: UserSuspendService }; const $UserAuthService: Provider = { provide: 'UserAuthService', useExisting: UserAuthService }; const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useExisting: VideoProcessingService }; -const $WebhookService: Provider = { provide: 'WebhookService', useExisting: WebhookService }; +const $UserWebhookService: Provider = { provide: 'UserWebhookService', useExisting: UserWebhookService }; +const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useExisting: SystemWebhookService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; @@ -223,6 +236,8 @@ const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRe const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService }; const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService }; +const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService }; +const $AbuseReportNotificationRecipientEntityService: Provider = { provide: 'AbuseReportNotificationRecipientEntityService', useExisting: AbuseReportNotificationRecipientEntityService }; const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService }; const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService }; const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; @@ -256,6 +271,7 @@ const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', u const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService }; const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService }; const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService }; +const $SystemWebhookEntityService: Provider = { provide: 'SystemWebhookEntityService', useExisting: SystemWebhookEntityService }; const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService }; const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService }; @@ -283,6 +299,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ], providers: [ LoggerService, + AbuseReportService, + AbuseReportNotificationService, AccountMoveService, AccountUpdateService, AiService, @@ -332,10 +350,12 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserSearchService, UserSuspendService, UserAuthService, VideoProcessingService, - WebhookService, + UserWebhookService, + SystemWebhookService, UtilityService, FileInfoService, SearchService, @@ -363,6 +383,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, + AbuseReportNotificationRecipientEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -396,6 +418,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting RoleEntityService, ReversiGameEntityService, MetaEntityService, + SystemWebhookEntityService, ApAudienceService, ApDbResolverService, @@ -419,6 +442,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AbuseReportService, + $AbuseReportNotificationService, $AccountMoveService, $AccountUpdateService, $AiService, @@ -468,10 +493,12 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserSearchService, $UserSuspendService, $UserAuthService, $VideoProcessingService, - $WebhookService, + $UserWebhookService, + $SystemWebhookService, $UtilityService, $FileInfoService, $SearchService, @@ -499,6 +526,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, + $AbuseReportNotificationRecipientEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -532,6 +561,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $RoleEntityService, $ReversiGameEntityService, $MetaEntityService, + $SystemWebhookEntityService, $ApAudienceService, $ApDbResolverService, @@ -556,6 +586,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting exports: [ QueueModule, LoggerService, + AbuseReportService, + AbuseReportNotificationService, AccountMoveService, AccountUpdateService, AiService, @@ -605,10 +637,12 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserKeypairService, UserListService, UserMutingService, + UserSearchService, UserSuspendService, UserAuthService, VideoProcessingService, - WebhookService, + UserWebhookService, + SystemWebhookService, UtilityService, FileInfoService, SearchService, @@ -635,6 +669,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ChartManagementService, AbuseUserReportEntityService, + AnnouncementEntityService, + AbuseReportNotificationRecipientEntityService, AntennaEntityService, AppEntityService, AuthSessionEntityService, @@ -668,6 +704,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting RoleEntityService, ReversiGameEntityService, MetaEntityService, + SystemWebhookEntityService, ApAudienceService, ApDbResolverService, @@ -691,6 +728,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting //#region 文字列ベースでのinjection用(循環参照対応のため) $LoggerService, + $AbuseReportService, + $AbuseReportNotificationService, $AccountMoveService, $AccountUpdateService, $AiService, @@ -740,10 +779,12 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserKeypairService, $UserListService, $UserMutingService, + $UserSearchService, $UserSuspendService, $UserAuthService, $VideoProcessingService, - $WebhookService, + $UserWebhookService, + $SystemWebhookService, $UtilityService, $FileInfoService, $SearchService, @@ -770,6 +811,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ChartManagementService, $AbuseUserReportEntityService, + $AnnouncementEntityService, + $AbuseReportNotificationRecipientEntityService, $AntennaEntityService, $AppEntityService, $AuthSessionEntityService, @@ -803,6 +846,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $RoleEntityService, $ReversiGameEntityService, $MetaEntityService, + $SystemWebhookEntityService, $ApAudienceService, $ApDbResolverService, diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 1c75566755ad..7e11b9cdca15 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -68,7 +68,7 @@ export class CustomEmojiService implements OnApplicationShutdown { localOnly: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction: MiRole['id'][]; }, moderator?: MiUser): Promise { - const emoji = await this.emojisRepository.insert({ + const emoji = await this.emojisRepository.insertOne({ id: this.idService.gen(), updatedAt: new Date(), name: data.name, @@ -82,7 +82,7 @@ export class CustomEmojiService implements OnApplicationShutdown { isSensitive: data.isSensitive, localOnly: data.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction, - }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + }); if (data.host == null) { this.localEmojisCache.refresh(); @@ -346,10 +346,11 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise> { const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost))); - const res = {} as any; + const res = {} as Record; for (let i = 0; i < emojiNames.length; i++) { - if (emojis[i] != null) { - res[emojiNames[i]] = emojis[i]; + const resolvedEmoji = emojis[i]; + if (resolvedEmoji != null) { + res[emojiNames[i]] = resolvedEmoji; } } return res; diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index cdd0f9536887..8aa04b4da733 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -222,7 +222,7 @@ export class DriveService { file.size = size; file.storedInternal = false; - return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + return await this.driveFilesRepository.insertOne(file); } else { // use internal storage const accessKey = randomUUID(); const thumbnailAccessKey = 'thumbnail-' + randomUUID(); @@ -256,7 +256,7 @@ export class DriveService { file.md5 = hash; file.size = size; - return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + return await this.driveFilesRepository.insertOne(file); } } @@ -499,14 +499,20 @@ export class DriveService { if (user && !force) { // Check if there is a file with the same hash - const much = await this.driveFilesRepository.findOneBy({ + const matched = await this.driveFilesRepository.findOneBy({ md5: info.md5, userId: user.id, }); - if (much) { - this.registerLogger.info(`file with same hash is found: ${much.id}`); - return much; + if (matched) { + this.registerLogger.info(`file with same hash is found: ${matched.id}`); + if (sensitive && !matched.isSensitive) { + // The file is federated as sensitive for this time, but was federated as non-sensitive before. + // Therefore, update the file to sensitive. + await this.driveFilesRepository.update({ id: matched.id }, { isSensitive: true }); + matched.isSensitive = true; + } + return matched; } } @@ -612,7 +618,7 @@ export class DriveService { file.type = info.type.mime; file.storedInternal = false; - file = await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0])); + file = await this.driveFilesRepository.insertOne(file); } catch (err) { // duplicate key error (when already registered) if (isDuplicateKeyValueError(err)) { diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index 08f8f80a6ed9..435dbbae28a9 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -16,6 +16,7 @@ import type { UserProfilesRepository } from '@/models/_.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() export class EmailService { @@ -32,6 +33,7 @@ export class EmailService { private loggerService: LoggerService, private utilityService: UtilityService, private httpRequestService: HttpRequestService, + private queueService: QueueService, ) { this.logger = this.loggerService.getLogger('email'); } diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index d5058f37c2a5..b05af99c5e5e 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -55,9 +55,6 @@ export class FanoutTimelineEndpointService { @bindThis private async getMiNotes(ps: TimelineOptions): Promise { - let noteIds: string[]; - let shouldFallbackToDb = false; - // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); @@ -67,12 +64,11 @@ export class FanoutTimelineEndpointService { const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId); // TODO: いい感じにgetMulti内でソート済だからuniqするときにredisResultが全てソート済なのを利用して再ソートを避けたい - const redisResultIds = Array.from(new Set(redisResult.flat(1))); - - redisResultIds.sort(idCompare); - noteIds = redisResultIds.slice(0, ps.limit); + const redisResultIds = Array.from(new Set(redisResult.flat(1))).sort(idCompare); - shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0); + let noteIds = redisResultIds.slice(0, ps.limit); + const oldestNoteId = ascending ? redisResultIds[0] : redisResultIds[redisResultIds.length - 1]; + const shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId; if (!shouldFallbackToDb) { let filter = ps.noteFilter ?? (_note => true); diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 66db2067d9f8..7aeeb781786c 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -40,6 +40,7 @@ export class FederatedInstanceService implements OnApplicationShutdown { firstRetrievedAt: new Date(parsed.firstRetrievedAt), latestRequestReceivedAt: parsed.latestRequestReceivedAt ? new Date(parsed.latestRequestReceivedAt) : null, infoUpdatedAt: parsed.infoUpdatedAt ? new Date(parsed.infoUpdatedAt) : null, + notRespondingSince: parsed.notRespondingSince ? new Date(parsed.notRespondingSince) : null, }; }, }); @@ -55,11 +56,11 @@ export class FederatedInstanceService implements OnApplicationShutdown { const index = await this.instancesRepository.findOneBy({ host }); if (index == null) { - const i = await this.instancesRepository.insert({ + const i = await this.instancesRepository.insertOne({ id: this.idService.gen(), host, firstRetrievedAt: new Date(), - }).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0])); + }); this.federatedInstanceCache.set(host, i); return i; diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index 8d173855f33d..aa16468ecb65 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -154,7 +154,7 @@ export class FetchInstanceMetadataService { throw new Error('No wellknown links'); } - const links = wellknown.links as any[]; + const links = wellknown.links as ({ rel: string, href: string; })[]; const link1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0'); const link2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0'); diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 90efd63f3a05..a70743bed203 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -18,6 +18,7 @@ import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import type { MiSignin } from '@/models/Signin.js'; import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; +import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -227,6 +228,9 @@ export interface InternalEventTypes { webhookCreated: MiWebhook; webhookDeleted: MiWebhook; webhookUpdated: MiWebhook; + systemWebhookCreated: MiSystemWebhook; + systemWebhookDeleted: MiSystemWebhook; + systemWebhookUpdated: MiSystemWebhook; antennaCreated: MiAntenna; antennaDeleted: MiAntenna; antennaUpdated: MiAntenna; diff --git a/packages/backend/src/core/LoggerService.ts b/packages/backend/src/core/LoggerService.ts index 96d9b099927b..f102461a5065 100644 --- a/packages/backend/src/core/LoggerService.ts +++ b/packages/backend/src/core/LoggerService.ts @@ -15,7 +15,7 @@ export class LoggerService { } @bindThis - public getLogger(domain: string, color?: KEYWORD | undefined, store?: boolean) { - return new Logger(domain, color, store); + public getLogger(domain: string, color?: KEYWORD | undefined) { + return new Logger(domain, color); } } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 9786f8b8bb7a..74536c68f515 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -13,10 +13,12 @@ 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'; +import type { DefaultTreeAdapterMap } from 'parse5'; import type * as mfm from 'mfm-js'; -const treeAdapter = TreeAdapter.defaultTreeAdapter; +const treeAdapter = parse5.defaultTreeAdapter; +type Node = DefaultTreeAdapterMap['node']; +type ChildNode = DefaultTreeAdapterMap['childNode']; const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; @@ -46,7 +48,7 @@ export class MfmService { return text.trim(); - function getText(node: TreeAdapter.Node): string { + function getText(node: Node): string { if (treeAdapter.isTextNode(node)) return node.value; if (!treeAdapter.isElementNode(node)) return ''; if (node.nodeName === 'br') return '\n'; @@ -58,7 +60,7 @@ export class MfmService { return ''; } - function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { + function appendChildren(childNodes: ChildNode[]): void { if (childNodes) { for (const n of childNodes) { analyze(n); @@ -66,14 +68,16 @@ export class MfmService { } } - function analyze(node: TreeAdapter.Node) { + function analyze(node: Node) { if (treeAdapter.isTextNode(node)) { text += node.value; return; } // Skip comment or document type node - if (!treeAdapter.isElementNode(node)) return; + if (!treeAdapter.isElementNode(node)) { + return; + } switch (node.nodeName) { case 'br': { @@ -81,8 +85,7 @@ export class MfmService { break; } - case 'a': - { + case 'a': { const txt = getText(node); const rel = node.attrs.find(x => x.name === 'rel'); const href = node.attrs.find(x => x.name === 'href'); @@ -90,7 +93,7 @@ export class MfmService { // ハッシュタグ if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { text += txt; - // メンション + // メンション } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { const part = txt.split('@'); @@ -102,7 +105,7 @@ export class MfmService { } else if (part.length === 3) { text += txt; } - // その他 + // その他 } else { const generateLink = () => { if (!href && !txt) { @@ -130,8 +133,7 @@ export class MfmService { break; } - case 'h1': - { + case 'h1': { text += '【'; appendChildren(node.childNodes); text += '】\n'; @@ -139,16 +141,14 @@ export class MfmService { } case 'b': - case 'strong': - { + case 'strong': { text += '**'; appendChildren(node.childNodes); text += '**'; break; } - case 'small': - { + case 'small': { text += ''; appendChildren(node.childNodes); text += ''; @@ -156,8 +156,7 @@ export class MfmService { } case 's': - case 'del': - { + case 'del': { text += '~~'; appendChildren(node.childNodes); text += '~~'; @@ -165,8 +164,7 @@ export class MfmService { } case 'i': - case 'em': - { + case 'em': { text += ''; appendChildren(node.childNodes); text += ''; @@ -207,8 +205,7 @@ export class MfmService { case 'h3': case 'h4': case 'h5': - case 'h6': - { + case 'h6': { text += '\n\n'; appendChildren(node.childNodes); break; @@ -221,8 +218,7 @@ export class MfmService { case 'article': case 'li': case 'dt': - case 'dd': - { + case 'dd': { text += '\n'; appendChildren(node.childNodes); break; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index f7ecb2844503..83ed1f2c7cde 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -38,7 +38,7 @@ import InstanceChart from '@/core/chart/charts/instance.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; -import { WebhookService } from '@/core/WebhookService.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { AntennaService } from '@/core/AntennaService.js'; import { QueueService } from '@/core/QueueService.js'; @@ -59,7 +59,6 @@ import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; import { trackPromise } from '@/misc/promise-tracker.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -205,7 +204,7 @@ export class NoteCreateService implements OnApplicationShutdown { private federatedInstanceService: FederatedInstanceService, private hashtagService: HashtagService, private antennaService: AntennaService, - private webhookService: WebhookService, + private webhookService: UserWebhookService, private featuredService: FeaturedService, private remoteUserResolveService: RemoteUserResolveService, private apDeliverManagerService: ApDeliverManagerService, @@ -609,7 +608,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.webhookService.getActiveWebhooks().then(webhooks => { webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'note', { + this.queueService.userWebhookDeliver(webhook, 'note', { note: noteObj, }); } @@ -636,7 +635,7 @@ export class NoteCreateService implements OnApplicationShutdown { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'reply', { + this.queueService.userWebhookDeliver(webhook, 'reply', { note: noteObj, }); } @@ -659,7 +658,7 @@ export class NoteCreateService implements OnApplicationShutdown { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'renote', { + this.queueService.userWebhookDeliver(webhook, 'renote', { note: noteObj, }); } @@ -791,7 +790,7 @@ export class NoteCreateService implements OnApplicationShutdown { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'mention', { + this.queueService.userWebhookDeliver(webhook, 'mention', { note: detailPackedNote, }); } @@ -842,7 +841,7 @@ export class NoteCreateService implements OnApplicationShutdown { const mentions = extractMentions(tokens); let mentionedUsers = (await Promise.all(mentions.map(m => this.remoteUserResolveService.resolveUser(m.username, m.host ?? user.host).catch(() => null), - ))).filter(isNotNull); + ))).filter(x => x != null); // Drop duplicate users mentionedUsers = mentionedUsers.filter((u, i, self) => diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index 216734e9e5e4..b10b8e589955 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -7,10 +7,17 @@ import { Inject, Module, OnApplicationShutdown } from '@nestjs/common'; import * as Bull from 'bullmq'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { QUEUE, baseQueueOptions } from '@/queue/const.js'; +import { baseQueueOptions, QUEUE } from '@/queue/const.js'; import { allSettled } from '@/misc/promise-tracker.js'; +import { + DeliverJobData, + EndedPollNotificationJobData, + InboxJobData, + RelationshipJobData, + UserWebhookDeliverJobData, + SystemWebhookDeliverJobData, +} from '../queue/types.js'; import type { Provider } from '@nestjs/common'; -import type { DeliverJobData, InboxJobData, EndedPollNotificationJobData, WebhookDeliverJobData, RelationshipJobData } from '../queue/types.js'; export type SystemQueue = Bull.Queue>; export type EndedPollNotificationQueue = Bull.Queue; @@ -19,7 +26,8 @@ export type InboxQueue = Bull.Queue; export type DbQueue = Bull.Queue; export type RelationshipQueue = Bull.Queue; export type ObjectStorageQueue = Bull.Queue; -export type WebhookDeliverQueue = Bull.Queue; +export type UserWebhookDeliverQueue = Bull.Queue; +export type SystemWebhookDeliverQueue = Bull.Queue; const $system: Provider = { provide: 'queue:system', @@ -63,9 +71,15 @@ const $objectStorage: Provider = { inject: [DI.config], }; -const $webhookDeliver: Provider = { - provide: 'queue:webhookDeliver', - useFactory: (config: Config) => new Bull.Queue(QUEUE.WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.WEBHOOK_DELIVER)), +const $userWebhookDeliver: Provider = { + provide: 'queue:userWebhookDeliver', + useFactory: (config: Config) => new Bull.Queue(QUEUE.USER_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.USER_WEBHOOK_DELIVER)), + inject: [DI.config], +}; + +const $systemWebhookDeliver: Provider = { + provide: 'queue:systemWebhookDeliver', + useFactory: (config: Config) => new Bull.Queue(QUEUE.SYSTEM_WEBHOOK_DELIVER, baseQueueOptions(config, QUEUE.SYSTEM_WEBHOOK_DELIVER)), inject: [DI.config], }; @@ -80,7 +94,8 @@ const $webhookDeliver: Provider = { $db, $relationship, $objectStorage, - $webhookDeliver, + $userWebhookDeliver, + $systemWebhookDeliver, ], exports: [ $system, @@ -90,7 +105,8 @@ const $webhookDeliver: Provider = { $db, $relationship, $objectStorage, - $webhookDeliver, + $userWebhookDeliver, + $systemWebhookDeliver, ], }) export class QueueModule implements OnApplicationShutdown { @@ -102,7 +118,8 @@ export class QueueModule implements OnApplicationShutdown { @Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, + @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, ) {} public async dispose(): Promise { @@ -117,7 +134,8 @@ export class QueueModule implements OnApplicationShutdown { this.dbQueue.close(), this.relationshipQueue.close(), this.objectStorageQueue.close(), - this.webhookDeliverQueue.close(), + this.userWebhookDeliverQueue.close(), + this.systemWebhookDeliverQueue.close(), ]); } diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index c258a22927d4..80827a500b56 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -8,15 +8,33 @@ import { Inject, Injectable } from '@nestjs/common'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js'; +import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, SystemQueue, WebhookDeliverQueue } from './QueueModule.js'; -import type { DbJobData, DeliverJobData, RelationshipJobData, ThinUser } from '../queue/types.js'; +import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; +import type { + DbJobData, + DeliverJobData, + RelationshipJobData, + SystemWebhookDeliverJobData, + ThinUser, + UserWebhookDeliverJobData, +} from '../queue/types.js'; +import type { + DbQueue, + DeliverQueue, + EndedPollNotificationQueue, + InboxQueue, + ObjectStorageQueue, + RelationshipQueue, + SystemQueue, + UserWebhookDeliverQueue, + SystemWebhookDeliverQueue, +} from './QueueModule.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; -import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; @Injectable() export class QueueService { @@ -31,7 +49,8 @@ export class QueueService { @Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, + @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, ) { this.systemQueue.add('tickCharts', { }, { @@ -431,9 +450,13 @@ export class QueueService { }); } + /** + * @see UserWebhookDeliverJobData + * @see WebhookDeliverProcessorService + */ @bindThis - public webhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) { - const data = { + public userWebhookDeliver(webhook: MiWebhook, type: typeof webhookEventTypes[number], content: unknown) { + const data: UserWebhookDeliverJobData = { type, content, webhookId: webhook.id, @@ -444,7 +467,33 @@ export class QueueService { eventId: randomUUID(), }; - return this.webhookDeliverQueue.add(webhook.id, data, { + return this.userWebhookDeliverQueue.add(webhook.id, data, { + attempts: 4, + backoff: { + type: 'custom', + }, + removeOnComplete: true, + removeOnFail: true, + }); + } + + /** + * @see SystemWebhookDeliverJobData + * @see WebhookDeliverProcessorService + */ + @bindThis + public systemWebhookDeliver(webhook: MiSystemWebhook, type: SystemWebhookEventType, content: unknown) { + const data: SystemWebhookDeliverJobData = { + type, + content, + webhookId: webhook.id, + to: webhook.url, + secret: webhook.secret, + createdAt: Date.now(), + eventId: randomUUID(), + }; + + return this.systemWebhookDeliverQueue.add(webhook.id, data, { attempts: 4, backoff: { type: 'custom', diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index d557e9ce86f7..371207c33a7d 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -29,6 +29,7 @@ import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { RoleService } from '@/core/RoleService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { trackPromise } from '@/misc/promise-tracker.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; const FALLBACK = '\u2764'; const PER_NOTE_REACTION_USER_PAIR_CACHE_MAX = 16; @@ -119,11 +120,16 @@ export class ReactionService { throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.'); } + // Check if note is Renote + if (isRenote(note) && !isQuote(note)) { + throw new IdentifiableError('12c35529-3c79-4327-b1cc-e2cf63a71925', 'You cannot react to Renote.'); + } + let reaction = _reaction ?? FALLBACK; if (note.reactionAcceptance === 'likeOnly' || ((note.reactionAcceptance === 'likeOnlyForRemote' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote') && (user.host != null))) { reaction = '\u2764'; - } else if (_reaction) { + } else if (_reaction != null) { const custom = reaction.match(isCustomEmojiRegexp); if (custom) { const reacterHost = this.utilityService.toPunyNullable(user.host); diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index e9dc9b57afef..8dd3d64f5b29 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -53,11 +53,11 @@ export class RelayService { @bindThis public async addRelay(inbox: string): Promise { - const relay = await this.relaysRepository.insert({ + const relay = await this.relaysRepository.insertOne({ id: this.idService.gen(), inbox, status: 'requesting', - }).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0])); + }); const relayActor = await this.getRelayActor(); const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 439bc0884585..7f939b99c7a6 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -281,7 +281,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { @bindThis private async matched(parentId: MiUser['id'], childId: MiUser['id'], options: { noIrregularRules: boolean; }): Promise { - const game = await this.reversiGamesRepository.insert({ + const game = await this.reversiGamesRepository.insertOne({ id: this.idService.gen(), user1Id: parentId, user2Id: childId, @@ -294,10 +294,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit { bw: 'random', isLlotheo: false, noIrregularRules: options.noIrregularRules, - }).then(x => this.reversiGamesRepository.findOneOrFail({ - where: { id: x.identifiers[0].id }, - relations: ['user1', 'user2'], - })); + }, { relations: ['user1', 'user2'] }); this.cacheGame(game); const packed = await this.reversiGameEntityService.packDetail(game); diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 70c537f9abdc..94026fd5030e 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -47,6 +47,7 @@ export type RolePolicies = { canHideAds: boolean; driveCapacityMb: number; alwaysMarkNsfw: boolean; + canUpdateBioMedia: boolean; pinLimit: number; antennaLimit: number; wordMuteLimit: number; @@ -75,6 +76,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canHideAds: false, driveCapacityMb: 100, alwaysMarkNsfw: false, + canUpdateBioMedia: true, pinLimit: 5, antennaLimit: 5, wordMuteLimit: 200, @@ -376,6 +378,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canHideAds: calc('canHideAds', vs => vs.some(v => v === true)), driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)), alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)), + canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)), pinLimit: calc('pinLimit', vs => Math.max(...vs)), antennaLimit: calc('antennaLimit', vs => Math.max(...vs)), wordMuteLimit: calc('wordMuteLimit', vs => Math.max(...vs)), @@ -410,14 +413,32 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } @bindThis - public async getModeratorIds(includeAdmins = true): Promise { + public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise { const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); - const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); - const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ - roleId: In(moderatorRoles.map(r => r.id)), - }) : []; + const moderatorRoles = includeAdmins + ? roles.filter(r => r.isModerator || r.isAdministrator) + : roles.filter(r => r.isModerator); + // TODO: isRootなアカウントも含める - return assigns.map(a => a.userId); + const assigns = moderatorRoles.length > 0 + ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) }) + : []; + + const now = Date.now(); + const result = [ + // Setを経由して重複を除去(ユーザIDは重複する可能性があるので) + ...new Set( + assigns + .filter(it => + (excludeExpire) + ? (it.expiresAt == null || it.expiresAt.getTime() > now) + : true, + ) + .map(a => a.userId), + ), + ]; + + return result.sort((x, y) => x.localeCompare(y)); } @bindThis @@ -471,12 +492,12 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } } - const created = await this.roleAssignmentsRepository.insert({ + const created = await this.roleAssignmentsRepository.insertOne({ id: this.idService.gen(now), expiresAt: expiresAt, roleId: roleId, userId: userId, - }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); + }); this.rolesRepository.update(roleId, { lastUsedAt: new Date(), @@ -558,7 +579,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async create(values: Partial, moderator?: MiUser): Promise { const date = new Date(); - const created = await this.rolesRepository.insert({ + const created = await this.rolesRepository.insertOne({ id: this.idService.gen(date.getTime()), updatedAt: date, lastUsedAt: date, @@ -576,7 +597,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canEditMembersByModerator: values.canEditMembersByModerator, displayOrder: values.displayOrder, policies: values.policies, - }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); + }); this.globalEventService.publishInternalEvent('roleCreated', created); diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts new file mode 100644 index 000000000000..bc6851f788da --- /dev/null +++ b/packages/backend/src/core/SystemWebhookService.ts @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { MiUser, SystemWebhooksRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js'; +import { IdService } from '@/core/IdService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import Logger from '@/logger.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class SystemWebhookService implements OnApplicationShutdown { + private logger: Logger; + private activeSystemWebhooksFetched = false; + private activeSystemWebhooks: MiSystemWebhook[] = []; + + constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.systemWebhooksRepository) + private systemWebhooksRepository: SystemWebhooksRepository, + private idService: IdService, + private queueService: QueueService, + private moderationLogService: ModerationLogService, + private loggerService: LoggerService, + private globalEventService: GlobalEventService, + ) { + this.redisForSub.on('message', this.onMessage); + this.logger = this.loggerService.getLogger('webhook'); + } + + @bindThis + public async fetchActiveSystemWebhooks() { + if (!this.activeSystemWebhooksFetched) { + this.activeSystemWebhooks = await this.systemWebhooksRepository.findBy({ + isActive: true, + }); + this.activeSystemWebhooksFetched = true; + } + + return this.activeSystemWebhooks; + } + + /** + * SystemWebhook の一覧を取得する. + */ + @bindThis + public async fetchSystemWebhooks(params?: { + ids?: MiSystemWebhook['id'][]; + isActive?: MiSystemWebhook['isActive']; + on?: MiSystemWebhook['on']; + }): Promise { + const query = this.systemWebhooksRepository.createQueryBuilder('systemWebhook'); + if (params) { + if (params.ids && params.ids.length > 0) { + query.andWhere('systemWebhook.id IN (:...ids)', { ids: params.ids }); + } + if (params.isActive !== undefined) { + query.andWhere('systemWebhook.isActive = :isActive', { isActive: params.isActive }); + } + if (params.on && params.on.length > 0) { + query.andWhere(':on <@ systemWebhook.on', { on: params.on }); + } + } + + return query.getMany(); + } + + /** + * SystemWebhook を作成する. + */ + @bindThis + public async createSystemWebhook( + params: { + isActive: MiSystemWebhook['isActive']; + name: MiSystemWebhook['name']; + on: MiSystemWebhook['on']; + url: MiSystemWebhook['url']; + secret: MiSystemWebhook['secret']; + }, + updater: MiUser, + ): Promise { + const id = this.idService.gen(); + await this.systemWebhooksRepository.insert({ + ...params, + id, + }); + + const webhook = await this.systemWebhooksRepository.findOneByOrFail({ id }); + this.globalEventService.publishInternalEvent('systemWebhookCreated', webhook); + this.moderationLogService + .log(updater, 'createSystemWebhook', { + systemWebhookId: webhook.id, + webhook: webhook, + }) + .then(); + + return webhook; + } + + /** + * SystemWebhook を更新する. + */ + @bindThis + public async updateSystemWebhook( + params: { + id: MiSystemWebhook['id']; + isActive: MiSystemWebhook['isActive']; + name: MiSystemWebhook['name']; + on: MiSystemWebhook['on']; + url: MiSystemWebhook['url']; + secret: MiSystemWebhook['secret']; + }, + updater: MiUser, + ): Promise { + const beforeEntity = await this.systemWebhooksRepository.findOneByOrFail({ id: params.id }); + await this.systemWebhooksRepository.update(beforeEntity.id, { + updatedAt: new Date(), + isActive: params.isActive, + name: params.name, + on: params.on, + url: params.url, + secret: params.secret, + }); + + const afterEntity = await this.systemWebhooksRepository.findOneByOrFail({ id: beforeEntity.id }); + this.globalEventService.publishInternalEvent('systemWebhookUpdated', afterEntity); + this.moderationLogService + .log(updater, 'updateSystemWebhook', { + systemWebhookId: beforeEntity.id, + before: beforeEntity, + after: afterEntity, + }) + .then(); + + return afterEntity; + } + + /** + * SystemWebhook を削除する. + */ + @bindThis + public async deleteSystemWebhook(id: MiSystemWebhook['id'], updater: MiUser) { + const webhook = await this.systemWebhooksRepository.findOneByOrFail({ id }); + await this.systemWebhooksRepository.delete(id); + + this.globalEventService.publishInternalEvent('systemWebhookDeleted', webhook); + this.moderationLogService + .log(updater, 'deleteSystemWebhook', { + systemWebhookId: webhook.id, + webhook, + }) + .then(); + } + + /** + * SystemWebhook をWebhook配送キューに追加する + * @see QueueService.systemWebhookDeliver + */ + @bindThis + public async enqueueSystemWebhook(webhook: MiSystemWebhook | MiSystemWebhook['id'], type: SystemWebhookEventType, content: unknown) { + const webhookEntity = typeof webhook === 'string' + ? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook) + : webhook; + if (!webhookEntity || !webhookEntity.isActive) { + this.logger.info(`Webhook is not active or not found : ${webhook}`); + return; + } + + if (!webhookEntity.on.includes(type)) { + this.logger.info(`Webhook ${webhookEntity.id} is not listening to ${type}`); + return; + } + + return this.queueService.systemWebhookDeliver(webhookEntity, type, content); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + if (obj.channel !== 'internal') { + return; + } + + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'systemWebhookCreated': { + if (body.isActive) { + this.activeSystemWebhooks.push(MiSystemWebhook.deserialize(body)); + } + break; + } + case 'systemWebhookUpdated': { + if (body.isActive) { + const i = this.activeSystemWebhooks.findIndex(a => a.id === body.id); + if (i > -1) { + this.activeSystemWebhooks[i] = MiSystemWebhook.deserialize(body); + } else { + this.activeSystemWebhooks.push(MiSystemWebhook.deserialize(body)); + } + } else { + this.activeSystemWebhooks = this.activeSystemWebhooks.filter(a => a.id !== body.id); + } + break; + } + case 'systemWebhookDeleted': { + this.activeSystemWebhooks = this.activeSystemWebhooks.filter(a => a.id !== body.id); + break; + } + default: + break; + } + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 96f389b54c55..2f1310b8efe7 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -16,7 +16,7 @@ import Logger from '@/logger.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { LoggerService } from '@/core/LoggerService.js'; -import { WebhookService } from '@/core/WebhookService.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -46,7 +46,7 @@ export class UserBlockingService implements OnModuleInit { private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, - private webhookService: WebhookService, + private webhookService: UserWebhookService, private apRendererService: ApRendererService, private loggerService: LoggerService, ) { @@ -121,7 +121,7 @@ export class UserBlockingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { + this.queueService.userWebhookDeliver(webhook, 'unfollow', { user: packed, }); } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index deeecdeb1f12..267a6a3f1b78 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -16,7 +16,7 @@ import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js import type { Packed } from '@/misc/json-schema.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { WebhookService } from '@/core/WebhookService.js'; +import { UserWebhookService } from '@/core/UserWebhookService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; @@ -82,7 +82,7 @@ export class UserFollowingService implements OnModuleInit { private metaService: MetaService, private notificationService: NotificationService, private federatedInstanceService: FederatedInstanceService, - private webhookService: WebhookService, + private webhookService: UserWebhookService, private apRendererService: ApRendererService, private accountMoveService: AccountMoveService, private fanoutTimelineService: FanoutTimelineService, @@ -331,7 +331,7 @@ export class UserFollowingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'follow', { + this.queueService.userWebhookDeliver(webhook, 'follow', { user: packed, }); } @@ -345,7 +345,7 @@ export class UserFollowingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'followed', { + this.queueService.userWebhookDeliver(webhook, 'followed', { user: packed, }); } @@ -398,7 +398,7 @@ export class UserFollowingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { + this.queueService.userWebhookDeliver(webhook, 'unfollow', { user: packed, }); } @@ -517,7 +517,7 @@ export class UserFollowingService implements OnModuleInit { followerId: follower.id, }); - const followRequest = await this.followRequestsRepository.insert({ + const followRequest = await this.followRequestsRepository.insertOne({ id: this.idService.gen(), followerId: follower.id, followeeId: followee.id, @@ -531,7 +531,7 @@ export class UserFollowingService implements OnModuleInit { followeeHost: followee.host, followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined, followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined, - }).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0])); + }); // Publish receiveRequest event if (this.userEntityService.isLocalUser(followee)) { @@ -740,7 +740,7 @@ export class UserFollowingService implements OnModuleInit { const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); for (const webhook of webhooks) { - this.queueService.webhookDeliver(webhook, 'unfollow', { + this.queueService.userWebhookDeliver(webhook, 'unfollow', { user: packedFollowee, }); } diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index bbdcfed73836..6333356fe9e8 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -95,7 +95,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { const currentCount = await this.userListMembershipsRepository.countBy({ userListId: list.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userEachUserListsLimit) { throw new UserListService.TooManyUsersError(); } diff --git a/packages/backend/src/core/UserSearchService.ts b/packages/backend/src/core/UserSearchService.ts new file mode 100644 index 000000000000..0d03cf6ee00f --- /dev/null +++ b/packages/backend/src/core/UserSearchService.ts @@ -0,0 +1,205 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets, SelectQueryBuilder } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import { type FollowingsRepository, MiUser, type UsersRepository } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import type { Config } from '@/config.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; + +function defaultActiveThreshold() { + return new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); +} + +@Injectable() +export class UserSearchService { + constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + private userEntityService: UserEntityService, + ) { + } + + /** + * ユーザ名とホスト名によるユーザ検索を行う. + * + * - 検索結果には優先順位がつけられており、以下の順序で検索が行われる. + * 1. フォローしているユーザのうち、一定期間以内(※)に更新されたユーザ + * 2. フォローしているユーザのうち、一定期間以内に更新されていないユーザ + * 3. フォローしていないユーザのうち、一定期間以内に更新されたユーザ + * 4. フォローしていないユーザのうち、一定期間以内に更新されていないユーザ + * - ログインしていない場合は、以下の順序で検索が行われる. + * 1. 一定期間以内に更新されたユーザ + * 2. 一定期間以内に更新されていないユーザ + * - それぞれの検索結果はユーザ名の昇順でソートされる. + * - 動作的には先に登場した検索結果の登場位置が優先される(条件的にユーザIDが重複することはないが). + * (1で既にヒットしていた場合、2, 3, 4でヒットしても無視される) + * - ユーザ名とホスト名の検索条件はそれぞれ前方一致で検索される. + * - ユーザ名の検索は大文字小文字を区別しない. + * - ホスト名の検索は大文字小文字を区別しない. + * - 検索結果は最大で {@link opts.limit} 件までとなる. + * + * ※一定期間とは {@link params.activeThreshold} で指定された日時から現在までの期間を指す. + * + * @param params 検索条件. + * @param opts 関数の動作を制御するオプション. + * @param me 検索を実行するユーザの情報. 未ログインの場合は指定しない. + * @see {@link UserSearchService#buildSearchUserQueries} + * @see {@link UserSearchService#buildSearchUserNoLoginQueries} + */ + @bindThis + public async search( + params: { + username?: string | null, + host?: string | null, + activeThreshold?: Date, + }, + opts?: { + limit?: number, + detail?: boolean, + }, + me?: MiUser | null, + ): Promise[]> { + const queries = me ? this.buildSearchUserQueries(me, params) : this.buildSearchUserNoLoginQueries(params); + + let resultSet = new Set(); + const limit = opts?.limit ?? 10; + for (const query of queries) { + const ids = await query + .select('user.id') + .limit(limit - resultSet.size) + .orderBy('user.usernameLower', 'ASC') + .getRawMany<{ user_id: MiUser['id'] }>() + .then(res => res.map(x => x.user_id)); + + resultSet = new Set([...resultSet, ...ids]); + if (resultSet.size >= limit) { + break; + } + } + + return this.userEntityService.packMany<'UserLite' | 'UserDetailed'>( + [...resultSet].slice(0, limit), + me, + { schema: opts?.detail ? 'UserDetailed' : 'UserLite' }, + ); + } + + /** + * ログイン済みユーザによる検索実行時のクエリ一覧を構築する. + * @param me + * @param params + * @private + */ + @bindThis + private buildSearchUserQueries( + me: MiUser, + params: { + username?: string | null, + host?: string | null, + activeThreshold?: Date, + }, + ) { + // デフォルト30日以内に更新されたユーザーをアクティブユーザーとする + const activeThreshold = params.activeThreshold ?? defaultActiveThreshold(); + + const followingUserQuery = this.followingsRepository.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :followerId', { followerId: me.id }); + + const activeFollowingUsersQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id IN (${followingUserQuery.getQuery()})`) + .andWhere('user.updatedAt > :activeThreshold', { activeThreshold }); + activeFollowingUsersQuery.setParameters(followingUserQuery.getParameters()); + + const inactiveFollowingUsersQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id IN (${followingUserQuery.getQuery()})`) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt <= :activeThreshold', { activeThreshold }); + })); + inactiveFollowingUsersQuery.setParameters(followingUserQuery.getParameters()); + + // 自分自身がヒットするとしたらここ + const activeUserQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`) + .andWhere('user.updatedAt > :activeThreshold', { activeThreshold }); + activeUserQuery.setParameters(followingUserQuery.getParameters()); + + const inactiveUserQuery = this.generateUserQueryBuilder(params) + .andWhere(`user.id NOT IN (${followingUserQuery.getQuery()})`) + .andWhere('user.updatedAt <= :activeThreshold', { activeThreshold }); + inactiveUserQuery.setParameters(followingUserQuery.getParameters()); + + return [activeFollowingUsersQuery, inactiveFollowingUsersQuery, activeUserQuery, inactiveUserQuery]; + } + + /** + * ログインしていないユーザによる検索実行時のクエリ一覧を構築する. + * @param params + * @private + */ + @bindThis + private buildSearchUserNoLoginQueries(params: { + username?: string | null, + host?: string | null, + activeThreshold?: Date, + }) { + // デフォルト30日以内に更新されたユーザーをアクティブユーザーとする + const activeThreshold = params.activeThreshold ?? defaultActiveThreshold(); + + const activeUserQuery = this.generateUserQueryBuilder(params) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold }); + })); + + const inactiveUserQuery = this.generateUserQueryBuilder(params) + .andWhere('user.updatedAt <= :activeThreshold', { activeThreshold }); + + return [activeUserQuery, inactiveUserQuery]; + } + + /** + * ユーザ検索クエリで共通する抽出条件をあらかじめ設定したクエリビルダを生成する. + * @param params + * @private + */ + @bindThis + private generateUserQueryBuilder(params: { + username?: string | null, + host?: string | null, + }): SelectQueryBuilder { + const userQuery = this.usersRepository.createQueryBuilder('user'); + + if (params.username) { + userQuery.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(params.username.toLowerCase()) + '%' }); + } + + if (params.host) { + if (params.host === this.config.hostname || params.host === '.') { + userQuery.andWhere('user.host IS NULL'); + } else { + userQuery.andWhere('user.host LIKE :host', { + host: sqlLikeEscape(params.host.toLowerCase()) + '%', + }); + } + } + + userQuery.andWhere('user.isSuspended = FALSE'); + + return userQuery; + } +} diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts new file mode 100644 index 000000000000..e96bfeea9581 --- /dev/null +++ b/packages/backend/src/core/UserWebhookService.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import type { WebhooksRepository } from '@/models/_.js'; +import type { MiWebhook } from '@/models/Webhook.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +@Injectable() +export class UserWebhookService implements OnApplicationShutdown { + private activeWebhooksFetched = false; + private activeWebhooks: MiWebhook[] = []; + + constructor( + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.webhooksRepository) + private webhooksRepository: WebhooksRepository, + ) { + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + public async getActiveWebhooks() { + if (!this.activeWebhooksFetched) { + this.activeWebhooks = await this.webhooksRepository.findBy({ + active: true, + }); + this.activeWebhooksFetched = true; + } + + return this.activeWebhooks; + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + if (obj.channel !== 'internal') { + return; + } + + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'webhookCreated': { + if (body.active) { + this.activeWebhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい + ...body, + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, + user: null, // joinなカラムは通常取ってこないので + }); + } + break; + } + case 'webhookUpdated': { + if (body.active) { + const i = this.activeWebhooks.findIndex(a => a.id === body.id); + if (i > -1) { + this.activeWebhooks[i] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい + ...body, + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, + user: null, // joinなカラムは通常取ってこないので + }; + } else { + this.activeWebhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい + ...body, + latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, + user: null, // joinなカラムは通常取ってこないので + }); + } + } else { + this.activeWebhooks = this.activeWebhooks.filter(a => a.id !== body.id); + } + break; + } + case 'webhookDeleted': { + this.activeWebhooks = this.activeWebhooks.filter(a => a.id !== body.id); + break; + } + default: + break; + } + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/WebhookService.ts b/packages/backend/src/core/WebhookService.ts deleted file mode 100644 index 6be34977b064..000000000000 --- a/packages/backend/src/core/WebhookService.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import * as Redis from 'ioredis'; -import type { WebhooksRepository } from '@/models/_.js'; -import type { MiWebhook } from '@/models/Webhook.js'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; -import type { GlobalEvents } from '@/core/GlobalEventService.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; - -@Injectable() -export class WebhookService implements OnApplicationShutdown { - private webhooksFetched = false; - private webhooks: MiWebhook[] = []; - - constructor( - @Inject(DI.redisForSub) - private redisForSub: Redis.Redis, - - @Inject(DI.webhooksRepository) - private webhooksRepository: WebhooksRepository, - ) { - //this.onMessage = this.onMessage.bind(this); - this.redisForSub.on('message', this.onMessage); - } - - @bindThis - public async getActiveWebhooks() { - if (!this.webhooksFetched) { - this.webhooks = await this.webhooksRepository.findBy({ - active: true, - }); - this.webhooksFetched = true; - } - - return this.webhooks; - } - - @bindThis - private async onMessage(_: string, data: string): Promise { - const obj = JSON.parse(data); - - if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; - switch (type) { - case 'webhookCreated': - if (body.active) { - this.webhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい - ...body, - latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, - user: null, // joinなカラムは通常取ってこないので - }); - } - break; - case 'webhookUpdated': - if (body.active) { - const i = this.webhooks.findIndex(a => a.id === body.id); - if (i > -1) { - this.webhooks[i] = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい - ...body, - latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, - user: null, // joinなカラムは通常取ってこないので - }; - } else { - this.webhooks.push({ // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい - ...body, - latestSentAt: body.latestSentAt ? new Date(body.latestSentAt) : null, - user: null, // joinなカラムは通常取ってこないので - }); - } - } else { - this.webhooks = this.webhooks.filter(a => a.id !== body.id); - } - break; - case 'webhookDeleted': - this.webhooks = this.webhooks.filter(a => a.id !== body.id); - break; - default: - break; - } - } - } - - @bindThis - public dispose(): void { - this.redisForSub.off('message', this.onMessage); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/activitypub/ApAudienceService.ts b/packages/backend/src/core/activitypub/ApAudienceService.ts index 0fccc7b95086..5a5a76f7d65f 100644 --- a/packages/backend/src/core/activitypub/ApAudienceService.ts +++ b/packages/backend/src/core/activitypub/ApAudienceService.ts @@ -8,7 +8,6 @@ import promiseLimit from 'promise-limit'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; import { concat, unique } from '@/misc/prelude/array.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { getApIds } from './type.js'; import { ApPersonService } from './models/ApPersonService.js'; import type { ApObject } from './type.js'; @@ -41,7 +40,7 @@ export class ApAudienceService { const limit = promiseLimit(2); const mentionedUsers = (await Promise.all( others.map(id => limit(() => this.apPersonService.resolvePerson(id, resolver).catch(() => null))), - )).filter(isNotNull); + )).filter(x => x != null); if (toGroups.public.length > 0) { return { diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 1621c41bcc54..e2164fec1d93 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -27,7 +27,8 @@ import { QueueService } from '@/core/QueueService.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; -import { isNotNull } from '@/misc/is-not-null.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; @@ -36,9 +37,8 @@ import { ApResolverService } from './ApResolverService.js'; import { ApAudienceService } from './ApAudienceService.js'; import { ApPersonService } from './models/ApPersonService.js'; import { ApQuestionService } from './models/ApQuestionService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Resolver } from './ApResolverService.js'; -import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove } from './type.js'; +import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js'; @Injectable() export class ApInboxService { @@ -57,9 +57,6 @@ export class ApInboxService { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, - @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, @@ -68,6 +65,7 @@ export class ApInboxService { private utilityService: UtilityService, private idService: IdService, private metaService: MetaService, + private abuseReportService: AbuseReportService, private userFollowingService: UserFollowingService, private apAudienceService: ApAudienceService, private reactionService: ReactionService, @@ -90,13 +88,15 @@ export class ApInboxService { } @bindThis - public async performActivity(actor: MiRemoteUser, activity: IObject): Promise { + public async performActivity(actor: MiRemoteUser, activity: IObject): Promise { + let result = undefined as string | void; if (isCollectionOrOrderedCollection(activity)) { + const results = [] as [string, string | void][]; const resolver = this.apResolverService.createResolver(); for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { const act = await resolver.resolve(item); try { - await this.performOneActivity(actor, act); + results.push([getApId(item), await this.performOneActivity(actor, act)]); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); @@ -105,8 +105,13 @@ export class ApInboxService { } } } + + const hasReason = results.some(([, reason]) => (reason != null && !reason.startsWith('ok'))); + if (hasReason) { + result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n'); + } } else { - await this.performOneActivity(actor, activity); + result = await this.performOneActivity(actor, activity); } // ついでにリモートユーザーの情報が古かったら更新しておく @@ -117,42 +122,43 @@ export class ApInboxService { }); } } + return result; } @bindThis - public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise { + public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise { if (actor.isSuspended) return; if (isCreate(activity)) { - await this.create(actor, activity); + return await this.create(actor, activity); } else if (isDelete(activity)) { - await this.delete(actor, activity); + return await this.delete(actor, activity); } else if (isUpdate(activity)) { - await this.update(actor, activity); + return await this.update(actor, activity); } else if (isFollow(activity)) { - await this.follow(actor, activity); + return await this.follow(actor, activity); } else if (isAccept(activity)) { - await this.accept(actor, activity); + return await this.accept(actor, activity); } else if (isReject(activity)) { - await this.reject(actor, activity); + return await this.reject(actor, activity); } else if (isAdd(activity)) { - await this.add(actor, activity).catch(err => this.logger.error(err)); + return await this.add(actor, activity); } else if (isRemove(activity)) { - await this.remove(actor, activity).catch(err => this.logger.error(err)); + return await this.remove(actor, activity); } else if (isAnnounce(activity)) { - await this.announce(actor, activity); + return await this.announce(actor, activity); } else if (isLike(activity)) { - await this.like(actor, activity); + return await this.like(actor, activity); } else if (isUndo(activity)) { - await this.undo(actor, activity); + return await this.undo(actor, activity); } else if (isBlock(activity)) { - await this.block(actor, activity); + return await this.block(actor, activity); } else if (isFlag(activity)) { - await this.flag(actor, activity); + return await this.flag(actor, activity); } else if (isMove(activity)) { - await this.move(actor, activity); + return await this.move(actor, activity); } else { - this.logger.warn(`unrecognized activity type: ${activity.type}`); + return `unrecognized activity type: ${activity.type}`; } } @@ -234,38 +240,49 @@ export class ApInboxService { } @bindThis - private async add(actor: MiRemoteUser, activity: IAdd): Promise { + private async add(actor: MiRemoteUser, activity: IAdd): Promise { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } if (activity.target == null) { - throw new Error('target is null'); + return 'target is null'; } if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); - if (note == null) throw new Error('note not found'); + if (note == null) return 'note not found'; await this.notePiningService.addPinned(actor, note.id); return; } - throw new Error(`unknown target: ${activity.target}`); + return `unknown target: ${activity.target}`; } @bindThis - private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise { + private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise { const uri = getApId(activity); this.logger.info(`Announce: ${uri}`); + const resolver = this.apResolverService.createResolver(); + + if (!activity.object) return 'skip: activity has no object property'; const targetUri = getApId(activity.object); + if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; + + const target = await resolver.resolve(activity.object).catch(e => { + this.logger.error(`Resolution failed: ${e}`); + return e; + }); + + if (isPost(target)) return await this.announceNote(actor, activity, target); - await this.announceNote(actor, activity, targetUri); + return `skip: unknown object type ${getApType(target)}`; } @bindThis - private async announceNote(actor: MiRemoteUser, activity: IAnnounce, targetUri: string): Promise { + private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise { const uri = getApId(activity); if (actor.isSuspended) { @@ -288,24 +305,21 @@ export class ApInboxService { // Announce対象をresolve let renote; try { - renote = await this.apNoteService.resolveNote(targetUri); - if (renote == null) throw new Error('announce target is null'); + renote = await this.apNoteService.resolveNote(target); + if (renote == null) return 'announce target is null'; } catch (err) { // 対象が4xxならスキップ if (err instanceof StatusError) { if (!err.isRetryable) { - this.logger.warn(`Ignored announce target ${targetUri} - ${err.statusCode}`); - return; + return `Ignored announce target ${target.id} - ${err.statusCode}`; } - - this.logger.warn(`Error in announce target ${targetUri} - ${err.statusCode}`); + return `Error in announce target ${target.id} - ${err.statusCode}`; } throw err; } if (!await this.noteEntityService.isVisibleForMe(renote, actor.id)) { - this.logger.warn('skip: invalid actor for this activity'); - return; + return 'skip: invalid actor for this activity'; } this.logger.info(`Creating the (Re)Note: ${uri}`); @@ -314,8 +328,7 @@ export class ApInboxService { const createdAt = activity.published ? new Date(activity.published) : null; if (createdAt && createdAt < this.idService.parse(renote.id).date) { - this.logger.warn('skip: malformed createdAt'); - return; + return 'skip: malformed createdAt'; } await this.noteCreateService.create(actor, { @@ -349,11 +362,15 @@ export class ApInboxService { } @bindThis - private async create(actor: MiRemoteUser, activity: ICreate): Promise { + private async create(actor: MiRemoteUser, activity: ICreate): Promise { const uri = getApId(activity); this.logger.info(`Create: ${uri}`); + if (!activity.object) return 'skip: activity has no object property'; + const targetUri = getApId(activity.object); + if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.'; + // copy audiences between activity <=> object. if (typeof activity.object === 'object') { const to = unique(concat([toArray(activity.to), toArray(activity.object.to)])); @@ -380,7 +397,7 @@ export class ApInboxService { if (isPost(object)) { await this.createNote(resolver, actor, object, false, activity); } else { - this.logger.warn(`Unknown type: ${getApType(object)}`); + return `Unknown type: ${getApType(object)}`; } } @@ -422,7 +439,7 @@ export class ApInboxService { @bindThis private async delete(actor: MiRemoteUser, activity: IDelete): Promise { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } // 削除対象objectのtype @@ -520,20 +537,19 @@ export class ApInboxService { const userIds = uris .filter(uri => uri.startsWith(this.config.url + '/users/')) .map(uri => uri.split('/').at(-1)) - .filter(isNotNull); + .filter(x => x != null); const users = await this.usersRepository.findBy({ id: In(userIds), }); if (users.length < 1) return 'skip'; - await this.abuseUserReportsRepository.insert({ - id: this.idService.gen(), + await this.abuseReportService.report([{ targetUserId: users[0].id, targetUserHost: users[0].host, reporterId: actor.id, reporterHost: actor.host, comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, - }); + }]); return 'ok'; } @@ -581,29 +597,29 @@ export class ApInboxService { } @bindThis - private async remove(actor: MiRemoteUser, activity: IRemove): Promise { + private async remove(actor: MiRemoteUser, activity: IRemove): Promise { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } if (activity.target == null) { - throw new Error('target is null'); + return 'target is null'; } if (activity.target === actor.featured) { const note = await this.apNoteService.resolveNote(activity.object); - if (note == null) throw new Error('note not found'); + if (note == null) return 'note not found'; await this.notePiningService.removePinned(actor, note.id); return; } - throw new Error(`unknown target: ${activity.target}`); + return `unknown target: ${activity.target}`; } @bindThis private async undo(actor: MiRemoteUser, activity: IUndo): Promise { if (actor.uri !== activity.actor) { - throw new Error('invalid actor'); + return 'invalid actor'; } const uri = activity.id ?? activity; @@ -614,7 +630,7 @@ export class ApInboxService { const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); - throw e; + return e; }); // don't queue because the sender may attempt again when timeout diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index ab75b9abbd99..4036d2794a4c 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -25,7 +25,7 @@ export class ApMfmService { } @bindThis - public getNoteHtml(note: MiNote, apAppend?: string) { + public getNoteHtml(note: Pick, apAppend?: string) { let noMisskeyContent = false; const srcMfm = (note.text ?? '') + (apAppend ?? ''); diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 4fc724b5480e..98e944f347a5 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -26,7 +26,6 @@ import type { MiUserKeypair } from '@/models/UserKeypair.js'; import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { IdService } from '@/core/IdService.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; @@ -317,7 +316,7 @@ export class ApRendererService { const getPromisedFiles = async (ids: string[]): Promise => { if (ids.length === 0) return []; const items = await this.driveFilesRepository.findBy({ id: In(ids) }); - return ids.map(id => items.find(item => item.id === id)).filter(isNotNull); + return ids.map(id => items.find(item => item.id === id)).filter(x => x != null); }; let inReplyTo; @@ -686,7 +685,7 @@ export class ApRendererService { if (names.length === 0) return []; const allEmojis = await this.customEmojiService.localEmojisCache.fetch(); - const emojis = names.map(name => allEmojis.get(name)).filter(isNotNull); + const emojis = names.map(name => allEmojis.get(name)).filter(x => x != null); return emojis; } diff --git a/packages/backend/src/core/activitypub/models/ApMentionService.ts b/packages/backend/src/core/activitypub/models/ApMentionService.ts index 0ced7e88aff2..2cd151fa048f 100644 --- a/packages/backend/src/core/activitypub/models/ApMentionService.ts +++ b/packages/backend/src/core/activitypub/models/ApMentionService.ts @@ -8,7 +8,6 @@ import promiseLimit from 'promise-limit'; import type { MiUser } from '@/models/_.js'; import { toArray, unique } from '@/misc/prelude/array.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { isMention } from '../type.js'; import { Resolver } from '../ApResolverService.js'; import { ApPersonService } from './ApPersonService.js'; @@ -28,7 +27,7 @@ export class ApMentionService { const limit = promiseLimit(2); const mentionedUsers = (await Promise.all( hrefs.map(x => limit(() => this.apPersonService.resolvePerson(x, resolver).catch(() => null))), - )).filter(isNotNull); + )).filter(x => x != null); return mentionedUsers; } diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 4e361b57bcf5..fc7aa1e0b972 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -24,7 +24,6 @@ import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApMfmService } from '../ApMfmService.js'; @@ -81,20 +80,20 @@ export class ApNoteService { const expectHost = this.utilityService.extractDbHost(uri); if (!validPost.includes(getApType(object))) { - return new Error(`invalid Note: invalid object type ${getApType(object)}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: invalid object type ${getApType(object)}`); } if (object.id && this.utilityService.extractDbHost(object.id) !== expectHost) { - return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: id has different host. expected: ${expectHost}, actual: ${this.utilityService.extractDbHost(object.id)}`); } const actualHost = object.attributedTo && this.utilityService.extractDbHost(getOneApId(object.attributedTo)); if (object.attributedTo && actualHost !== expectHost) { - return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`); } if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) { - return new Error('invalid Note: published timestamp is malformed'); + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed'); } return null; @@ -253,7 +252,7 @@ export class ApNoteService { } }; - const uris = unique([note._misskey_quote, note.quoteUrl].filter(isNotNull)); + const uris = unique([note._misskey_quote, note.quoteUrl].filter(x => x != null)); const results = await Promise.all(uris.map(tryResolveNote)); quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0); @@ -407,7 +406,7 @@ export class ApNoteService { this.logger.info(`register emoji host=${host}, name=${name}`); - return await this.emojisRepository.insert({ + return await this.emojisRepository.insertOne({ id: this.idService.gen(), host, name, @@ -416,7 +415,7 @@ export class ApNoteService { publicUrl: tag.icon.url, updatedAt: new Date(), aliases: [], - }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); + }); })); } } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index 744b1ea68376..457205e0238e 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -34,11 +34,11 @@ import { StatusError } from '@/misc/status-error.js'; import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { checkHttps } from '@/misc/check-https.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { getApId, getApType, getOneApHrefNullable, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js'; import { extractApHashtags } from './tag.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -101,6 +101,8 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + + private roleService: RoleService, ) { } @@ -239,6 +241,11 @@ export class ApPersonService implements OnModuleInit { return this.apImageService.resolveImage(user, img).catch(() => null); })); + if (((avatar != null && avatar.id != null) || (banner != null && banner.id != null)) + && !(await this.roleService.getUserPolicies(user.id)).canUpdateBioMedia) { + return {}; + } + /* we don't want to return nulls on errors! if the database fields are already null, nothing changes; if the database has old @@ -637,7 +644,7 @@ export class ApPersonService implements OnModuleInit { // とりあえずidを別の時間で生成して順番を維持 let td = 0; - for (const note of featuredNotes.filter(isNotNull)) { + for (const note of featuredNotes.filter(x => x != null)) { td -= 1000; transactionalEntityManager.insert(MiUserNotePining, { id: this.idService.gen(Date.now() + td), diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index d1936cfe1dff..4fae1e897be9 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -10,7 +10,6 @@ import type { Config } from '@/config.js'; import type { IPoll } from '@/models/Poll.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { isQuestion } from '../type.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApResolverService } from '../ApResolverService.js'; @@ -52,7 +51,7 @@ export class ApQuestionService { const choices = question[multiple ? 'anyOf' : 'oneOf'] ?.map((x) => x.name) - .filter(isNotNull) + .filter(x => x != null) ?? []; const votes = question[multiple ? 'anyOf' : 'oneOf']?.map((x) => x.replies?.totalItems ?? x._misskey_votes ?? 0); diff --git a/packages/backend/src/core/activitypub/models/tag.ts b/packages/backend/src/core/activitypub/models/tag.ts index e7ceec3262a3..f75cc45f7e4f 100644 --- a/packages/backend/src/core/activitypub/models/tag.ts +++ b/packages/backend/src/core/activitypub/models/tag.ts @@ -4,7 +4,6 @@ */ import { toArray } from '@/misc/prelude/array.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { isHashtag } from '../type.js'; import type { IObject, IApHashtag } from '../type.js'; @@ -16,7 +15,7 @@ export function extractApHashtags(tags: IObject | IObject[] | null | undefined): return hashtags.map(tag => { const m = tag.name.match(/^#(.+)/); return m ? m[1] : null; - }).filter(isNotNull); + }).filter(x => x != null); } export function extractApHashtagObjects(tags: IObject | IObject[] | null | undefined): IApHashtag[] { diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 09322888d518..5b6c6c8ca6cb 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -328,3 +328,4 @@ export const isAnnounce = (object: IObject): object is IAnnounce => getApType(ob export const isBlock = (object: IObject): object is IBlock => getApType(object) === 'Block'; export const isFlag = (object: IObject): object is IFlag => getApType(object) === 'Flag'; export const isMove = (object: IObject): object is IMove => getApType(object) === 'Move'; +export const isNote = (object: IObject): object is IPost => getApType(object) === 'Note'; diff --git a/packages/backend/src/core/chart/ChartLoggerService.ts b/packages/backend/src/core/chart/ChartLoggerService.ts index afc728d56478..20815ea96869 100644 --- a/packages/backend/src/core/chart/ChartLoggerService.ts +++ b/packages/backend/src/core/chart/ChartLoggerService.ts @@ -14,6 +14,6 @@ export class ChartLoggerService { constructor( private loggerService: LoggerService, ) { - this.logger = this.loggerService.getLogger('chart', 'white', process.env.NODE_ENV !== 'test'); + this.logger = this.loggerService.getLogger('chart', 'white'); } } diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index 5e4555ee96f5..c2329a2f7357 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -47,7 +47,7 @@ export default class FederationChart extends Chart { // eslint-di const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance') .select('instance.host') - .where('instance.isSuspended = true'); + .where('instance.suspensionState != \'none\''); const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f') .select('f.followerHost') @@ -89,7 +89,7 @@ export default class FederationChart extends Chart { // eslint-di .select('COUNT(instance.id)') .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere('instance.isSuspended = false') + .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() .then(x => parseInt(x.count, 10)), @@ -97,7 +97,7 @@ export default class FederationChart extends Chart { // eslint-di .select('COUNT(instance.id)') .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ANY(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) - .andWhere('instance.isSuspended = false') + .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() .then(x => parseInt(x.count, 10)), diff --git a/packages/backend/src/core/chart/core.ts b/packages/backend/src/core/chart/core.ts index f10e30ef1048..af5485a46ee9 100644 --- a/packages/backend/src/core/chart/core.ts +++ b/packages/backend/src/core/chart/core.ts @@ -14,7 +14,8 @@ import { EntitySchema, LessThan, Between } from 'typeorm'; import { dateUTC, isTimeSame, isTimeBefore, subtractTime, addTime } from '@/misc/prelude/time.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import type { Repository, DataSource } from 'typeorm'; +import { MiRepository, miRepository } from '@/models/_.js'; +import type { DataSource, Repository } from 'typeorm'; const COLUMN_PREFIX = '___' as const; const UNIQUE_TEMP_COLUMN_PREFIX = 'unique_temp___' as const; @@ -145,10 +146,10 @@ export default abstract class Chart { group: string | null; }[] = []; // ↓にしたいけどfindOneとかで型エラーになる - //private repositoryForHour: Repository>; - //private repositoryForDay: Repository>; - private repositoryForHour: Repository<{ id: number; group?: string | null; date: number; }>; - private repositoryForDay: Repository<{ id: number; group?: string | null; date: number; }>; + //private repositoryForHour: Repository> & MiRepository>; + //private repositoryForDay: Repository> & MiRepository>; + private repositoryForHour: Repository<{ id: number; group?: string | null; date: number; }> & MiRepository<{ id: number; group?: string | null; date: number; }>; + private repositoryForDay: Repository<{ id: number; group?: string | null; date: number; }> & MiRepository<{ id: number; group?: string | null; date: number; }>; /** * 1日に一回程度実行されれば良いような計算処理を入れる(主にCASCADE削除などアプリケーション側で感知できない変動によるズレの修正用) @@ -211,6 +212,10 @@ export default abstract class Chart { } { const createEntity = (span: 'hour' | 'day'): EntitySchema => new EntitySchema({ name: + span === 'hour' ? `ChartX${name}` : + span === 'day' ? `ChartDayX${name}` : + new Error('not happen') as never, + tableName: span === 'hour' ? `__chart__${camelToSnake(name)}` : span === 'day' ? `__chart_day__${camelToSnake(name)}` : new Error('not happen') as never, @@ -271,8 +276,8 @@ export default abstract class Chart { this.logger = logger; const { hour, day } = Chart.schemaToEntity(name, schema, grouped); - this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour); - this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day); + this.repositoryForHour = db.getRepository<{ id: number; group?: string | null; date: number; }>(hour).extend(miRepository as MiRepository<{ id: number; group?: string | null; date: number; }>); + this.repositoryForDay = db.getRepository<{ id: number; group?: string | null; date: number; }>(day).extend(miRepository as MiRepository<{ id: number; group?: string | null; date: number; }>); } @bindThis @@ -387,11 +392,11 @@ export default abstract class Chart { } // 新規ログ挿入 - log = await repository.insert({ + log = await repository.insertOne({ date: date, ...(group ? { group: group } : {}), ...columns, - }).then(x => repository.findOneByOrFail(x.identifiers[0])) as RawRecord; + }) as RawRecord; this.logger.info(`${this.name + (group ? `:${group}` : '')}(${span}): New commit created`); diff --git a/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts b/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts new file mode 100644 index 000000000000..1e23c194c58f --- /dev/null +++ b/packages/backend/src/core/entities/AbuseReportNotificationRecipientEntityService.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { AbuseReportNotificationRecipientRepository, MiAbuseReportNotificationRecipient } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { Packed } from '@/misc/json-schema.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; + +@Injectable() +export class AbuseReportNotificationRecipientEntityService { + constructor( + @Inject(DI.abuseReportNotificationRecipientRepository) + private abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository, + private userEntityService: UserEntityService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + } + + @bindThis + public async pack( + src: MiAbuseReportNotificationRecipient['id'] | MiAbuseReportNotificationRecipient, + opts?: { + users: Map>, + webhooks: Map>, + }, + ): Promise> { + const recipient = typeof src === 'object' + ? src + : await this.abuseReportNotificationRecipientRepository.findOneByOrFail({ id: src }); + const user = recipient.userId + ? (opts?.users.get(recipient.userId) ?? await this.userEntityService.pack<'UserLite'>(recipient.userId)) + : undefined; + const webhook = recipient.systemWebhookId + ? (opts?.webhooks.get(recipient.systemWebhookId) ?? await this.systemWebhookEntityService.pack(recipient.systemWebhookId)) + : undefined; + + return { + id: recipient.id, + isActive: recipient.isActive, + updatedAt: recipient.updatedAt.toISOString(), + name: recipient.name, + method: recipient.method, + userId: recipient.userId ?? undefined, + user: user, + systemWebhookId: recipient.systemWebhookId ?? undefined, + systemWebhook: webhook, + }; + } + + @bindThis + public async packMany( + src: MiAbuseReportNotificationRecipient['id'][] | MiAbuseReportNotificationRecipient[], + ): Promise[]> { + const objs = src.filter((it): it is MiAbuseReportNotificationRecipient => typeof it === 'object'); + const ids = src.filter((it): it is MiAbuseReportNotificationRecipient['id'] => typeof it === 'string'); + if (ids.length > 0) { + objs.push( + ...await this.abuseReportNotificationRecipientRepository.findBy({ id: In(ids) }), + ); + } + + const userIds = objs.map(it => it.userId).filter(x => x != null); + const users: Map> = (userIds.length > 0) + ? await this.userEntityService.packMany(userIds) + .then(it => new Map(it.map(it => [it.id, it]))) + : new Map(); + + const systemWebhookIds = objs.map(it => it.systemWebhookId).filter(x => x != null); + const systemWebhooks: Map> = (systemWebhookIds.length > 0) + ? await this.systemWebhookEntityService.packMany(systemWebhookIds) + .then(it => new Map(it.map(it => [it.id, it]))) + : new Map(); + + return Promise + .all( + objs.map(it => this.pack(it, { users: users, webhooks: systemWebhooks })), + ) + .then(it => it.sort((a, b) => a.id.localeCompare(b.id))); + } +} + diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index 49f256d870df..a13c244c19a1 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -10,6 +10,7 @@ import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -26,6 +27,11 @@ export class AbuseUserReportEntityService { @bindThis public async pack( src: MiAbuseUserReport['id'] | MiAbuseUserReport, + hint?: { + packedReporter?: Packed<'UserDetailedNotMe'>, + packedTargetUser?: Packed<'UserDetailedNotMe'>, + packedAssignee?: Packed<'UserDetailedNotMe'>, + }, ) { const report = typeof src === 'object' ? src : await this.abuseUserReportsRepository.findOneByOrFail({ id: src }); @@ -37,13 +43,13 @@ export class AbuseUserReportEntityService { reporterId: report.reporterId, targetUserId: report.targetUserId, assigneeId: report.assigneeId, - reporter: this.userEntityService.pack(report.reporter ?? report.reporterId, null, { + reporter: hint?.packedReporter ?? this.userEntityService.pack(report.reporter ?? report.reporterId, null, { schema: 'UserDetailedNotMe', }), - targetUser: this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { + targetUser: hint?.packedTargetUser ?? this.userEntityService.pack(report.targetUser ?? report.targetUserId, null, { schema: 'UserDetailedNotMe', }), - assignee: report.assigneeId ? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { + assignee: report.assigneeId ? hint?.packedAssignee ?? this.userEntityService.pack(report.assignee ?? report.assigneeId, null, { schema: 'UserDetailedNotMe', }) : null, forwarded: report.forwarded, @@ -51,9 +57,24 @@ export class AbuseUserReportEntityService { } @bindThis - public packMany( - reports: any[], + public async packMany( + reports: MiAbuseUserReport[], ) { - return Promise.all(reports.map(x => this.pack(x))); + const _reporters = reports.map(({ reporter, reporterId }) => reporter ?? reporterId); + const _targetUsers = reports.map(({ targetUser, targetUserId }) => targetUser ?? targetUserId); + const _assignees = reports.map(({ assignee, assigneeId }) => assignee ?? assigneeId).filter(x => x != null); + const _userMap = await this.userEntityService.packMany( + [..._reporters, ..._targetUsers, ..._assignees], + null, + { schema: 'UserDetailedNotMe' }, + ).then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + reports.map(report => { + const packedReporter = _userMap.get(report.reporterId); + const packedTargetUser = _userMap.get(report.targetUserId); + const packedAssignee = report.assigneeId != null ? _userMap.get(report.assigneeId) : undefined; + return this.pack(report, { packedReporter, packedTargetUser, packedAssignee }); + }), + ); } } diff --git a/packages/backend/src/core/entities/AnnouncementEntityService.ts b/packages/backend/src/core/entities/AnnouncementEntityService.ts new file mode 100644 index 000000000000..90b04d0229a6 --- /dev/null +++ b/packages/backend/src/core/entities/AnnouncementEntityService.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { AnnouncementsRepository, AnnouncementReadsRepository, MiAnnouncement, MiUser } from '@/models/_.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; + +@Injectable() +export class AnnouncementEntityService { + constructor( + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + + @Inject(DI.announcementReadsRepository) + private announcementReadsRepository: AnnouncementReadsRepository, + + private idService: IdService, + ) { + } + + @bindThis + public async pack( + src: MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null }, + me?: { id: MiUser['id'] } | null | undefined, + ): Promise> { + const announcement = typeof src === 'object' + ? src + : await this.announcementsRepository.findOneByOrFail({ + id: src, + }) as MiAnnouncement & { isRead?: boolean | null }; + + if (me && announcement.isRead === undefined) { + announcement.isRead = await this.announcementReadsRepository + .countBy({ + announcementId: announcement.id, + userId: me.id, + }) + .then((count: number) => count > 0); + } + + return { + id: announcement.id, + createdAt: this.idService.parse(announcement.id).date.toISOString(), + updatedAt: announcement.updatedAt?.toISOString() ?? null, + title: announcement.title, + text: announcement.text, + imageUrl: announcement.imageUrl, + icon: announcement.icon, + display: announcement.display, + forYou: announcement.userId === me?.id, + needConfirmationToRead: announcement.needConfirmationToRead, + silence: announcement.silence, + isRead: announcement.isRead !== null ? announcement.isRead : undefined, + }; + } + + @bindThis + public async packMany( + announcements: (MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null } | MiAnnouncement)[], + me?: { id: MiUser['id'] } | null | undefined, + ) : Promise[]> { + return (await Promise.allSettled(announcements.map(x => this.pack(x, me)))) + .filter(result => result.status === 'fulfilled') + .map(result => (result as PromiseFulfilledResult>).value); + } +} diff --git a/packages/backend/src/core/entities/AntennaEntityService.ts b/packages/backend/src/core/entities/AntennaEntityService.ts index 3ec8efa6bfd0..e770028af3e9 100644 --- a/packages/backend/src/core/entities/AntennaEntityService.ts +++ b/packages/backend/src/core/entities/AntennaEntityService.ts @@ -38,12 +38,12 @@ export class AntennaEntityService { users: antenna.users, caseSensitive: antenna.caseSensitive, localOnly: antenna.localOnly, - notify: antenna.notify, excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, isActive: antenna.isActive, hasUnreadNote: false, // TODO + notify: false, // 後方互換性のため }; } } diff --git a/packages/backend/src/core/entities/BlockingEntityService.ts b/packages/backend/src/core/entities/BlockingEntityService.ts index c8c1520ceb50..1e699032e234 100644 --- a/packages/backend/src/core/entities/BlockingEntityService.ts +++ b/packages/backend/src/core/entities/BlockingEntityService.ts @@ -29,6 +29,9 @@ export class BlockingEntityService { public async pack( src: MiBlocking['id'] | MiBlocking, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + blockee?: Packed<'UserDetailedNotMe'>, + }, ): Promise> { const blocking = typeof src === 'object' ? src : await this.blockingsRepository.findOneByOrFail({ id: src }); @@ -36,17 +39,20 @@ export class BlockingEntityService { id: blocking.id, createdAt: this.idService.parse(blocking.id).date.toISOString(), blockeeId: blocking.blockeeId, - blockee: this.userEntityService.pack(blocking.blockeeId, me, { + blockee: hint?.blockee ?? this.userEntityService.pack(blocking.blockeeId, me, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - blockings: any[], + public async packMany( + blockings: MiBlocking[], me: { id: MiUser['id'] }, ) { - return Promise.all(blockings.map(x => this.pack(x, me))); + const _blockees = blockings.map(({ blockee, blockeeId }) => blockee ?? blockeeId); + const _userMap = await this.userEntityService.packMany(_blockees, me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(blockings.map(blocking => this.pack(blocking, me, { blockee: _userMap.get(blocking.blockeeId) }))); } } diff --git a/packages/backend/src/core/entities/ClipEntityService.ts b/packages/backend/src/core/entities/ClipEntityService.ts index ce49c3458c54..d91564590692 100644 --- a/packages/backend/src/core/entities/ClipEntityService.ts +++ b/packages/backend/src/core/entities/ClipEntityService.ts @@ -35,6 +35,9 @@ export class ClipEntityService { public async pack( src: MiClip['id'] | MiClip, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise> { const meId = me ? me.id : null; const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src }); @@ -44,22 +47,25 @@ export class ClipEntityService { createdAt: this.idService.parse(clip.id).date.toISOString(), lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null, userId: clip.userId, - user: this.userEntityService.pack(clip.user ?? clip.userId), + user: hint?.packedUser ?? this.userEntityService.pack(clip.user ?? clip.userId), name: clip.name, description: clip.description, isPublic: clip.isPublic, favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }), isFavorited: meId ? await this.clipFavoritesRepository.exists({ where: { clipId: clip.id, userId: meId } }) : undefined, - notesCount: meId ? await this.clipNotesRepository.countBy({ clipId: clip.id }) : undefined, + notesCount: (meId === clip.userId) ? await this.clipNotesRepository.countBy({ clipId: clip.id }) : undefined, }); } @bindThis - public packMany( + public async packMany( clips: MiClip[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(clips.map(x => this.pack(x, me))); + const _users = clips.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(clips.map(clip => this.pack(clip, me, { packedUser: _userMap.get(clip.userId) }))); } } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index 26bf386cbc4b..c485555f90f9 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -16,7 +16,6 @@ import { appendQuery, query } from '@/misc/prelude/url.js'; import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { IdService } from '@/core/IdService.js'; import { UtilityService } from '../UtilityService.js'; import { VideoProcessingService } from '../VideoProcessingService.js'; @@ -222,6 +221,9 @@ export class DriveFileEntityService { public async packNullable( src: MiDriveFile['id'] | MiDriveFile, options?: PackOptions, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise | null> { const opts = Object.assign({ detail: false, @@ -249,7 +251,7 @@ export class DriveFileEntityService { detail: true, }) : null, userId: file.userId, - user: (opts.withUser && file.userId) ? this.userEntityService.pack(file.userId) : null, + user: (opts.withUser && file.userId) ? hint?.packedUser ?? this.userEntityService.pack(file.userId) : null, }); } @@ -258,8 +260,11 @@ export class DriveFileEntityService { files: MiDriveFile[], options?: PackOptions, ): Promise[]> { - const items = await Promise.all(files.map(f => this.packNullable(f, options))); - return items.filter(isNotNull); + const _user = files.map(({ user, userId }) => user ?? userId).filter(x => x != null); + const _userMap = await this.userEntityService.packMany(_user) + .then(users => new Map(users.map(user => [user.id, user]))); + const items = await Promise.all(files.map(f => this.packNullable(f, options, f.userId ? { packedUser: _userMap.get(f.userId) } : {}))); + return items.filter(x => x != null); } @bindThis @@ -284,6 +289,6 @@ export class DriveFileEntityService { ): Promise[]> { if (fileIds.length === 0) return []; const filesMap = await this.packManyByIdsMap(fileIds, options); - return fileIds.map(id => filesMap.get(id)).filter(isNotNull); + return fileIds.map(id => filesMap.get(id)).filter(x => x != null); } } diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index db4cf6d360d6..d110f7afc632 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -33,6 +33,9 @@ export class FlashEntityService { public async pack( src: MiFlash['id'] | MiFlash, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise> { const meId = me ? me.id : null; const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src }); @@ -42,7 +45,7 @@ export class FlashEntityService { createdAt: this.idService.parse(flash.id).date.toISOString(), updatedAt: flash.updatedAt.toISOString(), userId: flash.userId, - user: this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 + user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 title: flash.title, summary: flash.summary, script: flash.script, @@ -52,11 +55,14 @@ export class FlashEntityService { } @bindThis - public packMany( - flashs: MiFlash[], + public async packMany( + flashes: MiFlash[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(flashs.map(x => this.pack(x, me))); + const _users = flashes.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) }))); } } diff --git a/packages/backend/src/core/entities/FollowRequestEntityService.ts b/packages/backend/src/core/entities/FollowRequestEntityService.ts index 763b75101faa..0101ec8aa735 100644 --- a/packages/backend/src/core/entities/FollowRequestEntityService.ts +++ b/packages/backend/src/core/entities/FollowRequestEntityService.ts @@ -10,6 +10,7 @@ import type { } from '@/models/Blocking.js'; import type { MiUser } from '@/models/User.js'; import type { MiFollowRequest } from '@/models/FollowRequest.js'; import { bindThis } from '@/decorators.js'; +import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -26,14 +27,36 @@ export class FollowRequestEntityService { public async pack( src: MiFollowRequest['id'] | MiFollowRequest, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedFollower?: Packed<'UserLite'>, + packedFollowee?: Packed<'UserLite'>, + }, ) { const request = typeof src === 'object' ? src : await this.followRequestsRepository.findOneByOrFail({ id: src }); return { id: request.id, - follower: await this.userEntityService.pack(request.followerId, me), - followee: await this.userEntityService.pack(request.followeeId, me), + follower: hint?.packedFollower ?? await this.userEntityService.pack(request.followerId, me), + followee: hint?.packedFollowee ?? await this.userEntityService.pack(request.followeeId, me), }; } + + @bindThis + public async packMany( + requests: MiFollowRequest[], + me?: { id: MiUser['id'] } | null | undefined, + ) { + const _followers = requests.map(({ follower, followerId }) => follower ?? followerId); + const _followees = requests.map(({ followee, followeeId }) => followee ?? followeeId); + const _userMap = await this.userEntityService.packMany([..._followers, ..._followees], me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + requests.map(req => { + const packedFollower = _userMap.get(req.followerId); + const packedFollowee = _userMap.get(req.followeeId); + return this.pack(req, me, { packedFollower, packedFollowee }); + }), + ); + } } diff --git a/packages/backend/src/core/entities/FollowingEntityService.ts b/packages/backend/src/core/entities/FollowingEntityService.ts index 24cd33e3f7d4..d2dbaf227030 100644 --- a/packages/backend/src/core/entities/FollowingEntityService.ts +++ b/packages/backend/src/core/entities/FollowingEntityService.ts @@ -78,6 +78,10 @@ export class FollowingEntityService { populateFollowee?: boolean; populateFollower?: boolean; }, + hint?: { + packedFollowee?: Packed<'UserDetailedNotMe'>, + packedFollower?: Packed<'UserDetailedNotMe'>, + }, ): Promise> { const following = typeof src === 'object' ? src : await this.followingsRepository.findOneByOrFail({ id: src }); @@ -88,25 +92,35 @@ export class FollowingEntityService { createdAt: this.idService.parse(following.id).date.toISOString(), followeeId: following.followeeId, followerId: following.followerId, - followee: opts.populateFollowee ? this.userEntityService.pack(following.followee ?? following.followeeId, me, { + followee: opts.populateFollowee ? hint?.packedFollowee ?? this.userEntityService.pack(following.followee ?? following.followeeId, me, { schema: 'UserDetailedNotMe', }) : undefined, - follower: opts.populateFollower ? this.userEntityService.pack(following.follower ?? following.followerId, me, { + follower: opts.populateFollower ? hint?.packedFollower ?? this.userEntityService.pack(following.follower ?? following.followerId, me, { schema: 'UserDetailedNotMe', }) : undefined, }); } @bindThis - public packMany( - followings: any[], + public async packMany( + followings: MiFollowing[], me?: { id: MiUser['id'] } | null | undefined, opts?: { populateFollowee?: boolean; populateFollower?: boolean; }, ) { - return Promise.all(followings.map(x => this.pack(x, me, opts))); + const _followees = opts?.populateFollowee ? followings.map(({ followee, followeeId }) => followee ?? followeeId) : []; + const _followers = opts?.populateFollower ? followings.map(({ follower, followerId }) => follower ?? followerId) : []; + const _userMap = await this.userEntityService.packMany([..._followees, ..._followers], me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + followings.map(following => { + const packedFollowee = opts?.populateFollowee ? _userMap.get(following.followeeId) : undefined; + const packedFollower = opts?.populateFollower ? _userMap.get(following.followerId) : undefined; + return this.pack(following, me, opts, { packedFollowee, packedFollower }); + }), + ); } } diff --git a/packages/backend/src/core/entities/GalleryPostEntityService.ts b/packages/backend/src/core/entities/GalleryPostEntityService.ts index 101182a9e5aa..9746a4c1af3a 100644 --- a/packages/backend/src/core/entities/GalleryPostEntityService.ts +++ b/packages/backend/src/core/entities/GalleryPostEntityService.ts @@ -35,6 +35,9 @@ export class GalleryPostEntityService { public async pack( src: MiGalleryPost['id'] | MiGalleryPost, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise> { const meId = me ? me.id : null; const post = typeof src === 'object' ? src : await this.galleryPostsRepository.findOneByOrFail({ id: src }); @@ -44,7 +47,7 @@ export class GalleryPostEntityService { createdAt: this.idService.parse(post.id).date.toISOString(), updatedAt: post.updatedAt.toISOString(), userId: post.userId, - user: this.userEntityService.pack(post.user ?? post.userId, me), + user: hint?.packedUser ?? this.userEntityService.pack(post.user ?? post.userId, me), title: post.title, description: post.description, fileIds: post.fileIds, @@ -58,11 +61,14 @@ export class GalleryPostEntityService { } @bindThis - public packMany( + public async packMany( posts: MiGalleryPost[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(posts.map(x => this.pack(x, me))); + const _users = posts.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(posts.map(post => this.pack(post, me, { packedUser: _userMap.get(post.userId) }))); } } diff --git a/packages/backend/src/core/entities/InviteCodeEntityService.ts b/packages/backend/src/core/entities/InviteCodeEntityService.ts index 891543bc0fec..5d3e823a2a9d 100644 --- a/packages/backend/src/core/entities/InviteCodeEntityService.ts +++ b/packages/backend/src/core/entities/InviteCodeEntityService.ts @@ -29,6 +29,10 @@ export class InviteCodeEntityService { public async pack( src: MiRegistrationTicket['id'] | MiRegistrationTicket, me?: { id: MiUser['id'] } | null | undefined, + hints?: { + packedCreatedBy?: Packed<'UserLite'>, + packedUsedBy?: Packed<'UserLite'>, + }, ): Promise> { const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({ where: { @@ -42,18 +46,28 @@ export class InviteCodeEntityService { code: target.code, expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null, createdAt: this.idService.parse(target.id).date.toISOString(), - createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null, - usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null, + createdBy: target.createdBy ? hints?.packedCreatedBy ?? await this.userEntityService.pack(target.createdBy, me) : null, + usedBy: target.usedBy ? hints?.packedUsedBy ?? await this.userEntityService.pack(target.usedBy, me) : null, usedAt: target.usedAt ? target.usedAt.toISOString() : null, used: !!target.usedAt, }); } @bindThis - public packMany( - targets: any[], + public async packMany( + tickets: MiRegistrationTicket[], me: { id: MiUser['id'] }, ) { - return Promise.all(targets.map(x => this.pack(x, me))); + const _createdBys = tickets.map(({ createdBy, createdById }) => createdBy ?? createdById).filter(x => x != null); + const _usedBys = tickets.map(({ usedBy, usedById }) => usedBy ?? usedById).filter(x => x != null); + const _userMap = await this.userEntityService.packMany([..._createdBys, ..._usedBys], me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + tickets.map(ticket => { + const packedCreatedBy = ticket.createdById != null ? _userMap.get(ticket.createdById) : undefined; + const packedUsedBy = ticket.usedById != null ? _userMap.get(ticket.usedById) : undefined; + return this.pack(ticket, me, { packedCreatedBy, packedUsedBy }); + }), + ); } } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index 9d054ab6a156..09641ce48552 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -50,6 +50,22 @@ export class MetaEntityService { })) .getMany(); + // クライアントの手間を減らすためあらかじめJSONに変換しておく + let defaultLightTheme = null; + let defaultDarkTheme = null; + if (instance.defaultLightTheme) { + try { + defaultLightTheme = JSON.stringify(JSON5.parse(instance.defaultLightTheme)); + } catch (e) { + } + } + if (instance.defaultDarkTheme) { + try { + defaultDarkTheme = JSON.stringify(JSON5.parse(instance.defaultDarkTheme)); + } catch (e) { + } + } + const packed: Packed<'MetaLite'> = { maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, @@ -67,6 +83,7 @@ export class MetaEntityService { feedbackUrl: instance.feedbackUrl, impressumUrl: instance.impressumUrl, privacyPolicyUrl: instance.privacyPolicyUrl, + inquiryUrl: instance.inquiryUrl, disableRegistration: instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, enableHcaptcha: instance.enableHcaptcha, @@ -89,9 +106,8 @@ export class MetaEntityService { backgroundImageUrl: instance.backgroundImageUrl, logoImageUrl: instance.logoImageUrl, maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, - // クライアントの手間を減らすためあらかじめJSONに変換しておく - defaultLightTheme: instance.defaultLightTheme ? JSON.stringify(JSON5.parse(instance.defaultLightTheme)) : null, - defaultDarkTheme: instance.defaultDarkTheme ? JSON.stringify(JSON5.parse(instance.defaultDarkTheme)) : null, + defaultLightTheme, + defaultDarkTheme, ads: ads.map(ad => ({ id: ad.id, url: ad.url, diff --git a/packages/backend/src/core/entities/ModerationLogEntityService.ts b/packages/backend/src/core/entities/ModerationLogEntityService.ts index 205e147bd106..bf1b2a002cee 100644 --- a/packages/backend/src/core/entities/ModerationLogEntityService.ts +++ b/packages/backend/src/core/entities/ModerationLogEntityService.ts @@ -8,9 +8,10 @@ import { DI } from '@/di-symbols.js'; import type { ModerationLogsRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { } from '@/models/Blocking.js'; -import type { MiModerationLog } from '@/models/ModerationLog.js'; +import { MiModerationLog } from '@/models/ModerationLog.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import type { Packed } from '@/misc/json-schema.js'; import { UserEntityService } from './UserEntityService.js'; @Injectable() @@ -27,6 +28,9 @@ export class ModerationLogEntityService { @bindThis public async pack( src: MiModerationLog['id'] | MiModerationLog, + hint?: { + packedUser?: Packed<'UserDetailedNotMe'>, + }, ) { const log = typeof src === 'object' ? src : await this.moderationLogsRepository.findOneByOrFail({ id: src }); @@ -36,17 +40,20 @@ export class ModerationLogEntityService { type: log.type, info: log.info, userId: log.userId, - user: this.userEntityService.pack(log.user ?? log.userId, null, { + user: hint?.packedUser ?? this.userEntityService.pack(log.user ?? log.userId, null, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - reports: any[], + public async packMany( + reports: MiModerationLog[], ) { - return Promise.all(reports.map(x => this.pack(x))); + const _users = reports.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, null, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(reports.map(report => this.pack(report, { packedUser: _userMap.get(report.userId) }))); } } diff --git a/packages/backend/src/core/entities/MutingEntityService.ts b/packages/backend/src/core/entities/MutingEntityService.ts index 0a52f429a2b2..d361a20271b6 100644 --- a/packages/backend/src/core/entities/MutingEntityService.ts +++ b/packages/backend/src/core/entities/MutingEntityService.ts @@ -30,6 +30,9 @@ export class MutingEntityService { public async pack( src: MiMuting['id'] | MiMuting, me?: { id: MiUser['id'] } | null | undefined, + hints?: { + packedMutee?: Packed<'UserDetailedNotMe'>, + }, ): Promise> { const muting = typeof src === 'object' ? src : await this.mutingsRepository.findOneByOrFail({ id: src }); @@ -38,18 +41,21 @@ export class MutingEntityService { createdAt: this.idService.parse(muting.id).date.toISOString(), expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, muteeId: muting.muteeId, - mutee: this.userEntityService.pack(muting.muteeId, me, { + mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - mutings: any[], + public async packMany( + mutings: MiMuting[], me: { id: MiUser['id'] }, ) { - return Promise.all(mutings.map(x => this.pack(x, me))); + const _mutees = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId); + const _userMap = await this.userEntityService.packMany(_mutees, me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) }))); } } diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 22d01462e773..2cd092231cf5 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -14,7 +14,6 @@ import type { MiNote } from '@/models/Note.js'; import type { MiNoteReaction } from '@/models/NoteReaction.js'; import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -276,7 +275,7 @@ export class NoteEntityService implements OnModuleInit { packedFiles.set(k, v); } } - return fileIds.map(id => packedFiles.get(id)).filter(isNotNull); + return fileIds.map(id => packedFiles.get(id)).filter(x => x != null); } @bindThis @@ -290,6 +289,7 @@ export class NoteEntityService implements OnModuleInit { _hint_?: { myReactions: Map; packedFiles: Map | null>; + packedUsers: Map> }; }, ): Promise> { @@ -319,12 +319,13 @@ export class NoteEntityService implements OnModuleInit { .filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ .map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', '')); const packedFiles = options?._hint_?.packedFiles; + const packedUsers = options?._hint_?.packedUsers; const packed: Packed<'Note'> = await awaitAll({ id: note.id, createdAt: this.idService.parse(note.id).date.toISOString(), userId: note.userId, - user: this.userEntityService.pack(note.user ?? note.userId, me), + user: packedUsers?.get(note.userId) ?? this.userEntityService.pack(note.user ?? note.userId, me), text: text, cw: note.cw, visibility: note.visibility, @@ -447,14 +448,22 @@ export class NoteEntityService implements OnModuleInit { await this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)); // TODO: 本当は renote とか reply がないのに renoteId とか replyId があったらここで解決しておく - const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(isNotNull); + const fileIds = notes.map(n => [n.fileIds, n.renote?.fileIds, n.reply?.fileIds]).flat(2).filter(x => x != null); const packedFiles = fileIds.length > 0 ? await this.driveFileEntityService.packManyByIdsMap(fileIds) : new Map(); + const users = [ + ...notes.map(({ user, userId }) => user ?? userId), + ...notes.map(({ replyUserId }) => replyUserId).filter(x => x != null), + ...notes.map(({ renoteUserId }) => renoteUserId).filter(x => x != null), + ]; + const packedUsers = await this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))); return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { myReactions: myReactionsMap, packedFiles, + packedUsers, }, }))); } diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index 3f4fa3cf969d..46ec13704cfd 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -52,6 +52,9 @@ export class NoteReactionEntityService implements OnModuleInit { options?: { withNote: boolean; }, + hints?: { + packedUser?: Packed<'UserLite'> + }, ): Promise> { const opts = Object.assign({ withNote: false, @@ -62,7 +65,7 @@ export class NoteReactionEntityService implements OnModuleInit { return { id: reaction.id, createdAt: this.idService.parse(reaction.id).date.toISOString(), - user: await this.userEntityService.pack(reaction.user ?? reaction.userId, me), + user: hints?.packedUser ?? await this.userEntityService.pack(reaction.user ?? reaction.userId, me), type: this.reactionService.convertLegacyReaction(reaction.reaction), ...(opts.withNote ? { note: await this.noteEntityService.pack(reaction.note ?? reaction.noteId, me), @@ -81,7 +84,9 @@ export class NoteReactionEntityService implements OnModuleInit { const opts = Object.assign({ withNote: false, }, options); - - return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts))); + const _users = reactions.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) }))); } } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 94d56c883bfb..f393513510e4 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -13,7 +13,6 @@ import type { MiGroupedNotification, MiNotification } from '@/models/Notificatio import type { MiNote } from '@/models/Note.js'; import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js'; import { CacheService } from '@/core/CacheService.js'; import { RoleEntityService } from './RoleEntityService.js'; @@ -103,7 +102,7 @@ export class NotificationEntityService implements OnModuleInit { user, reaction: reaction.reaction, }; - }))).filter(r => isNotNull(r.user)); + }))).filter(r => r.user != null); // if all users have been deleted, don't show this notification if (reactions.length === 0) { return null; @@ -124,7 +123,7 @@ export class NotificationEntityService implements OnModuleInit { } return this.userEntityService.pack(userId, { id: meId }); - }))).filter(isNotNull); + }))).filter(x => x != null); // if all users have been deleted, don't show this notification if (users.length === 0) { return null; @@ -181,7 +180,7 @@ export class NotificationEntityService implements OnModuleInit { validNotifications = await this.#filterValidNotifier(validNotifications, meId); - const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); + const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(x => x != null); const notes = noteIds.length > 0 ? await this.notesRepository.find({ where: { id: In(noteIds) }, relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], @@ -223,7 +222,7 @@ export class NotificationEntityService implements OnModuleInit { ); }); - return (await Promise.all(packPromises)).filter(isNotNull); + return (await Promise.all(packPromises)).filter(x => x != null); } @bindThis @@ -305,7 +304,7 @@ export class NotificationEntityService implements OnModuleInit { this.cacheService.userProfileCache.fetch(meId).then(p => new Set(p.mutedInstances)), ]); - const notifierIds = notifications.map(notification => 'notifierId' in notification ? notification.notifierId : null).filter(isNotNull); + const notifierIds = notifications.map(notification => 'notifierId' in notification ? notification.notifierId : null).filter(x => x != null); const notifiers = notifierIds.length > 0 ? await this.usersRepository.find({ where: { id: In(notifierIds) }, }) : []; @@ -313,7 +312,7 @@ export class NotificationEntityService implements OnModuleInit { const filteredNotifications = ((await Promise.all(notifications.map(async (notification) => { const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers); return isValid ? notification : null; - }))) as [T | null] ).filter(isNotNull); + }))) as [T | null] ).filter(x => x != null); return filteredNotifications; } diff --git a/packages/backend/src/core/entities/PageEntityService.ts b/packages/backend/src/core/entities/PageEntityService.ts index 65c69a49a7ec..46bf51bb6d28 100644 --- a/packages/backend/src/core/entities/PageEntityService.ts +++ b/packages/backend/src/core/entities/PageEntityService.ts @@ -14,7 +14,6 @@ import type { MiPage } from '@/models/Page.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import { UserEntityService } from './UserEntityService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; @@ -40,6 +39,9 @@ export class PageEntityService { public async pack( src: MiPage['id'] | MiPage, me?: { id: MiUser['id'] } | null | undefined, + hint?: { + packedUser?: Packed<'UserLite'> + }, ): Promise> { const meId = me ? me.id : null; const page = typeof src === 'object' ? src : await this.pagesRepository.findOneByOrFail({ id: src }); @@ -91,7 +93,7 @@ export class PageEntityService { createdAt: this.idService.parse(page.id).date.toISOString(), updatedAt: page.updatedAt.toISOString(), userId: page.userId, - user: this.userEntityService.pack(page.user ?? page.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 + user: hint?.packedUser ?? this.userEntityService.pack(page.user ?? page.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 content: page.content, variables: page.variables, title: page.title, @@ -103,18 +105,21 @@ export class PageEntityService { script: page.script, eyeCatchingImageId: page.eyeCatchingImageId, eyeCatchingImage: page.eyeCatchingImageId ? await this.driveFileEntityService.pack(page.eyeCatchingImageId) : null, - attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(isNotNull)), + attachedFiles: this.driveFileEntityService.packMany((await Promise.all(attachedFiles)).filter(x => x != null)), likedCount: page.likedCount, isLiked: meId ? await this.pageLikesRepository.exists({ where: { pageId: page.id, userId: meId } }) : undefined, }); } @bindThis - public packMany( + public async packMany( pages: MiPage[], me?: { id: MiUser['id'] } | null | undefined, ) { - return Promise.all(pages.map(x => this.pack(x, me))); + const _users = pages.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(pages.map(page => this.pack(page, me, { packedUser: _userMap.get(page.userId) }))); } } diff --git a/packages/backend/src/core/entities/RenoteMutingEntityService.ts b/packages/backend/src/core/entities/RenoteMutingEntityService.ts index 0b05a5db8090..e4e154109a32 100644 --- a/packages/backend/src/core/entities/RenoteMutingEntityService.ts +++ b/packages/backend/src/core/entities/RenoteMutingEntityService.ts @@ -30,6 +30,9 @@ export class RenoteMutingEntityService { public async pack( src: MiRenoteMuting['id'] | MiRenoteMuting, me?: { id: MiUser['id'] } | null | undefined, + hints?: { + packedMutee?: Packed<'UserDetailedNotMe'> + }, ): Promise> { const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); @@ -37,18 +40,21 @@ export class RenoteMutingEntityService { id: muting.id, createdAt: this.idService.parse(muting.id).date.toISOString(), muteeId: muting.muteeId, - mutee: this.userEntityService.pack(muting.muteeId, me, { + mutee: hints?.packedMutee ?? this.userEntityService.pack(muting.muteeId, me, { schema: 'UserDetailedNotMe', }), }); } @bindThis - public packMany( - mutings: any[], + public async packMany( + mutings: MiRenoteMuting[], me: { id: MiUser['id'] }, ) { - return Promise.all(mutings.map(x => this.pack(x, me))); + const _users = mutings.map(({ mutee, muteeId }) => mutee ?? muteeId); + const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailedNotMe' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all(mutings.map(muting => this.pack(muting, me, { packedMutee: _userMap.get(muting.muteeId) }))); } } diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts index 32cbe631e4e2..df042e75c177 100644 --- a/packages/backend/src/core/entities/ReversiGameEntityService.ts +++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts @@ -28,13 +28,15 @@ export class ReversiGameEntityService { @bindThis public async packDetail( src: MiReversiGame['id'] | MiReversiGame, + hint?: { + packedUser1?: Packed<'UserLite'>, + packedUser2?: Packed<'UserLite'>, + }, ): Promise> { const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src }); - const users = await Promise.all([ - this.userEntityService.pack(game.user1 ?? game.user1Id), - this.userEntityService.pack(game.user2 ?? game.user2Id), - ]); + const user1 = hint?.packedUser1 ?? await this.userEntityService.pack(game.user1 ?? game.user1Id); + const user2 = hint?.packedUser2 ?? await this.userEntityService.pack(game.user2 ?? game.user2Id); return await awaitAll({ id: game.id, @@ -49,10 +51,10 @@ export class ReversiGameEntityService { user2Ready: game.user2Ready, user1Id: game.user1Id, user2Id: game.user2Id, - user1: users[0], - user2: users[1], + user1, + user2, winnerId: game.winnerId, - winner: game.winnerId ? users.find(u => u.id === game.winnerId)! : null, + winner: game.winnerId ? [user1, user2].find(u => u.id === game.winnerId)! : null, surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, @@ -68,22 +70,35 @@ export class ReversiGameEntityService { } @bindThis - public packDetailMany( - xs: MiReversiGame[], + public async packDetailMany( + games: MiReversiGame[], ) { - return Promise.all(xs.map(x => this.packDetail(x))); + const _user1s = games.map(({ user1, user1Id }) => user1 ?? user1Id); + const _user2s = games.map(({ user2, user2Id }) => user2 ?? user2Id); + const _userMap = await this.userEntityService.packMany([..._user1s, ..._user2s]) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + games.map(game => { + return this.packDetail(game, { + packedUser1: _userMap.get(game.user1Id), + packedUser2: _userMap.get(game.user2Id), + }); + }), + ); } @bindThis public async packLite( src: MiReversiGame['id'] | MiReversiGame, + hint?: { + packedUser1?: Packed<'UserLite'>, + packedUser2?: Packed<'UserLite'>, + }, ): Promise> { const game = typeof src === 'object' ? src : await this.reversiGamesRepository.findOneByOrFail({ id: src }); - const users = await Promise.all([ - this.userEntityService.pack(game.user1 ?? game.user1Id), - this.userEntityService.pack(game.user2 ?? game.user2Id), - ]); + const user1 = hint?.packedUser1 ?? await this.userEntityService.pack(game.user1 ?? game.user1Id); + const user2 = hint?.packedUser2 ?? await this.userEntityService.pack(game.user2 ?? game.user2Id); return await awaitAll({ id: game.id, @@ -94,10 +109,10 @@ export class ReversiGameEntityService { isEnded: game.isEnded, user1Id: game.user1Id, user2Id: game.user2Id, - user1: users[0], - user2: users[1], + user1, + user2, winnerId: game.winnerId, - winner: game.winnerId ? users.find(u => u.id === game.winnerId)! : null, + winner: game.winnerId ? [user1, user2].find(u => u.id === game.winnerId)! : null, surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, @@ -111,10 +126,21 @@ export class ReversiGameEntityService { } @bindThis - public packLiteMany( - xs: MiReversiGame[], + public async packLiteMany( + games: MiReversiGame[], ) { - return Promise.all(xs.map(x => this.packLite(x))); + const _user1s = games.map(({ user1, user1Id }) => user1 ?? user1Id); + const _user2s = games.map(({ user2, user2Id }) => user2 ?? user2Id); + const _userMap = await this.userEntityService.packMany([..._user1s, ..._user2s]) + .then(users => new Map(users.map(u => [u.id, u]))); + return Promise.all( + games.map(game => { + return this.packLite(game, { + packedUser1: _userMap.get(game.user1Id), + packedUser2: _userMap.get(game.user2Id), + }); + }), + ); } } diff --git a/packages/backend/src/core/entities/SystemWebhookEntityService.ts b/packages/backend/src/core/entities/SystemWebhookEntityService.ts new file mode 100644 index 000000000000..e18734091c62 --- /dev/null +++ b/packages/backend/src/core/entities/SystemWebhookEntityService.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { MiSystemWebhook, SystemWebhooksRepository } from '@/models/_.js'; +import { bindThis } from '@/decorators.js'; +import { Packed } from '@/misc/json-schema.js'; + +@Injectable() +export class SystemWebhookEntityService { + constructor( + @Inject(DI.systemWebhooksRepository) + private systemWebhooksRepository: SystemWebhooksRepository, + ) { + } + + @bindThis + public async pack( + src: MiSystemWebhook['id'] | MiSystemWebhook, + opts?: { + webhooks: Map + }, + ): Promise> { + const webhook = typeof src === 'object' + ? src + : opts?.webhooks.get(src) ?? await this.systemWebhooksRepository.findOneByOrFail({ id: src }); + + return { + id: webhook.id, + isActive: webhook.isActive, + updatedAt: webhook.updatedAt.toISOString(), + latestSentAt: webhook.latestSentAt?.toISOString() ?? null, + latestStatus: webhook.latestStatus, + name: webhook.name, + on: webhook.on, + url: webhook.url, + secret: webhook.secret, + }; + } + + @bindThis + public async packMany(src: MiSystemWebhook['id'][] | MiSystemWebhook[]): Promise[]> { + if (src.length === 0) { + return []; + } + + const webhooks = Array.of(); + webhooks.push( + ...src.filter((it): it is MiSystemWebhook => typeof it === 'object'), + ); + + const ids = src.filter((it): it is MiSystemWebhook['id'] => typeof it === 'string'); + if (ids.length > 0) { + webhooks.push( + ...await this.systemWebhooksRepository.findBy({ id: In(ids) }), + ); + } + + return Promise + .all( + webhooks.map(x => + this.pack(x, { + webhooks: new Map(webhooks.map(x => [x.id, x])), + }), + ), + ) + .then(it => it.sort((a, b) => a.id.localeCompare(b.id))); + } +} + diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index b80a1ec206b4..7fd093c1913a 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -47,7 +47,6 @@ import { IdService } from '@/core/IdService.js'; import type { AnnouncementService } from '@/core/AnnouncementService.js'; import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; -import { isNotNull } from '@/misc/is-not-null.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; @@ -502,11 +501,15 @@ export class UserEntityService implements OnModuleInit { emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), // パフォーマンス上の理由でローカルユーザーのみ - badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then(rs => rs.sort((a, b) => b.displayOrder - a.displayOrder).map(r => ({ - name: r.name, - iconUrl: r.iconUrl, - displayOrder: r.displayOrder, - }))) : undefined, + badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs + .filter((r) => r.isPublic || iAmModerator) + .sort((a, b) => b.displayOrder - a.displayOrder) + .map((r) => ({ + name: r.name, + iconUrl: r.iconUrl, + displayOrder: r.displayOrder, + })) + ) : undefined, ...(isDetailed ? { url: profile!.url, @@ -514,7 +517,7 @@ export class UserEntityService implements OnModuleInit { movedTo: user.movedToUri ? this.apPersonService.resolvePerson(user.movedToUri).then(user => user.id).catch(() => null) : null, alsoKnownAs: user.alsoKnownAs ? Promise.all(user.alsoKnownAs.map(uri => this.apPersonService.fetchPerson(uri).then(user => user?.id).catch(() => null))) - .then(xs => xs.length === 0 ? null : xs.filter(isNotNull)) + .then(xs => xs.length === 0 ? null : xs.filter(x => x != null)) : null, createdAt: this.idService.parse(user.id).date.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, diff --git a/packages/backend/src/core/entities/UserListEntityService.ts b/packages/backend/src/core/entities/UserListEntityService.ts index 09cab245212e..b77249c5cb72 100644 --- a/packages/backend/src/core/entities/UserListEntityService.ts +++ b/packages/backend/src/core/entities/UserListEntityService.ts @@ -50,11 +50,14 @@ export class UserListEntityService { public async packMembershipsMany( memberships: MiUserListMembership[], ) { + const _users = memberships.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users) + .then(users => new Map(users.map(u => [u.id, u]))); return Promise.all(memberships.map(async x => ({ id: x.id, createdAt: this.idService.parse(x.id).date.toISOString(), userId: x.userId, - user: await this.userEntityService.pack(x.userId), + user: _userMap.get(x.userId) ?? await this.userEntityService.pack(x.userId), withReplies: x.withReplies, }))); } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 919f4794a325..271082b4ff35 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -49,6 +49,7 @@ export const DI = { swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), hashtagsRepository: Symbol('hashtagsRepository'), abuseUserReportsRepository: Symbol('abuseUserReportsRepository'), + abuseReportNotificationRecipientRepository: Symbol('abuseReportNotificationRecipientRepository'), registrationTicketsRepository: Symbol('registrationTicketsRepository'), authSessionsRepository: Symbol('authSessionsRepository'), accessTokensRepository: Symbol('accessTokensRepository'), @@ -70,6 +71,7 @@ export const DI = { channelFavoritesRepository: Symbol('channelFavoritesRepository'), registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), + systemWebhooksRepository: Symbol('systemWebhooksRepository'), adsRepository: Symbol('adsRepository'), passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'), retentionAggregationsRepository: Symbol('retentionAggregationsRepository'), diff --git a/packages/backend/src/logger.ts b/packages/backend/src/logger.ts index d4705af60114..ff5363a425d7 100644 --- a/packages/backend/src/logger.ts +++ b/packages/backend/src/logger.ts @@ -22,31 +22,27 @@ type Level = 'error' | 'success' | 'warning' | 'debug' | 'info'; export default class Logger { private context: Context; private parentLogger: Logger | null = null; - private store: boolean; - constructor(context: string, color?: KEYWORD, store = true) { + constructor(context: string, color?: KEYWORD) { this.context = { name: context, color: color, }; - this.store = store; } @bindThis - public createSubLogger(context: string, color?: KEYWORD, store = true): Logger { - const logger = new Logger(context, color, store); + public createSubLogger(context: string, color?: KEYWORD): Logger { + const logger = new Logger(context, color); logger.parentLogger = this; return logger; } @bindThis - private log(level: Level, message: string, data?: Record | null, important = false, subContexts: Context[] = [], store = true): void { + private log(level: Level, message: string, data?: Record | null, important = false, subContexts: Context[] = []): void { if (envOption.quiet) return; - if (!this.store) store = false; - if (level === 'debug') store = false; if (this.parentLogger) { - this.parentLogger.log(level, message, data, important, [this.context].concat(subContexts), store); + this.parentLogger.log(level, message, data, important, [this.context].concat(subContexts)); return; } diff --git a/packages/backend/src/misc/is-not-null.ts b/packages/backend/src/misc/is-not-null.ts deleted file mode 100644 index 8d9dc8bb396e..000000000000 --- a/packages/backend/src/misc/is-not-null.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function isNotNull>(input: T | undefined | null): input is T { - return input != null; -} diff --git a/packages/backend/src/misc/is-user-related.ts b/packages/backend/src/misc/is-user-related.ts index 93c9b2b814bf..862d6e6a3839 100644 --- a/packages/backend/src/misc/is-user-related.ts +++ b/packages/backend/src/misc/is-user-related.ts @@ -4,6 +4,10 @@ */ export function isUserRelated(note: any, userIds: Set, ignoreAuthor = false): boolean { + if (!note) { + return false; + } + if (userIds.has(note.userId) && !ignoreAuthor) { return true; } diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 41e5bfe9e4ad..a721b8663c3c 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -4,12 +4,12 @@ */ import { - packedUserLiteSchema, - packedUserDetailedNotMeOnlySchema, packedMeDetailedOnlySchema, - packedUserDetailedNotMeSchema, packedMeDetailedSchema, + packedUserDetailedNotMeOnlySchema, + packedUserDetailedNotMeSchema, packedUserDetailedSchema, + packedUserLiteSchema, packedUserSchema, } from '@/models/json-schema/user.js'; import { packedNoteSchema } from '@/models/json-schema/note.js'; @@ -25,7 +25,7 @@ import { packedBlockingSchema } from '@/models/json-schema/blocking.js'; import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; import { packedHashtagSchema } from '@/models/json-schema/hashtag.js'; import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js'; -import { packedPageSchema, packedPageBlockSchema } from '@/models/json-schema/page.js'; +import { packedPageBlockSchema, packedPageSchema } from '@/models/json-schema/page.js'; import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js'; import { packedChannelSchema } from '@/models/json-schema/channel.js'; import { packedAntennaSchema } from '@/models/json-schema/antenna.js'; @@ -38,25 +38,27 @@ import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; import { - packedRoleLiteSchema, - packedRoleSchema, - packedRolePoliciesSchema, + packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, packedRoleCondFormulaLogicsSchema, - packedRoleCondFormulaValueNot, - packedRoleCondFormulaValueIsLocalOrRemoteSchema, packedRoleCondFormulaValueAssignedRoleSchema, packedRoleCondFormulaValueCreatedSchema, - packedRoleCondFormulaFollowersOrFollowingOrNotesSchema, + packedRoleCondFormulaValueIsLocalOrRemoteSchema, + packedRoleCondFormulaValueNot, packedRoleCondFormulaValueSchema, packedRoleCondFormulaValueUserSettingBooleanSchema, + packedRoleLiteSchema, + packedRolePoliciesSchema, + packedRoleSchema, } from '@/models/json-schema/role.js'; import { packedAdSchema } from '@/models/json-schema/ad.js'; -import { packedReversiGameLiteSchema, packedReversiGameDetailedSchema } from '@/models/json-schema/reversi-game.js'; +import { packedReversiGameDetailedSchema, packedReversiGameLiteSchema } from '@/models/json-schema/reversi-game.js'; import { - packedMetaLiteSchema, packedMetaDetailedOnlySchema, packedMetaDetailedSchema, + packedMetaLiteSchema, } from '@/models/json-schema/meta.js'; +import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; +import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -111,6 +113,8 @@ export const refs = { MetaLite: packedMetaLiteSchema, MetaDetailedOnly: packedMetaDetailedOnlySchema, MetaDetailed: packedMetaDetailedSchema, + SystemWebhook: packedSystemWebhookSchema, + AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, }; export type Packed = SchemaType; diff --git a/packages/backend/src/misc/prelude/array.ts b/packages/backend/src/misc/prelude/array.ts index dbfe1fff18c6..f741a0c91339 100644 --- a/packages/backend/src/misc/prelude/array.ts +++ b/packages/backend/src/misc/prelude/array.ts @@ -65,44 +65,6 @@ export function maximum(xs: number[]): number { return Math.max(...xs); } -/** - * Splits an array based on the equivalence relation. - * The concatenation of the result is equal to the argument. - */ -export function groupBy(f: EndoRelation, xs: T[]): T[][] { - const groups = [] as T[][]; - for (const x of xs) { - const lastGroup = groups.at(-1); - if (lastGroup !== undefined && f(lastGroup[0], x)) { - lastGroup.push(x); - } else { - groups.push([x]); - } - } - return groups; -} - -/** - * Splits an array based on the equivalence relation induced by the function. - * The concatenation of the result is equal to the argument. - */ -export function groupOn(f: (x: T) => S, xs: T[]): T[][] { - return groupBy((a, b) => f(a) === f(b), xs); -} - -export function groupByX(collections: T[], keySelector: (x: T) => string) { - return collections.reduce((obj: Record, item: T) => { - const key = keySelector(item); - if (!Object.prototype.hasOwnProperty.call(obj, key)) { - obj[key] = []; - } - - obj[key].push(item); - - return obj; - }, {}); -} - /** * Compare two arrays by lexicographical order */ diff --git a/packages/backend/src/misc/prelude/maybe.ts b/packages/backend/src/misc/prelude/maybe.ts deleted file mode 100644 index 1c58ccb9c778..000000000000 --- a/packages/backend/src/misc/prelude/maybe.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export interface IMaybe { - isJust(): this is IJust; -} - -export interface IJust extends IMaybe { - get(): T; -} - -export function just(value: T): IJust { - return { - isJust: () => true, - get: () => value, - }; -} - -export function nothing(): IMaybe { - return { - isJust: () => false, - }; -} diff --git a/packages/backend/src/misc/prelude/string.ts b/packages/backend/src/misc/prelude/string.ts deleted file mode 100644 index 67ea5299619c..000000000000 --- a/packages/backend/src/misc/prelude/string.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export function concat(xs: string[]): string { - return xs.join(''); -} - -export function capitalize(s: string): string { - return toUpperCase(s.charAt(0)) + toLowerCase(s.slice(1)); -} - -export function toUpperCase(s: string): string { - return s.toUpperCase(); -} - -export function toLowerCase(s: string): string { - return s.toLowerCase(); -} diff --git a/packages/backend/src/models/AbuseReportNotificationRecipient.ts b/packages/backend/src/models/AbuseReportNotificationRecipient.ts new file mode 100644 index 000000000000..fbff880afca4 --- /dev/null +++ b/packages/backend/src/models/AbuseReportNotificationRecipient.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { MiSystemWebhook } from '@/models/SystemWebhook.js'; +import { MiUserProfile } from '@/models/UserProfile.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +/** + * 通報受信時に通知を送信する方法. + */ +export type RecipientMethod = 'email' | 'webhook'; + +@Entity('abuse_report_notification_recipient') +export class MiAbuseReportNotificationRecipient { + @PrimaryColumn(id()) + public id: string; + + /** + * 有効かどうか. + */ + @Index() + @Column('boolean', { + default: true, + }) + public isActive: boolean; + + /** + * 更新日時. + */ + @Column('timestamp with time zone', { + default: () => 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + /** + * 通知設定名. + */ + @Column('varchar', { + length: 255, + }) + public name: string; + + /** + * 通知方法. + */ + @Index() + @Column('varchar', { + length: 64, + }) + public method: RecipientMethod; + + /** + * 通知先のユーザID. + */ + @Index() + @Column({ + ...id(), + nullable: true, + }) + public userId: MiUser['id'] | null; + + /** + * 通知先のユーザ. + */ + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'userId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId1' }) + public user: MiUser | null; + + /** + * 通知先のユーザプロフィール. + */ + @ManyToOne(type => MiUserProfile, {}) + @JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' }) + public userProfile: MiUserProfile | null; + + /** + * 通知先のシステムWebhookId. + */ + @Index() + @Column({ + ...id(), + nullable: true, + }) + public systemWebhookId: string | null; + + /** + * 通知先のシステムWebhook. + */ + @ManyToOne(type => MiSystemWebhook, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public systemWebhook: MiSystemWebhook | null; +} diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index f5e819059e03..33e6f4818952 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -90,9 +90,6 @@ export class MiAntenna { }) public expression: string | null; - @Column('boolean') - public notify: boolean; - @Index() @Column('boolean', { default: true, diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index ef4f97ceae9d..70d41801b5ee 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -381,6 +381,12 @@ export class MiMeta { }) public privacyPolicyUrl: string | null; + @Column('varchar', { + length: 1024, + nullable: true, + }) + public inquiryUrl: string | null; + @Column('varchar', { length: 8192, nullable: true, diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index bd447570ddf0..ea0f88babaa7 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -3,417 +3,500 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { Provider } from '@nestjs/common'; import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiAvatarDecoration, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListMembership, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook, MiBubbleGameRecord, MiReversiGame } from './_.js'; +import { + MiAbuseReportNotificationRecipient, + MiAbuseUserReport, + MiAccessToken, + MiAd, + MiAnnouncement, + MiAnnouncementRead, + MiAntenna, + MiApp, + MiAuthSession, + MiAvatarDecoration, + MiBlocking, + MiBubbleGameRecord, + MiChannel, + MiChannelFavorite, + MiChannelFollowing, + MiClip, + MiClipFavorite, + MiClipNote, + MiDriveFile, + MiDriveFolder, + MiEmoji, + MiFlash, + MiFlashLike, + MiFollowing, + MiFollowRequest, + MiGalleryLike, + MiGalleryPost, + MiHashtag, + MiInstance, + MiMeta, + MiModerationLog, + MiMuting, + MiNote, + MiNoteFavorite, + MiNoteReaction, + MiNoteThreadMuting, + MiNoteUnread, + MiPage, + MiPageLike, + MiPasswordResetRequest, + MiPoll, + MiPollVote, + MiPromoNote, + MiPromoRead, + MiRegistrationTicket, + MiRegistryItem, + MiRelay, + MiRenoteMuting, + MiRepository, + miRepository, + MiRetentionAggregation, + MiReversiGame, + MiRole, + MiRoleAssignment, + MiSignin, + MiSwSubscription, + MiSystemWebhook, + MiUsedUsername, + MiUser, + MiUserIp, + MiUserKeypair, + MiUserList, + MiUserListFavorite, + MiUserListMembership, + MiUserMemo, + MiUserNotePining, + MiUserPending, + MiUserProfile, + MiUserPublickey, + MiUserSecurityKey, + MiWebhook +} from './_.js'; import type { DataSource } from 'typeorm'; -import type { Provider } from '@nestjs/common'; const $usersRepository: Provider = { provide: DI.usersRepository, - useFactory: (db: DataSource) => db.getRepository(MiUser), + useFactory: (db: DataSource) => db.getRepository(MiUser).extend(miRepository as MiRepository), inject: [DI.db], }; const $notesRepository: Provider = { provide: DI.notesRepository, - useFactory: (db: DataSource) => db.getRepository(MiNote), + useFactory: (db: DataSource) => db.getRepository(MiNote).extend(miRepository as MiRepository), inject: [DI.db], }; const $announcementsRepository: Provider = { provide: DI.announcementsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAnnouncement), + useFactory: (db: DataSource) => db.getRepository(MiAnnouncement).extend(miRepository as MiRepository), inject: [DI.db], }; const $announcementReadsRepository: Provider = { provide: DI.announcementReadsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAnnouncementRead), + useFactory: (db: DataSource) => db.getRepository(MiAnnouncementRead).extend(miRepository as MiRepository), inject: [DI.db], }; const $appsRepository: Provider = { provide: DI.appsRepository, - useFactory: (db: DataSource) => db.getRepository(MiApp), + useFactory: (db: DataSource) => db.getRepository(MiApp).extend(miRepository as MiRepository), inject: [DI.db], }; const $avatarDecorationsRepository: Provider = { provide: DI.avatarDecorationsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAvatarDecoration), + useFactory: (db: DataSource) => db.getRepository(MiAvatarDecoration).extend(miRepository as MiRepository), inject: [DI.db], }; const $noteFavoritesRepository: Provider = { provide: DI.noteFavoritesRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite), + useFactory: (db: DataSource) => db.getRepository(MiNoteFavorite).extend(miRepository as MiRepository), inject: [DI.db], }; const $noteThreadMutingsRepository: Provider = { provide: DI.noteThreadMutingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteThreadMuting), + useFactory: (db: DataSource) => db.getRepository(MiNoteThreadMuting).extend(miRepository as MiRepository), inject: [DI.db], }; const $noteReactionsRepository: Provider = { provide: DI.noteReactionsRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteReaction), + useFactory: (db: DataSource) => db.getRepository(MiNoteReaction).extend(miRepository as MiRepository), inject: [DI.db], }; const $noteUnreadsRepository: Provider = { provide: DI.noteUnreadsRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteUnread), + useFactory: (db: DataSource) => db.getRepository(MiNoteUnread).extend(miRepository as MiRepository), inject: [DI.db], }; const $pollsRepository: Provider = { provide: DI.pollsRepository, - useFactory: (db: DataSource) => db.getRepository(MiPoll), + useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository), inject: [DI.db], }; const $pollVotesRepository: Provider = { provide: DI.pollVotesRepository, - useFactory: (db: DataSource) => db.getRepository(MiPollVote), + useFactory: (db: DataSource) => db.getRepository(MiPollVote).extend(miRepository as MiRepository), inject: [DI.db], }; const $userProfilesRepository: Provider = { provide: DI.userProfilesRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserProfile), + useFactory: (db: DataSource) => db.getRepository(MiUserProfile).extend(miRepository as MiRepository), inject: [DI.db], }; const $userKeypairsRepository: Provider = { provide: DI.userKeypairsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserKeypair), + useFactory: (db: DataSource) => db.getRepository(MiUserKeypair).extend(miRepository as MiRepository), inject: [DI.db], }; const $userPendingsRepository: Provider = { provide: DI.userPendingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserPending), + useFactory: (db: DataSource) => db.getRepository(MiUserPending).extend(miRepository as MiRepository), inject: [DI.db], }; const $userSecurityKeysRepository: Provider = { provide: DI.userSecurityKeysRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserSecurityKey), + useFactory: (db: DataSource) => db.getRepository(MiUserSecurityKey).extend(miRepository as MiRepository), inject: [DI.db], }; const $userPublickeysRepository: Provider = { provide: DI.userPublickeysRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserPublickey), + useFactory: (db: DataSource) => db.getRepository(MiUserPublickey).extend(miRepository as MiRepository), inject: [DI.db], }; const $userListsRepository: Provider = { provide: DI.userListsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserList), + useFactory: (db: DataSource) => db.getRepository(MiUserList).extend(miRepository as MiRepository), inject: [DI.db], }; const $userListFavoritesRepository: Provider = { provide: DI.userListFavoritesRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserListFavorite), + useFactory: (db: DataSource) => db.getRepository(MiUserListFavorite).extend(miRepository as MiRepository), inject: [DI.db], }; const $userListMembershipsRepository: Provider = { provide: DI.userListMembershipsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserListMembership), + useFactory: (db: DataSource) => db.getRepository(MiUserListMembership).extend(miRepository as MiRepository), inject: [DI.db], }; const $userNotePiningsRepository: Provider = { provide: DI.userNotePiningsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserNotePining), + useFactory: (db: DataSource) => db.getRepository(MiUserNotePining).extend(miRepository as MiRepository), inject: [DI.db], }; const $userIpsRepository: Provider = { provide: DI.userIpsRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserIp), + useFactory: (db: DataSource) => db.getRepository(MiUserIp).extend(miRepository as MiRepository), inject: [DI.db], }; const $usedUsernamesRepository: Provider = { provide: DI.usedUsernamesRepository, - useFactory: (db: DataSource) => db.getRepository(MiUsedUsername), + useFactory: (db: DataSource) => db.getRepository(MiUsedUsername).extend(miRepository as MiRepository), inject: [DI.db], }; const $followingsRepository: Provider = { provide: DI.followingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiFollowing), + useFactory: (db: DataSource) => db.getRepository(MiFollowing).extend(miRepository as MiRepository), inject: [DI.db], }; const $followRequestsRepository: Provider = { provide: DI.followRequestsRepository, - useFactory: (db: DataSource) => db.getRepository(MiFollowRequest), + useFactory: (db: DataSource) => db.getRepository(MiFollowRequest).extend(miRepository as MiRepository), inject: [DI.db], }; const $instancesRepository: Provider = { provide: DI.instancesRepository, - useFactory: (db: DataSource) => db.getRepository(MiInstance), + useFactory: (db: DataSource) => db.getRepository(MiInstance).extend(miRepository as MiRepository), inject: [DI.db], }; const $emojisRepository: Provider = { provide: DI.emojisRepository, - useFactory: (db: DataSource) => db.getRepository(MiEmoji), + useFactory: (db: DataSource) => db.getRepository(MiEmoji).extend(miRepository as MiRepository), inject: [DI.db], }; const $driveFilesRepository: Provider = { provide: DI.driveFilesRepository, - useFactory: (db: DataSource) => db.getRepository(MiDriveFile), + useFactory: (db: DataSource) => db.getRepository(MiDriveFile).extend(miRepository as MiRepository), inject: [DI.db], }; const $driveFoldersRepository: Provider = { provide: DI.driveFoldersRepository, - useFactory: (db: DataSource) => db.getRepository(MiDriveFolder), + useFactory: (db: DataSource) => db.getRepository(MiDriveFolder).extend(miRepository as MiRepository), inject: [DI.db], }; const $metasRepository: Provider = { provide: DI.metasRepository, - useFactory: (db: DataSource) => db.getRepository(MiMeta), + useFactory: (db: DataSource) => db.getRepository(MiMeta).extend(miRepository as MiRepository), inject: [DI.db], }; const $mutingsRepository: Provider = { provide: DI.mutingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiMuting), + useFactory: (db: DataSource) => db.getRepository(MiMuting).extend(miRepository as MiRepository), inject: [DI.db], }; const $renoteMutingsRepository: Provider = { provide: DI.renoteMutingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRenoteMuting), + useFactory: (db: DataSource) => db.getRepository(MiRenoteMuting).extend(miRepository as MiRepository), inject: [DI.db], }; const $blockingsRepository: Provider = { provide: DI.blockingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiBlocking), + useFactory: (db: DataSource) => db.getRepository(MiBlocking).extend(miRepository as MiRepository), inject: [DI.db], }; const $swSubscriptionsRepository: Provider = { provide: DI.swSubscriptionsRepository, - useFactory: (db: DataSource) => db.getRepository(MiSwSubscription), + useFactory: (db: DataSource) => db.getRepository(MiSwSubscription).extend(miRepository as MiRepository), inject: [DI.db], }; const $hashtagsRepository: Provider = { provide: DI.hashtagsRepository, - useFactory: (db: DataSource) => db.getRepository(MiHashtag), + useFactory: (db: DataSource) => db.getRepository(MiHashtag).extend(miRepository as MiRepository), inject: [DI.db], }; const $abuseUserReportsRepository: Provider = { provide: DI.abuseUserReportsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAbuseUserReport), + useFactory: (db: DataSource) => db.getRepository(MiAbuseUserReport).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $abuseReportNotificationRecipientRepository: Provider = { + provide: DI.abuseReportNotificationRecipientRepository, + useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient), inject: [DI.db], }; const $registrationTicketsRepository: Provider = { provide: DI.registrationTicketsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRegistrationTicket), + useFactory: (db: DataSource) => db.getRepository(MiRegistrationTicket).extend(miRepository as MiRepository), inject: [DI.db], }; const $authSessionsRepository: Provider = { provide: DI.authSessionsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAuthSession), + useFactory: (db: DataSource) => db.getRepository(MiAuthSession).extend(miRepository as MiRepository), inject: [DI.db], }; const $accessTokensRepository: Provider = { provide: DI.accessTokensRepository, - useFactory: (db: DataSource) => db.getRepository(MiAccessToken), + useFactory: (db: DataSource) => db.getRepository(MiAccessToken).extend(miRepository as MiRepository), inject: [DI.db], }; const $signinsRepository: Provider = { provide: DI.signinsRepository, - useFactory: (db: DataSource) => db.getRepository(MiSignin), + useFactory: (db: DataSource) => db.getRepository(MiSignin).extend(miRepository as MiRepository), inject: [DI.db], }; const $pagesRepository: Provider = { provide: DI.pagesRepository, - useFactory: (db: DataSource) => db.getRepository(MiPage), + useFactory: (db: DataSource) => db.getRepository(MiPage).extend(miRepository as MiRepository), inject: [DI.db], }; const $pageLikesRepository: Provider = { provide: DI.pageLikesRepository, - useFactory: (db: DataSource) => db.getRepository(MiPageLike), + useFactory: (db: DataSource) => db.getRepository(MiPageLike).extend(miRepository as MiRepository), inject: [DI.db], }; const $galleryPostsRepository: Provider = { provide: DI.galleryPostsRepository, - useFactory: (db: DataSource) => db.getRepository(MiGalleryPost), + useFactory: (db: DataSource) => db.getRepository(MiGalleryPost).extend(miRepository as MiRepository), inject: [DI.db], }; const $galleryLikesRepository: Provider = { provide: DI.galleryLikesRepository, - useFactory: (db: DataSource) => db.getRepository(MiGalleryLike), + useFactory: (db: DataSource) => db.getRepository(MiGalleryLike).extend(miRepository as MiRepository), inject: [DI.db], }; const $moderationLogsRepository: Provider = { provide: DI.moderationLogsRepository, - useFactory: (db: DataSource) => db.getRepository(MiModerationLog), + useFactory: (db: DataSource) => db.getRepository(MiModerationLog).extend(miRepository as MiRepository), inject: [DI.db], }; const $clipsRepository: Provider = { provide: DI.clipsRepository, - useFactory: (db: DataSource) => db.getRepository(MiClip), + useFactory: (db: DataSource) => db.getRepository(MiClip).extend(miRepository as MiRepository), inject: [DI.db], }; const $clipNotesRepository: Provider = { provide: DI.clipNotesRepository, - useFactory: (db: DataSource) => db.getRepository(MiClipNote), + useFactory: (db: DataSource) => db.getRepository(MiClipNote).extend(miRepository as MiRepository), inject: [DI.db], }; const $clipFavoritesRepository: Provider = { provide: DI.clipFavoritesRepository, - useFactory: (db: DataSource) => db.getRepository(MiClipFavorite), + useFactory: (db: DataSource) => db.getRepository(MiClipFavorite).extend(miRepository as MiRepository), inject: [DI.db], }; const $antennasRepository: Provider = { provide: DI.antennasRepository, - useFactory: (db: DataSource) => db.getRepository(MiAntenna), + useFactory: (db: DataSource) => db.getRepository(MiAntenna).extend(miRepository as MiRepository), inject: [DI.db], }; const $promoNotesRepository: Provider = { provide: DI.promoNotesRepository, - useFactory: (db: DataSource) => db.getRepository(MiPromoNote), + useFactory: (db: DataSource) => db.getRepository(MiPromoNote).extend(miRepository as MiRepository), inject: [DI.db], }; const $promoReadsRepository: Provider = { provide: DI.promoReadsRepository, - useFactory: (db: DataSource) => db.getRepository(MiPromoRead), + useFactory: (db: DataSource) => db.getRepository(MiPromoRead).extend(miRepository as MiRepository), inject: [DI.db], }; const $relaysRepository: Provider = { provide: DI.relaysRepository, - useFactory: (db: DataSource) => db.getRepository(MiRelay), + useFactory: (db: DataSource) => db.getRepository(MiRelay).extend(miRepository as MiRepository), inject: [DI.db], }; const $channelsRepository: Provider = { provide: DI.channelsRepository, - useFactory: (db: DataSource) => db.getRepository(MiChannel), + useFactory: (db: DataSource) => db.getRepository(MiChannel).extend(miRepository as MiRepository), inject: [DI.db], }; const $channelFollowingsRepository: Provider = { provide: DI.channelFollowingsRepository, - useFactory: (db: DataSource) => db.getRepository(MiChannelFollowing), + useFactory: (db: DataSource) => db.getRepository(MiChannelFollowing).extend(miRepository as MiRepository), inject: [DI.db], }; const $channelFavoritesRepository: Provider = { provide: DI.channelFavoritesRepository, - useFactory: (db: DataSource) => db.getRepository(MiChannelFavorite), + useFactory: (db: DataSource) => db.getRepository(MiChannelFavorite).extend(miRepository as MiRepository), inject: [DI.db], }; const $registryItemsRepository: Provider = { provide: DI.registryItemsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRegistryItem), + useFactory: (db: DataSource) => db.getRepository(MiRegistryItem).extend(miRepository as MiRepository), inject: [DI.db], }; const $webhooksRepository: Provider = { provide: DI.webhooksRepository, - useFactory: (db: DataSource) => db.getRepository(MiWebhook), + useFactory: (db: DataSource) => db.getRepository(MiWebhook).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $systemWebhooksRepository: Provider = { + provide: DI.systemWebhooksRepository, + useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook), inject: [DI.db], }; const $adsRepository: Provider = { provide: DI.adsRepository, - useFactory: (db: DataSource) => db.getRepository(MiAd), + useFactory: (db: DataSource) => db.getRepository(MiAd).extend(miRepository as MiRepository), inject: [DI.db], }; const $passwordResetRequestsRepository: Provider = { provide: DI.passwordResetRequestsRepository, - useFactory: (db: DataSource) => db.getRepository(MiPasswordResetRequest), + useFactory: (db: DataSource) => db.getRepository(MiPasswordResetRequest).extend(miRepository as MiRepository), inject: [DI.db], }; const $retentionAggregationsRepository: Provider = { provide: DI.retentionAggregationsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRetentionAggregation), + useFactory: (db: DataSource) => db.getRepository(MiRetentionAggregation).extend(miRepository as MiRepository), inject: [DI.db], }; const $flashsRepository: Provider = { provide: DI.flashsRepository, - useFactory: (db: DataSource) => db.getRepository(MiFlash), + useFactory: (db: DataSource) => db.getRepository(MiFlash).extend(miRepository as MiRepository), inject: [DI.db], }; const $flashLikesRepository: Provider = { provide: DI.flashLikesRepository, - useFactory: (db: DataSource) => db.getRepository(MiFlashLike), + useFactory: (db: DataSource) => db.getRepository(MiFlashLike).extend(miRepository as MiRepository), inject: [DI.db], }; const $rolesRepository: Provider = { provide: DI.rolesRepository, - useFactory: (db: DataSource) => db.getRepository(MiRole), + useFactory: (db: DataSource) => db.getRepository(MiRole).extend(miRepository as MiRepository), inject: [DI.db], }; const $roleAssignmentsRepository: Provider = { provide: DI.roleAssignmentsRepository, - useFactory: (db: DataSource) => db.getRepository(MiRoleAssignment), + useFactory: (db: DataSource) => db.getRepository(MiRoleAssignment).extend(miRepository as MiRepository), inject: [DI.db], }; const $userMemosRepository: Provider = { provide: DI.userMemosRepository, - useFactory: (db: DataSource) => db.getRepository(MiUserMemo), + useFactory: (db: DataSource) => db.getRepository(MiUserMemo).extend(miRepository as MiRepository), inject: [DI.db], }; const $bubbleGameRecordsRepository: Provider = { provide: DI.bubbleGameRecordsRepository, - useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord), + useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord).extend(miRepository as MiRepository), inject: [DI.db], }; const $reversiGamesRepository: Provider = { provide: DI.reversiGamesRepository, - useFactory: (db: DataSource) => db.getRepository(MiReversiGame), + useFactory: (db: DataSource) => db.getRepository(MiReversiGame).extend(miRepository as MiRepository), inject: [DI.db], }; @Module({ - imports: [ - ], + imports: [], providers: [ $usersRepository, $notesRepository, @@ -451,6 +534,7 @@ const $reversiGamesRepository: Provider = { $swSubscriptionsRepository, $hashtagsRepository, $abuseUserReportsRepository, + $abuseReportNotificationRecipientRepository, $registrationTicketsRepository, $authSessionsRepository, $accessTokensRepository, @@ -472,6 +556,7 @@ const $reversiGamesRepository: Provider = { $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, + $systemWebhooksRepository, $adsRepository, $passwordResetRequestsRepository, $retentionAggregationsRepository, @@ -520,6 +605,7 @@ const $reversiGamesRepository: Provider = { $swSubscriptionsRepository, $hashtagsRepository, $abuseUserReportsRepository, + $abuseReportNotificationRecipientRepository, $registrationTicketsRepository, $authSessionsRepository, $accessTokensRepository, @@ -541,6 +627,7 @@ const $reversiGamesRepository: Provider = { $channelFavoritesRepository, $registryItemsRepository, $webhooksRepository, + $systemWebhooksRepository, $adsRepository, $passwordResetRequestsRepository, $retentionAggregationsRepository, @@ -553,4 +640,5 @@ const $reversiGamesRepository: Provider = { $reversiGamesRepository, ], }) -export class RepositoryModule {} +export class RepositoryModule { +} diff --git a/packages/backend/src/models/SystemWebhook.ts b/packages/backend/src/models/SystemWebhook.ts new file mode 100644 index 000000000000..86fb323d1daf --- /dev/null +++ b/packages/backend/src/models/SystemWebhook.ts @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, PrimaryColumn } from 'typeorm'; +import { Serialized } from '@/types.js'; +import { id } from './util/id.js'; + +export const systemWebhookEventTypes = [ + // ユーザからの通報を受けたとき + 'abuseReport', + // 通報を処理したとき + 'abuseReportResolved', +] as const; +export type SystemWebhookEventType = typeof systemWebhookEventTypes[number]; + +@Entity('system_webhook') +export class MiSystemWebhook { + @PrimaryColumn(id()) + public id: string; + + /** + * 有効かどうか. + */ + @Index('IDX_system_webhook_isActive', { synchronize: false }) + @Column('boolean', { + default: true, + }) + public isActive: boolean; + + /** + * 更新日時. + */ + @Column('timestamp with time zone', { + default: () => 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + /** + * 最後に送信された日時. + */ + @Column('timestamp with time zone', { + nullable: true, + }) + public latestSentAt: Date | null; + + /** + * 最後に送信されたステータスコード + */ + @Column('integer', { + nullable: true, + }) + public latestStatus: number | null; + + /** + * 通知設定名. + */ + @Column('varchar', { + length: 255, + }) + public name: string; + + /** + * イベント種別. + */ + @Index('IDX_system_webhook_on', { synchronize: false }) + @Column('varchar', { + length: 128, + array: true, + default: '{}', + }) + public on: SystemWebhookEventType[]; + + /** + * Webhook送信先のURL. + */ + @Column('varchar', { + length: 1024, + }) + public url: string; + + /** + * Webhook検証用の値. + */ + @Column('varchar', { + length: 1024, + }) + public secret: string; + + static deserialize(obj: Serialized): MiSystemWebhook { + return { + ...obj, + updatedAt: new Date(obj.updatedAt), + latestSentAt: obj.latestSentAt ? new Date(obj.latestSentAt) : null, + }; + } +} diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 43d42d80dd32..c72bdaa72726 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -3,7 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm'; +import { DriverUtils } from 'typeorm/driver/DriverUtils.js'; +import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; +import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; +import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; +import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; +import { OrmUtils } from 'typeorm/util/OrmUtils.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; @@ -61,6 +69,7 @@ import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import { MiUserMemo } from '@/models/UserMemo.js'; import { MiWebhook } from '@/models/Webhook.js'; +import { MiSystemWebhook } from '@/models/SystemWebhook.js'; import { MiChannel } from '@/models/Channel.js'; import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; import { MiRole } from '@/models/Role.js'; @@ -70,11 +79,54 @@ import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; +import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; -import type { Repository } from 'typeorm'; +export interface MiRepository { + createTableColumnNames(this: Repository & MiRepository): string[]; + insertOne(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>): Promise; + selectAliasColumnNames(this: Repository & MiRepository, queryBuilder: InsertQueryBuilder, builder: SelectQueryBuilder): void; +} + +export const miRepository = { + createTableColumnNames() { + return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName); + }, + async insertOne(entity, findOptions?) { + const queryBuilder = this.createQueryBuilder().insert().values(entity); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const mainAlias = queryBuilder.expressionMap.mainAlias!; + const name = mainAlias.name; + mainAlias.name = 't'; + const columnNames = this.createTableColumnNames(); + queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2)); + const builder = this.createQueryBuilder().addCommonTableExpression(queryBuilder, 'cte', { columnNames }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + builder.expressionMap.mainAlias!.tablePath = 'cte'; + this.selectAliasColumnNames(queryBuilder, builder); + if (findOptions) { + builder.setFindOptions(findOptions); + } + const raw = await builder.execute(); + mainAlias.name = name; + const relationId = await new RelationIdLoader(builder.connection, this.queryRunner, builder.expressionMap.relationIdAttributes).load(raw); + const relationCount = await new RelationCountLoader(builder.connection, this.queryRunner, builder.expressionMap.relationCountAttributes).load(raw); + const result = new RawSqlResultsToEntityTransformer(builder.expressionMap, builder.connection.driver, relationId, relationCount, this.queryRunner).transform(raw, mainAlias); + return result[0]; + }, + selectAliasColumnNames(queryBuilder, builder) { + let selectOrAddSelect = (selection: string, selectionAliasName?: string) => { + selectOrAddSelect = (selection, selectionAliasName) => builder.addSelect(selection, selectionAliasName); + return builder.select(selection, selectionAliasName); + }; + for (const columnName of this.createTableColumnNames()) { + selectOrAddSelect(`${builder.alias}.${columnName}`, `${builder.alias}_${columnName}`); + } + }, +} satisfies MiRepository; export { MiAbuseUserReport, + MiAbuseReportNotificationRecipient, MiAccessToken, MiAd, MiAnnouncement, @@ -132,6 +184,7 @@ export { MiUserPublickey, MiUserSecurityKey, MiWebhook, + MiSystemWebhook, MiChannel, MiRetentionAggregation, MiRole, @@ -143,70 +196,72 @@ export { MiReversiGame, }; -export type AbuseUserReportsRepository = Repository; -export type AccessTokensRepository = Repository; -export type AdsRepository = Repository; -export type AnnouncementsRepository = Repository; -export type AnnouncementReadsRepository = Repository; -export type AntennasRepository = Repository; -export type AppsRepository = Repository; -export type AvatarDecorationsRepository = Repository; -export type AuthSessionsRepository = Repository; -export type BlockingsRepository = Repository; -export type ChannelFollowingsRepository = Repository; -export type ChannelFavoritesRepository = Repository; -export type ClipsRepository = Repository; -export type ClipNotesRepository = Repository; -export type ClipFavoritesRepository = Repository; -export type DriveFilesRepository = Repository; -export type DriveFoldersRepository = Repository; -export type EmojisRepository = Repository; -export type FollowingsRepository = Repository; -export type FollowRequestsRepository = Repository; -export type GalleryLikesRepository = Repository; -export type GalleryPostsRepository = Repository; -export type HashtagsRepository = Repository; -export type InstancesRepository = Repository; -export type MetasRepository = Repository; -export type ModerationLogsRepository = Repository; -export type MutingsRepository = Repository; -export type RenoteMutingsRepository = Repository; -export type NotesRepository = Repository; -export type NoteFavoritesRepository = Repository; -export type NoteReactionsRepository = Repository; -export type NoteThreadMutingsRepository = Repository; -export type NoteUnreadsRepository = Repository; -export type PagesRepository = Repository; -export type PageLikesRepository = Repository; -export type PasswordResetRequestsRepository = Repository; -export type PollsRepository = Repository; -export type PollVotesRepository = Repository; -export type PromoNotesRepository = Repository; -export type PromoReadsRepository = Repository; -export type RegistrationTicketsRepository = Repository; -export type RegistryItemsRepository = Repository; -export type RelaysRepository = Repository; -export type SigninsRepository = Repository; -export type SwSubscriptionsRepository = Repository; -export type UsedUsernamesRepository = Repository; -export type UsersRepository = Repository; -export type UserIpsRepository = Repository; -export type UserKeypairsRepository = Repository; -export type UserListsRepository = Repository; -export type UserListFavoritesRepository = Repository; -export type UserListMembershipsRepository = Repository; -export type UserNotePiningsRepository = Repository; -export type UserPendingsRepository = Repository; -export type UserProfilesRepository = Repository; -export type UserPublickeysRepository = Repository; -export type UserSecurityKeysRepository = Repository; -export type WebhooksRepository = Repository; -export type ChannelsRepository = Repository; -export type RetentionAggregationsRepository = Repository; -export type RolesRepository = Repository; -export type RoleAssignmentsRepository = Repository; -export type FlashsRepository = Repository; -export type FlashLikesRepository = Repository; -export type UserMemoRepository = Repository; -export type BubbleGameRecordsRepository = Repository; -export type ReversiGamesRepository = Repository; +export type AbuseUserReportsRepository = Repository & MiRepository; +export type AbuseReportNotificationRecipientRepository = Repository & MiRepository; +export type AccessTokensRepository = Repository & MiRepository; +export type AdsRepository = Repository & MiRepository; +export type AnnouncementsRepository = Repository & MiRepository; +export type AnnouncementReadsRepository = Repository & MiRepository; +export type AntennasRepository = Repository & MiRepository; +export type AppsRepository = Repository & MiRepository; +export type AvatarDecorationsRepository = Repository & MiRepository; +export type AuthSessionsRepository = Repository & MiRepository; +export type BlockingsRepository = Repository & MiRepository; +export type ChannelFollowingsRepository = Repository & MiRepository; +export type ChannelFavoritesRepository = Repository & MiRepository; +export type ClipsRepository = Repository & MiRepository; +export type ClipNotesRepository = Repository & MiRepository; +export type ClipFavoritesRepository = Repository & MiRepository; +export type DriveFilesRepository = Repository & MiRepository; +export type DriveFoldersRepository = Repository & MiRepository; +export type EmojisRepository = Repository & MiRepository; +export type FollowingsRepository = Repository & MiRepository; +export type FollowRequestsRepository = Repository & MiRepository; +export type GalleryLikesRepository = Repository & MiRepository; +export type GalleryPostsRepository = Repository & MiRepository; +export type HashtagsRepository = Repository & MiRepository; +export type InstancesRepository = Repository & MiRepository; +export type MetasRepository = Repository & MiRepository; +export type ModerationLogsRepository = Repository & MiRepository; +export type MutingsRepository = Repository & MiRepository; +export type RenoteMutingsRepository = Repository & MiRepository; +export type NotesRepository = Repository & MiRepository; +export type NoteFavoritesRepository = Repository & MiRepository; +export type NoteReactionsRepository = Repository & MiRepository; +export type NoteThreadMutingsRepository = Repository & MiRepository; +export type NoteUnreadsRepository = Repository & MiRepository; +export type PagesRepository = Repository & MiRepository; +export type PageLikesRepository = Repository & MiRepository; +export type PasswordResetRequestsRepository = Repository & MiRepository; +export type PollsRepository = Repository & MiRepository; +export type PollVotesRepository = Repository & MiRepository; +export type PromoNotesRepository = Repository & MiRepository; +export type PromoReadsRepository = Repository & MiRepository; +export type RegistrationTicketsRepository = Repository & MiRepository; +export type RegistryItemsRepository = Repository & MiRepository; +export type RelaysRepository = Repository & MiRepository; +export type SigninsRepository = Repository & MiRepository; +export type SwSubscriptionsRepository = Repository & MiRepository; +export type UsedUsernamesRepository = Repository & MiRepository; +export type UsersRepository = Repository & MiRepository; +export type UserIpsRepository = Repository & MiRepository; +export type UserKeypairsRepository = Repository & MiRepository; +export type UserListsRepository = Repository & MiRepository; +export type UserListFavoritesRepository = Repository & MiRepository; +export type UserListMembershipsRepository = Repository & MiRepository; +export type UserNotePiningsRepository = Repository & MiRepository; +export type UserPendingsRepository = Repository & MiRepository; +export type UserProfilesRepository = Repository & MiRepository; +export type UserPublickeysRepository = Repository & MiRepository; +export type UserSecurityKeysRepository = Repository & MiRepository; +export type WebhooksRepository = Repository & MiRepository; +export type SystemWebhooksRepository = Repository & MiRepository; +export type ChannelsRepository = Repository & MiRepository; +export type RetentionAggregationsRepository = Repository & MiRepository; +export type RolesRepository = Repository & MiRepository; +export type RoleAssignmentsRepository = Repository & MiRepository; +export type FlashsRepository = Repository & MiRepository; +export type FlashLikesRepository = Repository & MiRepository; +export type UserMemoRepository = Repository & MiRepository; +export type BubbleGameRecordsRepository = Repository & MiRepository; +export type ReversiGamesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/abuse-report-notification-recipient.ts b/packages/backend/src/models/json-schema/abuse-report-notification-recipient.ts new file mode 100644 index 000000000000..6215f0f5a202 --- /dev/null +++ b/packages/backend/src/models/json-schema/abuse-report-notification-recipient.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedAbuseReportNotificationRecipientSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + isActive: { + type: 'boolean', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + method: { + type: 'string', + optional: false, nullable: false, + enum: ['email', 'webhook'], + }, + userId: { + type: 'string', + optional: true, nullable: false, + }, + user: { + type: 'object', + optional: true, nullable: false, + ref: 'UserLite', + }, + systemWebhookId: { + type: 'string', + optional: true, nullable: false, + }, + systemWebhook: { + type: 'object', + optional: true, nullable: false, + ref: 'SystemWebhook', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/antenna.ts b/packages/backend/src/models/json-schema/antenna.ts index 78cf6d3ba27f..b5b9a5b42cdf 100644 --- a/packages/backend/src/models/json-schema/antenna.ts +++ b/packages/backend/src/models/json-schema/antenna.ts @@ -72,10 +72,6 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, - notify: { - type: 'boolean', - optional: false, nullable: false, - }, excludeBots: { type: 'boolean', optional: false, nullable: false, @@ -99,5 +95,10 @@ export const packedAntennaSchema = { optional: false, nullable: false, default: false, }, + notify: { + type: 'boolean', + optional: false, nullable: false, + default: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/drive-file.ts b/packages/backend/src/models/json-schema/drive-file.ts index ca88cc0e3975..5ee1561c50c4 100644 --- a/packages/backend/src/models/json-schema/drive-file.ts +++ b/packages/backend/src/models/json-schema/drive-file.ts @@ -20,7 +20,7 @@ export const packedDriveFileSchema = { name: { type: 'string', optional: false, nullable: false, - example: 'lenna.jpg', + example: '192.jpg', }, type: { type: 'string', diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 473339a1add5..e7bc6356e5e3 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -227,6 +227,10 @@ export const packedMetaLiteSchema = { type: 'string', optional: false, nullable: true, }, + inquiryUrl: { + type: 'string', + optional: false, nullable: true, + }, serverRules: { type: 'array', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index d9987a70c381..7366f053560d 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -228,6 +228,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canUpdateBioMedia: { + type: 'boolean', + optional: false, nullable: false, + }, pinLimit: { type: 'integer', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/system-webhook.ts b/packages/backend/src/models/json-schema/system-webhook.ts new file mode 100644 index 000000000000..d83065a74329 --- /dev/null +++ b/packages/backend/src/models/json-schema/system-webhook.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; + +export const packedSystemWebhookSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + isActive: { + type: 'boolean', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + latestSentAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: true, + }, + latestStatus: { + type: 'number', + optional: false, nullable: true, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + on: { + type: 'array', + items: { + type: 'string', + optional: false, nullable: false, + enum: systemWebhookEventTypes, + }, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + secret: { + type: 'string', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 2d14537bbb07..251a03c303a7 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -5,13 +5,12 @@ // https://github.com/typeorm/typeorm/issues/2400 import pg from 'pg'; -pg.types.setTypeParser(20, Number); - import { DataSource, Logger } from 'typeorm'; import * as highlight from 'cli-highlight'; import { entities as charts } from '@/core/chart/entities.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; +import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAd } from '@/models/Ad.js'; import { MiAnnouncement } from '@/models/Announcement.js'; @@ -69,6 +68,7 @@ import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import { MiWebhook } from '@/models/Webhook.js'; +import { MiSystemWebhook } from '@/models/SystemWebhook.js'; import { MiChannel } from '@/models/Channel.js'; import { MiRetentionAggregation } from '@/models/RetentionAggregation.js'; import { MiRole } from '@/models/Role.js'; @@ -83,9 +83,11 @@ import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +pg.types.setTypeParser(20, Number); + export const dbLogger = new MisskeyLogger('db'); -const sqlLogger = dbLogger.createSubLogger('sql', 'gray', false); +const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); class MyCustomLogger implements Logger { @bindThis @@ -167,6 +169,7 @@ export const entities = [ MiHashtag, MiSwSubscription, MiAbuseUserReport, + MiAbuseReportNotificationRecipient, MiRegistrationTicket, MiSignin, MiModerationLog, @@ -185,6 +188,7 @@ export const entities = [ MiPasswordResetRequest, MiUserPending, MiWebhook, + MiSystemWebhook, MiUserIp, MiRetentionAggregation, MiRole, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 808615899769..a1fd38fcc58c 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -11,7 +11,8 @@ import { QueueProcessorService } from './QueueProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; -import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; +import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; +import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; @@ -71,7 +72,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor DeleteFileProcessorService, CleanRemoteFilesProcessorService, RelationshipProcessorService, - WebhookDeliverProcessorService, + UserWebhookDeliverProcessorService, + SystemWebhookDeliverProcessorService, EndedPollNotificationProcessorService, DeliverProcessorService, InboxProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index ce999d9cef6b..7bd74f3210f8 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -5,11 +5,13 @@ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import * as Bull from 'bullmq'; +import * as Sentry from '@sentry/node'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import { WebhookDeliverProcessorService } from './processors/WebhookDeliverProcessorService.js'; +import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; +import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; @@ -75,7 +77,8 @@ export class QueueProcessorService implements OnApplicationShutdown { private dbQueueWorker: Bull.Worker; private deliverQueueWorker: Bull.Worker; private inboxQueueWorker: Bull.Worker; - private webhookDeliverQueueWorker: Bull.Worker; + private userWebhookDeliverQueueWorker: Bull.Worker; + private systemWebhookDeliverQueueWorker: Bull.Worker; private relationshipQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker; @@ -85,7 +88,8 @@ export class QueueProcessorService implements OnApplicationShutdown { private config: Config, private queueLoggerService: QueueLoggerService, - private webhookDeliverProcessorService: WebhookDeliverProcessorService, + private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService, + private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, private deliverProcessorService: DeliverProcessorService, private inboxProcessorService: InboxProcessorService, @@ -135,199 +139,367 @@ export class QueueProcessorService implements OnApplicationShutdown { } //#region system - this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { - switch (job.name) { - case 'tickCharts': return this.tickChartsProcessorService.process(); - case 'resyncCharts': return this.resyncChartsProcessorService.process(); - case 'cleanCharts': return this.cleanChartsProcessorService.process(); - case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); - case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); - case 'clean': return this.cleanProcessorService.process(); - default: throw new Error(`unrecognized job type ${job.name} for system`); - } - }, { - ...baseQueueOptions(this.config, QUEUE.SYSTEM), - autorun: false, - }); - - const systemLogger = this.logger.createSubLogger('system'); - - this.systemQueueWorker - .on('active', (job) => systemLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => systemLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => systemLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => systemLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => systemLogger.warn(`stalled id=${jobId}`)); + { + const processer = (job: Bull.Job) => { + switch (job.name) { + case 'tickCharts': return this.tickChartsProcessorService.process(); + case 'resyncCharts': return this.resyncChartsProcessorService.process(); + case 'cleanCharts': return this.cleanChartsProcessorService.process(); + case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); + case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); + case 'clean': return this.cleanProcessorService.process(); + default: throw new Error(`unrecognized job type ${job.name} for system`); + } + }; + + this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job)); + } else { + return processer(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.SYSTEM), + autorun: false, + }); + + const logger = this.logger.createSubLogger('system'); + + this.systemQueueWorker + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err: Error) => { + logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); + } //#endregion //#region db - this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { - switch (job.name) { - case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job); - case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job); - case 'exportNotes': return this.exportNotesProcessorService.process(job); - case 'exportClips': return this.exportClipsProcessorService.process(job); - case 'exportFavorites': return this.exportFavoritesProcessorService.process(job); - case 'exportFollowing': return this.exportFollowingProcessorService.process(job); - case 'exportMuting': return this.exportMutingProcessorService.process(job); - case 'exportBlocking': return this.exportBlockingProcessorService.process(job); - case 'exportUserLists': return this.exportUserListsProcessorService.process(job); - case 'exportAntennas': return this.exportAntennasProcessorService.process(job); - case 'importFollowing': return this.importFollowingProcessorService.process(job); - case 'importFollowingToDb': return this.importFollowingProcessorService.processDb(job); - case 'importMuting': return this.importMutingProcessorService.process(job); - case 'importBlocking': return this.importBlockingProcessorService.process(job); - case 'importBlockingToDb': return this.importBlockingProcessorService.processDb(job); - case 'importUserLists': return this.importUserListsProcessorService.process(job); - case 'importCustomEmojis': return this.importCustomEmojisProcessorService.process(job); - case 'importAntennas': return this.importAntennasProcessorService.process(job); - case 'deleteAccount': return this.deleteAccountProcessorService.process(job); - default: throw new Error(`unrecognized job type ${job.name} for db`); - } - }, { - ...baseQueueOptions(this.config, QUEUE.DB), - autorun: false, - }); - - const dbLogger = this.logger.createSubLogger('db'); - - this.dbQueueWorker - .on('active', (job) => dbLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => dbLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => dbLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => dbLogger.warn(`stalled id=${jobId}`)); + { + const processer = (job: Bull.Job) => { + switch (job.name) { + case 'deleteDriveFiles': return this.deleteDriveFilesProcessorService.process(job); + case 'exportCustomEmojis': return this.exportCustomEmojisProcessorService.process(job); + case 'exportNotes': return this.exportNotesProcessorService.process(job); + case 'exportClips': return this.exportClipsProcessorService.process(job); + case 'exportFavorites': return this.exportFavoritesProcessorService.process(job); + case 'exportFollowing': return this.exportFollowingProcessorService.process(job); + case 'exportMuting': return this.exportMutingProcessorService.process(job); + case 'exportBlocking': return this.exportBlockingProcessorService.process(job); + case 'exportUserLists': return this.exportUserListsProcessorService.process(job); + case 'exportAntennas': return this.exportAntennasProcessorService.process(job); + case 'importFollowing': return this.importFollowingProcessorService.process(job); + case 'importFollowingToDb': return this.importFollowingProcessorService.processDb(job); + case 'importMuting': return this.importMutingProcessorService.process(job); + case 'importBlocking': return this.importBlockingProcessorService.process(job); + case 'importBlockingToDb': return this.importBlockingProcessorService.processDb(job); + case 'importUserLists': return this.importUserListsProcessorService.process(job); + case 'importCustomEmojis': return this.importCustomEmojisProcessorService.process(job); + case 'importAntennas': return this.importAntennasProcessorService.process(job); + case 'deleteAccount': return this.deleteAccountProcessorService.process(job); + default: throw new Error(`unrecognized job type ${job.name} for db`); + } + }; + + this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job)); + } else { + return processer(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.DB), + autorun: false, + }); + + const logger = this.logger.createSubLogger('db'); + + this.dbQueueWorker + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => { + logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); + } //#endregion //#region deliver - this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => this.deliverProcessorService.process(job), { - ...baseQueueOptions(this.config, QUEUE.DELIVER), - autorun: false, - concurrency: this.config.deliverJobConcurrency ?? 128, - limiter: { - max: this.config.deliverJobPerSec ?? 128, - duration: 1000, - }, - settings: { - backoffStrategy: httpRelatedBackoff, - }, - }); - - const deliverLogger = this.logger.createSubLogger('deliver'); - - this.deliverQueueWorker - .on('active', (job) => deliverLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('completed', (job, result) => deliverLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => deliverLogger.warn(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`)) - .on('error', (err: Error) => deliverLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => deliverLogger.warn(`stalled id=${jobId}`)); + { + this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job)); + } else { + return this.deliverProcessorService.process(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.DELIVER), + autorun: false, + concurrency: this.config.deliverJobConcurrency ?? 128, + limiter: { + max: this.config.deliverJobPerSec ?? 128, + duration: 1000, + }, + settings: { + backoffStrategy: httpRelatedBackoff, + }, + }); + + const logger = this.logger.createSubLogger('deliver'); + + this.deliverQueueWorker + .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => { + logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: Deliver: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); + } //#endregion //#region inbox - this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => this.inboxProcessorService.process(job), { - ...baseQueueOptions(this.config, QUEUE.INBOX), - autorun: false, - concurrency: this.config.inboxJobConcurrency ?? 16, - limiter: { - max: this.config.inboxJobPerSec ?? 32, - duration: 1000, - }, - settings: { - backoffStrategy: httpRelatedBackoff, - }, - }); - - const inboxLogger = this.logger.createSubLogger('inbox'); - - this.inboxQueueWorker - .on('active', (job) => inboxLogger.debug(`active ${getJobInfo(job, true)}`)) - .on('completed', (job, result) => inboxLogger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) - .on('failed', (job, err) => inboxLogger.warn(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => inboxLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => inboxLogger.warn(`stalled id=${jobId}`)); + { + this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job)); + } else { + return this.inboxProcessorService.process(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.INBOX), + autorun: false, + concurrency: this.config.inboxJobConcurrency ?? 16, + limiter: { + max: this.config.inboxJobPerSec ?? 32, + duration: 1000, + }, + settings: { + backoffStrategy: httpRelatedBackoff, + }, + }); + + const logger = this.logger.createSubLogger('inbox'); + + this.inboxQueueWorker + .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) + .on('failed', (job, err) => { + logger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: Inbox: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); + } //#endregion - //#region webhook deliver - this.webhookDeliverQueueWorker = new Bull.Worker(QUEUE.WEBHOOK_DELIVER, (job) => this.webhookDeliverProcessorService.process(job), { - ...baseQueueOptions(this.config, QUEUE.WEBHOOK_DELIVER), - autorun: false, - concurrency: 64, - limiter: { - max: 64, - duration: 1000, - }, - settings: { - backoffStrategy: httpRelatedBackoff, - }, - }); - - const webhookLogger = this.logger.createSubLogger('webhook'); - - this.webhookDeliverQueueWorker - .on('active', (job) => webhookLogger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('completed', (job, result) => webhookLogger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) - .on('failed', (job, err) => webhookLogger.warn(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`)) - .on('error', (err: Error) => webhookLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => webhookLogger.warn(`stalled id=${jobId}`)); + //#region user-webhook deliver + { + this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job)); + } else { + return this.userWebhookDeliverProcessorService.process(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.USER_WEBHOOK_DELIVER), + autorun: false, + concurrency: 64, + limiter: { + max: 64, + duration: 1000, + }, + settings: { + backoffStrategy: httpRelatedBackoff, + }, + }); + + const logger = this.logger.createSubLogger('user-webhook'); + + this.userWebhookDeliverQueueWorker + .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => { + logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); + } + //#endregion + + //#region system-webhook deliver + { + this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job)); + } else { + return this.systemWebhookDeliverProcessorService.process(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.SYSTEM_WEBHOOK_DELIVER), + autorun: false, + concurrency: 16, + limiter: { + max: 16, + duration: 1000, + }, + settings: { + backoffStrategy: httpRelatedBackoff, + }, + }); + + const logger = this.logger.createSubLogger('system-webhook'); + + this.systemWebhookDeliverQueueWorker + .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) + .on('failed', (job, err) => { + logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); + } //#endregion //#region relationship - this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { - switch (job.name) { - case 'follow': return this.relationshipProcessorService.processFollow(job); - case 'unfollow': return this.relationshipProcessorService.processUnfollow(job); - case 'block': return this.relationshipProcessorService.processBlock(job); - case 'unblock': return this.relationshipProcessorService.processUnblock(job); - default: throw new Error(`unrecognized job type ${job.name} for relationship`); - } - }, { - ...baseQueueOptions(this.config, QUEUE.RELATIONSHIP), - autorun: false, - concurrency: this.config.relationshipJobConcurrency ?? 16, - limiter: { - max: this.config.relationshipJobPerSec ?? 64, - duration: 1000, - }, - }); - - const relationshipLogger = this.logger.createSubLogger('relationship'); - - this.relationshipQueueWorker - .on('active', (job) => relationshipLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => relationshipLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => relationshipLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => relationshipLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => relationshipLogger.warn(`stalled id=${jobId}`)); + { + const processer = (job: Bull.Job) => { + switch (job.name) { + case 'follow': return this.relationshipProcessorService.processFollow(job); + case 'unfollow': return this.relationshipProcessorService.processUnfollow(job); + case 'block': return this.relationshipProcessorService.processBlock(job); + case 'unblock': return this.relationshipProcessorService.processUnblock(job); + default: throw new Error(`unrecognized job type ${job.name} for relationship`); + } + }; + + this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job)); + } else { + return processer(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.RELATIONSHIP), + autorun: false, + concurrency: this.config.relationshipJobConcurrency ?? 16, + limiter: { + max: this.config.relationshipJobPerSec ?? 64, + duration: 1000, + }, + }); + + const logger = this.logger.createSubLogger('relationship'); + + this.relationshipQueueWorker + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => { + logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); + } //#endregion //#region object storage - this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { - switch (job.name) { - case 'deleteFile': return this.deleteFileProcessorService.process(job); - case 'cleanRemoteFiles': return this.cleanRemoteFilesProcessorService.process(job); - default: throw new Error(`unrecognized job type ${job.name} for objectStorage`); - } - }, { - ...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE), - autorun: false, - concurrency: 16, - }); - - const objectStorageLogger = this.logger.createSubLogger('objectStorage'); - - this.objectStorageQueueWorker - .on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) - .on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) - .on('failed', (job, err) => objectStorageLogger.warn(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) })) - .on('error', (err: Error) => objectStorageLogger.error(`error ${err.stack}`, { e: renderError(err) })) - .on('stalled', (jobId) => objectStorageLogger.warn(`stalled id=${jobId}`)); + { + const processer = (job: Bull.Job) => { + switch (job.name) { + case 'deleteFile': return this.deleteFileProcessorService.process(job); + case 'cleanRemoteFiles': return this.cleanRemoteFilesProcessorService.process(job); + default: throw new Error(`unrecognized job type ${job.name} for objectStorage`); + } + }; + + this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job)); + } else { + return processer(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.OBJECT_STORAGE), + autorun: false, + concurrency: 16, + }); + + const logger = this.logger.createSubLogger('objectStorage'); + + this.objectStorageQueueWorker + .on('active', (job) => logger.debug(`active id=${job.id}`)) + .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) + .on('failed', (job, err) => { + logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + if (config.sentryForBackend) { + Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, { + level: 'error', + extra: { job, err }, + }); + } + }) + .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); + } //#endregion //#region ended poll notification - this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => this.endedPollNotificationProcessorService.process(job), { - ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), - autorun: false, - }); + { + this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { + if (this.config.sentryForBackend) { + return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job)); + } else { + return this.endedPollNotificationProcessorService.process(job); + } + }, { + ...baseQueueOptions(this.config, QUEUE.ENDED_POLL_NOTIFICATION), + autorun: false, + }); + } //#endregion } @@ -338,7 +510,8 @@ export class QueueProcessorService implements OnApplicationShutdown { this.dbQueueWorker.run(), this.deliverQueueWorker.run(), this.inboxQueueWorker.run(), - this.webhookDeliverQueueWorker.run(), + this.userWebhookDeliverQueueWorker.run(), + this.systemWebhookDeliverQueueWorker.run(), this.relationshipQueueWorker.run(), this.objectStorageQueueWorker.run(), this.endedPollNotificationQueueWorker.run(), @@ -352,7 +525,8 @@ export class QueueProcessorService implements OnApplicationShutdown { this.dbQueueWorker.close(), this.deliverQueueWorker.close(), this.inboxQueueWorker.close(), - this.webhookDeliverQueueWorker.close(), + this.userWebhookDeliverQueueWorker.close(), + this.systemWebhookDeliverQueueWorker.close(), this.relationshipQueueWorker.close(), this.objectStorageQueueWorker.close(), this.endedPollNotificationQueueWorker.close(), diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 132e9166121c..67f689b61849 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -14,7 +14,8 @@ export const QUEUE = { DB: 'db', RELATIONSHIP: 'relationship', OBJECT_STORAGE: 'objectStorage', - WEBHOOK_DELIVER: 'webhookDeliver', + USER_WEBHOOK_DELIVER: 'userWebhookDeliver', + SYSTEM_WEBHOOK_DELIVER: 'systemWebhookDeliver', }; export function baseQueueOptions(config: Config, queueName: typeof QUEUE[keyof typeof QUEUE]): Bull.QueueOptions { diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index b73195afc3f8..d665945861e2 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -109,6 +109,12 @@ export class DeliverProcessorService { suspensionState: 'autoSuspendedForNotResponding', }); } + } else { + // isNotRespondingがtrueでnotRespondingSinceがnullの場合はnotRespondingSinceをセット + // notRespondingSinceは新たな機能なので、それ以前のデータにはnotRespondingSinceがない場合がある + this.federatedInstanceService.update(i.id, { + notRespondingSince: new Date(), + }); } this.apRequestChart.deliverFail(); diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index 1d8e90f367f0..88c4ea29c0e6 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -84,7 +84,6 @@ export class ExportAntennasProcessorService { excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, - notify: antenna.notify, })); if (antennas.length - 1 !== index) { write(', '); diff --git a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts index ff1c04de06c5..9c033b73e2b7 100644 --- a/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportAntennasProcessorService.ts @@ -47,9 +47,8 @@ const validate = new Ajv().compile({ excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, - notify: { type: 'boolean' }, }, - required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], + required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], }); @Injectable() @@ -77,7 +76,7 @@ export class ImportAntennasProcessorService { this.logger.warn('Validation Failed'); continue; } - const result = await this.antennasRepository.insert({ + const result = await this.antennasRepository.insertOne({ id: this.idService.gen(now.getTime()), lastUsedAt: now, userId: job.data.user.id, @@ -92,8 +91,7 @@ export class ImportAntennasProcessorService { excludeBots: antenna.excludeBots, withReplies: antenna.withReplies, withFile: antenna.withFile, - notify: antenna.notify, - }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); + }); this.logger.succ('Antenna created: ' + result.id); this.globalEventService.publishInternalEvent('antennaCreated', result); } diff --git a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts index a5992c28c846..db9255b35d5c 100644 --- a/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportUserListsProcessorService.ts @@ -79,11 +79,11 @@ export class ImportUserListsProcessorService { }); if (list == null) { - list = await this.userListsRepository.insert({ + list = await this.userListsRepository.insertOne({ id: this.idService.gen(), userId: user.id, name: listName, - }).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); + }); } let target = this.utilityService.isSelfHost(host!) ? await this.usersRepository.findOneBy({ diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index f465339075b9..fa7009f8f5d9 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -204,13 +204,22 @@ export class InboxProcessorService { // アクティビティを処理 try { - await this.apInboxService.performActivity(authUser.user, activity); + const result = await this.apInboxService.performActivity(authUser.user, activity); + if (result && !result.startsWith('ok')) { + this.logger.warn(`inbox activity ignored (maybe): id=${activity.id} reason=${result}`); + return result; + } } catch (e) { if (e instanceof IdentifiableError) { if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') { return 'blocked notes with prohibited words'; } - if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') return 'actor has been suspended'; + if (e.id === '85ab9bd7-3a41-4530-959d-f07073900109') { + return 'actor has been suspended'; + } + if (e.id === 'd450b8a9-48e4-4dab-ae36-f4db763fda7c') { // invalid Note + return e.message; + } } throw e; } diff --git a/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts new file mode 100644 index 000000000000..f6bef5268456 --- /dev/null +++ b/packages/backend/src/queue/processors/SystemWebhookDeliverProcessorService.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Bull from 'bullmq'; +import { DI } from '@/di-symbols.js'; +import type { SystemWebhooksRepository } from '@/models/_.js'; +import type { Config } from '@/config.js'; +import type Logger from '@/logger.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { StatusError } from '@/misc/status-error.js'; +import { bindThis } from '@/decorators.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import { SystemWebhookDeliverJobData } from '../types.js'; + +@Injectable() +export class SystemWebhookDeliverProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.systemWebhooksRepository) + private systemWebhooksRepository: SystemWebhooksRepository, + + private httpRequestService: HttpRequestService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('webhook'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + try { + this.logger.debug(`delivering ${job.data.webhookId}`); + + const res = await this.httpRequestService.send(job.data.to, { + method: 'POST', + headers: { + 'User-Agent': 'Misskey-Hooks', + 'X-Misskey-Host': this.config.host, + 'X-Misskey-Hook-Id': job.data.webhookId, + 'X-Misskey-Hook-Secret': job.data.secret, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + server: this.config.url, + hookId: job.data.webhookId, + eventId: job.data.eventId, + createdAt: job.data.createdAt, + type: job.data.type, + body: job.data.content, + }), + }); + + this.systemWebhooksRepository.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res.status, + }); + + return 'Success'; + } catch (res) { + this.logger.error(res as Error); + + this.systemWebhooksRepository.update({ id: job.data.webhookId }, { + latestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : 1, + }); + + if (res instanceof StatusError) { + // 4xx + if (!res.isRetryable) { + throw new Bull.UnrecoverableError(`${res.statusCode} ${res.statusMessage}`); + } + + // 5xx etc. + throw new Error(`${res.statusCode} ${res.statusMessage}`); + } else { + // DNS error, socket error, timeout ... + throw res; + } + } + } +} diff --git a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts b/packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts similarity index 92% rename from packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts rename to packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts index 8c260c0137ce..9ec630ef7047 100644 --- a/packages/backend/src/queue/processors/WebhookDeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/UserWebhookDeliverProcessorService.ts @@ -13,10 +13,10 @@ import { HttpRequestService } from '@/core/HttpRequestService.js'; import { StatusError } from '@/misc/status-error.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type { WebhookDeliverJobData } from '../types.js'; +import { UserWebhookDeliverJobData } from '../types.js'; @Injectable() -export class WebhookDeliverProcessorService { +export class UserWebhookDeliverProcessorService { private logger: Logger; constructor( @@ -33,7 +33,7 @@ export class WebhookDeliverProcessorService { } @bindThis - public async process(job: Bull.Job): Promise { + public async process(job: Bull.Job): Promise { try { this.logger.debug(`delivering ${job.data.webhookId}`); diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index ce57ba745eb4..a4077a0547ea 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -106,7 +106,17 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; -export type WebhookDeliverJobData = { +export type SystemWebhookDeliverJobData = { + type: string; + content: unknown; + webhookId: MiWebhook['id']; + to: string; + secret: string; + createdAt: number; + eventId: string; +}; + +export type UserWebhookDeliverJobData = { type: string; content: unknown; webhookId: MiWebhook['id']; diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 9db3aa1bfb37..77a637d89516 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -53,7 +53,7 @@ export class FileServerService { private internalStorageService: InternalStorageService, private loggerService: LoggerService, ) { - this.logger = this.loggerService.getLogger('server', 'gray', false); + this.logger = this.loggerService.getLogger('server', 'gray'); //this.createServer = this.createServer.bind(this); } diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index c1e5af08c903..cc18997fdc1c 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -37,12 +37,12 @@ export class NodeinfoServerService { @bindThis public getLinks() { return [{ - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', - href: this.config.url + nodeinfo2_1path - }, { - rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', - href: this.config.url + nodeinfo2_0path, - }]; + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', + href: this.config.url + nodeinfo2_1path, + }, { + rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', + href: this.config.url + nodeinfo2_0path, + }]; } @bindThis @@ -108,6 +108,7 @@ export class NodeinfoServerService { langs: meta.langs, tosUrl: meta.termsOfServiceUrl, privacyPolicyUrl: meta.privacyPolicyUrl, + inquiryUrl: meta.inquiryUrl, impressumUrl: meta.impressumUrl, repositoryUrl: meta.repositoryUrl, feedbackUrl: meta.feedbackUrl, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 3572f16627cc..9c849480f21f 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -68,7 +68,7 @@ export class ServerService implements OnApplicationShutdown { private loggerService: LoggerService, private oauth2ProviderService: OAuth2ProviderService, ) { - this.logger = this.loggerService.getLogger('server', 'gray', false); + this.logger = this.loggerService.getLogger('server', 'gray'); } @bindThis diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 9836689872ae..47f64f660939 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import * as stream from 'node:stream/promises'; import { Inject, Injectable } from '@nestjs/common'; +import * as Sentry from '@sentry/node'; import { DI } from '@/di-symbols.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; @@ -17,6 +18,7 @@ import { MetaService } from '@/core/MetaService.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; +import type { Config } from '@/config.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; @@ -38,6 +40,9 @@ export class ApiCallService implements OnApplicationShutdown { private userIpHistoriesClearIntervalId: NodeJS.Timeout; constructor( + @Inject(DI.config) + private config: Config, + @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, @@ -68,6 +73,16 @@ export class ApiCallService implements OnApplicationShutdown { reply.header('WWW-Authenticate', `Bearer realm="Misskey", error="insufficient_scope", error_description="${err.message}"`); } statusCode = statusCode ?? 403; + } else if (err.code === 'RATE_LIMIT_EXCEEDED') { + const info: unknown = err.info; + const unixEpochInSeconds = Date.now(); + if (typeof(info) === 'object' && info && 'resetMs' in info && typeof(info.resetMs) === 'number') { + const cooldownInSeconds = Math.ceil((info.resetMs - unixEpochInSeconds) / 1000); + // もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく + reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10)); + } else { + this.logger.warn(`rate limit information has unexpected type ${typeof(err.info?.reset)}`); + } } else if (!statusCode) { statusCode = 500; } @@ -88,6 +103,51 @@ export class ApiCallService implements OnApplicationShutdown { } } + #onExecError(ep: IEndpoint, data: any, err: Error, userId?: MiUser['id']): void { + if (err instanceof ApiError || err instanceof AuthenticationError) { + throw err; + } else { + const errId = randomUUID(); + this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { + ep: ep.name, + ps: data, + e: { + message: err.message, + code: err.name, + stack: err.stack, + id: errId, + }, + }); + + if (this.config.sentryForBackend) { + Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + level: 'error', + user: { + id: userId, + }, + extra: { + ep: ep.name, + ps: data, + e: { + message: err.message, + code: err.name, + stack: err.stack, + id: errId, + }, + }, + }); + } + + throw new ApiError(null, { + e: { + message: err.message, + code: err.name, + id: errId, + }, + }); + } + } + @bindThis public handleRequest( endpoint: IEndpoint & { exec: any }, @@ -258,12 +318,17 @@ export class ApiCallService implements OnApplicationShutdown { if (factor > 0) { // Rate limit await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable }, limitActor, factor).catch(err => { - throw new ApiError({ - message: 'Rate limit exceeded. Please try again later.', - code: 'RATE_LIMIT_EXCEEDED', - id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', - httpStatusCode: 429, - }); + if ('info' in err) { + // errはLimiter.LimiterInfoであることが期待される + throw new ApiError({ + message: 'Rate limit exceeded. Please try again later.', + code: 'RATE_LIMIT_EXCEEDED', + id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef', + httpStatusCode: 429, + }, err.info); + } else { + throw new TypeError('information must be a rate-limiter information.'); + } }); } } @@ -362,31 +427,15 @@ export class ApiCallService implements OnApplicationShutdown { } // API invoking - return await ep.exec(data, user, token, file, request.ip, request.headers).catch((err: Error) => { - if (err instanceof ApiError || err instanceof AuthenticationError) { - throw err; - } else { - const errId = randomUUID(); - this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, { - ep: ep.name, - ps: data, - e: { - message: err.message, - code: err.name, - stack: err.stack, - id: errId, - }, - }); - console.error(err, errId); - throw new ApiError(null, { - e: { - message: err.message, - code: err.name, - id: errId, - }, - }); - } - }); + if (this.config.sentryForBackend) { + return await Sentry.startSpan({ + name: 'API: ' + ep.name, + }, () => ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))); + } else { + return await ep.exec(data, user, token, file, request.ip, request.headers) + .catch((err: Error) => this.#onExecError(ep, data, err, user?.id)); + } } @bindThis diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 88d3999eb0b1..41576bedaae7 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -6,8 +6,13 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; -import * as ep___admin_meta from './endpoints/admin/meta.js'; +import * as ep___admin_abuseReport_notificationRecipient_list from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; +import * as ep___admin_abuseReport_notificationRecipient_show from '@/server/api/endpoints/admin/abuse-report/notification-recipient/show.js'; +import * as ep___admin_abuseReport_notificationRecipient_create from '@/server/api/endpoints/admin/abuse-report/notification-recipient/create.js'; +import * as ep___admin_abuseReport_notificationRecipient_update from '@/server/api/endpoints/admin/abuse-report/notification-recipient/update.js'; +import * as ep___admin_abuseReport_notificationRecipient_delete from '@/server/api/endpoints/admin/abuse-report/notification-recipient/delete.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; +import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; @@ -82,7 +87,13 @@ import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; +import * as ep___admin_systemWebhook_create from './endpoints/admin/system-webhook/create.js'; +import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webhook/delete.js'; +import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; +import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; +import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; import * as ep___announcements from './endpoints/announcements.js'; +import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; import * as ep___antennas_list from './endpoints/antennas/list.js'; @@ -380,6 +391,11 @@ import type { Provider } from '@nestjs/common'; const $admin_meta: Provider = { provide: 'ep:admin/meta', useClass: ep___admin_meta.default }; const $admin_abuseUserReports: Provider = { provide: 'ep:admin/abuse-user-reports', useClass: ep___admin_abuseUserReports.default }; +const $admin_abuseReport_notificationRecipient_list: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/list', useClass: ep___admin_abuseReport_notificationRecipient_list.default }; +const $admin_abuseReport_notificationRecipient_show: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/show', useClass: ep___admin_abuseReport_notificationRecipient_show.default }; +const $admin_abuseReport_notificationRecipient_create: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/create', useClass: ep___admin_abuseReport_notificationRecipient_create.default }; +const $admin_abuseReport_notificationRecipient_update: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/update', useClass: ep___admin_abuseReport_notificationRecipient_update.default }; +const $admin_abuseReport_notificationRecipient_delete: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/delete', useClass: ep___admin_abuseReport_notificationRecipient_delete.default }; const $admin_accounts_create: Provider = { provide: 'ep:admin/accounts/create', useClass: ep___admin_accounts_create.default }; const $admin_accounts_delete: Provider = { provide: 'ep:admin/accounts/delete', useClass: ep___admin_accounts_delete.default }; const $admin_accounts_findByEmail: Provider = { provide: 'ep:admin/accounts/find-by-email', useClass: ep___admin_accounts_findByEmail.default }; @@ -454,7 +470,13 @@ const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useCla const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default }; const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default }; const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default }; +const $admin_systemWebhook_create: Provider = { provide: 'ep:admin/system-webhook/create', useClass: ep___admin_systemWebhook_create.default }; +const $admin_systemWebhook_delete: Provider = { provide: 'ep:admin/system-webhook/delete', useClass: ep___admin_systemWebhook_delete.default }; +const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default }; +const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default }; +const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default }; const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; +const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default }; const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default }; const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default }; @@ -756,6 +778,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ ApiLoggerService, $admin_meta, $admin_abuseUserReports, + $admin_abuseReport_notificationRecipient_list, + $admin_abuseReport_notificationRecipient_show, + $admin_abuseReport_notificationRecipient_create, + $admin_abuseReport_notificationRecipient_update, + $admin_abuseReport_notificationRecipient_delete, $admin_accounts_create, $admin_accounts_delete, $admin_accounts_findByEmail, @@ -830,7 +857,13 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_roles_unassign, $admin_roles_updateDefaultPolicies, $admin_roles_users, + $admin_systemWebhook_create, + $admin_systemWebhook_delete, + $admin_systemWebhook_list, + $admin_systemWebhook_show, + $admin_systemWebhook_update, $announcements, + $announcements_show, $antennas_create, $antennas_delete, $antennas_list, @@ -1126,6 +1159,11 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ exports: [ $admin_meta, $admin_abuseUserReports, + $admin_abuseReport_notificationRecipient_list, + $admin_abuseReport_notificationRecipient_show, + $admin_abuseReport_notificationRecipient_create, + $admin_abuseReport_notificationRecipient_update, + $admin_abuseReport_notificationRecipient_delete, $admin_accounts_create, $admin_accounts_delete, $admin_accounts_findByEmail, @@ -1200,7 +1238,13 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $admin_roles_unassign, $admin_roles_updateDefaultPolicies, $admin_roles_users, + $admin_systemWebhook_create, + $admin_systemWebhook_delete, + $admin_systemWebhook_list, + $admin_systemWebhook_show, + $admin_systemWebhook_update, $announcements, + $announcements_show, $antennas_create, $antennas_delete, $antennas_list, diff --git a/packages/backend/src/server/api/RateLimiterService.ts b/packages/backend/src/server/api/RateLimiterService.ts index 0439cdfe5e03..52d73baa0adf 100644 --- a/packages/backend/src/server/api/RateLimiterService.ts +++ b/packages/backend/src/server/api/RateLimiterService.ts @@ -32,11 +32,13 @@ export class RateLimiterService { @bindThis public limit(limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string, factor = 1) { - return new Promise((ok, reject) => { - if (this.disabled) ok(); + { + if (this.disabled) { + return Promise.resolve(); + } // Short-term limit - const min = (): void => { + const min = new Promise((ok, reject) => { const minIntervalLimiter = new Limiter({ id: `${actor}:${limitation.key}:min`, duration: limitation.minInterval! * factor, @@ -46,25 +48,25 @@ export class RateLimiterService { minIntervalLimiter.get((err, info) => { if (err) { - return reject('ERR'); + return reject({ code: 'ERR', info }); } this.logger.debug(`${actor} ${limitation.key} min remaining: ${info.remaining}`); if (info.remaining === 0) { - reject('BRIEF_REQUEST_INTERVAL'); + return reject({ code: 'BRIEF_REQUEST_INTERVAL', info }); } else { if (hasLongTermLimit) { - max(); + return max.then(ok, reject); } else { - ok(); + return ok(); } } }); - }; + }); // Long term limit - const max = (): void => { + const max = new Promise((ok, reject) => { const limiter = new Limiter({ id: `${actor}:${limitation.key}`, duration: limitation.duration! * factor, @@ -74,18 +76,18 @@ export class RateLimiterService { limiter.get((err, info) => { if (err) { - return reject('ERR'); + return reject({ code: 'ERR', info }); } this.logger.debug(`${actor} ${limitation.key} max remaining: ${info.remaining}`); if (info.remaining === 0) { - reject('RATE_LIMIT_EXCEEDED'); + return reject({ code: 'RATE_LIMIT_EXCEEDED', info }); } else { - ok(); + return ok(); } }); - }; + }); const hasShortTermLimit = typeof limitation.minInterval === 'number'; @@ -94,12 +96,12 @@ export class RateLimiterService { typeof limitation.max === 'number'; if (hasShortTermLimit) { - min(); + return min; } else if (hasLongTermLimit) { - max(); + return max; } else { - ok(); + return Promise.resolve(); } - }); + } } } diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts index 714e56e8c3fa..70306c31135b 100644 --- a/packages/backend/src/server/api/SigninService.ts +++ b/packages/backend/src/server/api/SigninService.ts @@ -29,13 +29,13 @@ export class SigninService { public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) { setImmediate(async () => { // Append signin history - const record = await this.signinsRepository.insert({ + const record = await this.signinsRepository.insertOne({ id: this.idService.gen(), userId: user.id, ip: request.ip, headers: request.headers as any, success: true, - }).then(x => this.signinsRepository.findOneByOrFail(x.identifiers[0])); + }); // Publish signin event this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 546de48e6bdb..632b0c62bc5d 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -183,13 +183,13 @@ export class SignupApiService { const salt = await bcrypt.genSalt(8); const hash = await bcrypt.hash(password, salt); - const pendingUser = await this.userPendingsRepository.insert({ + const pendingUser = await this.userPendingsRepository.insertOne({ id: this.idService.gen(), code, email: emailAddress!, username: username, password: hash, - }).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0])); + }); const link = `${this.config.url}/signup-complete/${code}`; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index f7e64a7356d4..3dfb7fdad4c2 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -6,8 +6,18 @@ import { permissions } from 'misskey-js'; import type { KeyOf, Schema } from '@/misc/json-schema.js'; -import * as ep___admin_meta from './endpoints/admin/meta.js'; +import * as ep___admin_abuseReport_notificationRecipient_list + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; +import * as ep___admin_abuseReport_notificationRecipient_show + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/show.js'; +import * as ep___admin_abuseReport_notificationRecipient_create + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/create.js'; +import * as ep___admin_abuseReport_notificationRecipient_update + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/update.js'; +import * as ep___admin_abuseReport_notificationRecipient_delete + from '@/server/api/endpoints/admin/abuse-report/notification-recipient/delete.js'; import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; +import * as ep___admin_meta from './endpoints/admin/meta.js'; import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; @@ -44,7 +54,8 @@ import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-c import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; -import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; +import * as ep___admin_federation_refreshRemoteInstanceMetadata + from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; @@ -82,7 +93,13 @@ import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; +import * as ep___admin_systemWebhook_create from './endpoints/admin/system-webhook/create.js'; +import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webhook/delete.js'; +import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; +import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; +import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; import * as ep___announcements from './endpoints/announcements.js'; +import * as ep___announcements_show from './endpoints/announcements/show.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; import * as ep___antennas_list from './endpoints/antennas/list.js'; @@ -378,6 +395,11 @@ import * as ep___reversi_verify from './endpoints/reversi/verify.js'; const eps = [ ['admin/meta', ep___admin_meta], ['admin/abuse-user-reports', ep___admin_abuseUserReports], + ['admin/abuse-report/notification-recipient/list', ep___admin_abuseReport_notificationRecipient_list], + ['admin/abuse-report/notification-recipient/show', ep___admin_abuseReport_notificationRecipient_show], + ['admin/abuse-report/notification-recipient/create', ep___admin_abuseReport_notificationRecipient_create], + ['admin/abuse-report/notification-recipient/update', ep___admin_abuseReport_notificationRecipient_update], + ['admin/abuse-report/notification-recipient/delete', ep___admin_abuseReport_notificationRecipient_delete], ['admin/accounts/create', ep___admin_accounts_create], ['admin/accounts/delete', ep___admin_accounts_delete], ['admin/accounts/find-by-email', ep___admin_accounts_findByEmail], @@ -452,7 +474,13 @@ const eps = [ ['admin/roles/unassign', ep___admin_roles_unassign], ['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies], ['admin/roles/users', ep___admin_roles_users], + ['admin/system-webhook/create', ep___admin_systemWebhook_create], + ['admin/system-webhook/delete', ep___admin_systemWebhook_delete], + ['admin/system-webhook/list', ep___admin_systemWebhook_list], + ['admin/system-webhook/show', ep___admin_systemWebhook_show], + ['admin/system-webhook/update', ep___admin_systemWebhook_update], ['announcements', ep___announcements], + ['announcements/show', ep___announcements_show], ['antennas/create', ep___antennas_create], ['antennas/delete', ep___antennas_delete], ['antennas/list', ep___antennas_list], @@ -871,8 +899,12 @@ export interface IEndpoint { const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { return { name: name, - get meta() { return ep.meta ?? {}; }, - get params() { return ep.paramDef; }, + get meta() { + return ep.meta ?? {}; + }, + get params() { + return ep.paramDef; + }, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/create.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/create.ts new file mode 100644 index 000000000000..bdfbcba51897 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/create.ts @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { DI } from '@/di-symbols.js'; +import type { UserProfilesRepository } from '@/models/_.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:abuse-report:notification-recipient', + + res: { + type: 'object', + ref: 'AbuseReportNotificationRecipient', + }, + + errors: { + correlationCheckEmail: { + message: 'If "method" is email, "userId" must be set.', + code: 'CORRELATION_CHECK_EMAIL', + id: '348bb8ae-575a-6fe9-4327-5811999def8f', + httpStatusCode: 400, + }, + correlationCheckWebhook: { + message: 'If "method" is webhook, "systemWebhookId" must be set.', + code: 'CORRELATION_CHECK_WEBHOOK', + id: 'b0c15051-de2d-29ef-260c-9585cddd701a', + httpStatusCode: 400, + }, + emailAddressNotSet: { + message: 'Email address is not set.', + code: 'EMAIL_ADDRESS_NOT_SET', + id: '7cc1d85e-2f58-fc31-b644-3de8d0d3421f', + httpStatusCode: 400, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + isActive: { + type: 'boolean', + }, + name: { + type: 'string', + minLength: 1, + maxLength: 255, + }, + method: { + type: 'string', + enum: ['email', 'webhook'], + }, + userId: { + type: 'string', + format: 'misskey:id', + }, + systemWebhookId: { + type: 'string', + format: 'misskey:id', + }, + }, + required: [ + 'isActive', + 'name', + 'method', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private abuseReportNotificationService: AbuseReportNotificationService, + private abuseReportNotificationRecipientEntityService: AbuseReportNotificationRecipientEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + if (ps.method === 'email') { + const userProfile = await this.userProfilesRepository.findOneBy({ userId: ps.userId }); + if (!ps.userId || !userProfile) { + throw new ApiError(meta.errors.correlationCheckEmail); + } + + if (!userProfile.email || !userProfile.emailVerified) { + throw new ApiError(meta.errors.emailAddressNotSet); + } + } + + if (ps.method === 'webhook' && !ps.systemWebhookId) { + throw new ApiError(meta.errors.correlationCheckWebhook); + } + + const userId = ps.method === 'email' ? ps.userId : null; + const systemWebhookId = ps.method === 'webhook' ? ps.systemWebhookId : null; + const result = await this.abuseReportNotificationService.createRecipient( + { + isActive: ps.isActive, + name: ps.name, + method: ps.method, + userId: userId ?? null, + systemWebhookId: systemWebhookId ?? null, + }, + me, + ); + + return this.abuseReportNotificationRecipientEntityService.pack(result); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/delete.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/delete.ts new file mode 100644 index 000000000000..b6dc44e09c9a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/delete.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:abuse-report:notification-recipient', +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + }, + required: [ + 'id', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private abuseReportNotificationService: AbuseReportNotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.abuseReportNotificationService.deleteRecipient( + ps.id, + me, + ); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/list.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/list.ts new file mode 100644 index 000000000000..dad9161a8a74 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/list.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'read:admin:abuse-report:notification-recipient', + + res: { + type: 'array', + items: { + type: 'object', + ref: 'AbuseReportNotificationRecipient', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + method: { + type: 'array', + items: { + type: 'string', + enum: ['email', 'webhook'], + }, + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private abuseReportNotificationService: AbuseReportNotificationService, + private abuseReportNotificationRecipientEntityService: AbuseReportNotificationRecipientEntityService, + ) { + super(meta, paramDef, async (ps) => { + const recipients = await this.abuseReportNotificationService.fetchRecipients({ method: ps.method }); + return this.abuseReportNotificationRecipientEntityService.packMany(recipients); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/show.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/show.ts new file mode 100644 index 000000000000..557798f946e0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/show.ts @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'read:admin:abuse-report:notification-recipient', + + res: { + type: 'object', + ref: 'AbuseReportNotificationRecipient', + }, + + errors: { + noSuchRecipient: { + message: 'No such recipient.', + code: 'NO_SUCH_RECIPIENT', + id: '013de6a8-f757-04cb-4d73-cc2a7e3368e4', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private abuseReportNotificationService: AbuseReportNotificationService, + private abuseReportNotificationRecipientEntityService: AbuseReportNotificationRecipientEntityService, + ) { + super(meta, paramDef, async (ps) => { + const recipients = await this.abuseReportNotificationService.fetchRecipients({ ids: [ps.id] }); + if (recipients.length === 0) { + throw new ApiError(meta.errors.noSuchRecipient); + } + + return this.abuseReportNotificationRecipientEntityService.pack(recipients[0]); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/update.ts b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/update.ts new file mode 100644 index 000000000000..bd4b4852171c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-report/notification-recipient/update.ts @@ -0,0 +1,128 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { + AbuseReportNotificationRecipientEntityService, +} from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { DI } from '@/di-symbols.js'; +import type { UserProfilesRepository } from '@/models/_.js'; + +export const meta = { + tags: ['admin', 'abuse-report', 'notification-recipient'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:abuse-report:notification-recipient', + + res: { + type: 'object', + ref: 'AbuseReportNotificationRecipient', + }, + + errors: { + correlationCheckEmail: { + message: 'If "method" is email, "userId" must be set.', + code: 'CORRELATION_CHECK_EMAIL', + id: '348bb8ae-575a-6fe9-4327-5811999def8f', + httpStatusCode: 400, + }, + correlationCheckWebhook: { + message: 'If "method" is webhook, "systemWebhookId" must be set.', + code: 'CORRELATION_CHECK_WEBHOOK', + id: 'b0c15051-de2d-29ef-260c-9585cddd701a', + httpStatusCode: 400, + }, + emailAddressNotSet: { + message: 'Email address is not set.', + code: 'EMAIL_ADDRESS_NOT_SET', + id: '7cc1d85e-2f58-fc31-b644-3de8d0d3421f', + httpStatusCode: 400, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + isActive: { + type: 'boolean', + }, + name: { + type: 'string', + minLength: 1, + maxLength: 255, + }, + method: { + type: 'string', + enum: ['email', 'webhook'], + }, + userId: { + type: 'string', + format: 'misskey:id', + }, + systemWebhookId: { + type: 'string', + format: 'misskey:id', + }, + }, + required: [ + 'id', + 'isActive', + 'name', + 'method', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private abuseReportNotificationService: AbuseReportNotificationService, + private abuseReportNotificationRecipientEntityService: AbuseReportNotificationRecipientEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + if (ps.method === 'email') { + const userProfile = await this.userProfilesRepository.findOneBy({ userId: ps.userId }); + if (!ps.userId || !userProfile) { + throw new ApiError(meta.errors.correlationCheckEmail); + } + + if (!userProfile.email || !userProfile.emailVerified) { + throw new ApiError(meta.errors.emailAddressNotSet); + } + } + + if (ps.method === 'webhook' && !ps.systemWebhookId) { + throw new ApiError(meta.errors.correlationCheckWebhook); + } + + const userId = ps.method === 'email' ? ps.userId : null; + const systemWebhookId = ps.method === 'webhook' ? ps.systemWebhookId : null; + const result = await this.abuseReportNotificationService.updateRecipient( + { + id: ps.id, + isActive: ps.isActive, + name: ps.name, + method: ps.method, + userId: userId ?? null, + systemWebhookId: systemWebhookId ?? null, + }, + me, + ); + + return this.abuseReportNotificationRecipientEntityService.pack(result); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/ad/create.ts b/packages/backend/src/server/api/endpoints/admin/ad/create.ts index 1e7a9fb3ecf3..955154f4fb08 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -50,7 +50,7 @@ export default class extends Endpoint { // eslint- private moderationLogService: ModerationLogService, ) { super(meta, paramDef, async (ps, me) => { - const ad = await this.adsRepository.insert({ + const ad = await this.adsRepository.insertOne({ id: this.idService.gen(), expiresAt: new Date(ps.expiresAt), startsAt: new Date(ps.startsAt), @@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint- ratio: ps.ratio, place: ps.place, memo: ps.memo, - }).then(r => this.adsRepository.findOneByOrFail({ id: r.identifiers[0].id })); + }); this.moderationLogService.log(me, 'createAd', { adId: ad.id, diff --git a/packages/backend/src/server/api/endpoints/admin/ad/update.ts b/packages/backend/src/server/api/endpoints/admin/ad/update.ts index 62358457ff76..4e3d731acac9 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -40,7 +40,7 @@ export const paramDef = { startsAt: { type: 'integer' }, dayOfWeek: { type: 'integer' }, }, - required: ['id', 'memo', 'url', 'imageUrl', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'dayOfWeek'], + required: ['id'], } as const; @Injectable() @@ -63,8 +63,8 @@ export default class extends Endpoint { // eslint- ratio: ps.ratio, memo: ps.memo, imageUrl: ps.imageUrl, - expiresAt: new Date(ps.expiresAt), - startsAt: new Date(ps.startsAt), + expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : undefined, + startsAt: ps.startsAt ? new Date(ps.startsAt) : undefined, dayOfWeek: ps.dayOfWeek, }); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index 459d8880fad3..a7136d8c8cc8 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -61,7 +61,7 @@ export const meta = { name: { type: 'string', optional: false, nullable: false, - example: 'lenna.jpg', + example: '192.jpg', }, type: { type: 'string', diff --git a/packages/backend/src/server/api/endpoints/admin/invite/create.ts b/packages/backend/src/server/api/endpoints/admin/invite/create.ts index 0f551e1ba2c4..5ecae3161a39 100644 --- a/packages/backend/src/server/api/endpoints/admin/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/invite/create.ts @@ -66,11 +66,11 @@ export default class extends Endpoint { // eslint- const ticketsPromises = []; for (let i = 0; i < ps.count; i++) { - ticketsPromises.push(this.registrationTicketsRepository.insert({ + ticketsPromises.push(this.registrationTicketsRepository.insertOne({ id: this.idService.gen(), expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, code: generateInviteCode(), - }).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0]))); + })); } const tickets = await Promise.all(ticketsPromises); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 65d0c781fb23..2e7f73da73b2 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -437,6 +437,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + inquiryUrl: { + type: 'string', + optional: false, nullable: true, + }, repositoryUrl: { type: 'string', optional: false, nullable: true, @@ -523,6 +527,7 @@ export default class extends Endpoint { // eslint- feedbackUrl: instance.feedbackUrl, impressumUrl: instance.impressumUrl, privacyPolicyUrl: instance.privacyPolicyUrl, + inquiryUrl: instance.inquiryUrl, disableRegistration: instance.disableRegistration, emailRequiredForSignup: instance.emailRequiredForSignup, enableHcaptcha: instance.enableHcaptcha, diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts index 9694b3fa40a3..d7f9e4eaa301 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; export const meta = { tags: ['admin'], @@ -53,7 +53,8 @@ export default class extends Endpoint { // eslint- @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, + @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, ) { super(meta, paramDef, async (ps, me) => { const deliverJobCounts = await this.deliverQueue.getJobCounts(); diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts index 8b0456068b89..9b79100fcf50 100644 --- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -5,12 +5,10 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, AbuseUserReportsRepository } from '@/models/_.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import type { AbuseUserReportsRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { ApiError } from '@/server/api/error.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; export const meta = { tags: ['admin'], @@ -18,6 +16,16 @@ export const meta = { requireCredential: true, requireModerator: true, kind: 'write:admin:resolve-abuse-user-report', + + errors: { + noSuchAbuseReport: { + message: 'No such abuse report.', + code: 'NO_SUCH_ABUSE_REPORT', + id: 'ac3794dd-2ce4-d878-e546-73c60c06b398', + kind: 'server', + httpStatusCode: 404, + }, + }, } as const; export const paramDef = { @@ -29,47 +37,20 @@ export const paramDef = { required: ['reportId'], } as const; -// TODO: ロジックをサービスに切り出す - @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.abuseUserReportsRepository) private abuseUserReportsRepository: AbuseUserReportsRepository, - - private queueService: QueueService, - private instanceActorService: InstanceActorService, - private apRendererService: ApRendererService, - private moderationLogService: ModerationLogService, + private abuseReportService: AbuseReportService, ) { super(meta, paramDef, async (ps, me) => { const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); - - if (report == null) { - throw new Error('report not found'); + if (!report) { + throw new ApiError(meta.errors.noSuchAbuseReport); } - if (ps.forward && report.targetUserHost != null) { - const actor = await this.instanceActorService.getInstanceActor(); - const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); - - this.queueService.deliver(actor, this.apRendererService.addContext(this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment)), targetUser.inbox, false); - } - - await this.abuseUserReportsRepository.update(report.id, { - resolved: true, - assigneeId: me.id, - forwarded: ps.forward && report.targetUserHost != null, - }); - - this.moderationLogService.log(me, 'resolveAbuseReport', { - reportId: report.id, - report: report, - forwarded: ps.forward && report.targetUserHost != null, - }); + await this.abuseReportService.resolve([{ reportId: report.id, forward: ps.forward }], me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts index 5242e0be2f96..465ad7aaafbf 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -6,7 +6,6 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { RolesRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '@/server/api/error.js'; import { RoleService } from '@/core/RoleService.js'; @@ -50,19 +49,6 @@ export const paramDef = { }, required: [ 'roleId', - 'name', - 'description', - 'color', - 'iconUrl', - 'target', - 'condFormula', - 'isPublic', - 'isModerator', - 'isAdministrator', - 'asBadge', - 'canEditMembersByModerator', - 'displayOrder', - 'policies', ], } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/roles/users.ts b/packages/backend/src/server/api/endpoints/admin/roles/users.ts index 45758d4f50f4..198166bec26f 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/users.ts @@ -89,10 +89,13 @@ export default class extends Endpoint { // eslint- .limit(ps.limit) .getMany(); + const _users = assigns.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' }) + .then(users => new Map(users.map(u => [u.id, u]))); return await Promise.all(assigns.map(async assign => ({ id: assign.id, createdAt: this.idService.parse(assign.id).date.toISOString(), - user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), + user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), expiresAt: assign.expiresAt?.toISOString() ?? null, }))); }); diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/create.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/create.ts new file mode 100644 index 000000000000..28071e7a3382 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/create.ts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', + + res: { + type: 'object', + ref: 'SystemWebhook', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + isActive: { + type: 'boolean', + }, + name: { + type: 'string', + minLength: 1, + maxLength: 255, + }, + on: { + type: 'array', + items: { + type: 'string', + enum: systemWebhookEventTypes, + }, + }, + url: { + type: 'string', + minLength: 1, + maxLength: 1024, + }, + secret: { + type: 'string', + minLength: 1, + maxLength: 1024, + }, + }, + required: [ + 'isActive', + 'name', + 'on', + 'url', + 'secret', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.systemWebhookService.createSystemWebhook( + { + isActive: ps.isActive, + name: ps.name, + on: ps.on, + url: ps.url, + secret: ps.secret, + }, + me, + ); + + return this.systemWebhookEntityService.pack(result); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/delete.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/delete.ts new file mode 100644 index 000000000000..9cdfc7e70fec --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/delete.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + }, + required: [ + 'id', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.systemWebhookService.deleteSystemWebhook( + ps.id, + me, + ); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/list.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/list.ts new file mode 100644 index 000000000000..7a440a774ed3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/list.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', + + res: { + type: 'array', + items: { + type: 'object', + ref: 'SystemWebhook', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + isActive: { + type: 'boolean', + }, + on: { + type: 'array', + items: { + type: 'string', + enum: systemWebhookEventTypes, + }, + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + super(meta, paramDef, async (ps) => { + const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ + isActive: ps.isActive, + on: ps.on, + }); + return this.systemWebhookEntityService.packMany(webhooks); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/show.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/show.ts new file mode 100644 index 000000000000..75862c96a7d1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/show.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { ApiError } from '@/server/api/error.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', + + res: { + type: 'object', + ref: 'SystemWebhook', + }, + + errors: { + noSuchSystemWebhook: { + message: 'No such SystemWebhook.', + code: 'NO_SUCH_SYSTEM_WEBHOOK', + id: '38dd1ffe-04b4-6ff5-d8ba-4e6a6ae22c9d', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + }, + required: ['id'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + super(meta, paramDef, async (ps) => { + const webhooks = await this.systemWebhookService.fetchSystemWebhooks({ ids: [ps.id] }); + if (webhooks.length === 0) { + throw new ApiError(meta.errors.noSuchSystemWebhook); + } + + return this.systemWebhookEntityService.pack(webhooks[0]); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/system-webhook/update.ts b/packages/backend/src/server/api/endpoints/admin/system-webhook/update.ts new file mode 100644 index 000000000000..8d68bb8f87d8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/system-webhook/update.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; +import { systemWebhookEventTypes } from '@/models/SystemWebhook.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; + +export const meta = { + tags: ['admin', 'system-webhook'], + + requireCredential: true, + requireModerator: true, + secure: true, + kind: 'write:admin:system-webhook', + + res: { + type: 'object', + ref: 'SystemWebhook', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + }, + isActive: { + type: 'boolean', + }, + name: { + type: 'string', + minLength: 1, + maxLength: 255, + }, + on: { + type: 'array', + items: { + type: 'string', + enum: systemWebhookEventTypes, + }, + }, + url: { + type: 'string', + minLength: 1, + maxLength: 1024, + }, + secret: { + type: 'string', + minLength: 1, + maxLength: 1024, + }, + }, + required: [ + 'id', + 'isActive', + 'name', + 'on', + 'url', + 'secret', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private systemWebhookService: SystemWebhookService, + private systemWebhookEntityService: SystemWebhookEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const result = await this.systemWebhookService.updateSystemWebhook( + { + id: ps.id, + isActive: ps.isActive, + name: ps.name, + on: ps.on, + url: ps.url, + secret: ps.secret, + }, + me, + ); + + return this.systemWebhookEntityService.pack(result); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 147debb42c67..5efdc9d8c457 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -107,6 +107,7 @@ export const paramDef = { feedbackUrl: { type: 'string', nullable: true }, impressumUrl: { type: 'string', nullable: true }, privacyPolicyUrl: { type: 'string', nullable: true }, + inquiryUrl: { type: 'string', nullable: true }, useObjectStorage: { type: 'boolean' }, objectStorageBaseUrl: { type: 'string', nullable: true }, objectStorageBucket: { type: 'string', nullable: true }, @@ -437,6 +438,10 @@ export default class extends Endpoint { // eslint- set.privacyPolicyUrl = ps.privacyPolicyUrl; } + if (ps.inquiryUrl !== undefined) { + set.inquiryUrl = ps.inquiryUrl; + } + if (ps.useObjectStorage !== undefined) { set.useObjectStorage = ps.useObjectStorage; } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 3b12f5b62ccb..ff8dd73605c5 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; -import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { DI } from '@/di-symbols.js'; -import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/_.js'; +import type { AnnouncementsRepository } from '@/models/_.js'; export const meta = { tags: ['meta'], @@ -44,11 +44,8 @@ export default class extends Endpoint { // eslint- @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, - @Inject(DI.announcementReadsRepository) - private announcementReadsRepository: AnnouncementReadsRepository, - private queryService: QueryService, - private announcementService: AnnouncementService, + private announcementEntityService: AnnouncementEntityService, ) { super(meta, paramDef, async (ps, me) => { const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) @@ -60,7 +57,7 @@ export default class extends Endpoint { // eslint- const announcements = await query.limit(ps.limit).getMany(); - return this.announcementService.packMany(announcements, me); + return this.announcementEntityService.packMany(announcements, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/announcements/show.ts b/packages/backend/src/server/api/endpoints/announcements/show.ts new file mode 100644 index 000000000000..6312a0a54cb8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/announcements/show.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { EntityNotFoundError } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['meta'], + + requireCredential: false, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'Announcement', + }, + + errors: { + noSuchAnnouncement: { + message: 'No such announcement.', + code: 'NO_SUCH_ANNOUNCEMENT', + id: 'b57b5e1d-4f49-404a-9edb-46b00268f121', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + announcementId: { type: 'string', format: 'misskey:id' }, + }, + required: ['announcementId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private announcementService: AnnouncementService, + ) { + super(meta, paramDef, async (ps, me) => { + try { + return await this.announcementService.getAnnouncement(ps.announcementId, me); + } catch (err) { + if (err instanceof EntityNotFoundError) throw new ApiError(meta.errors.noSuchAnnouncement); + throw err; + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 57c8eb495850..577b9e1b1f8c 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -67,9 +67,8 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, - notify: { type: 'boolean' }, }, - required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile', 'notify'], + required: ['name', 'src', 'keywords', 'excludeKeywords', 'users', 'caseSensitive', 'withReplies', 'withFile'], } as const; @Injectable() @@ -94,7 +93,7 @@ export default class extends Endpoint { // eslint- const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id, }); - if (currentAntennasCount > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { + if (currentAntennasCount >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) { throw new ApiError(meta.errors.tooManyAntennas); } @@ -113,7 +112,7 @@ export default class extends Endpoint { // eslint- const now = new Date(); - const antenna = await this.antennasRepository.insert({ + const antenna = await this.antennasRepository.insertOne({ id: this.idService.gen(now.getTime()), lastUsedAt: now, userId: me.id, @@ -128,8 +127,7 @@ export default class extends Endpoint { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, - notify: ps.notify, - }).then(x => this.antennasRepository.findOneByOrFail(x.identifiers[0])); + }); this.globalEventService.publishInternalEvent('antennaCreated', antenna); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts index e6720aacf82e..0c30bca9e0bf 100644 --- a/packages/backend/src/server/api/endpoints/antennas/update.ts +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -66,7 +66,6 @@ export const paramDef = { excludeBots: { type: 'boolean' }, withReplies: { type: 'boolean' }, withFile: { type: 'boolean' }, - notify: { type: 'boolean' }, }, required: ['antennaId'], } as const; @@ -124,7 +123,6 @@ export default class extends Endpoint { // eslint- excludeBots: ps.excludeBots, withReplies: ps.withReplies, withFile: ps.withFile, - notify: ps.notify, isActive: true, lastUsedAt: new Date(), }); diff --git a/packages/backend/src/server/api/endpoints/app/create.ts b/packages/backend/src/server/api/endpoints/app/create.ts index 492705d6f921..ba847fc4f0ee 100644 --- a/packages/backend/src/server/api/endpoints/app/create.ts +++ b/packages/backend/src/server/api/endpoints/app/create.ts @@ -54,7 +54,7 @@ export default class extends Endpoint { // eslint- const permission = unique(ps.permission.map(v => v.replace(/^(.+)(\/|-)(read|write)$/, '$3:$1'))); // Create account - const app = await this.appsRepository.insert({ + const app = await this.appsRepository.insertOne({ id: this.idService.gen(), userId: me ? me.id : null, name: ps.name, @@ -62,7 +62,7 @@ export default class extends Endpoint { // eslint- permission, callbackUrl: ps.callbackUrl, secret: secret, - }).then(x => this.appsRepository.findOneByOrFail(x.identifiers[0])); + }); return await this.appEntityService.pack(app, null, { detail: true, diff --git a/packages/backend/src/server/api/endpoints/auth/session/generate.ts b/packages/backend/src/server/api/endpoints/auth/session/generate.ts index 26dd8931385e..f8ddfdb75cbd 100644 --- a/packages/backend/src/server/api/endpoints/auth/session/generate.ts +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -78,11 +78,11 @@ export default class extends Endpoint { // eslint- const token = randomUUID(); // Create session token document - const doc = await this.authSessionsRepository.insert({ + const doc = await this.authSessionsRepository.insertOne({ id: this.idService.gen(), appId: app.id, token: token, - }).then(x => this.authSessionsRepository.findOneByOrFail(x.identifiers[0])); + }); return { token: doc.token, diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index 2866db54240c..e3a6d2d670e6 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -80,7 +80,7 @@ export default class extends Endpoint { // eslint- } } - const channel = await this.channelsRepository.insert({ + const channel = await this.channelsRepository.insertOne({ id: this.idService.gen(), userId: me.id, name: ps.name, @@ -89,7 +89,7 @@ export default class extends Endpoint { // eslint- isSensitive: ps.isSensitive ?? false, ...(ps.color !== undefined ? { color: ps.color } : {}), allowRenoteToExternal: ps.allowRenoteToExternal ?? true, - } as MiChannel).then(x => this.channelsRepository.findOneByOrFail(x.identifiers[0])); + } as MiChannel); return await this.channelEntityService.pack(channel, me); }); diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index 3b44ba81b3e6..603a3ccf3dc5 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { ClipService } from '@/core/ClipService.js'; @@ -41,7 +41,7 @@ export const paramDef = { isPublic: { type: 'boolean' }, description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, }, - required: ['clipId', 'name'], + required: ['clipId'], } as const; @Injectable() diff --git a/packages/backend/src/server/api/endpoints/drive/files/find.ts b/packages/backend/src/server/api/endpoints/drive/files/find.ts index 595a6957b2f3..502d42f9e0b8 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/find.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/find.ts @@ -54,7 +54,7 @@ export default class extends Endpoint { // eslint- folderId: ps.folderId ?? IsNull(), }); - return await Promise.all(files.map(file => this.driveFileEntityService.pack(file, { self: true }))); + return await this.driveFileEntityService.packMany(files, { self: true }); }); } } diff --git a/packages/backend/src/server/api/endpoints/drive/folders/create.ts b/packages/backend/src/server/api/endpoints/drive/folders/create.ts index c94070d9ff09..08d9d9cdc340 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/create.ts @@ -75,12 +75,12 @@ export default class extends Endpoint { // eslint- } // Create folder - const folder = await this.driveFoldersRepository.insert({ + const folder = await this.driveFoldersRepository.insertOne({ id: this.idService.gen(), name: ps.name, parentId: parent !== null ? parent.id : null, userId: me.id, - }).then(x => this.driveFoldersRepository.findOneByOrFail(x.identifiers[0])); + }); const folderObj = await this.driveFolderEntityService.pack(folder); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts index 52b8b335b501..62b04e1df36d 100644 --- a/packages/backend/src/server/api/endpoints/drive/folders/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -95,15 +95,14 @@ export default class extends Endpoint { // eslint- // Check if the circular reference will occur const checkCircle = async (folderId: string): Promise => { - // Fetch folder - const folder2 = await this.driveFoldersRepository.findOneBy({ + const folder2 = await this.driveFoldersRepository.findOneByOrFail({ id: folderId, }); - if (folder2!.id === folder!.id) { + if (folder2.id === folder.id) { return true; - } else if (folder2!.parentId) { - return await checkCircle(folder2!.parentId); + } else if (folder2.parentId) { + return await checkCircle(folder2.parentId); } else { return false; } diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts index 4ef4315fb308..36f4bf5aa682 100644 --- a/packages/backend/src/server/api/endpoints/federation/instances.ts +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -117,9 +117,9 @@ export default class extends Endpoint { // eslint- if (typeof ps.suspended === 'boolean') { if (ps.suspended) { - query.andWhere('instance.isSuspended = TRUE'); + query.andWhere('instance.suspensionState != \'none\''); } else { - query.andWhere('instance.isSuspended = FALSE'); + query.andWhere('instance.suspensionState = \'none\''); } } diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts index 361496e17e12..64f13a577e35 100644 --- a/packages/backend/src/server/api/endpoints/flash/create.ts +++ b/packages/backend/src/server/api/endpoints/flash/create.ts @@ -59,7 +59,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - const flash = await this.flashsRepository.insert({ + const flash = await this.flashsRepository.insertOne({ id: this.idService.gen(), userId: me.id, updatedAt: new Date(), @@ -68,7 +68,7 @@ export default class extends Endpoint { // eslint- script: ps.script, permissions: ps.permissions, visibility: ps.visibility, - }).then(x => this.flashsRepository.findOneByOrFail(x.identifiers[0])); + }); return await this.flashEntityService.pack(flash); }); diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts index 88f559138b4f..fa59e38976d8 100644 --- a/packages/backend/src/server/api/endpoints/following/requests/list.ts +++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts @@ -71,7 +71,7 @@ export default class extends Endpoint { // eslint- .limit(ps.limit) .getMany(); - return await Promise.all(requests.map(req => this.followRequestEntityService.pack(req))); + return await this.followRequestEntityService.packMany(requests, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts index b07cdf1ed979..504a9c789e8d 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -12,7 +12,6 @@ import type { MiDriveFile } from '@/models/DriveFile.js'; import { IdService } from '@/core/IdService.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { DI } from '@/di-symbols.js'; -import { isNotNull } from '@/misc/is-not-null.js'; export const meta = { tags: ['gallery'], @@ -70,13 +69,13 @@ export default class extends Endpoint { // eslint- id: fileId, userId: me.id, }), - ))).filter(isNotNull); + ))).filter(x => x != null); if (files.length === 0) { throw new Error(); } - const post = await this.galleryPostsRepository.insert(new MiGalleryPost({ + const post = await this.galleryPostsRepository.insertOne(new MiGalleryPost({ id: this.idService.gen(), updatedAt: new Date(), title: ps.title, @@ -84,7 +83,7 @@ export default class extends Endpoint { // eslint- userId: me.id, isSensitive: ps.isSensitive, fileIds: files.map(file => file.id), - })).then(x => this.galleryPostsRepository.findOneByOrFail(x.identifiers[0])); + })); return await this.galleryPostEntityService.pack(post, me); }); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts index 8bd83ff5ba0a..5243ee960314 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -10,7 +10,6 @@ import type { DriveFilesRepository, GalleryPostsRepository } from '@/models/_.js import type { MiDriveFile } from '@/models/DriveFile.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { DI } from '@/di-symbols.js'; -import { isNotNull } from '@/misc/is-not-null.js'; export const meta = { tags: ['gallery'], @@ -48,7 +47,7 @@ export const paramDef = { } }, isSensitive: { type: 'boolean', default: false }, }, - required: ['postId', 'title', 'fileIds'], + required: ['postId'], } as const; @Injectable() @@ -63,15 +62,19 @@ export default class extends Endpoint { // eslint- private galleryPostEntityService: GalleryPostEntityService, ) { super(meta, paramDef, async (ps, me) => { - const files = (await Promise.all(ps.fileIds.map(fileId => - this.driveFilesRepository.findOneBy({ - id: fileId, - userId: me.id, - }), - ))).filter(isNotNull); - - if (files.length === 0) { - throw new Error(); + let files: Array | undefined; + + if (ps.fileIds) { + files = (await Promise.all(ps.fileIds.map(fileId => + this.driveFilesRepository.findOneBy({ + id: fileId, + userId: me.id, + }), + ))).filter(x => x != null); + + if (files.length === 0) { + throw new Error(); + } } await this.galleryPostsRepository.update({ @@ -82,7 +85,7 @@ export default class extends Endpoint { // eslint- title: ps.title, description: ps.description, isSensitive: ps.isSensitive, - fileIds: files.map(file => file.id), + fileIds: files ? files.map(file => file.id) : undefined, }); const post = await this.galleryPostsRepository.findOneByOrFail({ id: ps.postId }); diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index b4661a93e2b1..bc46163e3d27 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -78,7 +78,7 @@ export default class extends Endpoint { if (file.size === 0) throw new ApiError(meta.errors.emptyFile); const antennas: (_Antenna & { userListAccts: string[] | null })[] = JSON.parse(await this.downloadService.downloadTextFile(file.url)); const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id }); - if (currentAntennasCount + antennas.length > (await this.roleService.getUserPolicies(me.id)).antennaLimit) { + if (currentAntennasCount + antennas.length >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) { throw new ApiError(meta.errors.tooManyAntennas); } this.queueService.createImportAntennasJob(me, antennas); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 84a1931a3df5..a1e2fa5e4cdf 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -25,7 +25,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; -import { RoleService } from '@/core/RoleService.js'; +import { RolePolicies, RoleService } from '@/core/RoleService.js'; import { CacheService } from '@/core/CacheService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; @@ -256,8 +256,16 @@ export default class extends Endpoint { // eslint- const profileUpdates = {} as Partial; const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - - if (ps.name !== undefined) updates.name = ps.name; + let policies: RolePolicies | null = null; + + if (ps.name !== undefined) { + if (ps.name === null) { + updates.name = null; + } else { + const trimmedName = ps.name.trim(); + updates.name = trimmedName === '' ? null : trimmedName; + } + } if (ps.description !== undefined) profileUpdates.description = ps.description; if (ps.lang !== undefined) profileUpdates.lang = ps.lang; if (ps.location !== undefined) profileUpdates.location = ps.location; @@ -289,14 +297,16 @@ export default class extends Endpoint { // eslint- } if (ps.mutedWords !== undefined) { - checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + policies ??= await this.roleService.getUserPolicies(user.id); + checkMuteWordCount(ps.mutedWords, policies.wordMuteLimit); validateMuteWordRegex(ps.mutedWords); profileUpdates.mutedWords = ps.mutedWords; profileUpdates.enableWordMute = ps.mutedWords.length > 0; } if (ps.hardMutedWords !== undefined) { - checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit); + policies ??= await this.roleService.getUserPolicies(user.id); + checkMuteWordCount(ps.hardMutedWords, policies.wordMuteLimit); validateMuteWordRegex(ps.hardMutedWords); profileUpdates.hardMutedWords = ps.hardMutedWords; } @@ -315,13 +325,17 @@ export default class extends Endpoint { // eslint- if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; if (typeof ps.alwaysMarkNsfw === 'boolean') { - if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); + policies ??= await this.roleService.getUserPolicies(user.id); + if (policies.alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole); profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; } if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive; if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; if (ps.avatarId) { + policies ??= await this.roleService.getUserPolicies(user.id); + if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole); + const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId }); if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar); @@ -337,6 +351,9 @@ export default class extends Endpoint { // eslint- } if (ps.bannerId) { + policies ??= await this.roleService.getUserPolicies(user.id); + if (!policies.canUpdateBioMedia) throw new ApiError(meta.errors.restrictedByRole); + const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId }); if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner); @@ -352,14 +369,15 @@ export default class extends Endpoint { // eslint- } if (ps.avatarDecorations) { + policies ??= await this.roleService.getUserPolicies(user.id); const decorations = await this.avatarDecorationService.getAll(true); - const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]); + const myRoles = await this.roleService.getUserRoles(user.id); const allRoles = await this.roleService.getRoles(); const decorationIds = decorations .filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id))) .map(d => d.id); - if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); + if (ps.avatarDecorations.length > policies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole); updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({ id: d.id, @@ -498,26 +516,32 @@ export default class extends Endpoint { // eslint- private async verifyLink(url: string, user: MiLocalUser) { if (!safeForSql(url)) return; - const html = await this.httpRequestService.getHtml(url); + try { + const html = await this.httpRequestService.getHtml(url); + + const { window } = new JSDOM(html); + const doc = window.document; - const { window } = new JSDOM(html); - const doc = window.document; + const myLink = `${this.config.url}/@${user.username}`; - const myLink = `${this.config.url}/@${user.username}`; + const aEls = Array.from(doc.getElementsByTagName('a')); + const linkEls = Array.from(doc.getElementsByTagName('link')); - const aEls = Array.from(doc.getElementsByTagName('a')); - const linkEls = Array.from(doc.getElementsByTagName('link')); + const includesMyLink = aEls.some(a => a.href === myLink); + const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); - const includesMyLink = aEls.some(a => a.href === myLink); - const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink); + if (includesMyLink || includesRelMeLinks) { + await this.userProfilesRepository.createQueryBuilder('profile').update() + .where('userId = :userId', { userId: user.id }) + .set({ + verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている + }) + .execute(); + } - if (includesMyLink || includesRelMeLinks) { - await this.userProfilesRepository.createQueryBuilder('profile').update() - .where('userId = :userId', { userId: user.id }) - .set({ - verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている - }) - .execute(); + window.close(); + } catch (err) { + // なにもしない } } } diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts index 535a3ea30850..9eb7f5b3a033 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -85,18 +85,18 @@ export default class extends Endpoint { // eslint- const currentWebhooksCount = await this.webhooksRepository.countBy({ userId: me.id, }); - if (currentWebhooksCount > (await this.roleService.getUserPolicies(me.id)).webhookLimit) { + if (currentWebhooksCount >= (await this.roleService.getUserPolicies(me.id)).webhookLimit) { throw new ApiError(meta.errors.tooManyWebhooks); } - const webhook = await this.webhooksRepository.insert({ + const webhook = await this.webhooksRepository.insertOne({ id: this.idService.gen(), userId: me.id, name: ps.name, url: ps.url, secret: ps.secret, on: ps.on, - }).then(x => this.webhooksRepository.findOneByOrFail(x.identifiers[0])); + }); this.globalEventService.publishInternalEvent('webhookCreated', webhook); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts index 6e380d76f89e..07a25bd82aad 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts @@ -34,13 +34,13 @@ export const paramDef = { webhookId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 100 }, url: { type: 'string', minLength: 1, maxLength: 1024 }, - secret: { type: 'string', maxLength: 1024, default: '' }, + secret: { type: 'string', nullable: true, maxLength: 1024 }, on: { type: 'array', items: { type: 'string', enum: webhookEventTypes, } }, active: { type: 'boolean' }, }, - required: ['webhookId', 'name', 'url', 'on', 'active'], + required: ['webhookId'], } as const; // TODO: ロジックをサービスに切り出す @@ -66,7 +66,7 @@ export default class extends Endpoint { // eslint- await this.webhooksRepository.update(webhook.id, { name: ps.name, url: ps.url, - secret: ps.secret, + secret: ps.secret === null ? '' : ps.secret, on: ps.on, active: ps.active, }); diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts index 0ff125ad9cbe..a70b587da7e5 100644 --- a/packages/backend/src/server/api/endpoints/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/invite/create.ts @@ -66,13 +66,13 @@ export default class extends Endpoint { // eslint- } } - const ticket = await this.registrationTicketsRepository.insert({ + const ticket = await this.registrationTicketsRepository.insertOne({ id: this.idService.gen(), createdBy: me, createdById: me.id, expiresAt: policies.inviteExpirationTime ? new Date(Date.now() + (policies.inviteExpirationTime * 1000 * 60)) : null, code: generateInviteCode(), - }).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0])); + }); return await this.inviteCodeEntityService.pack(ticket, me); }); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts index a91c506afd27..f33f49075bc0 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -144,12 +144,12 @@ export default class extends Endpoint { // eslint- } // Create vote - const vote = await this.pollVotesRepository.insert({ + const vote = await this.pollVotesRepository.insertOne({ id: this.idService.gen(createdAt.getTime()), noteId: note.id, userId: me.id, choice: ps.choice, - }).then(x => this.pollVotesRepository.findOneByOrFail(x.identifiers[0])); + }); // Increment votes count const index = ps.choice + 1; // In SQL, array index is 1 based diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts index a0a1fd9728c9..97b12ab7f765 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -76,7 +76,7 @@ export default class extends Endpoint { // eslint- const reactions = await query.limit(ps.limit).getMany(); - return await Promise.all(reactions.map(reaction => this.noteReactionEntityService.pack(reaction, me))); + return await this.noteReactionEntityService.packMany(reactions, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts index b9899608bf16..0f0dcca605ab 100644 --- a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -36,6 +36,12 @@ export const meta = { code: 'YOU_HAVE_BEEN_BLOCKED', id: '20ef5475-9f38-4e4c-bd33-de6d979498ec', }, + + cannotReactToRenote: { + message: 'You cannot react to Renote.', + code: 'CANNOT_REACT_TO_RENOTE', + id: 'eaccdc08-ddef-43fe-908f-d108faad57f5', + }, }, } as const; @@ -62,6 +68,7 @@ export default class extends Endpoint { // eslint- await this.reactionService.create(me, note, ps.reaction).catch(err => { if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') throw new ApiError(meta.errors.alreadyReacted); if (err.id === 'e70412a4-7197-4726-8e74-f3e0deb92aa7') throw new ApiError(meta.errors.youHaveBeenBlocked); + if (err.id === '12c35529-3c79-4327-b1cc-e2cf63a71925') throw new ApiError(meta.errors.cannotReactToRenote); throw err; }); return; diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index 3a02d359f80b..fa03b0b4575f 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -102,7 +102,7 @@ export default class extends Endpoint { // eslint- } }); - const page = await this.pagesRepository.insert(new MiPage({ + const page = await this.pagesRepository.insertOne(new MiPage({ id: this.idService.gen(), updatedAt: new Date(), title: ps.title, @@ -117,7 +117,7 @@ export default class extends Endpoint { // eslint- alignCenter: ps.alignCenter, hideTitleWhenPinned: ps.hideTitleWhenPinned, font: ps.font, - })).then(x => this.pagesRepository.findOneByOrFail(x.identifiers[0])); + })); return await this.pageEntityService.pack(page); }); diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index b8e5e70a25a4..f11bbbcb1a47 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -70,7 +70,7 @@ export const paramDef = { alignCenter: { type: 'boolean' }, hideTitleWhenPinned: { type: 'boolean' }, }, - required: ['pageId', 'title', 'name', 'content', 'variables', 'script'], + required: ['pageId'], } as const; @Injectable() @@ -91,9 +91,8 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.accessDenied); } - let eyeCatchingImage = null; if (ps.eyeCatchingImageId != null) { - eyeCatchingImage = await this.driveFilesRepository.findOneBy({ + const eyeCatchingImage = await this.driveFilesRepository.findOneBy({ id: ps.eyeCatchingImageId, userId: me.id, }); @@ -116,23 +115,15 @@ export default class extends Endpoint { // eslint- await this.pagesRepository.update(page.id, { updatedAt: new Date(), title: ps.title, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - name: ps.name === undefined ? page.name : ps.name, + name: ps.name, summary: ps.summary === undefined ? page.summary : ps.summary, content: ps.content, variables: ps.variables, script: ps.script, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - font: ps.font === undefined ? page.font : ps.font, - eyeCatchingImageId: ps.eyeCatchingImageId === null - ? null - : ps.eyeCatchingImageId === undefined - ? page.eyeCatchingImageId - : eyeCatchingImage!.id, + alignCenter: ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned, + font: ps.font, + eyeCatchingImageId: ps.eyeCatchingImageId, }); }); } diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts index 784766bcb551..15832ef7f8d7 100644 --- a/packages/backend/src/server/api/endpoints/pinned-users.ts +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -12,7 +12,6 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { MetaService } from '@/core/MetaService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; -import { isNotNull } from '@/misc/is-not-null.js'; export const meta = { tags: ['users'], @@ -53,7 +52,7 @@ export default class extends Endpoint { // eslint- host: acct.host ?? IsNull(), }))); - return await this.userEntityService.packMany(users.filter(isNotNull), me, { schema: 'UserDetailed' }); + return await this.userEntityService.packMany(users.filter(x => x != null), me, { schema: 'UserDetailed' }); }); } } diff --git a/packages/backend/src/server/api/endpoints/roles/users.ts b/packages/backend/src/server/api/endpoints/roles/users.ts index 85d100ce1c5a..48d350af5935 100644 --- a/packages/backend/src/server/api/endpoints/roles/users.ts +++ b/packages/backend/src/server/api/endpoints/roles/users.ts @@ -92,9 +92,12 @@ export default class extends Endpoint { // eslint- .limit(ps.limit) .getMany(); + const _users = assigns.map(({ user, userId }) => user ?? userId); + const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' }) + .then(users => new Map(users.map(u => [u.id, u]))); return await Promise.all(assigns.map(async assign => ({ id: assign.id, - user: await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), + user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }), }))); }); } diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts index 02aa037466e5..9248a2fa6811 100644 --- a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -118,12 +118,14 @@ export default class extends Endpoint { // eslint- const repliedUsersSorted = Object.keys(repliedUsers).sort((a, b) => repliedUsers[b] - repliedUsers[a]); // Extract top replied users - const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit); + const topRepliedUserIds = repliedUsersSorted.slice(0, ps.limit); // Make replies object (includes weights) - const repliesObj = await Promise.all(topRepliedUsers.map(async (user) => ({ - user: await this.userEntityService.pack(user, me, { schema: 'UserDetailed' }), - weight: repliedUsers[user] / peak, + const _userMap = await this.userEntityService.packMany(topRepliedUserIds, me, { schema: 'UserDetailed' }) + .then(users => new Map(users.map(u => [u.id, u]))); + const repliesObj = await Promise.all(topRepliedUserIds.map(async (userId) => ({ + user: _userMap.get(userId) ?? await this.userEntityService.pack(userId, me, { schema: 'UserDetailed' }), + weight: repliedUsers[userId] / peak, }))); return repliesObj; diff --git a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts index e2db71c5c7f4..7e44d501ab31 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create-from-public.ts @@ -100,15 +100,15 @@ export default class extends Endpoint { // eslint- const currentCount = await this.userListsRepository.countBy({ userId: me.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } - const userList = await this.userListsRepository.insert({ + const userList = await this.userListsRepository.insertOne({ id: this.idService.gen(), userId: me.id, name: ps.name, - } as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); + } as MiUserList); const users = (await this.userListMembershipsRepository.findBy({ userListId: ps.listId, diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts index 952580e639fd..7daf05ba4ece 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/create.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -61,15 +61,15 @@ export default class extends Endpoint { // eslint- const currentCount = await this.userListsRepository.countBy({ userId: me.id, }); - if (currentCount > (await this.roleService.getUserPolicies(me.id)).userListLimit) { + if (currentCount >= (await this.roleService.getUserPolicies(me.id)).userListLimit) { throw new ApiError(meta.errors.tooManyUserLists); } - const userList = await this.userListsRepository.insert({ + const userList = await this.userListsRepository.insertOne({ id: this.idService.gen(), userId: me.id, name: ps.name, - } as MiUserList).then(x => this.userListsRepository.findOneByOrFail(x.identifiers[0])); + } as MiUserList); return await this.userListEntityService.pack(userList); }); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index aca883a052bb..7805ae328861 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { RoleService } from '@/core/RoleService.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -74,6 +75,7 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { + const userIdsWhoBlockingMe = me ? await this.cacheService.userBlockedCache.fetch(me.id) : new Set(); const iAmModerator = me ? await this.roleService.isModerator(me) : false; // Moderators can see reactions of all users if (!iAmModerator) { const user = await this.cacheService.findUserById(ps.userId); @@ -85,8 +87,15 @@ export default class extends Endpoint { // eslint- if ((me == null || me.id !== ps.userId) && !profile.publicReactions) { throw new ApiError(meta.errors.reactionsNotPublic); } + + // early return if me is blocked by requesting user + if (userIdsWhoBlockingMe.has(ps.userId)) { + return []; + } } + const userIdsWhoMeMuting = me ? await this.cacheService.userMutingsCache.fetch(me.id) : new Set(); + const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('reaction.userId = :userId', { userId: ps.userId }) @@ -94,9 +103,15 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); - const reactions = await query + const reactions = (await query .limit(ps.limit) - .getMany(); + .getMany()).filter(reaction => { + if (reaction.note?.userId === ps.userId) return true; // we can see reactions to note of requesting user + if (me && isUserRelated(reaction.note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(reaction.note, userIdsWhoMeMuting)) return false; + + return true; + }); return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true }); }); diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index 1750dd6206f1..5ff6de37d2ca 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -3,17 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import sanitizeHtml from 'sanitize-html'; -import { Inject, Injectable } from '@nestjs/common'; -import type { AbuseUserReportsRepository } from '@/models/_.js'; -import { IdService } from '@/core/IdService.js'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { MetaService } from '@/core/MetaService.js'; -import { EmailService } from '@/core/EmailService.js'; -import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -57,60 +51,32 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.abuseUserReportsRepository) - private abuseUserReportsRepository: AbuseUserReportsRepository, - - private idService: IdService, - private metaService: MetaService, - private emailService: EmailService, private getterService: GetterService, private roleService: RoleService, - private globalEventService: GlobalEventService, + private abuseReportService: AbuseReportService, ) { super(meta, paramDef, async (ps, me) => { // Lookup user - const user = await this.getterService.getUser(ps.userId).catch(err => { + const targetUser = await this.getterService.getUser(ps.userId).catch(err => { if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); throw err; }); - if (user.id === me.id) { + if (targetUser.id === me.id) { throw new ApiError(meta.errors.cannotReportYourself); } - if (await this.roleService.isAdministrator(user)) { + if (await this.roleService.isAdministrator(targetUser)) { throw new ApiError(meta.errors.cannotReportAdmin); } - const report = await this.abuseUserReportsRepository.insert({ - id: this.idService.gen(), - targetUserId: user.id, - targetUserHost: user.host, + await this.abuseReportService.report([{ + targetUserId: targetUser.id, + targetUserHost: targetUser.host, reporterId: me.id, reporterHost: null, comment: ps.comment, - }).then(x => this.abuseUserReportsRepository.findOneByOrFail(x.identifiers[0])); - - // Publish event to moderators - setImmediate(async () => { - const moderators = await this.roleService.getModerators(); - - for (const moderator of moderators) { - this.globalEventService.publishAdminStream(moderator.id, 'newAbuseUserReport', { - id: report.id, - targetUserId: report.targetUserId, - reporterId: report.reporterId, - comment: report.comment, - }); - } - - const meta = await this.metaService.fetch(); - if (meta.email) { - this.emailService.sendEmail(meta.email, 'New abuse report', - sanitizeHtml(ps.comment), - sanitizeHtml(ps.comment)); - } - }); + }]); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 7b3bdab327eb..8ff952dcb544 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -3,15 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets } from 'typeorm'; -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, FollowingsRepository } from '@/models/_.js'; -import type { Config } from '@/config.js'; -import type { MiUser } from '@/models/User.js'; +import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { UserSearchService } from '@/core/UserSearchService.js'; export const meta = { tags: ['users'], @@ -49,89 +43,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.followingsRepository) - private followingsRepository: FollowingsRepository, - - private userEntityService: UserEntityService, + private userSearchService: UserSearchService, ) { - super(meta, paramDef, async (ps, me) => { - const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => { - if (ps.username) { - query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' }); - } - - if (ps.host) { - if (ps.host === this.config.hostname || ps.host === '.') { - query.andWhere('user.host IS NULL'); - } else { - query.andWhere('user.host LIKE :host', { - host: sqlLikeEscape(ps.host.toLowerCase()) + '%', - }); - } - } - - return query; - }; - - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - - let users: MiUser[] = []; - - if (me) { - const followingQuery = this.followingsRepository.createQueryBuilder('following') - .select('following.followeeId') - .where('following.followerId = :followerId', { followerId: me.id }); - - const query = setUsernameAndHostQuery() - .andWhere(`user.id IN (${ followingQuery.getQuery() })`) - .andWhere('user.id != :meId', { meId: me.id }) - .andWhere('user.isSuspended = FALSE') - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })); - - query.setParameters(followingQuery.getParameters()); - - users = await query - .orderBy('user.usernameLower', 'ASC') - .limit(ps.limit) - .getMany(); - - if (users.length < ps.limit) { - const otherQuery = setUsernameAndHostQuery() - .andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`) - .andWhere('user.isSuspended = FALSE') - .andWhere('user.updatedAt IS NOT NULL'); - - otherQuery.setParameters(followingQuery.getParameters()); - - const otherUsers = await otherQuery - .orderBy('user.updatedAt', 'DESC') - .limit(ps.limit - users.length) - .getMany(); - - users = users.concat(otherUsers); - } - } else { - const query = setUsernameAndHostQuery() - .andWhere('user.isSuspended = FALSE') - .andWhere('user.updatedAt IS NOT NULL'); - - users = await query - .orderBy('user.updatedAt', 'DESC') - .limit(ps.limit - users.length) - .getMany(); - } - - return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }); + super(meta, paramDef, (ps, me) => { + return this.userSearchService.search({ + username: ps.username, + host: ps.host, + }, { + limit: ps.limit, + detail: ps.detail, + }, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index 26cfa921c56a..062326e28d1e 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -117,9 +117,9 @@ export default class extends Endpoint { // eslint- if (user != null) _users.push(user); } - return await Promise.all(_users.map(u => this.userEntityService.pack(u, me, { - schema: 'UserDetailed', - }))); + const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' }) + .then(users => new Map(users.map(u => [u.id, u]))); + return _users.map(u => _userMap.get(u.id)!); } else { // Lookup user if (typeof ps.host === 'string' && typeof ps.username === 'string') { diff --git a/packages/backend/src/server/api/openapi/OpenApiServerService.ts b/packages/backend/src/server/api/openapi/OpenApiServerService.ts index 5210e4d2bc3c..f124aa9f39e8 100644 --- a/packages/backend/src/server/api/openapi/OpenApiServerService.ts +++ b/packages/backend/src/server/api/openapi/OpenApiServerService.ts @@ -25,7 +25,7 @@ export class OpenApiServerService { public createServer(fastify: FastifyInstance, _options: FastifyPluginOptions, done: (err?: Error) => void) { fastify.get('/api-doc', async (_request, reply) => { reply.header('Cache-Control', 'public, max-age=86400'); - return await reply.sendFile('/redoc.html', staticAssets); + return await reply.sendFile('/api-doc.html', staticAssets); }); fastify.get('/api.json', (_request, reply) => { reply.header('Cache-Control', 'public, max-age=600'); diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index 2a14270a2497..efa47a698624 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -15,7 +15,6 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { info: { version: config.version, title: 'Misskey API', - 'x-logo': { url: '/static-assets/api-doc.png' }, }, externalDocs: { diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index f35ec8ba31ca..f55790b6363b 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -25,7 +25,16 @@ import { getNoteSummary } from '@/misc/get-note-summary.js'; import { DI } from '@/di-symbols.js'; import * as Acct from '@/misc/acct.js'; import { MetaService } from '@/core/MetaService.js'; -import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { + DbQueue, + DeliverQueue, + EndedPollNotificationQueue, + InboxQueue, + ObjectStorageQueue, + SystemQueue, + UserWebhookDeliverQueue, + SystemWebhookDeliverQueue, +} from '@/core/QueueModule.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; @@ -111,7 +120,8 @@ export class ClientServerService { @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, - @Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue, + @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, + @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, ) { //this.createServer = this.createServer.bind(this); } @@ -239,7 +249,8 @@ export class ClientServerService { this.inboxQueue, this.dbQueue, this.objectStorageQueue, - this.webhookDeliverQueue, + this.userWebhookDeliverQueue, + this.systemWebhookDeliverQueue, ].map(q => new BullMQAdapter(q)), serverAdapter, }); @@ -466,7 +477,9 @@ export class ClientServerService { }; // Atom - fastify.get<{ Params: { user: string; } }>('/@:user.atom', async (request, reply) => { + fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => { + if (request.params.user == null) return await renderBase(reply); + const feed = await getFeed(request.params.user); if (feed) { @@ -479,7 +492,9 @@ export class ClientServerService { }); // RSS - fastify.get<{ Params: { user: string; } }>('/@:user.rss', async (request, reply) => { + fastify.get<{ Params: { user?: string; } }>('/@:user.rss', async (request, reply) => { + if (request.params.user == null) return await renderBase(reply); + const feed = await getFeed(request.params.user); if (feed) { @@ -492,7 +507,9 @@ export class ClientServerService { }); // JSON - fastify.get<{ Params: { user: string; } }>('/@:user.json', async (request, reply) => { + fastify.get<{ Params: { user?: string; } }>('/@:user.json', async (request, reply) => { + if (request.params.user == null) return await renderBase(reply); + const feed = await getFeed(request.params.user); if (feed) { diff --git a/packages/backend/src/server/web/FeedService.ts b/packages/backend/src/server/web/FeedService.ts index 10e3ed26820c..9d810ddc846b 100644 --- a/packages/backend/src/server/web/FeedService.ts +++ b/packages/backend/src/server/web/FeedService.ts @@ -14,6 +14,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { MfmService } from "@/core/MfmService.js"; +import { parse as mfmParse } from 'mfm-js'; @Injectable() export class FeedService { @@ -33,6 +35,7 @@ export class FeedService { private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, + private mfmService: MfmService, ) { } @@ -76,13 +79,14 @@ export class FeedService { id: In(note.fileIds), }) : []; const file = files.find(file => file.type.startsWith('image/')); + const text = note.text; feed.addItem({ title: `New note by ${author.name}`, link: `${this.config.url}/notes/${note.id}`, date: this.idService.parse(note.id).date, description: note.cw ?? undefined, - content: note.text ?? undefined, + content: text ? this.mfmService.toHtml(mfmParse(text), JSON.parse(note.mentionedRemoteUsers)) ?? undefined : undefined, image: file ? this.driveFileEntityService.getPublicUrl(file) : undefined, }); } diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 396536948ef4..4275dc952715 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -29,7 +29,8 @@ let forceError = localStorage.getItem('forceError'); if (forceError != null) { - renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.') + renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.'); + return; } //#region Detect language & fetch translations @@ -155,7 +156,12 @@ document.head.appendChild(css); } - function renderError(code, details) { + async function renderError(code, details) { + // Cannot set property 'innerHTML' of null を回避 + if (document.readyState === 'loading') { + await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); + } + let errorsElement = document.getElementById('errors'); if (!errorsElement) { @@ -314,6 +320,6 @@ #errorInfo { width: 50%; } - `) + }`) } })(); diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 929070d0d290..ecbbee4eff1b 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -90,6 +90,12 @@ export const moderationLogTypes = [ 'deleteAvatarDecoration', 'unsetUserAvatar', 'unsetUserBanner', + 'createSystemWebhook', + 'updateSystemWebhook', + 'deleteSystemWebhook', + 'createAbuseReportNotificationRecipient', + 'updateAbuseReportNotificationRecipient', + 'deleteAbuseReportNotificationRecipient', ] as const; export type ModerationLogPayloads = { @@ -282,6 +288,32 @@ export type ModerationLogPayloads = { userHost: string | null; fileId: string; }; + createSystemWebhook: { + systemWebhookId: string; + webhook: any; + }; + updateSystemWebhook: { + systemWebhookId: string; + before: any; + after: any; + }; + deleteSystemWebhook: { + systemWebhookId: string; + webhook: any; + }; + createAbuseReportNotificationRecipient: { + recipientId: string; + recipient: any; + }; + updateAbuseReportNotificationRecipient: { + recipientId: string; + before: any; + after: any; + }; + deleteAbuseReportNotificationRecipient: { + recipientId: string; + recipient: any; + }; }; export type Serialized = { diff --git a/packages/backend/test-server/.eslintrc.cjs b/packages/backend/test-server/.eslintrc.cjs deleted file mode 100644 index c261741a36a1..000000000000 --- a/packages/backend/test-server/.eslintrc.cjs +++ /dev/null @@ -1,32 +0,0 @@ -module.exports = { - parserOptions: { - tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], - }, - extends: [ - '../../shared/.eslintrc.js', - ], - rules: { - 'import/order': ['warn', { - 'groups': ['builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'], - 'pathGroups': [ - { - 'pattern': '@/**', - 'group': 'external', - 'position': 'after' - } - ], - }], - 'no-restricted-globals': [ - 'error', - { - 'name': '__dirname', - 'message': 'Not in ESModule. Use `import.meta.url` instead.' - }, - { - 'name': '__filename', - 'message': 'Not in ESModule. Use `import.meta.url` instead.' - } - ] - }, -}; diff --git a/packages/backend/test-server/eslint.config.js b/packages/backend/test-server/eslint.config.js new file mode 100644 index 000000000000..b9c16d469fc3 --- /dev/null +++ b/packages/backend/test-server/eslint.config.js @@ -0,0 +1,43 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../shared/eslint.config.js'; + +export default [ + ...sharedConfig, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + 'import/order': ['warn', { + groups: [ + 'builtin', + 'external', + 'internal', + 'parent', + 'sibling', + 'index', + 'object', + 'type', + ], + pathGroups: [{ + pattern: '@/**', + group: 'external', + position: 'after', + }], + }], + 'no-restricted-globals': ['error', { + name: '__dirname', + message: 'Not in ESModule. Use `import.meta.url` instead.', + }, { + name: '__filename', + message: 'Not in ESModule. Use `import.meta.url` instead.', + }], + }, + }, +]; diff --git a/packages/backend/test/.eslintrc.cjs b/packages/backend/test/.eslintrc.cjs deleted file mode 100644 index 41ecea0c3fba..000000000000 --- a/packages/backend/test/.eslintrc.cjs +++ /dev/null @@ -1,11 +0,0 @@ -module.exports = { - parserOptions: { - tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], - }, - extends: ['../.eslintrc.cjs'], - env: { - node: true, - jest: true, - }, -}; diff --git a/packages/backend/test/docker-compose.yml b/packages/backend/test/compose.yml similarity index 94% rename from packages/backend/test/docker-compose.yml rename to packages/backend/test/compose.yml index f2d899075808..6593fc33dd6c 100644 --- a/packages/backend/test/docker-compose.yml +++ b/packages/backend/test/compose.yml @@ -1,5 +1,3 @@ -version: "3" - services: redistest: image: redis:7 diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index 13c56b88a693..06548fa7da03 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -206,7 +206,7 @@ describe('2要素認証', () => { username, }, alice); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); const signinResponse = await api('signin', { ...signinParam(), @@ -248,7 +248,7 @@ describe('2要素認証', () => { keyName, credentialId, creationOptions: registerKeyResponse.body, - }) as any, alice); + } as any) as any, alice); assert.strictEqual(keyDoneResponse.status, 200); assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); assert.strictEqual(keyDoneResponse.body.name, keyName); @@ -257,22 +257,22 @@ describe('2要素認証', () => { username, }); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.securityKeys, true); + assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, true); const signinResponse = await api('signin', { ...signinParam(), }); assert.strictEqual(signinResponse.status, 200); assert.strictEqual(signinResponse.body.i, undefined); - assert.notEqual(signinResponse.body.challenge, undefined); - assert.notEqual(signinResponse.body.allowCredentials, undefined); - assert.strictEqual(signinResponse.body.allowCredentials[0].id, credentialId.toString('base64url')); + assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined); + assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined); + assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url')); const signinResponse2 = await api('signin', signinWithSecurityKeyParam({ keyName, credentialId, requestOptions: signinResponse.body, - })); + } as any)); assert.strictEqual(signinResponse2.status, 200); assert.notEqual(signinResponse2.body.i, undefined); @@ -307,7 +307,7 @@ describe('2要素認証', () => { keyName, credentialId, creationOptions: registerKeyResponse.body, - }) as any, alice); + } as any) as any, alice); assert.strictEqual(keyDoneResponse.status, 200); const passwordLessResponse = await api('i/2fa/password-less', { @@ -319,7 +319,7 @@ describe('2要素認証', () => { username, }); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.usePasswordLessLogin, true); + assert.strictEqual((usersShowResponse.body as unknown as { usePasswordLessLogin: boolean }).usePasswordLessLogin, true); const signinResponse = await api('signin', { ...signinParam(), @@ -333,7 +333,7 @@ describe('2要素認証', () => { keyName, credentialId, requestOptions: signinResponse.body, - }), + } as any), password: '', }); assert.strictEqual(signinResponse2.status, 200); @@ -370,7 +370,7 @@ describe('2要素認証', () => { keyName, credentialId, creationOptions: registerKeyResponse.body, - }) as any, alice); + } as any) as any, alice); assert.strictEqual(keyDoneResponse.status, 200); const renamedKey = 'other-key'; @@ -383,6 +383,7 @@ describe('2要素認証', () => { const iResponse = await api('i', { }, alice); assert.strictEqual(iResponse.status, 200); + assert.ok(iResponse.body.securityKeysList); const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url')); assert.strictEqual(securityKeys.length, 1); assert.strictEqual(securityKeys[0].name, renamedKey); @@ -419,13 +420,14 @@ describe('2要素認証', () => { keyName, credentialId, creationOptions: registerKeyResponse.body, - }) as any, alice); + } as any) as any, alice); assert.strictEqual(keyDoneResponse.status, 200); // テストの実行順によっては複数残ってるので全部消す const iResponse = await api('i', { }, alice); assert.strictEqual(iResponse.status, 200); + assert.ok(iResponse.body.securityKeysList); for (const key of iResponse.body.securityKeysList) { const removeKeyResponse = await api('i/2fa/remove-key', { token: otpToken(registerResponse.body.secret), @@ -439,7 +441,7 @@ describe('2要素認証', () => { username, }); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.securityKeys, false); + assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, false); const signinResponse = await api('signin', { ...signinParam(), @@ -470,7 +472,7 @@ describe('2要素認証', () => { username, }); assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual(usersShowResponse.body.twoFactorEnabled, true); + assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); const unregisterResponse = await api('i/2fa/unregister', { token: otpToken(registerResponse.body.secret), diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index cf5c7dd130ea..6ac14cd8dcde 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -38,7 +38,6 @@ describe('アンテナ', () => { excludeKeywords: [['']], keywords: [['keyword']], name: 'test', - notify: false, src: 'all' as const, userListId: null, users: [''], @@ -151,7 +150,6 @@ describe('アンテナ', () => { isActive: true, keywords: [['keyword']], name: 'test', - notify: false, src: 'all', userListId: null, users: [''], @@ -159,13 +157,13 @@ describe('アンテナ', () => { withReplies: false, excludeBots: false, localOnly: false, + notify: false, }; assert.deepStrictEqual(response, expected); }); test('が上限いっぱいまで作成できること', async () => { - // antennaLimit + 1まで作れるのがキモ - const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit + 1)].map(() => successfulApiCall({ + const response = await Promise.all([...Array(DEFAULT_POLICIES.antennaLimit)].map(() => successfulApiCall({ endpoint: 'antennas/create', parameters: { ...defaultParam }, user: alice, @@ -219,8 +217,6 @@ describe('アンテナ', () => { { parameters: () => ({ withReplies: true }) }, { parameters: () => ({ withFile: false }) }, { parameters: () => ({ withFile: true }) }, - { parameters: () => ({ notify: false }) }, - { parameters: () => ({ notify: true }) }, ]; test.each(antennaParamPattern)('を作成できること($#)', async ({ parameters }) => { const response = await successfulApiCall({ diff --git a/packages/backend/test/e2e/api-visibility.ts b/packages/backend/test/e2e/api-visibility.ts index c61b0c2a8674..2dd645d97a99 100644 --- a/packages/backend/test/e2e/api-visibility.ts +++ b/packages/backend/test/e2e/api-visibility.ts @@ -410,21 +410,21 @@ describe('API visibility', () => { test('[HTL] public-post が 自分が見れる', async () => { const res = await api('notes/timeline', { limit: 100 }, alice); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === pub.id); + const notes = res.body.filter(n => n.id === pub.id); assert.strictEqual(notes[0].text, 'x'); }); test('[HTL] public-post が 非フォロワーから見れない', async () => { const res = await api('notes/timeline', { limit: 100 }, other); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === pub.id); + const notes = res.body.filter(n => n.id === pub.id); assert.strictEqual(notes.length, 0); }); test('[HTL] followers-post が フォロワーから見れる', async () => { const res = await api('notes/timeline', { limit: 100 }, follower); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === fol.id); + const notes = res.body.filter(n => n.id === fol.id); assert.strictEqual(notes[0].text, 'x'); }); //#endregion @@ -433,21 +433,21 @@ describe('API visibility', () => { test('[replies] followers-reply が フォロワーから見れる', async () => { const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, follower); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); + const notes = res.body.filter(n => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); test('[replies] followers-reply が 非フォロワー (リプライ先ではない) から見れない', async () => { const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, other); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); + const notes = res.body.filter(n => n.id === folR.id); assert.strictEqual(notes.length, 0); }); test('[replies] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { const res = await api('notes/replies', { noteId: tgt.id, limit: 100 }, target); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); + const notes = res.body.filter(n => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); //#endregion @@ -456,14 +456,14 @@ describe('API visibility', () => { test('[mentions] followers-reply が 非フォロワー (リプライ先である) から見れる', async () => { const res = await api('notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folR.id); + const notes = res.body.filter(n => n.id === folR.id); assert.strictEqual(notes[0].text, 'x'); }); test('[mentions] followers-mention が 非フォロワー (メンション先である) から見れる', async () => { const res = await api('notes/mentions', { limit: 100 }, target); assert.strictEqual(res.status, 200); - const notes = res.body.filter((n: any) => n.id === folM.id); + const notes = res.body.filter(n => n.id === folM.id); assert.strictEqual(notes[0].text, '@target x'); }); //#endregion diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index e4f798498f56..35b0e59383fb 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -6,7 +6,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { api, post, signup } from '../utils.js'; +import { api, castAsError, post, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Block', () => { @@ -33,7 +33,7 @@ describe('Block', () => { const res = await api('following/create', { userId: alice.id }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); + assert.strictEqual(castAsError(res.body).error.id, 'c4ab57cc-4e41-45e9-bfd9-584f61e35ce0'); }); test('ブロックされているユーザーにリアクションできない', async () => { @@ -42,7 +42,8 @@ describe('Block', () => { const res = await api('notes/reactions/create', { noteId: note.id, reaction: '👍' }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.id, '20ef5475-9f38-4e4c-bd33-de6d979498ec'); }); test('ブロックされているユーザーに返信できない', async () => { @@ -51,7 +52,8 @@ describe('Block', () => { const res = await api('notes/create', { replyId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); }); test('ブロックされているユーザーのノートをRenoteできない', async () => { @@ -60,7 +62,7 @@ describe('Block', () => { const res = await api('notes/create', { renoteId: note.id, text: 'yo' }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); + assert.strictEqual(castAsError(res.body).error.id, 'b390d7e1-8a5e-46ed-b625-06271cafd3d3'); }); // TODO: ユーザーリストに入れられないテスト diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index ba6f9d6a651c..a130c3698db7 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -79,14 +79,14 @@ describe('クリップ', () => { }; const deleteClip = async (parameters: Misskey.entities.ClipsDeleteRequest, request: Partial> = {}): Promise => { - return await successfulApiCall({ + await successfulApiCall({ endpoint: 'clips/delete', parameters, user: alice, ...request, }, { status: 204, - }) as any as void; + }); }; const show = async (parameters: Misskey.entities.ClipsShowRequest, request: Partial> = {}): Promise => { @@ -153,8 +153,7 @@ describe('クリップ', () => { }); test('の作成はポリシーで定められた数以上はできない。', async () => { - // ポリシー + 1まで作れるという所がミソ - const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clipLimit = DEFAULT_POLICIES.clipLimit; for (let i = 0; i < clipLimit; i++) { await create(); } @@ -327,7 +326,7 @@ describe('クリップ', () => { }); test('の一覧(clips/list)が取得できる(上限いっぱい)', async () => { - const clipLimit = DEFAULT_POLICIES.clipLimit + 1; + const clipLimit = DEFAULT_POLICIES.clipLimit; const clips = await createMany({}, clipLimit); const res = await list({ parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる @@ -455,25 +454,25 @@ describe('クリップ', () => { let aliceClip: Misskey.entities.Clip; const favorite = async (parameters: Misskey.entities.ClipsFavoriteRequest, request: Partial> = {}): Promise => { - return successfulApiCall({ + await successfulApiCall({ endpoint: 'clips/favorite', parameters, user: alice, ...request, }, { status: 204, - }) as any as void; + }); }; const unfavorite = async (parameters: Misskey.entities.ClipsUnfavoriteRequest, request: Partial> = {}): Promise => { - return successfulApiCall({ + await successfulApiCall({ endpoint: 'clips/unfavorite', parameters, user: alice, ...request, }, { status: 204, - }) as any as void; + }); }; const myFavorites = async (request: Partial> = {}): Promise => { @@ -705,7 +704,7 @@ describe('クリップ', () => { // TODO: 17000msくらいかかる... test('をポリシーで定められた上限いっぱい(200)を超えて追加はできない。', async () => { - const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit + 1; + const noteLimit = DEFAULT_POLICIES.noteEachClipsLimit; const noteList = await Promise.all([...Array(noteLimit)].map((_, i) => post(alice, { text: `test ${i}`, }) as unknown)) as Misskey.entities.Note[]; diff --git a/packages/backend/test/e2e/drive.ts b/packages/backend/test/e2e/drive.ts index 828c5200ef0d..43a73163ebab 100644 --- a/packages/backend/test/e2e/drive.ts +++ b/packages/backend/test/e2e/drive.ts @@ -23,7 +23,7 @@ describe('Drive', () => { const marker = Math.random().toString(); - const url = 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'; + const url = 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg'; const catcher = makeStreamCatcher( alice, @@ -41,14 +41,14 @@ describe('Drive', () => { const file = await catcher; assert.strictEqual(res.status, 204); - assert.strictEqual(file.name, 'Lenna.jpg'); + assert.strictEqual(file.name, '192.jpg'); assert.strictEqual(file.type, 'image/jpeg'); }); test('ローカルからアップロードできる', async () => { // APIレスポンスを直接使用するので utils.js uploadFile が通過することで成功とする - const res = await uploadFile(alice, { path: 'Lenna.jpg', name: 'テスト画像' }); + const res = await uploadFile(alice, { path: '192.jpg', name: 'テスト画像' }); assert.strictEqual(res.body?.name, 'テスト画像.jpg'); assert.strictEqual(res.body.type, 'image/jpeg'); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index bc89dc37f4d8..5aaec7f6f9d0 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -10,7 +10,7 @@ import * as assert from 'assert'; // https://github.com/node-fetch/node-fetch/pull/1664 import { Blob } from 'node-fetch'; import { MiUser } from '@/models/_.js'; -import { api, initTestDb, post, signup, simpleGet, uploadFile } from '../utils.js'; +import { api, castAsError, initTestDb, post, signup, simpleGet, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Endpoints', () => { @@ -117,12 +117,21 @@ describe('Endpoints', () => { assert.strictEqual(res.body.birthday, myBirthday); }); - test('名前を空白にできる', async () => { + test('名前を空白のみにした場合nullになる', async () => { const res = await api('i/update', { name: ' ', }, alice); assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.name, ' '); + assert.strictEqual(res.body.name, null); + }); + + test('名前の前後に空白(ホワイトスペース)を入れてもトリムされる', async () => { + const res = await api('i/update', { + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Lexical_grammar#white_space + name: ' あ い う \u0009\u000b\u000c\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\ufeff', + }, alice); + assert.strictEqual(res.status, 200); + assert.strictEqual(res.body.name, 'あ い う'); }); test('誕生日の設定を削除できる', async () => { @@ -155,7 +164,7 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body.id, alice.id); + assert.strictEqual((res.body as unknown as { id: string }).id, alice.id); }); test('ユーザーが存在しなかったら怒る', async () => { @@ -266,6 +275,68 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 400); }); + test('リノートにリアクションできない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const bobRenote = await post(bob, { renoteId: bobNote.id }); + + const res = await api('notes/reactions/create', { + noteId: bobRenote.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 400); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.code, 'CANNOT_REACT_TO_RENOTE'); + }); + + test('引用にリアクションできる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const bobRenote = await post(bob, { text: 'hi again', renoteId: bobNote.id }); + + const res = await api('notes/reactions/create', { + noteId: bobRenote.id, + reaction: '🚀', + }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('空文字列のリアクションは\u2764にフォールバックされる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const res = await api('notes/reactions/create', { + noteId: bobNote.id, + reaction: '', + }, alice); + + assert.strictEqual(res.status, 204); + + const reaction = await api('notes/reactions', { + noteId: bobNote.id, + }); + + assert.strictEqual(reaction.body.length, 1); + assert.strictEqual(reaction.body[0].type, '\u2764'); + }); + + test('絵文字ではない文字列のリアクションは\u2764にフォールバックされる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const res = await api('notes/reactions/create', { + noteId: bobNote.id, + reaction: 'Hello!', + }, alice); + + assert.strictEqual(res.status, 204); + + const reaction = await api('notes/reactions', { + noteId: bobNote.id, + }); + + assert.strictEqual(reaction.body.length, 1); + assert.strictEqual(reaction.body[0].type, '\u2764'); + }); + test('空のパラメータで怒られる', async () => { // @ts-expect-error param must not be empty const res = await api('notes/reactions/create', {}, alice); @@ -523,7 +594,7 @@ describe('Endpoints', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); - assert.strictEqual(res.body!.name, 'Lenna.jpg'); + assert.strictEqual(res.body!.name, '192.jpg'); }); test('ファイルに名前を付けられる', async () => { @@ -993,7 +1064,7 @@ describe('Endpoints', () => { userId: bob.id, }, alice); assert.strictEqual(res1.status, 204); - assert.strictEqual(res2.body?.memo, memo); + assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo); }); test('自分に関するメモを更新できる', async () => { @@ -1008,7 +1079,7 @@ describe('Endpoints', () => { userId: alice.id, }, alice); assert.strictEqual(res1.status, 204); - assert.strictEqual(res2.body?.memo, memo); + assert.strictEqual((res2.body as unknown as { memo: string })?.memo, memo); }); test('メモを削除できる', async () => { @@ -1029,7 +1100,7 @@ describe('Endpoints', () => { }, alice); // memoには常に文字列かnullが入っている(5cac151) - assert.strictEqual(res.body.memo, null); + assert.strictEqual((res.body as unknown as { memo: string | null }).memo, null); }); test('メモは個人ごとに独立して保存される', async () => { @@ -1056,8 +1127,8 @@ describe('Endpoints', () => { }, carol), ]); - assert.strictEqual(resAlice.body.memo, memoAliceToBob); - assert.strictEqual(resCarol.body.memo, memoCarolToBob); + assert.strictEqual((resAlice.body as unknown as { memo: string }).memo, memoAliceToBob); + assert.strictEqual((resCarol.body as unknown as { memo: string }).memo, memoCarolToBob); }); }); }); diff --git a/packages/backend/test/e2e/exports.ts b/packages/backend/test/e2e/exports.ts index 80a5331a6dce..4bcecc9716cb 100644 --- a/packages/backend/test/e2e/exports.ts +++ b/packages/backend/test/e2e/exports.ts @@ -61,14 +61,14 @@ describe('export-clips', () => { }); test('basic export', async () => { - let res = await api('clips/create', { + const res1 = await api('clips/create', { name: 'foo', description: 'bar', }, alice); - assert.strictEqual(res.status, 200); + assert.strictEqual(res1.status, 200); - res = await api('i/export-clips', {}, alice); - assert.strictEqual(res.status, 204); + const res2 = await api('i/export-clips', {}, alice); + assert.strictEqual(res2.status, 204); const exported = await pollFirstDriveFile(); assert.strictEqual(exported[0].name, 'foo'); @@ -77,7 +77,7 @@ describe('export-clips', () => { }); test('export with notes', async () => { - let res = await api('clips/create', { + const res = await api('clips/create', { name: 'foo', description: 'bar', }, alice); @@ -96,15 +96,15 @@ describe('export-clips', () => { }); for (const note of [note1, note2]) { - res = await api('clips/add-note', { + const res2 = await api('clips/add-note', { clipId: clip.id, noteId: note.id, }, alice); - assert.strictEqual(res.status, 204); + assert.strictEqual(res2.status, 204); } - res = await api('i/export-clips', {}, alice); - assert.strictEqual(res.status, 204); + const res3 = await api('i/export-clips', {}, alice); + assert.strictEqual(res3.status, 204); const exported = await pollFirstDriveFile(); assert.strictEqual(exported[0].name, 'foo'); @@ -116,19 +116,19 @@ describe('export-clips', () => { }); test('multiple clips', async () => { - let res = await api('clips/create', { + const res1 = await api('clips/create', { name: 'kawaii', description: 'kawaii', }, alice); - assert.strictEqual(res.status, 200); - const clip1 = res.body; + assert.strictEqual(res1.status, 200); + const clip1 = res1.body; - res = await api('clips/create', { + const res2 = await api('clips/create', { name: 'yuri', description: 'yuri', }, alice); - assert.strictEqual(res.status, 200); - const clip2 = res.body; + assert.strictEqual(res2.status, 200); + const clip2 = res2.body; const note1 = await post(alice, { text: 'baz1', @@ -138,20 +138,26 @@ describe('export-clips', () => { text: 'baz2', }); - res = await api('clips/add-note', { - clipId: clip1.id, - noteId: note1.id, - }, alice); - assert.strictEqual(res.status, 204); + { + const res = await api('clips/add-note', { + clipId: clip1.id, + noteId: note1.id, + }, alice); + assert.strictEqual(res.status, 204); + } - res = await api('clips/add-note', { - clipId: clip2.id, - noteId: note2.id, - }, alice); - assert.strictEqual(res.status, 204); + { + const res = await api('clips/add-note', { + clipId: clip2.id, + noteId: note2.id, + }, alice); + assert.strictEqual(res.status, 204); + } - res = await api('i/export-clips', {}, alice); - assert.strictEqual(res.status, 204); + { + const res = await api('i/export-clips', {}, alice); + assert.strictEqual(res.status, 204); + } const exported = await pollFirstDriveFile(); assert.strictEqual(exported[0].name, 'kawaii'); @@ -163,7 +169,7 @@ describe('export-clips', () => { }); test('Clipping other user\'s note', async () => { - let res = await api('clips/create', { + const res = await api('clips/create', { name: 'kawaii', description: 'kawaii', }, alice); @@ -175,14 +181,14 @@ describe('export-clips', () => { visibility: 'followers', }); - res = await api('clips/add-note', { + const res2 = await api('clips/add-note', { clipId: clip.id, noteId: note.id, }, alice); - assert.strictEqual(res.status, 204); + assert.strictEqual(res2.status, 204); - res = await api('i/export-clips', {}, alice); - assert.strictEqual(res.status, 204); + const res3 = await api('i/export-clips', {}, alice); + assert.strictEqual(res3.status, 204); const exported = await pollFirstDriveFile(); assert.strictEqual(exported[0].name, 'kawaii'); diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 4851ed14be6a..7efd688ec279 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -153,6 +153,23 @@ describe('Webリソース', () => { path: path('nonexisting'), status: 404, })); + + describe(' has entry such ', () => { + beforeEach(() => { + post(alice, { text: "**a**" }) + }); + + test('MFMを含まない。', async () => { + const content = await simpleGet(path(alice.username), "*/*", undefined, res => res.text()); + const _body: unknown = content.body; + // JSONフィードのときは改めて文字列化する + const body: string = typeof (_body) === "object" ? JSON.stringify(_body) : _body as string; + + if (body.includes("**a**")) { + throw new Error("MFM shouldn't be included"); + } + }); + }) }); describe.each([{ path: '/api/foo' }])('$path', ({ path }) => { diff --git a/packages/backend/test/e2e/move.ts b/packages/backend/test/e2e/move.ts index 4e5306da97da..fd798bdb25bc 100644 --- a/packages/backend/test/e2e/move.ts +++ b/packages/backend/test/e2e/move.ts @@ -7,19 +7,20 @@ import { INestApplicationContext } from '@nestjs/common'; process.env.NODE_ENV = 'test'; +import { setTimeout } from 'node:timers/promises'; import * as assert from 'assert'; import { loadConfig } from '@/config.js'; -import { MiUser, UsersRepository } from '@/models/_.js'; +import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { jobQueue } from '@/boot/common.js'; -import { api, initTestDb, signup, sleep, successfulApiCall, uploadFile } from '../utils.js'; +import { api, castAsError, initTestDb, signup, successfulApiCall, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Account Move', () => { let jq: INestApplicationContext; let url: URL; - let root: any; + let root: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse; let bob: misskey.entities.SignupResponse; let carol: misskey.entities.SignupResponse; @@ -42,7 +43,7 @@ describe('Account Move', () => { dave = await signup({ username: 'dave' }); eve = await signup({ username: 'eve' }); frank = await signup({ username: 'frank' }); - Users = connection.getRepository(MiUser); + Users = connection.getRepository(MiUser).extend(miRepository as MiRepository); }, 1000 * 60 * 2); afterAll(async () => { @@ -92,8 +93,8 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_USER'); + assert.strictEqual(castAsError(res.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); }); test('Unable to add duplicated aliases to alsoKnownAs', async () => { @@ -102,8 +103,8 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'INVALID_PARAM'); - assert.strictEqual(res.body.error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); + assert.strictEqual(castAsError(res.body).error.code, 'INVALID_PARAM'); + assert.strictEqual(castAsError(res.body).error.id, '3d81ceae-475f-4600-b2a8-2bc116157532'); }); test('Unable to add itself', async () => { @@ -112,8 +113,8 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'FORBIDDEN_TO_SET_YOURSELF'); - assert.strictEqual(res.body.error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); + assert.strictEqual(castAsError(res.body).error.code, 'FORBIDDEN_TO_SET_YOURSELF'); + assert.strictEqual(castAsError(res.body).error.id, '25c90186-4ab0-49c8-9bba-a1fa6c202ba4'); }); test('Unable to add a nonexisting local account to alsoKnownAs', async () => { @@ -122,16 +123,16 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res1.status, 400); - assert.strictEqual(res1.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res1.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + assert.strictEqual(castAsError(res1.body).error.code, 'NO_SUCH_USER'); + assert.strictEqual(castAsError(res1.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); const res2 = await api('i/update', { alsoKnownAs: ['@alice', 'nonexist'], }, bob); assert.strictEqual(res2.status, 400); - assert.strictEqual(res2.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res2.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + assert.strictEqual(castAsError(res2.body).error.code, 'NO_SUCH_USER'); + assert.strictEqual(castAsError(res2.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); }); test('Able to add two existing local account to alsoKnownAs', async () => { @@ -191,7 +192,6 @@ describe('Account Move', () => { localOnly: false, withReplies: false, withFile: false, - notify: false, }, alice); antennaId = antenna.body.id; @@ -241,8 +241,8 @@ describe('Account Move', () => { }, root); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NOT_ROOT_FORBIDDEN'); - assert.strictEqual(res.body.error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); + assert.strictEqual(castAsError(res.body).error.code, 'NOT_ROOT_FORBIDDEN'); + assert.strictEqual(castAsError(res.body).error.id, '4362e8dc-731f-4ad8-a694-be2a88922a24'); }); test('Unable to move to a nonexisting local account', async () => { @@ -251,8 +251,8 @@ describe('Account Move', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_USER'); - assert.strictEqual(res.body.error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_USER'); + assert.strictEqual(castAsError(res.body).error.id, 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5'); }); test('Unable to move if alsoKnownAs is invalid', async () => { @@ -261,8 +261,8 @@ describe('Account Move', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); - assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + assert.strictEqual(castAsError(res.body).error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(castAsError(res.body).error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); }); test('Relationships have been properly migrated', async () => { @@ -272,43 +272,51 @@ describe('Account Move', () => { assert.strictEqual(move.status, 200); - await sleep(1000 * 3); // wait for jobs to finish + await setTimeout(1000 * 3); // wait for jobs to finish // Unfollow delayed? const aliceFollowings = await api('users/following', { userId: alice.id, }, alice); assert.strictEqual(aliceFollowings.status, 200); + assert.ok(aliceFollowings); assert.strictEqual(aliceFollowings.body.length, 3); const carolFollowings = await api('users/following', { userId: carol.id, }, carol); assert.strictEqual(carolFollowings.status, 200); + assert.ok(carolFollowings); assert.strictEqual(carolFollowings.body.length, 2); assert.strictEqual(carolFollowings.body[0].followeeId, bob.id); assert.strictEqual(carolFollowings.body[1].followeeId, alice.id); const blockings = await api('blocking/list', {}, dave); assert.strictEqual(blockings.status, 200); + assert.ok(blockings); assert.strictEqual(blockings.body.length, 2); assert.strictEqual(blockings.body[0].blockeeId, bob.id); assert.strictEqual(blockings.body[1].blockeeId, alice.id); const mutings = await api('mute/list', {}, dave); assert.strictEqual(mutings.status, 200); + assert.ok(mutings); assert.strictEqual(mutings.body.length, 2); assert.strictEqual(mutings.body[0].muteeId, bob.id); assert.strictEqual(mutings.body[1].muteeId, alice.id); const rootLists = await api('users/lists/list', {}, root); assert.strictEqual(rootLists.status, 200); + assert.ok(rootLists); + assert.ok(rootLists.body[0].userIds); assert.strictEqual(rootLists.body[0].userIds.length, 2); assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id)); assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id)); const eveLists = await api('users/lists/list', {}, eve); assert.strictEqual(eveLists.status, 200); + assert.ok(eveLists); + assert.ok(eveLists.body[0].userIds); assert.strictEqual(eveLists.body[0].userIds.length, 1); assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id)); }); @@ -331,7 +339,7 @@ describe('Account Move', () => { }); test('Unfollowed after 10 sec (24 hours in production).', async () => { - await sleep(1000 * 8); + await setTimeout(1000 * 8); const following = await api('users/following', { userId: alice.id, @@ -347,8 +355,8 @@ describe('Account Move', () => { }, bob); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'DESTINATION_ACCOUNT_FORBIDS'); - assert.strictEqual(res.body.error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); + assert.strictEqual(castAsError(res.body).error.code, 'DESTINATION_ACCOUNT_FORBIDS'); + assert.strictEqual(castAsError(res.body).error.id, 'b5c90186-4ab0-49c8-9bba-a1f766282ba4'); }); test('Follow and follower counts are properly adjusted', async () => { @@ -419,8 +427,9 @@ describe('Account Move', () => { ] as const)('Prohibit access after moving: %s', async (endpoint) => { const res = await api(endpoint, {}, alice); assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); }); test('Prohibit access after moving: /antennas/update', async () => { @@ -435,20 +444,22 @@ describe('Account Move', () => { localOnly: false, withReplies: false, withFile: false, - notify: false, }, alice); assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); }); test('Prohibit access after moving: /drive/files/create', async () => { + // FIXME: 一旦逃げておく const res = await uploadFile(alice); assert.strictEqual(res.status, 403); - assert.strictEqual((res.body! as any as { error: misskey.api.APIError }).error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual((res.body! as any as { error: misskey.api.APIError }).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + assert.ok(res.body); + assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); }); test('Prohibit updating alsoKnownAs after moving', async () => { @@ -457,8 +468,8 @@ describe('Account Move', () => { }, alice); assert.strictEqual(res.status, 403); - assert.strictEqual(res.body.error.code, 'YOUR_ACCOUNT_MOVED'); - assert.strictEqual(res.body.error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); + assert.strictEqual(castAsError(res.body).error.code, 'YOUR_ACCOUNT_MOVED'); + assert.strictEqual(castAsError(res.body).error.id, '56f20ec9-fd06-4fa5-841b-edd6d7d4fa31'); }); }); }); diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index 0e52c5decc37..f37da288b776 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -47,8 +47,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { @@ -92,9 +92,9 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { @@ -108,9 +108,9 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); }); @@ -124,8 +124,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリプライが含まれない', async () => { @@ -138,8 +138,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリプライが含まれない', async () => { @@ -152,8 +152,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { @@ -166,8 +166,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリノートが含まれない', async () => { @@ -180,8 +180,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { @@ -193,8 +193,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, carol); @@ -210,8 +210,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, carol); @@ -228,8 +228,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリプライが含まれない', async () => { const aliceNote = await post(alice, { text: 'hi' }); @@ -241,8 +241,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリプライが含まれない', async () => { @@ -255,8 +255,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからの引用リノートが含まれない', async () => { @@ -269,8 +269,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのリノートが含まれない', async () => { @@ -283,8 +283,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); test('通知にミュートしているユーザーからのフォロー通知が含まれない', async () => { @@ -296,8 +296,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); await api('following/delete', { userId: alice.id }, bob); await api('following/delete', { userId: alice.id }, carol); @@ -313,8 +313,8 @@ describe('Mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === bob.id), true); - assert.strictEqual(res.body.some((notification: any) => notification.userId === carol.id), false); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === bob.id), true); + assert.strictEqual(res.body.some(notification => 'userId' in notification && notification.userId === carol.id), false); }); }); }); diff --git a/packages/backend/test/e2e/note.ts b/packages/backend/test/e2e/note.ts index bda31d964074..5937eb9b4924 100644 --- a/packages/backend/test/e2e/note.ts +++ b/packages/backend/test/e2e/note.ts @@ -3,16 +3,18 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import type { Repository } from "typeorm"; + process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { MiNote } from '@/models/Note.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { api, initTestDb, post, role, signup, uploadFile, uploadUrl } from '../utils.js'; +import { api, castAsError, initTestDb, post, role, signup, uploadFile, uploadUrl } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Note', () => { - let Notes: any; + let Notes: Repository; let root: misskey.entities.SignupResponse; let alice: misskey.entities.SignupResponse; @@ -41,7 +43,7 @@ describe('Note', () => { }); test('ファイルを添付できる', async () => { - const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg'); const res = await api('notes/create', { fileIds: [file.id], @@ -53,7 +55,7 @@ describe('Note', () => { }, 1000 * 10); test('他人のファイルで怒られる', async () => { - const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg'); const res = await api('notes/create', { text: 'test', @@ -61,8 +63,8 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); - assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE'); + assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); }, 1000 * 10); test('存在しないファイルで怒られる', async () => { @@ -72,8 +74,8 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); - assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE'); + assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); }); test('不正なファイルIDで怒られる', async () => { @@ -81,8 +83,8 @@ describe('Note', () => { fileIds: ['kyoppie'], }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_FILE'); - assert.strictEqual(res.body.error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_FILE'); + assert.strictEqual(castAsError(res.body).error.id, 'b6992544-63e7-67f0-fa7f-32444b1b5306'); }); test('返信できる', async () => { @@ -101,6 +103,7 @@ describe('Note', () => { assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.replyId, alicePost.replyId); + assert.ok(res.body.createdNote.reply); assert.strictEqual(res.body.createdNote.reply.text, bobPost.text); }); @@ -118,6 +121,7 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); + assert.ok(res.body.createdNote.renote); assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); }); @@ -137,6 +141,7 @@ describe('Note', () => { assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.text, alicePost.text); assert.strictEqual(res.body.createdNote.renoteId, alicePost.renoteId); + assert.ok(res.body.createdNote.renote); assert.strictEqual(res.body.createdNote.renote.text, bobPost.text); }); @@ -218,7 +223,7 @@ describe('Note', () => { }, bob); assert.strictEqual(bobReply.status, 400); - assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE'); + assert.strictEqual(castAsError(bobReply.body).error.code, 'CANNOT_REPLY_TO_AN_INVISIBLE_NOTE'); }); test('visibility: specifiedなノートに対してvisibility: specifiedで返信できる', async () => { @@ -256,7 +261,7 @@ describe('Note', () => { }, bob); assert.strictEqual(bobReply.status, 400); - assert.strictEqual(bobReply.body.error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY'); + assert.strictEqual(castAsError(bobReply.body).error.code, 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY'); }); test('文字数ぎりぎりで怒られない', async () => { @@ -333,6 +338,7 @@ describe('Note', () => { assert.strictEqual(res.body.createdNote.text, post.text); const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); + assert.ok(noteDoc); assert.deepStrictEqual(noteDoc.mentions, [bob.id]); }); @@ -345,6 +351,7 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.ok(res.body.createdNote.files); assert.strictEqual(res.body.createdNote.files.length, 1); assert.strictEqual(res.body.createdNote.files[0].id, file.body!.id); }); @@ -363,8 +370,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - const myNote = res.body.find((note: { id: string; files: { id: string }[] }) => note.id === createdNote.body.createdNote.id); - assert.notEqual(myNote, null); + const myNote = res.body.find(note => note.id === createdNote.body.createdNote.id); + assert.ok(myNote); + assert.ok(myNote.files); assert.strictEqual(myNote.files.length, 1); assert.strictEqual(myNote.files[0].id, file.body!.id); }); @@ -389,7 +397,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); - assert.notEqual(myNote, null); + assert.ok(myNote); + assert.ok(myNote.renote); + assert.ok(myNote.renote.files); assert.strictEqual(myNote.renote.files.length, 1); assert.strictEqual(myNote.renote.files[0].id, file.body!.id); }); @@ -415,7 +425,9 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); const myNote = res.body.find((note: { id: string }) => note.id === reply.body.createdNote.id); - assert.notEqual(myNote, null); + assert.ok(myNote); + assert.ok(myNote.reply); + assert.ok(myNote.reply.files); assert.strictEqual(myNote.reply.files.length, 1); assert.strictEqual(myNote.reply.files[0].id, file.body!.id); }); @@ -446,7 +458,10 @@ describe('Note', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); const myNote = res.body.find((note: { id: string }) => note.id === renoted.body.createdNote.id); - assert.notEqual(myNote, null); + assert.ok(myNote); + assert.ok(myNote.renote); + assert.ok(myNote.renote.reply); + assert.ok(myNote.renote.reply.files); assert.strictEqual(myNote.renote.reply.files.length, 1); assert.strictEqual(myNote.renote.reply.files[0].id, file.body!.id); }); @@ -474,7 +489,7 @@ describe('Note', () => { priority: 0, value: true, }, - } as any, + }, }, root); assert.strictEqual(res.status, 200); @@ -498,7 +513,7 @@ describe('Note', () => { }, alice); assert.strictEqual(liftnsfw.status, 400); - assert.strictEqual(liftnsfw.body.error.code, 'RESTRICTED_BY_ROLE'); + assert.strictEqual(castAsError(liftnsfw.body).error.code, 'RESTRICTED_BY_ROLE'); const oldaddnsfw = await api('drive/files/update', { fileId: file.body!.id, @@ -710,7 +725,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note1.status, 400); - assert.strictEqual(note1.body.error.code, 'CONTAINS_PROHIBITED_WORDS'); + assert.strictEqual(castAsError(note1.body).error.code, 'CONTAINS_PROHIBITED_WORDS'); }); test('禁止ワードを含む投稿はエラーになる (正規表現)', async () => { @@ -727,7 +742,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note2.status, 400); - assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS'); + assert.strictEqual(castAsError(note2.body).error.code, 'CONTAINS_PROHIBITED_WORDS'); }); test('禁止ワードを含む投稿はエラーになる (スペースアンド)', async () => { @@ -744,7 +759,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note2.status, 400); - assert.strictEqual(note2.body.error.code, 'CONTAINS_PROHIBITED_WORDS'); + assert.strictEqual(castAsError(note2.body).error.code, 'CONTAINS_PROHIBITED_WORDS'); }); test('禁止ワードを含んでるリモートノートもエラーになる', async () => { @@ -786,7 +801,7 @@ describe('Note', () => { priority: 1, value: 0, }, - } as any, + }, }, root); assert.strictEqual(res.status, 200); @@ -807,7 +822,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note.status, 400); - assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS'); + assert.strictEqual(castAsError(note.body).error.code, 'CONTAINS_TOO_MANY_MENTIONS'); await api('admin/roles/unassign', { userId: alice.id, @@ -840,7 +855,7 @@ describe('Note', () => { priority: 1, value: 0, }, - } as any, + }, }, root); assert.strictEqual(res.status, 200); @@ -863,7 +878,7 @@ describe('Note', () => { }, alice); assert.strictEqual(note.status, 400); - assert.strictEqual(note.body.error.code, 'CONTAINS_TOO_MANY_MENTIONS'); + assert.strictEqual(castAsError(note.body).error.code, 'CONTAINS_TOO_MANY_MENTIONS'); await api('admin/roles/unassign', { userId: alice.id, @@ -896,7 +911,7 @@ describe('Note', () => { priority: 1, value: 1, }, - } as any, + }, }, root); assert.strictEqual(res.status, 200); @@ -951,6 +966,7 @@ describe('Note', () => { assert.strictEqual(deleteOneRes.status, 204); let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); + assert.ok(mainNote); assert.strictEqual(mainNote.repliesCount, 1); const deleteTwoRes = await api('notes/delete', { @@ -959,6 +975,7 @@ describe('Note', () => { assert.strictEqual(deleteTwoRes.status, 204); mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); + assert.ok(mainNote); assert.strictEqual(mainNote.repliesCount, 0); }); }); @@ -980,7 +997,7 @@ describe('Note', () => { }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); + assert.strictEqual(castAsError(res.body).error.code, 'UNAVAILABLE'); }); afterAll(async () => { @@ -992,7 +1009,7 @@ describe('Note', () => { const res = await api('notes/translate', { noteId: 'foo', targetLang: 'ja' }, alice); assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'NO_SUCH_NOTE'); + assert.strictEqual(castAsError(res.body).error.code, 'NO_SUCH_NOTE'); }); test('不可視なノートは翻訳できない', async () => { @@ -1000,7 +1017,7 @@ describe('Note', () => { const bobTranslateAttempt = await api('notes/translate', { noteId: aliceNote.id, targetLang: 'ja' }, bob); assert.strictEqual(bobTranslateAttempt.status, 400); - assert.strictEqual(bobTranslateAttempt.body.error.code, 'CANNOT_TRANSLATE_INVISIBLE_NOTE'); + assert.strictEqual(castAsError(bobTranslateAttempt.body).error.code, 'CANNOT_TRANSLATE_INVISIBLE_NOTE'); }); test('text: null なノートを翻訳すると空のレスポンスが返ってくる', async () => { @@ -1016,7 +1033,7 @@ describe('Note', () => { // NOTE: デフォルトでは登録されていないので落ちる assert.strictEqual(res.status, 400); - assert.strictEqual(res.body.error.code, 'UNAVAILABLE'); + assert.strictEqual(castAsError(res.body).error.code, 'UNAVAILABLE'); }); }); }); diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts index 1abbb4f04494..0f636b9ae205 100644 --- a/packages/backend/test/e2e/renote-mute.ts +++ b/packages/backend/test/e2e/renote-mute.ts @@ -6,7 +6,8 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { api, post, signup, sleep, waitFire } from '../utils.js'; +import { setTimeout } from 'node:timers/promises'; +import { api, post, signup, waitFire } from '../utils.js'; import type * as misskey from 'misskey-js'; describe('Renote Mute', () => { @@ -35,15 +36,15 @@ describe('Renote Mute', () => { const carolNote = await post(carol, { text: 'hi' }); // redisに追加されるのを待つ - await sleep(100); + await setTimeout(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 === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { @@ -52,15 +53,15 @@ describe('Renote Mute', () => { const carolNote = await post(carol, { text: 'hi' }); // redisに追加されるのを待つ - await sleep(100); + await setTimeout(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 === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolRenote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); // #12956 @@ -69,14 +70,14 @@ describe('Renote Mute', () => { const bobRenote = await post(bob, { renoteId: carolNote.id }); // redisに追加されるのを待つ - await sleep(100); + await setTimeout(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); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobRenote.id), true); }); test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { diff --git a/packages/backend/test/e2e/reversi-game.ts b/packages/backend/test/e2e/reversi-game.ts new file mode 100644 index 000000000000..788255beac12 --- /dev/null +++ b/packages/backend/test/e2e/reversi-game.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { ReversiMatchResponse } from 'misskey-js/entities.js'; +import { api, signup } from '../utils.js'; +import type * as misskey from 'misskey-js'; + +describe('ReversiGame', () => { + let alice: misskey.entities.SignupResponse; + let bob: misskey.entities.SignupResponse; + + beforeAll(async () => { + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + }, 1000 * 60 * 2); + + test('matches when alice invites bob and bob accepts', async () => { + const response1 = await api('reversi/match', { userId: bob.id }, alice); + assert.strictEqual(response1.status, 204); + assert.strictEqual(response1.body, null); + const response2 = await api('reversi/match', { userId: alice.id }, bob); + assert.strictEqual(response2.status, 200); + assert.notStrictEqual(response2.body, null); + const body = response2.body as ReversiMatchResponse; + assert.strictEqual(body.user1.id, alice.id); + assert.strictEqual(body.user2.id, bob.id); + }); +}); diff --git a/packages/backend/test/e2e/synalio/abuse-report.ts b/packages/backend/test/e2e/synalio/abuse-report.ts new file mode 100644 index 000000000000..b0cc3d13ecb4 --- /dev/null +++ b/packages/backend/test/e2e/synalio/abuse-report.ts @@ -0,0 +1,401 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { entities } from 'misskey-js'; +import { beforeEach, describe, test } from '@jest/globals'; +import Fastify from 'fastify'; +import { api, randomString, role, signup, startJobQueue, UserToken } from '../../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +const WEBHOOK_HOST = 'http://localhost:15080'; +const WEBHOOK_PORT = 15080; +process.env.NODE_ENV = 'test'; + +describe('[シナリオ] ユーザ通報', () => { + let queue: INestApplicationContext; + let admin: entities.SignupResponse; + let alice: entities.SignupResponse; + let bob: entities.SignupResponse; + + type SystemWebhookPayload = { + server: string; + hookId: string; + eventId: string; + createdAt: string; + type: string; + body: any; + } + + // ------------------------------------------------------------------------------------------- + + async function captureWebhook(postAction: () => Promise): Promise { + const fastify = Fastify(); + + let timeoutHandle: NodeJS.Timeout | null = null; + const result = await new Promise(async (resolve, reject) => { + fastify.all('/', async (req, res) => { + timeoutHandle && clearTimeout(timeoutHandle); + + const body = JSON.stringify(req.body); + res.status(200).send('ok'); + await fastify.close(); + resolve(body); + }); + + await fastify.listen({ port: WEBHOOK_PORT }); + + timeoutHandle = setTimeout(async () => { + await fastify.close(); + reject(new Error('timeout')); + }, 3000); + + try { + await postAction(); + } catch (e) { + await fastify.close(); + reject(e); + } + }); + + await fastify.close(); + + return JSON.parse(result) as T; + } + + async function createSystemWebhook(args?: Partial, credential?: UserToken): Promise { + const res = await api( + 'admin/system-webhook/create', + { + isActive: true, + name: randomString(), + on: ['abuseReport'], + url: WEBHOOK_HOST, + secret: randomString(), + ...args, + }, + credential ?? admin, + ); + return res.body; + } + + async function createAbuseReportNotificationRecipient(args?: Partial, credential?: UserToken): Promise { + const res = await api( + 'admin/abuse-report/notification-recipient/create', + { + isActive: true, + name: randomString(), + method: 'webhook', + ...args, + }, + credential ?? admin, + ); + return res.body; + } + + async function createAbuseReport(args?: Partial, credential?: UserToken): Promise { + const res = await api( + 'users/report-abuse', + { + userId: alice.id, + comment: randomString(), + ...args, + }, + credential ?? admin, + ); + return res.body; + } + + async function resolveAbuseReport(args?: Partial, credential?: UserToken): Promise { + const res = await api( + 'admin/resolve-abuse-user-report', + { + reportId: admin.id, + ...args, + }, + credential ?? admin, + ); + return res.body; + } + + // ------------------------------------------------------------------------------------------- + + beforeAll(async () => { + queue = await startJobQueue(); + admin = await signup({ username: 'admin' }); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + + await role(admin, { isAdministrator: true }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await queue.close(); + }); + + // ------------------------------------------------------------------------------------------- + + describe('SystemWebhook', () => { + beforeEach(async () => { + const webhooks = await api('admin/system-webhook/list', {}, admin); + for (const webhook of webhooks.body) { + await api('admin/system-webhook/delete', { id: webhook.id }, admin); + } + }); + + test('通報を受けた -> abuseReportが送出される', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }); + + console.log(JSON.stringify(webhookBody, null, 2)); + + expect(webhookBody.hookId).toBe(webhook.id); + expect(webhookBody.type).toBe('abuseReport'); + expect(webhookBody.body.targetUserId).toBe(alice.id); + expect(webhookBody.body.reporterId).toBe(bob.id); + expect(webhookBody.body.comment).toBe(abuse.comment); + }); + + test('通報を受けた -> abuseReportが送出される -> 解決 -> abuseReportResolvedが送出される', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport', 'abuseReportResolved'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }); + + console.log(JSON.stringify(webhookBody1, null, 2)); + expect(webhookBody1.hookId).toBe(webhook.id); + expect(webhookBody1.type).toBe('abuseReport'); + expect(webhookBody1.body.targetUserId).toBe(alice.id); + expect(webhookBody1.body.reporterId).toBe(bob.id); + expect(webhookBody1.body.assigneeId).toBeNull(); + expect(webhookBody1.body.resolved).toBe(false); + expect(webhookBody1.body.comment).toBe(abuse.comment); + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: webhookBody1.body.id, + forward: false, + }, admin); + }); + + console.log(JSON.stringify(webhookBody2, null, 2)); + expect(webhookBody2.hookId).toBe(webhook.id); + expect(webhookBody2.type).toBe('abuseReportResolved'); + expect(webhookBody2.body.targetUserId).toBe(alice.id); + expect(webhookBody2.body.reporterId).toBe(bob.id); + expect(webhookBody2.body.assigneeId).toBe(admin.id); + expect(webhookBody2.body.resolved).toBe(true); + expect(webhookBody2.body.comment).toBe(abuse.comment); + }); + + test('通報を受けた -> abuseReportが未許可の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: [], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody).toBe('timeout'); + }); + + test('通報を受けた -> abuseReportが未許可の場合は送出されない -> 解決 -> abuseReportResolvedが送出される', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReportResolved'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody1).toBe('timeout'); + + const abuseReportId = (await api('admin/abuse-user-reports', {}, admin)).body[0].id; + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: abuseReportId, + forward: false, + }, admin); + }); + + console.log(JSON.stringify(webhookBody2, null, 2)); + expect(webhookBody2.hookId).toBe(webhook.id); + expect(webhookBody2.type).toBe('abuseReportResolved'); + expect(webhookBody2.body.targetUserId).toBe(alice.id); + expect(webhookBody2.body.reporterId).toBe(bob.id); + expect(webhookBody2.body.assigneeId).toBe(admin.id); + expect(webhookBody2.body.resolved).toBe(true); + expect(webhookBody2.body.comment).toBe(abuse.comment); + }); + + test('通報を受けた -> abuseReportが送出される -> 解決 -> abuseReportResolvedが未許可の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }); + + console.log(JSON.stringify(webhookBody1, null, 2)); + expect(webhookBody1.hookId).toBe(webhook.id); + expect(webhookBody1.type).toBe('abuseReport'); + expect(webhookBody1.body.targetUserId).toBe(alice.id); + expect(webhookBody1.body.reporterId).toBe(bob.id); + expect(webhookBody1.body.assigneeId).toBeNull(); + expect(webhookBody1.body.resolved).toBe(false); + expect(webhookBody1.body.comment).toBe(abuse.comment); + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: webhookBody1.body.id, + forward: false, + }, admin); + }).catch(e => e.message); + + expect(webhookBody2).toBe('timeout'); + }); + + test('通報を受けた -> abuseReportが未許可の場合は送出されない -> 解決 -> abuseReportResolvedが未許可の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: [], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody1).toBe('timeout'); + + const abuseReportId = (await api('admin/abuse-user-reports', {}, admin)).body[0].id; + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: abuseReportId, + forward: false, + }, admin); + }).catch(e => e.message); + + expect(webhookBody2).toBe('timeout'); + }); + + test('通報を受けた -> Webhookが無効の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport', 'abuseReportResolved'], + isActive: false, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody1).toBe('timeout'); + + const abuseReportId = (await api('admin/abuse-user-reports', {}, admin)).body[0].id; + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: abuseReportId, + forward: false, + }, admin); + }).catch(e => e.message); + + expect(webhookBody2).toBe('timeout'); + }); + + test('通報を受けた -> 通知設定が無効の場合は送出されない', async () => { + const webhook = await createSystemWebhook({ + on: ['abuseReport', 'abuseReportResolved'], + isActive: true, + }); + await createAbuseReportNotificationRecipient({ systemWebhookId: webhook.id, isActive: false }); + + // 通報(bob -> alice) + const abuse = { + userId: alice.id, + comment: randomString(), + }; + const webhookBody1 = await captureWebhook(async () => { + await createAbuseReport(abuse, bob); + }).catch(e => e.message); + + expect(webhookBody1).toBe('timeout'); + + const abuseReportId = (await api('admin/abuse-user-reports', {}, admin)).body[0].id; + + // 解決 + const webhookBody2 = await captureWebhook(async () => { + await resolveAbuseReport({ + reportId: abuseReportId, + forward: false, + }, admin); + }).catch(e => e.message); + + expect(webhookBody2).toBe('timeout'); + }); + }); +}); diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 53bb6eb765a6..1ac99df884fe 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -33,9 +33,9 @@ describe('Note thread mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolReply.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolReplyWithoutMention.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolReply.id), false); + assert.strictEqual(res.body.some(note => note.id === carolReplyWithoutMention.id), false); }); test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { @@ -93,8 +93,8 @@ describe('Note thread mute', () => { assert.strictEqual(res.status, 200); assert.strictEqual(Array.isArray(res.body), true); - assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReply.id), false); - assert.strictEqual(res.body.some((notification: any) => notification.note.id === carolReplyWithoutMention.id), false); + assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReply.id), false); + assert.strictEqual(res.body.some(notification => 'note' in notification && notification.note.id === carolReplyWithoutMention.id), false); // NOTE: bobの投稿はスレッドミュート前に行われたため通知に含まれていてもよい }); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 5487292afcd5..540b866b2816 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -7,17 +7,26 @@ // pnpm jest -- e2e/timelines.ts import * as assert from 'assert'; -import { api, post, randomString, sendEnvUpdateRequest, signup, sleep, uploadUrl } from '../utils.js'; +import { setTimeout } from 'node:timers/promises'; +import { Redis } from 'ioredis'; +import { loadConfig } from '@/config.js'; +import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl } from '../utils.js'; function genHost() { return randomString() + '.example.com'; } function waitForPushToTl() { - return sleep(500); + return setTimeout(500); } +let redisForTimelines: Redis; + describe('Timelines', () => { + beforeAll(() => { + redisForTimelines = new Redis(loadConfig().redisForTimelines); + }); + describe('Home TL', () => { test.concurrent('自分の visibility: followers なノートが含まれる', async () => { const [alice] = await Promise.all([signup()]); @@ -28,15 +37,15 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); test.concurrent('フォローしているユーザーのノートが含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi' }); const carolNote = await post(carol, { text: 'hi' }); @@ -44,15 +53,15 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); const carolNote = await post(carol, { text: 'hi' }); @@ -60,16 +69,16 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); @@ -77,8 +86,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { @@ -86,7 +95,7 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); await api('following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); @@ -94,8 +103,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { @@ -103,7 +112,7 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); await api('following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); @@ -111,8 +120,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { @@ -120,7 +129,7 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); await api('following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); @@ -128,8 +137,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { @@ -139,7 +148,7 @@ describe('Timelines', () => { await api('following/create', { userId: carol.id }, alice); await api('following/create', { userId: carol.id }, bob); await api('following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi', visibility: 'followers' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); @@ -147,9 +156,9 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'hi'); }); test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { @@ -158,7 +167,7 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); await api('following/create', { userId: carol.id }, alice); await api('following/update', { userId: bob.id, withReplies: true }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); @@ -166,15 +175,15 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote1 = await post(bob, { text: 'hi' }); const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); @@ -182,15 +191,15 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const aliceNote = await post(alice, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); @@ -198,8 +207,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('自分の他人への返信が含まれる', async () => { @@ -212,15 +221,15 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); }); test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { renoteId: carolNote.id }); @@ -228,15 +237,15 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { renoteId: carolNote.id }); @@ -246,15 +255,15 @@ describe('Timelines', () => { withRenotes: false, }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); @@ -264,22 +273,22 @@ describe('Timelines', () => { withRenotes: false, }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); await waitForPushToTl(); const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { @@ -287,7 +296,7 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); await api('mute/create', { userId: carol.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); @@ -295,8 +304,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { @@ -305,7 +314,7 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); await api('following/update', { userId: bob.id, withReplies: true }, alice); await api('mute/create', { userId: carol.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); @@ -313,8 +322,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { @@ -329,7 +338,7 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { @@ -344,14 +353,14 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const [bobFile, carolFile] = await Promise.all([ uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), uploadUrl(carol, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'), @@ -365,10 +374,10 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote2.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); }, 1000 * 10); test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { @@ -376,14 +385,14 @@ describe('Timelines', () => { const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); await waitForPushToTl(); const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('自分の visibility: specified なノートが含まれる', async () => { @@ -395,23 +404,23 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); await waitForPushToTl(); const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => { @@ -423,21 +432,21 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); await waitForPushToTl(); const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { @@ -450,8 +459,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'ok'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'ok'); }); /* TODO @@ -465,8 +474,8 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'ok'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id).text, 'ok'); }); */ @@ -481,7 +490,7 @@ describe('Timelines', () => { const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); }); @@ -496,8 +505,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('他人の他人への返信が含まれない', async () => { @@ -510,8 +519,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); test.concurrent('他人のその人自身への返信が含まれる', async () => { @@ -524,8 +533,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); test.concurrent('チャンネル投稿が含まれない', async () => { @@ -538,7 +547,7 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('リモートユーザーのノートが含まれない', async () => { @@ -550,7 +559,7 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); // 含まれても良いと思うけど実装が面倒なので含まれない @@ -558,7 +567,7 @@ describe('Timelines', () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: carol.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); const bobNote = await post(bob, { text: 'hi' }); @@ -566,15 +575,15 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('ミュートしているユーザーのノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('mute/create', { userId: carol.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi' }); @@ -582,8 +591,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { @@ -591,7 +600,7 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); await api('mute/create', { userId: carol.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); @@ -599,8 +608,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { @@ -609,7 +618,7 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); await api('following/update', { userId: bob.id, withReplies: true }, alice); await api('mute/create', { userId: carol.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); @@ -617,15 +626,15 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const aliceNote = await post(alice, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); @@ -633,8 +642,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { @@ -647,7 +656,7 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { @@ -661,8 +670,8 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); }); @@ -676,7 +685,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => { @@ -688,28 +697,28 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); await waitForPushToTl(); const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const aliceNote = await post(alice, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); @@ -717,8 +726,8 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('他人の他人への返信が含まれない', async () => { @@ -731,8 +740,8 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); test.concurrent('リモートユーザーのノートが含まれない', async () => { @@ -744,7 +753,7 @@ describe('Timelines', () => { const res = await api('notes/local-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { @@ -759,7 +768,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { @@ -774,7 +783,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { @@ -787,7 +796,7 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { @@ -801,8 +810,8 @@ describe('Timelines', () => { const res = await api('notes/hybrid-timeline', { limit: 100, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); }); @@ -812,14 +821,14 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi' }); await waitForPushToTl(); const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { @@ -827,14 +836,14 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); await waitForPushToTl(); const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { @@ -842,14 +851,14 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); await waitForPushToTl(); const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { @@ -857,7 +866,7 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); @@ -865,7 +874,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { @@ -873,7 +882,7 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote1 = await post(bob, { text: 'hi' }); const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); @@ -881,8 +890,8 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); test.concurrent('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { @@ -891,7 +900,7 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); await api('users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: false }, alice); - await sleep(1000); + await setTimeout(1000); const aliceNote = await post(alice, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); @@ -899,7 +908,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('withReplies: false でリスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { @@ -908,7 +917,7 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); await api('users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: false }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); @@ -916,7 +925,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { @@ -925,7 +934,7 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); await api('users/lists/update-membership', { listId: list.id, userId: bob.id, withReplies: true }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id }); @@ -933,7 +942,7 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { @@ -942,14 +951,14 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); await waitForPushToTl(); const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { @@ -958,15 +967,15 @@ describe('Timelines', () => { await api('following/create', { userId: bob.id }, alice); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); await waitForPushToTl(); const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => { @@ -974,15 +983,15 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: alice.id }, alice); - await sleep(1000); + await setTimeout(1000); const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); await waitForPushToTl(); const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => { @@ -991,14 +1000,14 @@ describe('Timelines', () => { const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', channelId: channel.id }); await waitForPushToTl(); const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { @@ -1014,8 +1023,8 @@ describe('Timelines', () => { const res = await api('notes/user-list-timeline', { listId: list.id, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { @@ -1023,15 +1032,15 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); await waitForPushToTl(); const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); test.concurrent('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => { @@ -1040,14 +1049,14 @@ describe('Timelines', () => { const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] }); await waitForPushToTl(); const res = await api('notes/user-list-timeline', { listId: list.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); }); @@ -1061,7 +1070,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => { @@ -1073,22 +1082,22 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); await waitForPushToTl(); const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); + assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); test.concurrent('自身の visibility: followers なノートが含まれる', async () => { @@ -1100,8 +1109,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: alice.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); - assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi'); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); + assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); test.concurrent('チャンネル投稿が含まれない', async () => { @@ -1114,7 +1123,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('[withReplies: false] 他人への返信が含まれない', async () => { @@ -1128,8 +1137,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); }); test.concurrent('[withReplies: true] 他人への返信が含まれる', async () => { @@ -1143,8 +1152,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); test.concurrent('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { @@ -1158,8 +1167,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); }); test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { @@ -1173,8 +1182,8 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withFiles: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { @@ -1187,7 +1196,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => { @@ -1200,7 +1209,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => { @@ -1213,14 +1222,14 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, bob); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); test.concurrent('ミュートしているユーザーに関連する投稿が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('mute/create', { userId: carol.id }, alice); - await sleep(1000); + await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { text: 'hi', renoteId: carolNote.id }); @@ -1228,14 +1237,14 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('mute/create', { userId: bob.id }, alice); - await sleep(1000); + await setTimeout(1000); const bobNote1 = await post(bob, { text: 'hi' }); const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id }); const bobNote3 = await post(bob, { text: 'hi', renoteId: bobNote1.id }); @@ -1244,9 +1253,9 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote1.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); + assert.strictEqual(res.body.some(note => note.id === bobNote3.id), true); }); test.concurrent('自身の visibility: specified なノートが含まれる', async () => { @@ -1258,7 +1267,7 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: alice.id, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); }); test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => { @@ -1270,7 +1279,34 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: bob.id, withReplies: true }, alice); - assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + }); + + /** @see https://github.com/misskey-dev/misskey/issues/14000 */ + test.concurrent('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId による絞り込みが正しく動作する', async () => { + const alice = await signup(); + const noteSince = await post(alice, { text: 'Note where id will be `sinceId`.' }); + const note1 = await post(alice, { text: '1' }); + const note2 = await post(alice, { text: '2' }); + await redisForTimelines.del('list:userTimeline:' + alice.id); + const note3 = await post(alice, { text: '3' }); + + const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id }); + assert.deepStrictEqual(res.body, [note1, note2, note3]); + }); + + test.concurrent('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId と untilId による絞り込みが正しく動作する', async () => { + const alice = await signup(); + const noteSince = await post(alice, { text: 'Note where id will be `sinceId`.' }); + const note1 = await post(alice, { text: '1' }); + const note2 = await post(alice, { text: '2' }); + await redisForTimelines.del('list:userTimeline:' + alice.id); + const note3 = await post(alice, { text: '3' }); + const noteUntil = await post(alice, { text: 'Note where id will be `untilId`.' }); + await post(alice, { text: '4' }); + + const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id, untilId: noteUntil.id }); + assert.deepStrictEqual(res.body, [note3, note2, note1]); }); }); diff --git a/packages/backend/test/e2e/user-notes.ts b/packages/backend/test/e2e/user-notes.ts index 331e053935f8..cc07c5ae71ac 100644 --- a/packages/backend/test/e2e/user-notes.ts +++ b/packages/backend/test/e2e/user-notes.ts @@ -17,8 +17,8 @@ describe('users/notes', () => { beforeAll(async () => { alice = await signup({ username: 'alice' }); - const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); - const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png'); + const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.jpg'); + const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/192.png'); jpgNote = await post(alice, { fileIds: [jpg.id], }); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 3458e06384f2..61fd7599322a 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -231,7 +231,7 @@ describe('ユーザー', () => { rolePublic = await role(root, { isPublic: true, name: 'Public Role' }); await api('admin/roles/assign', { userId: userRolePublic.id, roleId: rolePublic.id }, root); userRoleBadge = await signup({ username: 'userRoleBadge' }); - roleBadge = await role(root, { asBadge: true, name: 'Badge Role' }); + roleBadge = await role(root, { asBadge: true, name: 'Badge Role', isPublic: true }); await api('admin/roles/assign', { userId: userRoleBadge.id, roleId: roleBadge.id }, root); userSilenced = await signup({ username: 'userSilenced' }); await post(userSilenced, { text: 'test' }); @@ -655,7 +655,16 @@ describe('ユーザー', () => { iconUrl: roleBadge.iconUrl, displayOrder: roleBadge.displayOrder, }]); - assert.deepStrictEqual(response.roles, []); // バッヂだからといってrolesが取れるとは限らない + assert.deepStrictEqual(response.roles, [{ + id: roleBadge.id, + name: roleBadge.name, + color: roleBadge.color, + iconUrl: roleBadge.iconUrl, + description: roleBadge.description, + isModerator: roleBadge.isModerator, + isAdministrator: roleBadge.isAdministrator, + displayOrder: roleBadge.displayOrder, + }]); }); test('をID指定のリスト形式で取得することができる(空)', async () => { const parameters = { userIds: [] }; diff --git a/packages/backend/test/eslint.config.js b/packages/backend/test/eslint.config.js new file mode 100644 index 000000000000..a0f43babadfb --- /dev/null +++ b/packages/backend/test/eslint.config.js @@ -0,0 +1,22 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../shared/eslint.config.js'; + +export default [ + ...sharedConfig, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + globals: { + ...globals.node, + ...globals.jest, + }, + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/backend/test/prelude/maybe.ts b/packages/backend/test/prelude/maybe.ts deleted file mode 100644 index 16e92216d483..000000000000 --- a/packages/backend/test/prelude/maybe.ts +++ /dev/null @@ -1,23 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import * as assert from 'assert'; -import { just, nothing } from '../../src/misc/prelude/maybe.js'; - -describe('just', () => { - test('has a value', () => { - assert.deepStrictEqual(just(3).isJust(), true); - }); - - test('has the inverse called get', () => { - assert.deepStrictEqual(just(3).get(), 3); - }); -}); - -describe('nothing', () => { - test('has no value', () => { - assert.deepStrictEqual(nothing().isJust(), false); - }); -}); diff --git a/packages/backend/test/resources/192.jpg b/packages/backend/test/resources/192.jpg new file mode 100644 index 000000000000..76374628e0ac Binary files /dev/null and b/packages/backend/test/resources/192.jpg differ diff --git a/packages/backend/test/resources/192.png b/packages/backend/test/resources/192.png new file mode 100644 index 000000000000..15fd1e373121 Binary files /dev/null and b/packages/backend/test/resources/192.png differ diff --git a/packages/backend/test/resources/Lenna.jpg b/packages/backend/test/resources/Lenna.jpg deleted file mode 100644 index 6b5b32281c1f..000000000000 Binary files a/packages/backend/test/resources/Lenna.jpg and /dev/null differ diff --git a/packages/backend/test/resources/Lenna.png b/packages/backend/test/resources/Lenna.png deleted file mode 100644 index 59ef68aabd03..000000000000 Binary files a/packages/backend/test/resources/Lenna.png and /dev/null differ diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts new file mode 100644 index 000000000000..e971659070ea --- /dev/null +++ b/packages/backend/test/unit/AbuseReportNotificationService.ts @@ -0,0 +1,343 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; +import { + AbuseReportNotificationRecipientRepository, + MiAbuseReportNotificationRecipient, + MiSystemWebhook, + MiUser, + SystemWebhooksRepository, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { IdService } from '@/core/IdService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { randomString } from '../utils.js'; + +process.env.NODE_ENV = 'test'; + +describe('AbuseReportNotificationService', () => { + let app: TestingModule; + let service: AbuseReportNotificationService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let systemWebhooksRepository: SystemWebhooksRepository; + let abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository; + let idService: IdService; + let roleService: jest.Mocked; + let emailService: jest.Mocked; + let webhookService: jest.Mocked; + + // -------------------------------------------------------------------------------------- + + let root: MiUser; + let alice: MiUser; + let bob: MiUser; + let systemWebhook1: MiSystemWebhook; + let systemWebhook2: MiSystemWebhook; + + // -------------------------------------------------------------------------------------- + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createWebhook(data: Partial = {}) { + return systemWebhooksRepository + .insert({ + id: idService.gen(), + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + ...data, + }) + .then(x => systemWebhooksRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createRecipient(data: Partial = {}) { + return abuseReportNotificationRecipientRepository + .insert({ + id: idService.gen(), + isActive: true, + name: randomString(), + ...data, + }) + .then(x => abuseReportNotificationRecipientRepository.findOneByOrFail(x.identifiers[0])); + } + + // -------------------------------------------------------------------------------------- + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + AbuseReportNotificationService, + IdService, + { + provide: RoleService, useFactory: () => ({ getModeratorIds: jest.fn() }), + }, + { + provide: SystemWebhookService, useFactory: () => ({ enqueueSystemWebhook: jest.fn() }), + }, + { + provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }), + }, + { + provide: MetaService, useFactory: () => ({ fetch: jest.fn() }), + }, + { + provide: ModerationLogService, useFactory: () => ({ log: () => Promise.resolve() }), + }, + { + provide: GlobalEventService, useFactory: () => ({ publishAdminStream: jest.fn() }), + }, + ], + }) + .compile(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + systemWebhooksRepository = app.get(DI.systemWebhooksRepository); + abuseReportNotificationRecipientRepository = app.get(DI.abuseReportNotificationRecipientRepository); + + service = app.get(AbuseReportNotificationService); + idService = app.get(IdService); + roleService = app.get(RoleService) as jest.Mocked; + emailService = app.get(EmailService) as jest.Mocked; + webhookService = app.get(SystemWebhookService) as jest.Mocked; + + app.enableShutdownHooks(); + }); + + beforeEach(async () => { + root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); + alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false }); + bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false }); + systemWebhook1 = await createWebhook(); + systemWebhook2 = await createWebhook(); + + roleService.getModeratorIds.mockResolvedValue([root.id, alice.id, bob.id]); + }); + + afterEach(async () => { + emailService.sendEmail.mockClear(); + webhookService.enqueueSystemWebhook.mockClear(); + + await usersRepository.delete({}); + await userProfilesRepository.delete({}); + await systemWebhooksRepository.delete({}); + await abuseReportNotificationRecipientRepository.delete({}); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('createRecipient', () => { + test('作成成功1', async () => { + const params = { + isActive: true, + name: randomString(), + method: 'email' as RecipientMethod, + userId: alice.id, + systemWebhookId: null, + }; + + const recipient1 = await service.createRecipient(params, root); + expect(recipient1).toMatchObject(params); + }); + + test('作成成功2', async () => { + const params = { + isActive: true, + name: randomString(), + method: 'webhook' as RecipientMethod, + userId: null, + systemWebhookId: systemWebhook1.id, + }; + + const recipient1 = await service.createRecipient(params, root); + expect(recipient1).toMatchObject(params); + }); + }); + + describe('updateRecipient', () => { + test('更新成功1', async () => { + const recipient1 = await createRecipient({ + method: 'email', + userId: alice.id, + }); + + const params = { + id: recipient1.id, + isActive: false, + name: randomString(), + method: 'email' as RecipientMethod, + userId: bob.id, + systemWebhookId: null, + }; + + const recipient2 = await service.updateRecipient(params, root); + expect(recipient2).toMatchObject(params); + }); + + test('更新成功2', async () => { + const recipient1 = await createRecipient({ + method: 'webhook', + systemWebhookId: systemWebhook1.id, + }); + + const params = { + id: recipient1.id, + isActive: false, + name: randomString(), + method: 'webhook' as RecipientMethod, + userId: null, + systemWebhookId: systemWebhook2.id, + }; + + const recipient2 = await service.updateRecipient(params, root); + expect(recipient2).toMatchObject(params); + }); + }); + + describe('deleteRecipient', () => { + test('削除成功1', async () => { + const recipient1 = await createRecipient({ + method: 'email', + userId: alice.id, + }); + + await service.deleteRecipient(recipient1.id, root); + + await expect(abuseReportNotificationRecipientRepository.findOneBy({ id: recipient1.id })).resolves.toBeNull(); + }); + }); + + describe('fetchRecipients', () => { + async function create() { + const recipient1 = await createRecipient({ + method: 'email', + userId: alice.id, + }); + const recipient2 = await createRecipient({ + method: 'email', + userId: bob.id, + }); + + const recipient3 = await createRecipient({ + method: 'webhook', + systemWebhookId: systemWebhook1.id, + }); + const recipient4 = await createRecipient({ + method: 'webhook', + systemWebhookId: systemWebhook2.id, + }); + + return [recipient1, recipient2, recipient3, recipient4]; + } + + test('フィルタなし', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({}); + expect(recipients).toEqual([recipient1, recipient2, recipient3, recipient4]); + }); + + test('フィルタなし(非モデレータは除外される)', async () => { + roleService.getModeratorIds.mockClear(); + roleService.getModeratorIds.mockResolvedValue([root.id, bob.id]); + + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({}); + // aliceはモデレータではないので除外される + expect(recipients).toEqual([recipient2, recipient3, recipient4]); + }); + + test('フィルタなし(非モデレータでも除外されないオプション設定)', async () => { + roleService.getModeratorIds.mockClear(); + roleService.getModeratorIds.mockResolvedValue([root.id, bob.id]); + + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({}, { removeUnauthorized: false }); + expect(recipients).toEqual([recipient1, recipient2, recipient3, recipient4]); + }); + + test('emailのみ', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ method: ['email'] }); + expect(recipients).toEqual([recipient1, recipient2]); + }); + + test('webhookのみ', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ method: ['webhook'] }); + expect(recipients).toEqual([recipient3, recipient4]); + }); + + test('すべて', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ method: ['email', 'webhook'] }); + expect(recipients).toEqual([recipient1, recipient2, recipient3, recipient4]); + }); + + test('ID指定', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ ids: [recipient1.id, recipient3.id] }); + expect(recipients).toEqual([recipient1, recipient3]); + }); + + test('ID指定(method=emailではないIDが混ざりこまない)', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ ids: [recipient1.id, recipient3.id], method: ['email'] }); + expect(recipients).toEqual([recipient1]); + }); + + test('ID指定(method=webhookではないIDが混ざりこまない)', async () => { + const [recipient1, recipient2, recipient3, recipient4] = await create(); + + const recipients = await service.fetchRecipients({ ids: [recipient1.id, recipient3.id], method: ['webhook'] }); + expect(recipients).toEqual([recipient3]); + }); + }); +}); diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index aa082ff2f280..81da0fac3126 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -10,6 +10,7 @@ import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository, @@ -67,6 +68,7 @@ describe('AnnouncementService', () => { ], providers: [ AnnouncementService, + AnnouncementEntityService, CacheService, IdService, ], diff --git a/packages/backend/test/unit/ApMfmService.ts b/packages/backend/test/unit/ApMfmService.ts index 79cb81f5c9ff..e81a321c9be3 100644 --- a/packages/backend/test/unit/ApMfmService.ts +++ b/packages/backend/test/unit/ApMfmService.ts @@ -23,10 +23,10 @@ describe('ApMfmService', () => { describe('getNoteHtml', () => { test('Do not provide _misskey_content for simple text', () => { - const note: MiNote = { + const note = { text: 'テキスト #タグ @mention 🍊 :emoji: https://example.com', mentionedRemoteUsers: '[]', - } as any; + }; const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); @@ -35,10 +35,10 @@ describe('ApMfmService', () => { }); test('Provide _misskey_content for MFM', () => { - const note: MiNote = { + const note = { text: '$[tada foo]', mentionedRemoteUsers: '[]', - } as any; + }; const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index 40d187f5a882..29bd03a20121 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -12,7 +12,7 @@ import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import { afterAll, beforeAll, describe, test } from '@jest/globals'; import { GlobalModule } from '@/GlobalModule.js'; -import { FileInfoService } from '@/core/FileInfoService.js'; +import { FileInfo, FileInfoService } from '@/core/FileInfoService.js'; //import { DI } from '@/di-symbols.js'; import { AiService } from '@/core/AiService.js'; import { LoggerService } from '@/core/LoggerService.js'; @@ -28,6 +28,15 @@ const moduleMocker = new ModuleMocker(global); describe('FileInfoService', () => { let app: TestingModule; let fileInfoService: FileInfoService; + const strip = (fileInfo: FileInfo): Omit, 'warnings' | 'blurhash' | 'sensitive' | 'porn'> => { + const fi: Partial = fileInfo; + delete fi.warnings; + delete fi.sensitive; + delete fi.blurhash; + delete fi.porn; + + return fi; + } beforeAll(async () => { app = await Test.createTestingModule({ @@ -63,11 +72,7 @@ describe('FileInfoService', () => { test('Empty file', async () => { const path = `${resources}/emptyfile`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 0, md5: 'd41d8cd98f00b204e9800998ecf8427e', @@ -83,32 +88,24 @@ describe('FileInfoService', () => { describe('IMAGE', () => { test('Generic JPEG', async () => { - const path = `${resources}/Lenna.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const path = `${resources}/192.jpg`; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { - size: 25360, - md5: '091b3f259662aa31e2ffef4519951168', + size: 5131, + md5: '8c9ed0677dd2b8f9f7472c3af247e5e3', type: { mime: 'image/jpeg', ext: 'jpg', }, - width: 512, - height: 512, + width: 192, + height: 192, orientation: undefined, }); }); test('Generic APNG', async () => { const path = `${resources}/anime.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 1868, md5: '08189c607bea3b952704676bb3c979e0', @@ -124,11 +121,7 @@ describe('FileInfoService', () => { test('Generic AGIF', async () => { const path = `${resources}/anime.gif`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 2248, md5: '32c47a11555675d9267aee1a86571e7e', @@ -144,11 +137,7 @@ describe('FileInfoService', () => { test('PNG with alpha', async () => { const path = `${resources}/with-alpha.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 3772, md5: 'f73535c3e1e27508885b69b10cf6e991', @@ -164,11 +153,7 @@ describe('FileInfoService', () => { test('Generic SVG', async () => { const path = `${resources}/image.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 505, md5: 'b6f52b4b021e7b92cdd04509c7267965', @@ -185,11 +170,7 @@ describe('FileInfoService', () => { test('SVG with XML definition', async () => { // https://github.com/misskey-dev/misskey/issues/4413 const path = `${resources}/with-xml-def.svg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 544, md5: '4b7a346cde9ccbeb267e812567e33397', @@ -205,11 +186,7 @@ describe('FileInfoService', () => { test('Dimension limit', async () => { const path = `${resources}/25000x25000.png`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 75933, md5: '268c5dde99e17cf8fe09f1ab3f97df56', @@ -225,11 +202,7 @@ describe('FileInfoService', () => { test('Rotate JPEG', async () => { const path = `${resources}/rotate.jpg`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); assert.deepStrictEqual(info, { size: 12624, md5: '68d5b2d8d1d1acbbce99203e3ec3857e', @@ -247,11 +220,7 @@ describe('FileInfoService', () => { describe('AUDIO', () => { test('MP3', async () => { const path = `${resources}/kick_gaba7.mp3`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -267,11 +236,7 @@ describe('FileInfoService', () => { test('WAV', async () => { const path = `${resources}/kick_gaba7.wav`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -287,11 +252,7 @@ describe('FileInfoService', () => { test('AAC', async () => { const path = `${resources}/kick_gaba7.aac`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -307,11 +268,7 @@ describe('FileInfoService', () => { test('FLAC', async () => { const path = `${resources}/kick_gaba7.flac`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -327,11 +284,7 @@ describe('FileInfoService', () => { test('MPEG-4 AUDIO (M4A)', async () => { const path = `${resources}/kick_gaba7.m4a`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; @@ -347,11 +300,7 @@ describe('FileInfoService', () => { test('WEBM AUDIO', async () => { const path = `${resources}/kick_gaba7.webm`; - const info = await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true }) as any; - delete info.warnings; - delete info.blurhash; - delete info.sensitive; - delete info.porn; + const info = strip(await fileInfoService.getFileInfo(path, { skipSensitiveDetection: true })); delete info.width; delete info.height; delete info.orientation; diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index ec441735d7e6..b6cbe4c520d1 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -3,17 +3,23 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { UserEntityService } from '@/core/entities/UserEntityService.js'; - process.env.NODE_ENV = 'test'; +import { setTimeout } from 'node:timers/promises'; import { jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; -import type { MiRole, MiUser, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/_.js'; +import { + MiRole, + MiRoleAssignment, + MiUser, + RoleAssignmentsRepository, + RolesRepository, + UsersRepository, +} from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { genAidx } from '@/misc/id/aidx.js'; @@ -23,7 +29,7 @@ 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 { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -39,27 +45,27 @@ describe('RoleService', () => { let notificationService: jest.Mocked; let clock: lolex.InstalledClock; - function createUser(data: Partial = {}) { + async function createUser(data: Partial = {}) { const un = secureRndstr(16); - return usersRepository.insert({ + const x = await usersRepository.insert({ id: genAidx(Date.now()), username: un, usernameLower: un, ...data, - }) - .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + }); + return await usersRepository.findOneByOrFail(x.identifiers[0]); } - function createRole(data: Partial = {}) { - return rolesRepository.insert({ + async function createRole(data: Partial = {}) { + const x = await rolesRepository.insert({ id: genAidx(Date.now()), updatedAt: new Date(), lastUsedAt: new Date(), name: '', description: '', ...data, - }) - .then(x => rolesRepository.findOneByOrFail(x.identifiers[0])); + }); + return await rolesRepository.findOneByOrFail(x.identifiers[0]); } function createConditionalRole(condFormula: RoleCondFormulaValue, data: Partial = {}) { @@ -71,6 +77,20 @@ describe('RoleService', () => { }); } + async function assignRole(args: Partial) { + const id = genAidx(Date.now()); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 1); + + await roleAssignmentsRepository.insert({ + id, + expiresAt, + ...args, + }); + + return await roleAssignmentsRepository.findOneByOrFail({ id }); + } + function aidx() { return genAidx(Date.now()); } @@ -258,13 +278,103 @@ describe('RoleService', () => { // ストリーミング経由で反映されるまでちょっと待つ clock.uninstall(); - await sleep(100); + await setTimeout(100); const resultAfter25hAgain = await roleService.getUserPolicies(user.id); expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true); }); }); + describe('getModeratorIds', () => { + test('includeAdmins = false, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds(false, false); + expect(result).toEqual([modeUser1.id, modeUser2.id]); + }); + + test('includeAdmins = false, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds(false, true); + expect(result).toEqual([modeUser1.id]); + }); + + test('includeAdmins = true, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds(true, false); + expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id]); + }); + + test('includeAdmins = true, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds(true, true); + expect(result).toEqual([adminUser1.id, modeUser1.id]); + }); + }); + describe('conditional role', () => { test('~かつ~', async () => { const [user1, user2, user3, user4] = await Promise.all([ @@ -697,7 +807,7 @@ describe('RoleService', () => { await roleService.assign(user.id, role.id); clock.uninstall(); - await sleep(100); + await setTimeout(100); const assignments = await roleAssignmentsRepository.find({ where: { @@ -725,7 +835,7 @@ describe('RoleService', () => { await roleService.assign(user.id, role.id); clock.uninstall(); - await sleep(100); + await setTimeout(100); const assignments = await roleAssignmentsRepository.find({ where: { diff --git a/packages/backend/test/unit/SystemWebhookService.ts b/packages/backend/test/unit/SystemWebhookService.ts new file mode 100644 index 000000000000..790cd1490eb5 --- /dev/null +++ b/packages/backend/test/unit/SystemWebhookService.ts @@ -0,0 +1,516 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { setTimeout } from 'node:timers/promises'; +import { afterEach, beforeEach, describe, expect, jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { MiUser } from '@/models/User.js'; +import { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js'; +import { SystemWebhooksRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { QueueService } from '@/core/QueueService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { randomString } from '../utils.js'; + +describe('SystemWebhookService', () => { + let app: TestingModule; + let service: SystemWebhookService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let systemWebhooksRepository: SystemWebhooksRepository; + let idService: IdService; + let queueService: jest.Mocked; + + // -------------------------------------------------------------------------------------- + + let root: MiUser; + + // -------------------------------------------------------------------------------------- + + async function createUser(data: Partial = {}) { + return await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createWebhook(data: Partial = {}) { + return systemWebhooksRepository + .insert({ + id: idService.gen(), + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + ...data, + }) + .then(x => systemWebhooksRepository.findOneByOrFail(x.identifiers[0])); + } + + // -------------------------------------------------------------------------------------- + + async function beforeAllImpl() { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + SystemWebhookService, + IdService, + LoggerService, + GlobalEventService, + { + provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn() }), + }, + { + provide: ModerationLogService, useFactory: () => ({ log: () => Promise.resolve() }), + }, + ], + }) + .compile(); + + usersRepository = app.get(DI.usersRepository); + systemWebhooksRepository = app.get(DI.systemWebhooksRepository); + + service = app.get(SystemWebhookService); + idService = app.get(IdService); + queueService = app.get(QueueService) as jest.Mocked; + + app.enableShutdownHooks(); + } + + async function afterAllImpl() { + await app.close(); + } + + async function beforeEachImpl() { + root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' }); + } + + async function afterEachImpl() { + await usersRepository.delete({}); + await systemWebhooksRepository.delete({}); + } + + // -------------------------------------------------------------------------------------- + + describe('アプリを毎回作り直す必要のないグループ', () => { + beforeAll(beforeAllImpl); + afterAll(afterAllImpl); + beforeEach(beforeEachImpl); + afterEach(afterEachImpl); + + describe('fetchSystemWebhooks', () => { + test('フィルタなし', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks(); + expect(fetchedWebhooks).toEqual([webhook1, webhook2, webhook3, webhook4]); + }); + + test('activeのみ', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ isActive: true }); + expect(fetchedWebhooks).toEqual([webhook1, webhook3]); + }); + + test('特定のイベントのみ', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ on: ['abuseReport'] }); + expect(fetchedWebhooks).toEqual([webhook1, webhook2]); + }); + + test('activeな特定のイベントのみ', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ on: ['abuseReport'], isActive: true }); + expect(fetchedWebhooks).toEqual([webhook1]); + }); + + test('ID指定', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ ids: [webhook1.id, webhook4.id] }); + expect(fetchedWebhooks).toEqual([webhook1, webhook4]); + }); + + test('ID指定(他条件とANDになるか見たい)', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook3 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: [], + }); + + const fetchedWebhooks = await service.fetchSystemWebhooks({ ids: [webhook1.id, webhook4.id], isActive: false }); + expect(fetchedWebhooks).toEqual([webhook4]); + }); + }); + + describe('createSystemWebhook', () => { + test('作成成功 ', async () => { + const params = { + isActive: true, + name: randomString(), + on: ['abuseReport'] as SystemWebhookEventType[], + url: 'https://example.com', + secret: randomString(), + }; + + const webhook = await service.createSystemWebhook(params, root); + expect(webhook).toMatchObject(params); + }); + }); + + describe('updateSystemWebhook', () => { + test('更新成功', async () => { + const webhook = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + + const params = { + id: webhook.id, + isActive: false, + name: randomString(), + on: ['abuseReport'] as SystemWebhookEventType[], + url: randomString(), + secret: randomString(), + }; + + const updatedWebhook = await service.updateSystemWebhook(params, root); + expect(updatedWebhook).toMatchObject(params); + }); + }); + + describe('deleteSystemWebhook', () => { + test('削除成功', async () => { + const webhook = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + + await service.deleteSystemWebhook(webhook.id, root); + + await expect(systemWebhooksRepository.findOneBy({ id: webhook.id })).resolves.toBeNull(); + }); + }); + }); + + describe('アプリを毎回作り直す必要があるグループ', () => { + beforeEach(async () => { + await beforeAllImpl(); + await beforeEachImpl(); + }); + + afterEach(async () => { + await afterEachImpl(); + await afterAllImpl(); + }); + + describe('enqueueSystemWebhook', () => { + test('キューに追加成功', async () => { + const webhook = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' }); + + expect(queueService.systemWebhookDeliver).toHaveBeenCalled(); + }); + + test('非アクティブなWebhookはキューに追加されない', async () => { + const webhook = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' }); + + expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); + }); + + test('未許可のイベント種別が渡された場合はWebhookはキューに追加されない', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: [], + }); + const webhook2 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' }); + await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' }); + + expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); + }); + }); + + describe('fetchActiveSystemWebhooks', () => { + describe('systemWebhookCreated', () => { + test('ActiveなWebhookが追加された時、キャッシュに追加されている', async () => { + const webhook = await service.createSystemWebhook( + { + isActive: true, + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await setTimeout(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks).toEqual([webhook]); + }); + + test('NotActiveなWebhookが追加された時、キャッシュに追加されていない', async () => { + const webhook = await service.createSystemWebhook( + { + isActive: false, + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await setTimeout(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks).toEqual([]); + }); + }); + + describe('systemWebhookUpdated', () => { + test('ActiveなWebhookが編集された時、キャッシュに反映されている', async () => { + const id = idService.gen(); + await createWebhook({ id }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていることをチェック + expect(webhook1.length).toEqual(1); + expect(webhook1[0].id).toEqual(id); + + const webhook2 = await service.updateSystemWebhook( + { + id, + isActive: true, + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await setTimeout(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks).toEqual([webhook2]); + }); + + test('NotActiveなWebhookが編集された時、キャッシュに追加されない', async () => { + const id = idService.gen(); + await createWebhook({ id, isActive: false }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていないことをチェック + expect(webhook1.length).toEqual(0); + + const webhook2 = await service.updateSystemWebhook( + { + id, + isActive: false, + name: randomString(), + on: ['abuseReport'], + url: 'https://example.com', + secret: randomString(), + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await setTimeout(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks.length).toEqual(0); + }); + + test('NotActiveなWebhookがActiveにされた時、キャッシュに追加されている', async () => { + const id = idService.gen(); + const baseWebhook = await createWebhook({ id, isActive: false }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていないことをチェック + expect(webhook1.length).toEqual(0); + + const webhook2 = await service.updateSystemWebhook( + { + ...baseWebhook, + isActive: true, + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await setTimeout(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks).toEqual([webhook2]); + }); + + test('ActiveなWebhookがNotActiveにされた時、キャッシュから削除されている', async () => { + const id = idService.gen(); + const baseWebhook = await createWebhook({ id, isActive: true }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていることをチェック + expect(webhook1.length).toEqual(1); + expect(webhook1[0].id).toEqual(id); + + const webhook2 = await service.updateSystemWebhook( + { + ...baseWebhook, + isActive: false, + }, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await setTimeout(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks.length).toEqual(0); + }); + }); + + describe('systemWebhookDeleted', () => { + test('キャッシュから削除されている', async () => { + const id = idService.gen(); + const baseWebhook = await createWebhook({ id, isActive: true }); + // キャッシュ作成 + const webhook1 = await service.fetchActiveSystemWebhooks(); + // 読み込まれていることをチェック + expect(webhook1.length).toEqual(1); + expect(webhook1[0].id).toEqual(id); + + const webhook2 = await service.deleteSystemWebhook( + id, + root, + ); + + // redisでの配信経由で更新されるのでちょっと待つ + await setTimeout(500); + + const fetchedWebhooks = await service.fetchActiveSystemWebhooks(); + expect(fetchedWebhooks.length).toEqual(0); + }); + }); + }); + }); +}); diff --git a/packages/backend/test/unit/UserSearchService.ts b/packages/backend/test/unit/UserSearchService.ts new file mode 100644 index 000000000000..7ea325d4209b --- /dev/null +++ b/packages/backend/test/unit/UserSearchService.ts @@ -0,0 +1,265 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { describe, jest, test } from '@jest/globals'; +import { In } from 'typeorm'; +import { UserSearchService } from '@/core/UserSearchService.js'; +import { FollowingsRepository, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DI } from '@/di-symbols.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; + +describe('UserSearchService', () => { + let app: TestingModule; + let service: UserSearchService; + + let usersRepository: UsersRepository; + let followingsRepository: FollowingsRepository; + let idService: IdService; + let userProfilesRepository: UserProfilesRepository; + + let root: MiUser; + let alice: MiUser; + let alyce: MiUser; + let alycia: MiUser; + let alysha: MiUser; + let alyson: MiUser; + let alyssa: MiUser; + let bob: MiUser; + let bobbi: MiUser; + let bobbie: MiUser; + let bobby: MiUser; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createFollowings(follower: MiUser, followees: MiUser[]) { + for (const followee of followees) { + await followingsRepository.insert({ + id: idService.gen(), + followerId: follower.id, + followeeId: followee.id, + }); + } + } + + async function setActive(users: MiUser[]) { + for (const user of users) { + await usersRepository.update(user.id, { + updatedAt: new Date(), + }); + } + } + + async function setInactive(users: MiUser[]) { + for (const user of users) { + await usersRepository.update(user.id, { + updatedAt: new Date(0), + }); + } + } + + async function setSuspended(users: MiUser[]) { + for (const user of users) { + await usersRepository.update(user.id, { + isSuspended: true, + }); + } + } + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + UserSearchService, + { + provide: UserEntityService, useFactory: jest.fn(() => ({ + // とりあえずIDが返れば確認が出来るので + packMany: (value: any) => value, + })), + }, + IdService, + ], + }) + .compile(); + + await app.init(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + followingsRepository = app.get(DI.followingsRepository); + + service = app.get(UserSearchService); + idService = app.get(IdService); + }); + + beforeEach(async () => { + root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); + alice = await createUser({ username: 'Alice', usernameLower: 'alice' }); + alyce = await createUser({ username: 'Alyce', usernameLower: 'alyce' }); + alycia = await createUser({ username: 'Alycia', usernameLower: 'alycia' }); + alysha = await createUser({ username: 'Alysha', usernameLower: 'alysha' }); + alyson = await createUser({ username: 'Alyson', usernameLower: 'alyson', host: 'example.com' }); + alyssa = await createUser({ username: 'Alyssa', usernameLower: 'alyssa', host: 'example.com' }); + bob = await createUser({ username: 'Bob', usernameLower: 'bob' }); + bobbi = await createUser({ username: 'Bobbi', usernameLower: 'bobbi' }); + bobbie = await createUser({ username: 'Bobbie', usernameLower: 'bobbie', host: 'example.com' }); + bobby = await createUser({ username: 'Bobby', usernameLower: 'bobby', host: 'example.com' }); + }); + + afterEach(async () => { + await usersRepository.delete({}); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('search', () => { + test('フォロー中のアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setActive([alice, alyce, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alycia, alysha, alyson]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + // alycia, alysha, alysonは非アクティブなので後ろに行く + expect(result).toEqual([alice, alyce, alyssa, alycia, alysha, alyson].map(x => x.id)); + }); + + test('フォロー中の非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await createFollowings(root, [alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + // alice, alyceはフォローしていないので後ろに行く + expect(result).toEqual([alycia, alysha, alyson, alyssa, alice, alyce].map(x => x.id)); + }); + + test('フォローしていないアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alice, alyce, alycia]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + // alice, alyce, alyciaは非アクティブなので後ろに行く + expect(result).toEqual([alysha, alyson, alyssa, alice, alyce, alycia].map(x => x.id)); + }); + + test('フォローしていない非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + expect(result).toEqual([alice, alyce, alycia, alysha, alyson, alyssa].map(x => x.id)); + }); + + test('フォロー(アクティブ)、フォロー(非アクティブ)、非フォロー(アクティブ)、非フォロー(非アクティブ)混在時の優先順位度確認', async () => { + await createFollowings(root, [alyson, alyssa, bob, bobbi, bobbie]); + await setActive([root, alyssa, bob, bobbi, alyce, alycia]); + await setInactive([alyson, alice, alysha, bobbie, bobby]); + + const result = await service.search( + { }, + { limit: 100 }, + root, + ); + + // 見る用 + // const users = await usersRepository.findBy({ id: In(result) }).then(it => new Map(it.map(x => [x.id, x]))); + // console.log(result.map(x => users.get(x as any)).map(it => it?.username)); + + // フォローしててアクティブなので先頭: alyssa, bob, bobbi + // フォローしてて非アクティブなので次: alyson, bobbie + // フォローしてないけどアクティブなので次: alyce, alycia, root(アルファベット順的にここになる) + // フォローしてないし非アクティブなので最後: alice, alysha, bobby + expect(result).toEqual([alyssa, bob, bobbi, alyson, bobbie, alyce, alycia, root, alice, alysha, bobby].map(x => x.id)); + }); + + test('[非ログイン] アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setInactive([alice, alyce, alycia]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + ); + + // alice, alyce, alyciaは非アクティブなので後ろに行く + expect(result).toEqual([alysha, alyson, alyssa, alice, alyce, alycia].map(x => x.id)); + }); + + test('[非ログイン] 非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { + await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + ); + + expect(result).toEqual([alice, alyce, alycia, alysha, alyson, alyssa].map(x => x.id)); + }); + + test('フォロー中のアクティブユーザのうち、"al"から始まり"example.com"にいる人が全員ヒットする', async () => { + await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + + const result = await service.search( + { username: 'al', host: 'exam' }, + { limit: 100 }, + root, + ); + + expect(result).toEqual([alyson, alyssa].map(x => x.id)); + }); + + test('サスペンド済みユーザは出ない', async () => { + await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); + await setSuspended([alice, alyce, alycia]); + + const result = await service.search( + { username: 'al' }, + { limit: 100 }, + root, + ); + + expect(result).toEqual([alysha, alyson, alyssa].map(x => x.id)); + }); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 86814fffe0e4..e70befeebe34 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -17,6 +17,8 @@ import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/val import { entities } from '../src/postgres.js'; import { loadConfig } from '../src/config.js'; import type * as misskey from 'misskey-js'; +import { type Response } from 'node-fetch'; +import { ApiError } from "@/server/api/error.js"; export { server as startServer, jobQueue as startJobQueue } from '@/boot/common.js'; @@ -47,27 +49,28 @@ export const successfulApiCall = async ; }; -export const failedApiCall = async (request: ApiRequest, assertion: { +export const failedApiCall = async (request: ApiRequest, assertion: { status: number, code: string, id: string -}): Promise => { +}): Promise => { const { endpoint, parameters, user } = request; const { status, code, id } = assertion; const res = await api(endpoint, parameters, user); assert.strictEqual(res.status, status, inspect(res.body)); - assert.strictEqual(res.body.error.code, code, inspect(res.body)); - assert.strictEqual(res.body.error.id, id, inspect(res.body)); - return res.body; + assert.ok(res.body); + assert.strictEqual(castAsError(res.body as any).error.code, code, inspect(res.body)); + assert.strictEqual(castAsError(res.body as any).error.id, id, inspect(res.body)); }; -export const api = async (path: E, params: misskey.Endpoints[E]['req'], me?: UserToken): Promise<{ +export const api = async (path: E, params: P, me?: UserToken): Promise<{ status: number, headers: Headers, - body: any + body: misskey.api.SwitchCaseResponseType }> => { const bodyAuth: Record = {}; const headers: Record = { @@ -88,13 +91,14 @@ export const api = async (path: E, params: mi }); const body = res.headers.get('content-type') === 'application/json; charset=utf-8' - ? await res.json() + ? await res.json() as misskey.api.SwitchCaseResponseType : null; return { status: res.status, headers: res.headers, - body, + // FIXME: removing this non-null assertion: requires better typing around empty response. + body: body!, }; }; @@ -140,7 +144,8 @@ export const post = async (user: UserToken, params: misskey.Endpoints['notes/cre const res = await api('notes/create', q, user); - return res.body ? res.body.createdNote : null; + // FIXME: the return type should reflect this fact. + return (res.body ? res.body.createdNote : null)!; }; export const createAppToken = async (user: UserToken, permissions: (typeof misskey.permissions)[number][]) => { @@ -296,7 +301,7 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO body: misskey.entities.DriveFile | null }> => { const absPath = path == null - ? new URL('resources/Lenna.jpg', import.meta.url) + ? new URL('resources/192.jpg', import.meta.url) : isAbsolute(path.toString()) ? new URL(path) : new URL(path, new URL('resources/', import.meta.url)); @@ -454,7 +459,7 @@ export type SimpleGetResponse = { type: string | null, location: string | null }; -export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined): Promise => { +export const simpleGet = async (path: string, accept = '*/*', cookie: any = undefined, bodyExtractor: (res: Response) => Promise = _ => Promise.resolve(null)): Promise => { const res = await relativeFetch(path, { headers: { Accept: accept, @@ -482,7 +487,7 @@ export const simpleGet = async (path: string, accept = '*/*', cookie: any = unde const body = jsonTypes.includes(res.headers.get('content-type') ?? '') ? await res.json() : htmlTypes.includes(res.headers.get('content-type') ?? '') ? new JSDOM(await res.text()) : - null; + await bodyExtractor(res); return { status: res.status, @@ -604,14 +609,6 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) { return db; } -export function sleep(msec: number) { - return new Promise(res => { - setTimeout(() => { - res(); - }, msec); - }); -} - export async function sendEnvUpdateRequest(params: { key: string, value?: string }) { const res = await fetch( `http://localhost:${port + 1000}/env`, @@ -642,3 +639,9 @@ export async function sendEnvResetRequest() { throw new Error('server env update failed.'); } } + +// 与えられた値を強制的にエラーとみなす。この関数は型安全性を破壊するため、異常系のアサーション以外で用いられるべきではない。 +// FIXME(misskey-js): misskey-jsがエラー情報を公開するようになったらこの関数を廃止する +export function castAsError(obj: Record): { error: ApiError } { + return obj as { error: ApiError }; +} diff --git a/packages/frontend/.eslintrc.cjs b/packages/frontend/.eslintrc.cjs deleted file mode 100644 index 20f88dc0786c..000000000000 --- a/packages/frontend/.eslintrc.cjs +++ /dev/null @@ -1,82 +0,0 @@ -module.exports = { - root: true, - env: { - 'node': false, - }, - parser: 'vue-eslint-parser', - parserOptions: { - 'parser': '@typescript-eslint/parser', - tsconfigRootDir: __dirname, - project: ['./tsconfig.json'], - extraFileExtensions: ['.vue'], - }, - extends: [ - '../shared/.eslintrc.js', - 'plugin:vue/vue3-recommended', - ], - rules: { - '@typescript-eslint/no-empty-interface': [ - 'error', - { - 'allowSingleExtends': true, - }, - ], - // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため - // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため - 'id-denylist': ['error', 'window', 'e'], - 'no-shadow': ['warn'], - 'vue/attributes-order': ['error', { - 'alphabetical': false, - }], - 'vue/no-use-v-if-with-v-for': ['error', { - 'allowUsingIterationVar': false, - }], - 'vue/no-ref-as-operand': 'error', - 'vue/no-multi-spaces': ['error', { - 'ignoreProperties': false, - }], - 'vue/no-v-html': 'warn', - 'vue/order-in-components': 'error', - 'vue/html-indent': ['warn', 'tab', { - 'attribute': 1, - 'baseIndent': 0, - 'closeBracket': 0, - 'alignAttributesVertically': true, - 'ignores': [], - }], - 'vue/html-closing-bracket-spacing': ['warn', { - 'startTag': 'never', - 'endTag': 'never', - 'selfClosingTag': 'never', - }], - 'vue/multi-word-component-names': 'warn', - 'vue/require-v-for-key': 'warn', - 'vue/no-unused-components': 'warn', - 'vue/no-unused-vars': 'warn', - 'vue/no-dupe-keys': 'warn', - 'vue/valid-v-for': 'warn', - 'vue/return-in-computed-property': 'warn', - 'vue/no-setup-props-destructure': 'warn', - 'vue/max-attributes-per-line': 'off', - 'vue/html-self-closing': 'off', - 'vue/singleline-html-element-content-newline': 'off', - 'vue/v-on-event-hyphenation': ['error', 'never', { autofix: true }], - 'vue/attribute-hyphenation': ['error', 'never'], - }, - globals: { - // Node.js - 'module': false, - 'require': false, - '__dirname': false, - - // Misskey - '_DEV_': false, - '_LANGS_': false, - '_VERSION_': false, - '_ENV_': false, - '_PERF_PREFIX_': false, - '_DATA_TRANSFER_DRIVE_FILE_': false, - '_DATA_TRANSFER_DRIVE_FOLDER_': false, - '_DATA_TRANSFER_DECK_COLUMN_': false, - }, -}; diff --git a/packages/frontend/.storybook/changes.ts b/packages/frontend/.storybook/changes.ts index 7c70972e1e3d..bc7601441c32 100644 --- a/packages/frontend/.storybook/changes.ts +++ b/packages/frontend/.storybook/changes.ts @@ -47,7 +47,6 @@ await fs.readFile( ) ) .map((path) => path.replace(/(?:(?<=\.stories)\.(?:impl|meta)|\.msw)(?=\.ts$)/g, '')) - .map((path) => (path.startsWith('.') ? path : `./${path}`)) ); if ( micromatch(Array.from(modules), [ diff --git a/packages/frontend/.storybook/charts.ts b/packages/frontend/.storybook/charts.ts new file mode 100644 index 000000000000..5015012a8246 --- /dev/null +++ b/packages/frontend/.storybook/charts.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { DefaultBodyType, HttpResponse, HttpResponseResolver, JsonBodyType, PathParams, http } from 'msw'; +import seedrandom from 'seedrandom'; +import { action } from '@storybook/addon-actions'; + +function getChartArray(seed: string, limit: number, option?: { accumulate?: boolean, mul?: number }): number[] { + const rng = seedrandom(seed); + const max = Math.floor(option?.mul ?? 250 * rng()); + let accumulation = 0; + const array: number[] = []; + for (let i = 0; i < limit; i++) { + const num = Math.floor((max + 1) * rng()); + if (option?.accumulate) { + accumulation += num; + array.unshift(accumulation); + } else { + array.push(num); + } + } + return array; +} + +export function getChartResolver(fields: string[], option?: { accumulate?: boolean, mulMap?: Record }): HttpResponseResolver { + return ({ request }) => { + action(`GET ${request.url}`)(); + const limitParam = new URL(request.url).searchParams.get('limit'); + const limit = limitParam ? parseInt(limitParam) : 30; + const res = {}; + for (const field of fields) { + const layers = field.split('.'); + let current = res; + while (layers.length > 1) { + const currentKey = layers.shift()!; + if (current[currentKey] == null) current[currentKey] = {}; + current = current[currentKey]; + } + current[layers[0]] = getChartArray(field, limit, { + accumulate: option?.accumulate, + mul: option?.mulMap != null && field in option.mulMap ? option.mulMap[field] : undefined, + }); + } + return HttpResponse.json(res); + }; +} diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 3a24ccb248ed..9d789a34ff90 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -22,6 +22,66 @@ export function abuseUserReport() { }; } +export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl: string | null = 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true'): entities.Channel { + return { + id, + createdAt: '2016-12-28T22:49:51.000Z', + lastNotedAt: '2016-12-28T22:49:51.000Z', + name, + description: null, + userId: null, + bannerUrl, + pinnedNoteIds: [], + color: '#000', + isArchived: false, + usersCount: 1, + notesCount: 1, + isSensitive: false, + allowRenoteToExternal: false, + }; +} + +export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip { + return { + id, + createdAt: '2016-12-28T22:49:51.000Z', + lastClippedAt: null, + userId: 'someuserid', + user: { + id: 'someuserid', + name: 'Misskey User', + username: 'miskist', + host: 'misskey-hub.net', + 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: {}, + badgeRoles: [], + onlineStatus: 'unknown', + }, + notesCount: undefined, + name, + description: 'Some clip description', + isPublic: false, + favoritedCount: 0, + }; +} + +export function emojiDetailed(id = 'someemojiid', name = 'some_emoji'): entities.EmojiDetailed { + return { + id, + aliases: ['alias1', 'alias2'], + name, + category: 'emojiCategory', + host: null, + url: '/client-assets/about-icon.png', + license: null, + isSensitive: false, + localOnly: false, + roleIdsThatCanBeUsedThisEmojiAsReaction: ['roleId1', 'roleId2'], + }; +} + export function galleryPost(isSensitive = false) { return { id: 'somepostid', @@ -65,6 +125,35 @@ export function file(isSensitive = false) { }; } +export function federationInstance(): entities.FederationInstance { + return { + id: 'someinstanceid', + firstRetrievedAt: '2021-01-01T00:00:00.000Z', + host: 'misskey-hub.net', + usersCount: 10, + notesCount: 20, + followingCount: 5, + followersCount: 15, + isNotResponding: false, + isSuspended: false, + suspensionState: 'none', + isBlocked: false, + softwareName: 'misskey', + softwareVersion: '2024.5.0', + openRegistrations: false, + name: 'Misskey Hub', + description: '', + maintainerName: '', + maintainerEmail: '', + isSilenced: false, + iconUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true', + faviconUrl: '', + themeColor: '', + infoUpdatedAt: '', + latestRequestReceivedAt: '', + }; +} + export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed { return { id, diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index d74c83a50007..7b6c86447edc 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -397,12 +397,13 @@ function toStories(component: string): Promise { const globs = await Promise.all([ glob('src/components/global/Mk*.vue'), glob('src/components/global/RouterView.vue'), - glob('src/components/Mk{A,B}*.vue'), + glob('src/components/Mk[A-C]*.vue'), glob('src/components/MkDigitalClock.vue'), glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkUserSetupDialog.vue'), glob('src/components/MkUserSetupDialog.*.vue'), + glob('src/components/MkInstanceCardMini.vue'), glob('src/components/MkInviteCode.vue'), glob('src/pages/user/home.vue'), ]); diff --git a/packages/frontend/.storybook/main.ts b/packages/frontend/.storybook/main.ts index d3822942cd46..9f318cf449b9 100644 --- a/packages/frontend/.storybook/main.ts +++ b/packages/frontend/.storybook/main.ts @@ -15,6 +15,7 @@ const _dirname = fileURLToPath(new URL('.', import.meta.url)); const config = { stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + staticDirs: [{ from: '../assets', to: '/client-assets' }], addons: [ getAbsolutePath('@storybook/addon-essentials'), getAbsolutePath('@storybook/addon-interactions'), diff --git a/packages/frontend/.storybook/preview.ts b/packages/frontend/.storybook/preview.ts index 982a2979ac8c..d000a282328f 100644 --- a/packages/frontend/.storybook/preview.ts +++ b/packages/frontend/.storybook/preview.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { FORCE_REMOUNT } from '@storybook/core-events'; +import { FORCE_RE_RENDER, FORCE_REMOUNT } from '@storybook/core-events'; import { addons } from '@storybook/preview-api'; import { type Preview, setup } from '@storybook/vue3'; import isChromatic from 'chromatic/isChromatic'; -import { initialize, mswDecorator } from 'msw-storybook-addon'; +import { initialize, mswLoader } from 'msw-storybook-addon'; import { userDetailed } from './fakes.js'; import locale from './locale.js'; import { commonHandlers, onUnhandledRequest } from './mocks.js'; @@ -16,7 +16,7 @@ import '../src/style.scss'; const appInitialized = Symbol(); -let lastStory = null; +let lastStory: string | null = null; let moduleInitialized = false; let unobserve = () => {}; let misskeyOS = null; @@ -110,7 +110,7 @@ const preview = { }).catch(() => {}); Promise.all([resetIndexedDBPromise, resetDefaultStorePromise]).then(() => { initLocalStorage(); - channel.emit(FORCE_REMOUNT, { storyId: context.id }); + channel.emit(FORCE_RE_RENDER, { storyId: context.id }); }); } const story = Story(); @@ -122,7 +122,6 @@ const preview = { } return story; }, - mswDecorator, (Story, context) => { return { setup() { @@ -137,6 +136,7 @@ const preview = { }; }, ], + loaders: [mswLoader], parameters: { controls: { exclude: /^__/, diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js new file mode 100644 index 000000000000..dd8f03dac510 --- /dev/null +++ b/packages/frontend/eslint.config.js @@ -0,0 +1,95 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import parser from 'vue-eslint-parser'; +import pluginVue from 'eslint-plugin-vue'; +import pluginMisskey from '@misskey-dev/eslint-plugin'; +import sharedConfig from '../shared/eslint.config.js'; + +export default [ + ...sharedConfig, + { + files: ['src/**/*.vue'], + ...pluginMisskey.configs.typescript, + }, + ...pluginVue.configs['flat/recommended'], + { + files: ['src/**/*.{ts,vue}'], + languageOptions: { + globals: { + ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])), + ...globals.browser, + + // Node.js + module: false, + require: false, + __dirname: false, + + // Misskey + _DEV_: false, + _LANGS_: false, + _VERSION_: false, + _ENV_: false, + _PERF_PREFIX_: false, + _DATA_TRANSFER_DRIVE_FILE_: false, + _DATA_TRANSFER_DRIVE_FOLDER_: false, + _DATA_TRANSFER_DECK_COLUMN_: false, + }, + parser, + parserOptions: { + extraFileExtensions: ['.vue'], + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/no-empty-interface': ['error', { + allowSingleExtends: true, + }], + // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため + // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため + 'id-denylist': ['error', 'window', 'e'], + 'no-shadow': ['warn'], + 'vue/attributes-order': ['error', { + alphabetical: false, + }], + 'vue/no-use-v-if-with-v-for': ['error', { + allowUsingIterationVar: false, + }], + 'vue/no-ref-as-operand': 'error', + 'vue/no-multi-spaces': ['error', { + ignoreProperties: false, + }], + 'vue/no-v-html': 'warn', + 'vue/order-in-components': 'error', + 'vue/html-indent': ['warn', 'tab', { + attribute: 1, + baseIndent: 0, + closeBracket: 0, + alignAttributesVertically: true, + ignores: [], + }], + 'vue/html-closing-bracket-spacing': ['warn', { + startTag: 'never', + endTag: 'never', + selfClosingTag: 'never', + }], + 'vue/multi-word-component-names': 'warn', + 'vue/require-v-for-key': 'warn', + 'vue/no-unused-components': 'warn', + 'vue/no-unused-vars': 'warn', + 'vue/no-dupe-keys': 'warn', + 'vue/valid-v-for': 'warn', + 'vue/return-in-computed-property': 'warn', + 'vue/no-setup-props-reactivity-loss': 'warn', + 'vue/max-attributes-per-line': 'off', + 'vue/html-self-closing': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/v-on-event-hyphenation': ['error', 'never', { + autofix: true, + }], + 'vue/attribute-hyphenation': ['error', 'never'], + }, + }, +]; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 56b824c0c5c0..a0c1c6988306 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -22,24 +22,24 @@ "@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@misskey-dev/browser-image-resizer": "2024.1.0", "@rollup/plugin-json": "6.1.0", - "@rollup/plugin-replace": "5.0.5", + "@rollup/plugin-replace": "5.0.7", "@rollup/pluginutils": "5.1.0", "@syuilo/aiscript": "0.18.0", "@tabler/icons-webfont": "3.3.0", "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.0.4", - "@vue/compiler-sfc": "3.4.26", - "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.9", + "@vitejs/plugin-vue": "5.0.5", + "@vue/compiler-sfc": "3.4.31", + "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11", "astring": "1.8.6", "broadcast-channel": "7.0.0", "buraha": "0.0.1", "canvas-confetti": "1.9.3", - "chart.js": "4.4.2", + "chart.js": "4.4.3", "chartjs-adapter-date-fns": "3.0.0", "chartjs-chart-matrix": "2.0.1", "chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-zoom": "2.0.1", - "chromatic": "11.3.0", + "chromatic": "11.5.4", "compare-versions": "6.1.0", "cropperjs": "2.0.0-beta.5", "date-fns": "2.30.0", @@ -55,87 +55,87 @@ "misskey-bubble-game": "workspace:*", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", - "photoswipe": "5.4.3", + "photoswipe": "5.4.4", "punycode": "2.3.1", - "rollup": "4.17.2", + "rollup": "4.18.0", "sanitize-html": "2.13.0", - "sass": "1.76.0", - "shiki": "1.4.0", + "sass": "1.77.6", + "shiki": "1.10.0", "strict-event-emitter-types": "2.0.0", "textarea-caret": "3.1.0", - "three": "0.164.1", - "throttle-debounce": "5.0.0", + "three": "0.165.0", + "throttle-debounce": "5.0.2", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.8", + "tsc-alias": "1.8.10", "tsconfig-paths": "4.2.0", - "typescript": "5.4.5", - "uuid": "9.0.1", - "v-code-diff": "1.11.0", - "vite": "5.2.11", - "vue": "3.4.26", + "typescript": "5.5.3", + "uuid": "10.0.0", + "v-code-diff": "1.12.0", + "vite": "5.3.2", + "vue": "3.4.31", "vuedraggable": "next" }, "devDependencies": { - "@misskey-dev/eslint-plugin": "1.0.0", "@misskey-dev/summaly": "5.1.0", - "@storybook/addon-actions": "8.0.9", - "@storybook/addon-essentials": "8.0.9", - "@storybook/addon-interactions": "8.0.9", - "@storybook/addon-links": "8.0.9", - "@storybook/addon-mdx-gfm": "8.0.9", - "@storybook/addon-storysource": "8.0.9", - "@storybook/blocks": "8.0.9", - "@storybook/components": "8.0.9", - "@storybook/core-events": "8.0.9", - "@storybook/manager-api": "8.0.9", - "@storybook/preview-api": "8.0.9", - "@storybook/react": "8.0.9", - "@storybook/react-vite": "8.0.9", - "@storybook/test": "8.0.9", - "@storybook/theming": "8.0.9", - "@storybook/types": "8.0.9", - "@storybook/vue3": "8.0.9", - "@storybook/vue3-vite": "8.0.9", - "@testing-library/vue": "8.0.3", + "@storybook/addon-actions": "8.1.11", + "@storybook/addon-essentials": "8.1.11", + "@storybook/addon-interactions": "8.1.11", + "@storybook/addon-links": "8.1.11", + "@storybook/addon-mdx-gfm": "8.1.11", + "@storybook/addon-storysource": "8.1.11", + "@storybook/blocks": "8.1.11", + "@storybook/components": "8.1.11", + "@storybook/core-events": "8.1.11", + "@storybook/manager-api": "8.1.11", + "@storybook/preview-api": "8.1.11", + "@storybook/react": "8.1.11", + "@storybook/react-vite": "8.1.11", + "@storybook/test": "8.1.11", + "@storybook/theming": "8.1.11", + "@storybook/types": "8.1.11", + "@storybook/vue3": "8.1.11", + "@storybook/vue3-vite": "8.1.11", + "@testing-library/vue": "8.1.0", "@types/escape-regexp": "0.0.3", "@types/estree": "1.0.5", "@types/matter-js": "0.19.6", - "@types/micromatch": "4.0.7", - "@types/node": "20.12.7", + "@types/micromatch": "4.0.9", + "@types/node": "20.14.9", "@types/punycode": "2.1.4", "@types/sanitize-html": "2.11.0", + "@types/seedrandom": "3.0.8", "@types/throttle-debounce": "5.0.2", "@types/tinycolor2": "1.4.6", - "@types/uuid": "9.0.8", + "@types/uuid": "10.0.0", "@types/ws": "8.5.10", - "@typescript-eslint/eslint-plugin": "7.7.1", - "@typescript-eslint/parser": "7.7.1", - "@vitest/coverage-v8": "0.34.6", - "@vue/runtime-core": "3.4.26", - "acorn": "8.11.3", + "@typescript-eslint/eslint-plugin": "7.15.0", + "@typescript-eslint/parser": "7.15.0", + "@vitest/coverage-v8": "1.6.0", + "@vue/runtime-core": "3.4.31", + "acorn": "8.12.0", "cross-env": "7.0.3", - "cypress": "13.8.1", - "eslint": "8.57.0", + "cypress": "13.13.0", "eslint-plugin-import": "2.29.1", - "eslint-plugin-vue": "9.25.0", + "eslint-plugin-vue": "9.26.0", "fast-glob": "3.3.2", "happy-dom": "10.0.3", "intersection-observer": "0.12.2", - "micromatch": "4.0.5", - "msw": "2.2.14", - "msw-storybook-addon": "2.0.1", - "nodemon": "3.1.0", - "prettier": "3.2.5", + "micromatch": "4.0.7", + "msw": "2.3.1", + "msw-storybook-addon": "2.0.2", + "nodemon": "3.1.4", + "prettier": "3.3.2", "react": "18.3.1", "react-dom": "18.3.1", - "start-server-and-test": "2.0.3", - "storybook": "8.0.9", + "seedrandom": "3.0.5", + "start-server-and-test": "2.0.4", + "storybook": "8.1.11", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "vite-plugin-turbosnap": "1.0.3", - "vitest": "0.34.6", + "vitest": "1.6.0", "vitest-fetch-mock": "0.2.2", - "vue-component-type-helpers": "2.0.16", - "vue-eslint-parser": "9.4.2", - "vue-tsc": "2.0.16" + "vue-component-type-helpers": "2.0.24", + "vue-eslint-parser": "9.4.3", + "vue-tsc": "2.0.24" } } diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 7f20e0b1a275..4172016f8984 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -120,7 +120,7 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr res.json().then(done2, fail2); })) .then(async res => { - if (res.error) { + if ('error' in res) { if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') { // SUSPENDED if (forceShowDialog || $i && (token === $i.token || id === $i.id)) { @@ -184,10 +184,12 @@ export async function refreshAccount() { export async function login(token: Account['token'], redirect?: string) { const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { success: false, showing: showing, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); if (_DEV_) console.log('logging as token ', token); const me = await fetchAccount(token, undefined, true) .catch(reason => { @@ -223,21 +225,23 @@ export async function openAccountMenu(opts: { if (!$i) return; function showSigninDialog() { - popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { done: res => { addAccount(res.id, res.i); success(); }, - }, 'closed'); + closed: () => dispose(), + }); } function createAccount() { - popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { done: res => { addAccount(res.id, res.i); switchAccountWithToken(res.i); }, - }, 'closed'); + closed: () => dispose(), + }); } async function switchAccount(account: Misskey.entities.UserDetailed) { diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 5cb19f388aee..d32701631726 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -13,7 +13,6 @@ import * as sound from '@/scripts/sound.js'; import { $i, signout, updateAccount } from '@/account.js'; import { instance } from '@/instance.js'; import { ColdDeviceStorage, defaultStore } from '@/store.js'; -import { makeHotkey } from '@/scripts/hotkey.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { miLocalStorage } from '@/local-storage.js'; import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js'; @@ -21,6 +20,7 @@ import { initializeSw } from '@/scripts/initialize-sw.js'; import { deckStore } from '@/ui/deck/deck-store.js'; import { emojiPicker } from '@/scripts/emoji-picker.js'; import { mainRouter } from '@/router/main.js'; +import { type Keymap, makeHotkey } from '@/scripts/hotkey.js'; export async function mainBoot() { const { isClientUpdated } = await common(() => createApp( @@ -35,7 +35,9 @@ export async function mainBoot() { emojiPicker.init(); if (isClientUpdated && $i) { - popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, { + closed: () => dispose(), + }); } const stream = useStream(); @@ -67,14 +69,6 @@ export async function mainBoot() { }); } - const hotkeys = { - 'd': (): void => { - defaultStore.set('darkMode', !defaultStore.state.darkMode); - }, - 's': (): void => { - mainRouter.push('/search'); - }, - }; try { if (defaultStore.state.enableSeasonalScreenEffect) { const month = new Date().getMonth() + 1; @@ -96,34 +90,37 @@ export async function mainBoot() { }).render(); } } - } + } } catch (error) { // console.error(error); console.error('Failed to initialise the seasonal screen effect canvas context:', error); } if ($i) { - // only add post shortcuts if logged in - hotkeys['p|n'] = post; - defaultStore.loaded.then(() => { if (defaultStore.state.accountSetupWizard !== -1) { - popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed'); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, { + closed: () => dispose(), + }); } }); for (const announcement of ($i.unreadAnnouncements ?? []).filter(x => x.display === 'dialog')) { - popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { announcement, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); } stream.on('announcementCreated', (ev) => { const announcement = ev.announcement; if (announcement.display === 'dialog') { - popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { announcement, - }, {}, 'closed'); + }, { + closed: () => dispose(), + }); } }); @@ -247,13 +244,17 @@ export async function mainBoot() { const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); if (neverShowDonationInfo !== 'true' && (createdAt.getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) { if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) { - popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed'); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, { + closed: () => dispose(), + }); } } const modifiedVersionMustProminentlyOfferInAgplV3Section13Read = miLocalStorage.getItem('modifiedVersionMustProminentlyOfferInAgplV3Section13Read'); if (modifiedVersionMustProminentlyOfferInAgplV3Section13Read !== 'true' && instance.repositoryUrl !== 'https://github.com/misskey-dev/misskey') { - popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, {}, 'closed'); + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSourceCodeAvailablePopup.vue')), {}, { + closed: () => dispose(), + }); } if ('Notification' in window) { @@ -322,7 +323,19 @@ export async function mainBoot() { } // shortcut - document.addEventListener('keydown', makeHotkey(hotkeys)); + const keymap = { + 'p|n': () => { + if ($i == null) return; + post(); + }, + 'd': () => { + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': () => { + mainRouter.push('/search'); + }, + } as const satisfies Keymap; + document.addEventListener('keydown', makeHotkey(keymap), { passive: false }); initializeSw(); } diff --git a/packages/frontend/src/cache.ts b/packages/frontend/src/cache.ts index b286528de6c9..bfe8fbe0e40a 100644 --- a/packages/frontend/src/cache.ts +++ b/packages/frontend/src/cache.ts @@ -11,3 +11,4 @@ export const clipsCache = new Cache(1000 * 60 * 30, () export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list')); export const userListsCache = new Cache(1000 * 60 * 30, () => misskeyApi('users/lists/list')); export const antennasCache = new Cache(1000 * 60 * 30, () => misskeyApi('antennas/list')); +export const favoritedChannelsCache = new Cache(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 })); diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index 5d103fa789af..c8134416b506 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -153,7 +153,7 @@ onMounted(() => { background: linear-gradient(0deg, #ffee20, #eb7018); } - &:before { + &::before { content: ""; display: block; position: absolute; @@ -173,7 +173,7 @@ onMounted(() => { background: linear-gradient(0deg, #e1e1e1, #7c7c7c); } - &:before { + &::before { content: ""; display: block; position: absolute; diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 3489255b9152..9560efb7d95f 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :type="type" :name="name" :value="value" + :disabled="disabled" @click="emit('click', $event)" @mousedown="onMousedown" > @@ -55,6 +56,7 @@ const props = defineProps<{ asLike?: boolean; name?: string; value?: string; + disabled?: boolean; }>(); const emit = defineEmits<{ @@ -248,7 +250,6 @@ function onMousedown(evt: MouseEvent): void { } &:focus-visible { - outline: solid 2px var(--focus); outline-offset: 2px; } diff --git a/packages/frontend/src/components/MkCaptcha.vue b/packages/frontend/src/components/MkCaptcha.vue index c64bb47e771b..c5b6e0caede7 100644 --- a/packages/frontend/src/components/MkCaptcha.vue +++ b/packages/frontend/src/components/MkCaptcha.vue @@ -104,7 +104,6 @@ async function requestRender() { }); } else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) { const { default: Widget } = await import('@mcaptcha/vanilla-glue'); - // @ts-expect-error avoid typecheck error new Widget({ siteKey: { instanceUrl: new URL(props.instanceUrl), diff --git a/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts new file mode 100644 index 000000000000..b9770670dcdd --- /dev/null +++ b/packages/frontend/src/components/MkChannelFollowButton.stories.impl.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable import/no-default-export */ +import { StoryObj } from '@storybook/vue3'; +import { HttpResponse, http } from 'msw'; +import { action } from '@storybook/addon-actions'; +import { expect, userEvent, within } from '@storybook/test'; +import { channel } from '../../.storybook/fakes.js'; +import { commonHandlers } from '../../.storybook/mocks.js'; +import MkChannelFollowButton from './MkChannelFollowButton.vue'; +import { i18n } from '@/i18n.js'; + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export const Default = { + render(args) { + return { + components: { + MkChannelFollowButton, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '', + }; + }, + args: { + channel: channel(), + full: true, + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const buttonElement = canvas.getByRole('button'); + await expect(buttonElement).toHaveTextContent(i18n.ts.follow); + await userEvent.click(buttonElement); + await sleep(1000); + await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow); + await userEvent.click(buttonElement); + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + http.post('/api/channels/follow', async ({ request }) => { + action('POST /api/channels/follow')(await request.json()); + return HttpResponse.json({}); + }), + http.post('/api/channels/unfollow', async ({ request }) => { + action('POST /api/channels/unfollow')(await request.json()); + return HttpResponse.json({}); + }), + ], + }, + }, +} satisfies StoryObj; diff --git a/packages/frontend/src/components/MkChannelFollowButton.vue b/packages/frontend/src/components/MkChannelFollowButton.vue index 6b1b380e4118..35dc3ad4bf83 100644 --- a/packages/frontend/src/components/MkChannelFollowButton.vue +++ b/packages/frontend/src/components/MkChannelFollowButton.vue @@ -26,17 +26,18 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 4106b0a4363d..90284890a5df 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -115,14 +115,14 @@ function onDragend() { background: rgba(#000, 0.05); > .label { - &:before, - &:after { + &::before, + &::after { background: #0b65a5; } &.red { - &:before, - &:after { + &::before, + &::after { background: #c12113; } } @@ -133,14 +133,14 @@ function onDragend() { background: rgba(#000, 0.1); > .label { - &:before, - &:after { + &::before, + &::after { background: #0b588c; } &.red { - &:before, - &:after { + &::before, + &::after { background: #ce2212; } } @@ -159,8 +159,8 @@ function onDragend() { } > .label { - &:before, - &:after { + &::before, + &::after { display: none; } } @@ -181,8 +181,8 @@ function onDragend() { left: 0; pointer-events: none; - &:before, - &:after { + &::before, + &::after { content: ""; display: block; position: absolute; @@ -190,14 +190,14 @@ function onDragend() { background: #0c7ac9; } - &:before { + &::before { top: 0; left: 57px; width: 28px; height: 8px; } - &:after { + &::after { top: 57px; left: 0; width: 8px; @@ -205,8 +205,8 @@ function onDragend() { } &.red { - &:before, - &:after { + &::before, + &::after { background: #c12113; } } diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 8da0d78f355f..1790e57c24e2 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -257,10 +257,11 @@ function onContextmenu(ev: MouseEvent) { text: i18n.ts.openInWindow, icon: 'ti ti-app-window', action: () => { - os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), { initialFolder: props.folder, }, { - }, 'closed'); + closed: () => dispose(), + }); }, }, { type: 'divider' }, { text: i18n.ts.rename, @@ -295,7 +296,7 @@ function onContextmenu(ev: MouseEvent) { cursor: pointer; &.draghover { - &:after { + &::after { content: ""; pointer-events: none; position: absolute; diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 8a6bef54d83f..4a3ed69f470d 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -5,7 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue index c5dd87797105..6783804cc587 100644 --- a/packages/frontend/src/components/MkFlashPreview.vue +++ b/packages/frontend/src/components/MkFlashPreview.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> - + diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index 07845bacbbf6..e96402d13bb8 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -271,7 +271,7 @@ function onDrop(ev) { border-radius: 10px; &.draghover { - &:after { + &::after { content: ""; display: block; position: absolute; @@ -285,7 +285,7 @@ function onDrop(ev) { } &.dragging { - &:after { + &::after { content: ""; display: block; position: absolute; diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts index 70b55e8172a4..bb3c04cd5c8f 100644 --- a/packages/frontend/src/ui/deck/deck-store.ts +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -9,6 +9,7 @@ import { notificationTypes } from 'misskey-js'; import { Storage } from '@/pizzax.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { deepClone } from '@/scripts/clone.js'; +import { SoundStore } from '@/store.js'; type ColumnWidget = { name: string; @@ -33,6 +34,7 @@ export type Column = { withRenotes?: boolean; withReplies?: boolean; onlyFiles?: boolean; + soundSetting: SoundStore; }; export const deckStore = markRaw(new Storage('deck', { diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue index 70ea54326f34..5369112494c6 100644 --- a/packages/frontend/src/ui/deck/list-column.vue +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ column.name }} - + @@ -21,6 +21,10 @@ import MkTimeline from '@/components/MkTimeline.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; +import { MenuItem } from '@/types/menu.js'; +import { SoundStore } from '@/store.js'; +import { soundSettingsButton } from '@/ui/deck/tl-note-notification.js'; +import * as sound from '@/scripts/sound.js'; const props = defineProps<{ column: Column; @@ -29,6 +33,7 @@ const props = defineProps<{ const timeline = shallowRef>(); const withRenotes = ref(props.column.withRenotes ?? true); +const soundSetting = ref(props.column.soundSetting ?? { type: null, volume: 1 }); if (props.column.listId == null) { setList(); @@ -40,6 +45,10 @@ watch(withRenotes, v => { }); }); +watch(soundSetting, v => { + updateColumn(props.column.id, { soundSetting: v }); +}); + async function setList() { const lists = await misskeyApi('users/lists/list'); const { canceled, result: list } = await os.select({ @@ -59,7 +68,11 @@ function editList() { os.pageWindow('my/lists/' + props.column.listId); } -const menu = [ +function onNote() { + sound.playMisskeySfxFile(soundSetting.value); +} + +const menu: MenuItem[] = [ { icon: 'ti ti-pencil', text: i18n.ts.selectList, @@ -75,5 +88,10 @@ const menu = [ text: i18n.ts.showRenotes, ref: withRenotes, }, + { + icon: 'ti ti-bell', + text: i18n.ts._deck.newNoteNotificationSettings, + action: () => soundSettingsButton(soundSetting), + }, ]; diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue index 451cc58791d2..23b0fd4f7b0d 100644 --- a/packages/frontend/src/ui/deck/notifications-column.vue +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -27,7 +27,7 @@ const props = defineProps<{ const notificationsComponent = shallowRef>(); function func() { - os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), { excludeTypes: props.column.excludeTypes, }, { done: async (res) => { @@ -36,7 +36,8 @@ function func() { excludeTypes: excludeTypes, }); }, - }, 'closed'); + closed: () => dispose(), + }); } const menu = [{ diff --git a/packages/frontend/src/ui/deck/role-timeline-column.vue b/packages/frontend/src/ui/deck/role-timeline-column.vue index eae2ee13f344..32ab7527b471 100644 --- a/packages/frontend/src/ui/deck/role-timeline-column.vue +++ b/packages/frontend/src/ui/deck/role-timeline-column.vue @@ -9,18 +9,22 @@ SPDX-License-Identifier: AGPL-3.0-only {{ column.name }} - +