diff --git a/.config/example.yml b/.config/example.yml index c127eaae22..c7884a3687 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -105,6 +105,54 @@ port: 3000 # socket: /path/to/misskey.sock # chmodSocket: '777' +# Proxy trust settings +# +# Specifies the IP addresses that Misskey will use as trusted +# reverse proxies (e.g., nginx, Cloudflare). This affects how +# Misskey determines the source IP for each request and is used +# for important rate limiting and security features. If the value +# is not set correctly, Misskey may use the IP address of the +# reverse proxy instead of the actual source IP, which may lead to +# unintended rate limiting or security vulnerabilities. +# By default, the loopback network and private network address +# ranges shown below are trusted. +# If you are using a single reverse proxy and it is on the same +# machine or the same private network as Misskey, it is unlikely you +# need to change this setting, and the default setting is fine. +# Also, if you are using multiple reverse proxy servers and they are +# all on the same private network as Misskey, the default setting +# is fine. +# However, if you are using a reverse proxy server that accesses +# Misskey web servers and streaming servers via public IP addresses +# (for example, Cloudflare), you must set this variable. +# When changing this setting, you can use one of the following values: +# +# - true: Trust all proxies +# - false: Do not trust any proxies +# - IP address, IP address range, or array of them: Trust hops that +# match the specified criteria. +# - Integer: Trust the nth hop from the front-facing proxy server as +# the client. +# For more information on how to configure this setting, please refer +# to the Fastify documentation: +# https://fastify.dev/docs/latest/Reference/Server/#trustproxy +# +# Note that if this variable is set, it overrides the default range, +# so if you have both an external reverse proxy and a proxy on the +# local host, you must include both IPs (or IP ranges). +# +#trustProxy: +# - '10.0.0.0/8' +# - '172.16.0.0/12' +# - '192.168.0.0/16' +# - '127.0.0.1/32' +# - '::1/128' +# - 'fc00::/7' +# # Example: If you are using some external reverse proxies like CDNs, +# # you may need to add the CDN IP ranges here. +# # If you're using Cloudflare, you can find IP Ranges at: +# # https://www.cloudflare.com/ips/ + # ┌──────────────────────────┐ #───┘ PostgreSQL configuration └──────────────────────────────── @@ -273,6 +321,10 @@ id: 'aidx' # Whether disable HSTS #disableHsts: true +# Enable internal IP-based rate limiting (default: true) +# To configure them in reverse proxy instead, set this to false. +#enableIpRateLimit: true + # Number of worker processes #clusterLimit: 1 diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b6ebcf6ad3..d208ad6ecf 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1 +1 @@ -FROM mcr.microsoft.com/devcontainers/javascript-node:0-18 +FROM mcr.microsoft.com/devcontainers/javascript-node:4.0.3-24-trixie diff --git a/.devcontainer/compose.yml b/.devcontainer/compose.yml index d02d2a8f4a..501f78c814 100644 --- a/.devcontainer/compose.yml +++ b/.devcontainer/compose.yml @@ -28,7 +28,7 @@ services: db: restart: unless-stopped - image: postgres:15-alpine + image: postgres:18-alpine networks: - internal_network environment: diff --git a/.dockerignore b/.dockerignore index f204349160..39cbe2726f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,7 @@ Dockerfile build/ built/ +src-js/ db/ .devcontainer/compose.yml node_modules/ diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.yml b/.github/ISSUE_TEMPLATE/01_bug-report.yml index 077855b5bf..00da7e9a2a 100644 --- a/.github/ISSUE_TEMPLATE/01_bug-report.yml +++ b/.github/ISSUE_TEMPLATE/01_bug-report.yml @@ -54,7 +54,7 @@ body: * Model and OS of the device(s): MacBook Pro (14inch, 2021), macOS Ventura 13.4 * Browser: Chrome 113.0.5672.126 * Server URL: misskey.example.com - * Misskey: 2025.x.x + * Misskey: 2026.x.x value: | * Model and OS of the device(s): * Browser: @@ -74,9 +74,9 @@ body: Examples: * Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment - * Misskey: 2025.x.x + * Misskey: 2026.x.x * Node: 20.x.x - * PostgreSQL: 15.x.x + * PostgreSQL: 18.x.x * Redis: 7.x.x * OS and Architecture: Ubuntu 24.04.2 LTS aarch64 value: | diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 6117e69c03..49ca3058f3 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -16,13 +16,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Setup Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 5ca27749bb..d17999a271 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -12,9 +12,9 @@ jobs: steps: - name: Checkout head - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 - name: Setup Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 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 22d500c306..8a81e85521 100644 --- a/.github/workflows/check-misskey-js-autogen.yml +++ b/.github/workflows/check-misskey-js-autogen.yml @@ -18,7 +18,7 @@ jobs: if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }} steps: - name: checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 with: submodules: true persist-credentials: false @@ -29,7 +29,7 @@ jobs: - name: setup node id: setup-node - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: pnpm @@ -53,7 +53,7 @@ jobs: # packages/misskey-js/generator/built/autogen - name: Upload Generated - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: generated-misskey-js path: packages/misskey-js/generator/built/autogen @@ -66,14 +66,14 @@ jobs: if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }} steps: - name: checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 with: submodules: true persist-credentials: false ref: refs/pull/${{ github.event.pull_request.number }}/merge - name: Upload From Merged - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: actual-misskey-js path: packages/misskey-js/src/autogen @@ -86,13 +86,13 @@ jobs: pull-requests: write steps: - name: download generated-misskey-js - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: generated-misskey-js path: misskey-js-generated - name: download actual-misskey-js - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: actual-misskey-js path: misskey-js-actual @@ -113,9 +113,9 @@ jobs: - name: send message if: steps.check-changes.outputs.changes == 'true' - uses: thollander/actions-comment-pull-request@v2 + uses: thollander/actions-comment-pull-request@v3 with: - comment_tag: check-misskey-js-autogen + comment-tag: check-misskey-js-autogen message: |- Thank you for sending us a great Pull Request! 👍 Please regenerate misskey-js type definitions! 🙏 @@ -127,9 +127,9 @@ jobs: - name: send message if: steps.check-changes.outputs.changes == 'false' - uses: thollander/actions-comment-pull-request@v2 + uses: thollander/actions-comment-pull-request@v3 with: - comment_tag: check-misskey-js-autogen + comment-tag: check-misskey-js-autogen mode: delete message: "Thank you!" create_if_not_exists: false diff --git a/.github/workflows/check-misskey-js-version.yml b/.github/workflows/check-misskey-js-version.yml index 2b15cbee53..ad07d47b65 100644 --- a/.github/workflows/check-misskey-js-version.yml +++ b/.github/workflows/check-misskey-js-version.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 - name: Check version run: | if [ "$(jq -r '.version' package.json)" != "$(jq -r '.version' packages/misskey-js/package.json)" ]; then diff --git a/.github/workflows/check-spdx-license-id.yml b/.github/workflows/check-spdx-license-id.yml index cf1fd6007d..fe71473ea3 100644 --- a/.github/workflows/check-spdx-license-id.yml +++ b/.github/workflows/check-spdx-license-id.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 - name: Check run: | counter=0 diff --git a/.github/workflows/check_copyright_year.yml b/.github/workflows/check_copyright_year.yml index eaf922d4bc..40016d39c5 100644 --- a/.github/workflows/check_copyright_year.yml +++ b/.github/workflows/check_copyright_year.yml @@ -10,7 +10,7 @@ jobs: check_copyright_year: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 - run: | if [ "$(grep Copyright COPYING | sed -e 's/.*2014-\([0-9]*\) .*/\1/g')" -ne "$(date +%Y)" ]; then echo "Please change copyright year!" diff --git a/.github/workflows/deploy-test-environment.yml b/.github/workflows/deploy-test-environment.yml index 46baf7421b..32c7c6b6ea 100644 --- a/.github/workflows/deploy-test-environment.yml +++ b/.github/workflows/deploy-test-environment.yml @@ -28,7 +28,7 @@ jobs: wait_time: ${{ steps.get-wait-time.outputs.wait_time }} steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 - name: Check allowed users id: check-allowed-users diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml index 56dedf273d..8a97959907 100644 --- a/.github/workflows/docker-develop.yml +++ b/.github/workflows/docker-develop.yml @@ -27,7 +27,7 @@ jobs: platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Check out the repo - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub @@ -53,7 +53,7 @@ jobs: digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: digests-${{ env.PLATFORM_PAIR }} path: /tmp/digests/* @@ -66,7 +66,7 @@ jobs: - build steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: /tmp/digests pattern: digests-* diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index eb98273ba0..37f6aca588 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -32,7 +32,7 @@ jobs: platform=${{ matrix.platform }} echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV - name: Check out the repo - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Docker meta @@ -64,7 +64,7 @@ jobs: digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: digests-${{ env.PLATFORM_PAIR }} path: /tmp/digests/* @@ -77,7 +77,7 @@ jobs: - build steps: - name: Download digests - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: /tmp/digests pattern: digests-* diff --git a/.github/workflows/dockle.yml b/.github/workflows/dockle.yml index f006a45ea4..ec7073c9fd 100644 --- a/.github/workflows/dockle.yml +++ b/.github/workflows/dockle.yml @@ -11,22 +11,43 @@ on: jobs: dockle: runs-on: ubuntu-latest + env: DOCKER_CONTENT_TRUST: 1 - DOCKLE_VERSION: 0.4.14 + DOCKLE_VERSION: 0.4.15 + steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 + - name: Download and install dockle v${{ env.DOCKLE_VERSION }} run: | + set -eux 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 ./compose_example.yml ./compose.yml - - run: | - docker compose up -d web - docker tag "$(docker compose images --format json web | jq -r '.[] | .ID')" misskey-web:latest - - run: | - cmd="dockle --exit-code 1 misskey-web:latest ${image_name}" - echo "> ${cmd}" - eval "${cmd}" + + - name: Build web image (docker build) + run: | + set -eux + docker build -t "misskey-web:ci" . + docker image ls + + - name: Mount tmpfs for Dockle tar + env: + TMPFS_SIZE: 8G + run: | + set -eux + sudo mkdir -p /mnt/dockle-tmp + sudo mount -t tmpfs -o size=${{ env.TMPFS_SIZE }} tmpfs /mnt/dockle-tmp + free -h + df -h + + - name: Save image tar into tmpfs + run: | + set -eux + docker save misskey-web:ci -o /mnt/dockle-tmp/misskey-web.tar + ls -lh /mnt/dockle-tmp/misskey-web.tar + + - name: Run Dockle Scan (tar input) + run: | + set -eux + dockle --exit-code 1 --input /mnt/dockle-tmp/misskey-web.tar diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 933404dfa5..f8a0c4aaa4 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -25,14 +25,14 @@ jobs: ref: refs/pull/${{ github.event.number }}/merge steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: ref: ${{ matrix.ref }} submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -48,7 +48,7 @@ jobs: - name: Copy API.json run: cp packages/backend/built/api.json ${{ matrix.api-json-name }} - name: Upload Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: api-artifact-${{ matrix.api-json-name }} path: ${{ matrix.api-json-name }} @@ -61,7 +61,7 @@ jobs: PR_NUMBER: ${{ github.event.number }} run: | echo "$PR_NUMBER" > ./pr_number - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 with: name: api-artifact-pr-number path: pr_number diff --git a/.github/workflows/get-backend-memory.yml b/.github/workflows/get-backend-memory.yml new file mode 100644 index 0000000000..99f89631bb --- /dev/null +++ b/.github/workflows/get-backend-memory.yml @@ -0,0 +1,87 @@ +# this name is used in report-backend-memory.yml so be careful when change name +name: Get backend memory usage + +on: + pull_request: + branches: + - master + - develop + paths: + - packages/backend/** + - packages/misskey-js/** + - .github/workflows/get-backend-memory.yml + +jobs: + get-memory-usage: + runs-on: ubuntu-latest + permissions: + contents: read + + strategy: + matrix: + memory-json-name: [memory-base.json, memory-head.json] + include: + - memory-json-name: memory-base.json + ref: ${{ github.base_ref }} + - memory-json-name: memory-head.json + ref: refs/pull/${{ github.event.number }}/merge + + services: + postgres: + image: postgres:18 + ports: + - 54312:5432 + env: + POSTGRES_DB: test-misskey + POSTGRES_HOST_AUTH_METHOD: trust + redis: + image: redis:7 + ports: + - 56312:6379 + + steps: + - uses: actions/checkout@v6.0.1 + with: + ref: ${{ matrix.ref }} + submodules: true + - name: Setup pnpm + uses: pnpm/action-setup@v4.2.0 + - name: Use Node.js + uses: actions/setup-node@v6.1.0 + with: + node-version-file: '.node-version' + cache: 'pnpm' + - run: pnpm i --frozen-lockfile + - name: Check pnpm-lock.yaml + run: git diff --exit-code pnpm-lock.yaml + - name: Copy Configure + run: cp .github/misskey/test.yml .config/default.yml + - name: Compile Configure + run: pnpm compile-config + - name: Build + run: pnpm build + - name: Run migrations + run: pnpm --filter backend migrate + - name: Measure memory usage + run: | + # Start the server and measure memory usage + node packages/backend/scripts/measure-memory.mjs > ${{ matrix.memory-json-name }} + - name: Upload Artifact + uses: actions/upload-artifact@v6 + with: + name: memory-artifact-${{ matrix.memory-json-name }} + path: ${{ matrix.memory-json-name }} + + save-pr-number: + runs-on: ubuntu-latest + permissions: {} + steps: + - name: Save PR number + env: + PR_NUMBER: ${{ github.event.number }} + run: | + echo "$PR_NUMBER" > ./pr_number + - uses: actions/upload-artifact@v6 + with: + name: memory-artifact-pr-number + path: pr_number diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 88e2aceaed..5787572dd5 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -11,6 +11,6 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v6 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 550438e308..91cbe52c38 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -36,13 +36,13 @@ jobs: pnpm_install: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: fetch-depth: 0 submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 - - uses: actions/setup-node@v4.4.0 + uses: pnpm/action-setup@v4.2.0 + - uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -69,19 +69,19 @@ jobs: eslint-cache-version: v1 eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }} steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: fetch-depth: 0 submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 - - uses: actions/setup-node@v4.4.0 + uses: pnpm/action-setup@v4.2.0 + - uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - name: Restore eslint cache - uses: actions/cache@v4.2.3 + uses: actions/cache@v4.3.0 with: path: ${{ env.eslint-cache-path }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} @@ -96,22 +96,20 @@ jobs: matrix: workspace: - backend + - frontend - sw - misskey-js steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: fetch-depth: 0 submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 - - uses: actions/setup-node@v4.4.0 + uses: pnpm/action-setup@v4.2.0 + - uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' - run: pnpm i --frozen-lockfile - - run: pnpm --filter misskey-js run build - if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'sw' }} - - run: pnpm --filter misskey-reversi run build - if: ${{ matrix.workspace == 'backend' }} + - run: pnpm --filter "${{ matrix.workspace }}^..." run build - run: pnpm --filter ${{ matrix.workspace }} run typecheck diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index 68e45fdf61..15cc9153f6 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -3,10 +3,12 @@ name: Lint on: push: paths: + - packages/i18n/** - locales/** - .github/workflows/locale.yml pull_request: paths: + - packages/i18n/** - locales/** - .github/workflows/locale.yml jobs: @@ -14,15 +16,18 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v4.2.2 - with: - fetch-depth: 0 - submodules: true - - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 - - uses: actions/setup-node@v4.4.0 - with: - node-version-file: '.node-version' - cache: 'pnpm' - - run: pnpm i --frozen-lockfile - - run: cd locales && node verify.js + - uses: actions/checkout@v6.0.1 + with: + fetch-depth: 0 + submodules: true + - name: Setup pnpm + uses: pnpm/action-setup@v4.2.0 + - uses: actions/setup-node@v6.1.0 + with: + node-version-file: ".node-version" + cache: "pnpm" + - run: pnpm i --frozen-lockfile + - run: pnpm --filter i18n build + - name: Verify Locales + working-directory: ./packages/i18n + run: pnpm run verify diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index 7787d6055b..c9a47385a0 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -16,13 +16,13 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/release-edit-with-push.yml b/.github/workflows/release-edit-with-push.yml index 57657a4ba7..bc16dbcef2 100644 --- a/.github/workflows/release-edit-with-push.yml +++ b/.github/workflows/release-edit-with-push.yml @@ -19,7 +19,7 @@ jobs: edit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得 - name: Get PR run: | diff --git a/.github/workflows/release-with-dispatch.yml b/.github/workflows/release-with-dispatch.yml index d750001b71..f318200584 100644 --- a/.github/workflows/release-with-dispatch.yml +++ b/.github/workflows/release-with-dispatch.yml @@ -36,7 +36,7 @@ jobs: outputs: pr_number: ${{ steps.get_pr.outputs.pr_number }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 # headが$GITHUB_REF_NAME, baseが$STABLE_BRANCHかつopenのPRを1つ取得 - name: Get PRs run: | diff --git a/.github/workflows/report-api-diff.yml b/.github/workflows/report-api-diff.yml index 1170f898ce..59b92d022e 100644 --- a/.github/workflows/report-api-diff.yml +++ b/.github/workflows/report-api-diff.yml @@ -16,7 +16,7 @@ jobs: # api-artifact steps: - name: Download artifact - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 with: script: | const fs = require('fs'); @@ -60,7 +60,7 @@ jobs: - name: Echo full diff run: cat ./api-full.json.diff - name: Upload full diff to Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: api-artifact path: | @@ -73,9 +73,9 @@ jobs: HEADER="このPRによるapi.jsonの差分" FOOTER="[Get diff files from Workflow Page](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" DIFF_BYTES="$(stat ./api.json.diff -c '%s' | tr -d '\n')" - + echo "$HEADER" > ./output.md - + if (( "$DIFF_BYTES" <= 1 )); then echo '差分はありません。' >> ./output.md else @@ -87,18 +87,18 @@ jobs: echo '```' >> ./output.md echo '' >> .output.md fi - + echo "$FOOTER" >> ./output.md - - uses: thollander/actions-comment-pull-request@v2 + - uses: thollander/actions-comment-pull-request@v3 with: - pr_number: ${{ steps.load-pr-num.outputs.pr-number }} - comment_tag: show_diff - filePath: ./output.md + pr-number: ${{ steps.load-pr-num.outputs.pr-number }} + comment-tag: show_diff + file-path: ./output.md - name: Tell error to PR - uses: thollander/actions-comment-pull-request@v2 + uses: thollander/actions-comment-pull-request@v3 if: failure() && steps.load-pr-num.outputs.pr-number with: - pr_number: ${{ steps.load-pr-num.outputs.pr-number }} - comment_tag: show_diff_error + pr-number: ${{ steps.load-pr-num.outputs.pr-number }} + comment-tag: show_diff_error message: | api.jsonの差分作成中にエラーが発生しました。詳細は[Workflowのログ](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})を確認してください。 diff --git a/.github/workflows/report-backend-memory.yml b/.github/workflows/report-backend-memory.yml new file mode 100644 index 0000000000..bf2e311c83 --- /dev/null +++ b/.github/workflows/report-backend-memory.yml @@ -0,0 +1,177 @@ +name: Report backend memory + +on: + workflow_run: + types: [completed] + workflows: + - Get backend memory usage # get-backend-memory.yml + +jobs: + compare-memory: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + permissions: + pull-requests: write + + steps: + - name: Download artifact + uses: actions/github-script@v8.0.0 + with: + script: | + const fs = require('fs'); + let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: context.payload.workflow_run.id, + }); + let matchArtifacts = allArtifacts.data.artifacts.filter((artifact) => { + return artifact.name.startsWith("memory-artifact-") || artifact.name == "memory-artifact" + }); + await Promise.all(matchArtifacts.map(async (artifact) => { + let download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: artifact.id, + archive_format: 'zip', + }); + await fs.promises.writeFile(`${process.env.GITHUB_WORKSPACE}/${artifact.name}.zip`, Buffer.from(download.data)); + })); + - name: Extract all artifacts + run: | + find . -mindepth 1 -maxdepth 1 -type f -name '*.zip' -exec unzip {} -d artifacts ';' + ls -la artifacts/ + - name: Load PR Number + id: load-pr-num + run: echo "pr-number=$(cat artifacts/pr_number)" >> "$GITHUB_OUTPUT" + + - name: Output base + run: cat ./artifacts/memory-base.json + - name: Output head + run: cat ./artifacts/memory-head.json + - name: Compare memory usage + id: compare + run: | + BASE_MEMORY=$(cat ./artifacts/memory-base.json) + HEAD_MEMORY=$(cat ./artifacts/memory-head.json) + + variation() { + calc() { + BASE=$(echo "$BASE_MEMORY" | jq -r ".${1}.${2} // 0") + HEAD=$(echo "$HEAD_MEMORY" | jq -r ".${1}.${2} // 0") + + DIFF=$((HEAD - BASE)) + if [ "$BASE" -gt 0 ]; then + DIFF_PERCENT=$(echo "scale=2; ($DIFF * 100) / $BASE" | bc) + else + DIFF_PERCENT=0 + fi + + # Convert KB to MB for readability + BASE_MB=$(echo "scale=2; $BASE / 1024" | bc) + HEAD_MB=$(echo "scale=2; $HEAD / 1024" | bc) + DIFF_MB=$(echo "scale=2; $DIFF / 1024" | bc) + + JSON=$(jq -c -n \ + --argjson base "$BASE_MB" \ + --argjson head "$HEAD_MB" \ + --argjson diff "$DIFF_MB" \ + --argjson diff_percent "$DIFF_PERCENT" \ + '{base: $base, head: $head, diff: $diff, diff_percent: $diff_percent}') + + echo "$JSON" + } + + JSON=$(jq -c -n \ + --argjson VmRSS "$(calc $1 VmRSS)" \ + --argjson VmHWM "$(calc $1 VmHWM)" \ + --argjson VmSize "$(calc $1 VmSize)" \ + --argjson VmData "$(calc $1 VmData)" \ + '{VmRSS: $VmRSS, VmHWM: $VmHWM, VmSize: $VmSize, VmData: $VmData}') + + echo "$JSON" + } + + JSON=$(jq -c -n \ + --argjson beforeGc "$(variation beforeGc)" \ + --argjson afterGc "$(variation afterGc)" \ + --argjson afterRequest "$(variation afterRequest)" \ + '{beforeGc: $beforeGc, afterGc: $afterGc, afterRequest: $afterRequest}') + + echo "res=$JSON" >> "$GITHUB_OUTPUT" + - id: build-comment + name: Build memory comment + env: + RES: ${{ steps.compare.outputs.res }} + run: | + HEADER="## Backend memory usage comparison" + FOOTER="[See workflow logs for details](https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID})" + + echo "$HEADER" > ./output.md + echo >> ./output.md + + table() { + echo "| Metric | base (MB) | head (MB) | Diff (MB) | Diff (%) |" >> ./output.md + echo "|--------|------:|------:|------:|------:|" >> ./output.md + + line() { + METRIC=$2 + BASE=$(echo "$RES" | jq -r ".${1}.${2}.base") + HEAD=$(echo "$RES" | jq -r ".${1}.${2}.head") + DIFF=$(echo "$RES" | jq -r ".${1}.${2}.diff") + DIFF_PERCENT=$(echo "$RES" | jq -r ".${1}.${2}.diff_percent") + + if (( $(echo "$DIFF_PERCENT > 0" | bc -l) )); then + DIFF="+$DIFF" + DIFF_PERCENT="+$DIFF_PERCENT" + fi + + # highlight VmRSS + if [ "$2" = "VmRSS" ]; then + METRIC="**${METRIC}**" + BASE="**${BASE}**" + HEAD="**${HEAD}**" + DIFF="**${DIFF}**" + DIFF_PERCENT="**${DIFF_PERCENT}**" + fi + + echo "| ${METRIC} | ${BASE} MB | ${HEAD} MB | ${DIFF} MB | ${DIFF_PERCENT}% |" >> ./output.md + } + + line $1 VmRSS + line $1 VmHWM + line $1 VmSize + line $1 VmData + } + + echo "### Before GC" >> ./output.md + table beforeGc + echo >> ./output.md + + echo "### After GC" >> ./output.md + table afterGc + echo >> ./output.md + + echo "### After Request" >> ./output.md + table afterRequest + echo >> ./output.md + + # Determine if this is a significant change (more than 5% increase) + if [ "$(echo "$RES" | jq -r '.afterGc.VmRSS.diff_percent | tonumber > 5')" = "true" ]; then + echo "⚠️ **Warning**: Memory usage has increased by more than 5%. Please verify this is not an unintended change." >> ./output.md + echo >> ./output.md + fi + + echo "$FOOTER" >> ./output.md + - uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ steps.load-pr-num.outputs.pr-number }} + comment-tag: show_memory_diff + file-path: ./output.md + - name: Tell error to PR + uses: thollander/actions-comment-pull-request@v3 + if: failure() && steps.load-pr-num.outputs.pr-number + with: + pr-number: ${{ steps.load-pr-num.outputs.pr-number }} + comment-tag: show_memory_diff_error + message: | + An error occurred while comparing backend memory usage. See [workflow logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. diff --git a/.github/workflows/request-release-review.yml b/.github/workflows/request-release-review.yml new file mode 100644 index 0000000000..9b6768149b --- /dev/null +++ b/.github/workflows/request-release-review.yml @@ -0,0 +1,51 @@ +name: Request release review + +on: + issue_comment: + types: [created] + +jobs: + reply: + if: github.event.comment.body == '/request-release-review' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - name: Reply + uses: actions/github-script@v8 + with: + script: | + const body = `To dev team (@misskey-dev/dev): + + リリースが提案されています :rocket: + + GOの場合はapprove、NO GOの場合はその旨コメントをお願いいたします。 + + 判断にあたって考慮すべき観点は、 + + - やり残したことはないか? + - CHANGELOGは過不足ないか? + - バージョンに問題はないか?(月跨いでいるのに更新忘れているなど) + - 再考すべき仕様・実装はないか? + - ベータ版を検証したサーバーから不具合の報告等は上がってないか? + - (セキュリティの修正や重要なバグ修正などのため)リリースを急いだ方が良いか?そうではないか? + - Actionsが落ちていないか? + + などが挙げられます。 + + ご協力ありがとうございます :sparkles: + ` + + const issue_number = context.payload.issue ? context.payload.issue.number : (context.payload.pull_request && context.payload.pull_request.number) + if (!issue_number) { + console.log('No issue or PR number found in payload; skipping') + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + body, + }) + } diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index b1d95c1b33..c28b1f6e93 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -22,12 +22,12 @@ jobs: NODE_OPTIONS: "--max_old_space_size=7168" steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 if: github.event_name != 'pull_request_target' with: fetch-depth: 0 submodules: true - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 if: github.event_name == 'pull_request_target' with: fetch-depth: 0 @@ -37,9 +37,9 @@ jobs: if: github.event_name == 'pull_request_target' run: git checkout "$(git rev-list --parents -n1 HEAD | cut -d" " -f3)" - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -90,7 +90,7 @@ jobs: env: CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} - name: Notify that Chromatic detects changes - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v8.0.0 if: github.event_name != 'pull_request_target' && steps.chromatic_push.outputs.success == 'false' with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -102,7 +102,7 @@ jobs: body: 'Chromatic detects changes. Please [review the changes on Chromatic](https://www.chromatic.com/builds?appId=6428f7d7b962f0b79f97d6e4).' }) - name: Upload Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: storybook path: packages/frontend/storybook-static diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 5358df3dc4..77bbdb2b0a 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -38,7 +38,7 @@ jobs: services: postgres: - image: postgres:15 + image: postgres:18 ports: - 54312:5432 env: @@ -48,13 +48,20 @@ jobs: image: redis:7 ports: - 56312:6379 + meilisearch: + image: getmeili/meilisearch:v1.3.4 + ports: + - 57712:7700 + env: + MEILI_NO_ANALYTICS: true + MEILI_ENV: development steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Get current date id: current-date run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT @@ -86,7 +93,7 @@ jobs: fi done - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' @@ -117,7 +124,7 @@ jobs: services: postgres: - image: postgres:15 + image: postgres:18 ports: - 54312:5432 env: @@ -129,13 +136,13 @@ jobs: - 56312:6379 steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' @@ -165,7 +172,7 @@ jobs: services: postgres: - image: postgres:15 + image: postgres:18 ports: - 54312:5432 env: @@ -173,16 +180,16 @@ jobs: POSTGRES_HOST_AUTH_METHOD: trust steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Get current date id: current-date run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml index 873396f622..7f8fe547e1 100644 --- a/.github/workflows/test-federation.yml +++ b/.github/workflows/test-federation.yml @@ -32,11 +32,11 @@ jobs: - .node-version - .github/min.node-version steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Get current date id: current-date run: echo "today=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT @@ -68,7 +68,7 @@ jobs: fi done - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: ${{ matrix.node-version-file }} cache: 'pnpm' diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index 94e43cf91e..52723e894c 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -28,13 +28,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -64,7 +64,7 @@ jobs: services: postgres: - image: postgres:15 + image: postgres:18 ports: - 54312:5432 env: @@ -76,7 +76,7 @@ jobs: - 56312:6379 steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: submodules: true # https://github.com/cypress-io/cypress-docker-images/issues/150 @@ -86,9 +86,9 @@ jobs: #- uses: browser-actions/setup-firefox@latest # if: ${{ matrix.browser == 'firefox' }} - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' @@ -113,12 +113,12 @@ jobs: wait-on: 'http://localhost:61812' headed: true browser: ${{ matrix.browser }} - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 if: failure() with: name: ${{ matrix.browser }}-cypress-screenshots path: cypress/screenshots - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v6 if: always() with: name: ${{ matrix.browser }}-cypress-videos diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index f6d16bbd76..428cbce3b8 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -22,13 +22,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.1 - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Setup Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 751c374608..9c0ea4d738 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -16,13 +16,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index edff7dbecb..8ffc60fc6e 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -17,13 +17,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.2.2 + - uses: actions/checkout@v6.0.1 with: submodules: true - name: Setup pnpm - uses: pnpm/action-setup@v4.1.0 + uses: pnpm/action-setup@v4.2.0 - name: Use Node.js - uses: actions/setup-node@v4.4.0 + uses: actions/setup-node@v6.1.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.gitignore b/.gitignore index ac7502f384..7839e4de66 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ docker-compose.yml built built-test js-built +src-js /data /.cache-loader /db diff --git a/.vscode/settings.json b/.vscode/settings.json index 5f36a32af4..2d11d24db2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "**/node_modules": true }, "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, "files.associations": { "*.test.ts": "typescript" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index c860ed44eb..376c17d818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,236 @@ +## 2026.1.0 + +### Note +- `users/following` の `birthday` プロパティは非推奨になりました。代わりに `users/get-following-birthday-users` をご利用ください。 + +### General +- Enhance: 「もうすぐ誕生日のユーザー」ウィジェットで、誕生日が至近のユーザーも表示できるように + (Cherry-picked from https://github.com/MisskeyIO/misskey) + - 「今日誕生日のユーザー」は「もうすぐ誕生日のユーザー」に名称変更されました +- 依存関係の更新 + +### Client +- Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に +- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように +- Enhance: ウィジェットの設定項目のラベルの多言語対応 +- Enhance: 画面幅が広いときにメディアを横並びで表示できるようにするオプションを追加 +- Enhance: パフォーマンスの向上 +- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061 +- Fix: 非ログイン時にログインを求めるダイアログが表示された後にダイアログのぼかしが解除されず操作不能になることがある問題を修正 +- Fix: ドライブのソートが「登録日(昇順)」の場合に正しく動作しない問題を修正 +- Fix: 高度なMFMのピッカーを使用する際の挙動を改善 +- Fix: 管理画面でアーカイブ済のお知らせを表示した際にアクティブなお知らせが多い旨の警告が出る問題を修正 +- Fix: ファイルタブのセンシティブメディアを開く際に確認ダイアログを出す設定が適用されない問題を修正 +- Fix: 2月29日を誕生日に設定している場合、閏年以外は3月1日を誕生日として扱うように修正 +- Fix: `Mk:C:container` の `borderWidth` が正しく反映されない問題を修正 + +### Server +- Enhance: OAuthのクライアント情報取得(Client Information Discovery)において、IndieWeb Living Standard 11 July 2024で定義されているJSONドキュメント形式に対応しました + - JSONによるClient Information Discoveryを行うには、レスポンスの`Content-Type`ヘッダーが`application/json`である必要があります + - 従来の実装(12 February 2022版・HTML Microformat形式)も引き続きサポートされます +- Enhance: メモリ使用量を削減 + +## 2025.12.2 + +### Note +v2025.12.0で行われた「configの`trustProxy`のデフォルト値を`false`に変更」について、正しく環境に応じた設定を行わないとサインインが困難になるといった状態を緩和するために、以下の対応を行いました。 + +**正しく設定しないと、上記のような不具合の原因となったり、セキュリティリスクが高まったりする可能性があります。必ず現在のconfigをご確認の上、必要に応じて値を変更してください。** + +- `trustProxy`について、デフォルト(configに値が設定されていない状態)ではループバックアドレスとローカルIPアドレス空間を信頼するようにしました。 +- `trustProxy`の設定方法について、より詳細に記述しました。 +- リバースプロキシやCDNなどのより上流のレイヤでレートリミットを設定したい場合や、緊急時の一時的な緩和策として、Misskey内部でのIPアドレスペースでのレートリミットを無効化できるようにしました。 + +### General +- 依存関係の更新 + +### Client +- Enhance: デッキのUI説明を追加 +- Enhance: 設定がブラウザによって消去されないようにするオプションを追加 +- Fix: バージョン表記のないPlayが正しく動作しない問題を修正 + バージョン表記のないものは v0.x 系として実行されます。v1.x 系で動作させたい場合は必ずバージョン表記を含めてください。 +- Fix: デッキUIでメニュー位置を下にしているとプロファイル削除ボタンが表示されないのを修正 +- Fix: 一部のUnicode絵文字のリアクションがボタンにならない問題を修正 + +### Server +- Enhance: Misskey内部でのIPアドレスペースでのレートリミットを無効化できるように + - リバースプロキシやCDNなど別のレイヤで別途レートリミットを設定する場合や、ローカルでのテスト用途等として利用することを想定しています。 + - デフォルトは `enableIpRateLimit: true`(Misskey内部でのIPアドレスペースでのレートリミットは有効)です。 +- Fix: コントロールパネルのジョブキューページで使用される一部APIの応答速度を改善 + +## 2025.12.1 + +### Client +- Fix: 特定の条件下でMisskeyが起動せず空白のページが表示されることがある問題を軽減 +- Fix: 初回読み込み時などに、言語設定で不整合が発生することがある問題を修正 +- Fix: 削除されたノートのリノートが正しく動作されない問題を修正 +- Fix: チャンネルオーナーが削除済みの時にチャンネルのヘッダーメニューが表示されない不具合を修正 +- Fix: ドライブで登録日以外でソートする場合は月でグループ化して表示しないように +- Fix: `null` を返す note_view_intrruptor プラグインが動作しない問題を修正 + +### Server +- Fix: ジョブキューでSentryが有効にならない問題を修正 + + +## 2025.12.0 + +### Note +- configの`trustProxy`のデフォルト値を`false`に変更しました。アップデート前に現在のconfigをご確認の上、必要に応じて値を変更してください。 + +### Client +- Fix: stacking router viewで連続して戻る操作を行うと何も表示されなくなる問題を修正 + +### Server +- Enhance: メモリ使用量を削減しました +- Enhance: ActivityPubアクティビティを送信する際のパフォーマンス向上 +- Enhance: 依存関係の更新 +- Fix: セキュリティに関する修正 + +## 2025.11.1 + +### Client + +- Enhance: リアクションの受け入れ設定にキャプションを追加 #15921 +- Fix: ページの内容がはみ出ることがある問題を修正 +- Fix: ナビゲーションバーを下に表示しているときに、項目数が多いと表示が崩れる問題を修正 +- Fix: ヘッダーメニューのチャンネルの新規作成の項目でチャンネル作成ページに飛べない問題を修正 #16816 +- Fix: ラジオボタンに空白の選択肢が表示される問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1105) +- Fix: 一部のシチュエーションで投稿フォームのツアーが正しく表示されない問題を修正 +- Fix: 投稿フォームのリセットボタンで注釈がリセットされない問題を修正 +- Fix: PlayのAiScriptバージョン判定(v0.x系・v1.x系の判定)が正しく動作しない問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1129) +- Fix: フォロー申請をキャンセルする際の確認ダイアログの文言が不正確な問題を修正 +- Fix: 初回読み込み時にエラーになることがある問題を修正 +- Fix: お気に入りクリップの一覧表示が正しく動作しない問題を修正 +- Fix: AiScript Misskey 拡張APIにおいて、各種関数の引数で明示的に `null` が指定されている場合のハンドリングを修正 + +### Server +- Enhance: メモリ使用量を削減しました +- Enhance: 依存関係の更新 +- Fix: ワードミュートの文字数計算を修正 +- Fix: チャンネルのリアルタイム更新時に、ロックダウン設定にて非ログイン時にノートを表示しない設定にしている場合でもノートが表示されてしまう問題を修正 +- Fix: DeepL APIのAPIキー指定方式変更に対応 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1096) + - 内部実装の変更にて対応可能な更新です。Misskey側の設定方法に変更はありません。 +- Fix: DBレプリケーションを利用する環境でクエリーが失敗する問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/1123) + +## 2025.11.0 + +### General +- Feat: チャンネルミュート機能の実装 #10649 + - チャンネルの概要画面の右上からミュートできます(リンクコピー、共有、設定と同列) +- Enhance: Node.js 24.10.0をサポートするようになりました +- Enhance: DockerのNode.jsが24.10.0に更新されました +- 依存関係の更新 + +### Client +- Feat: 画像にメタデータを含むフレームをつけられる機能 +- Enhance: プリセットを作成しなくても画像にウォーターマークを付与できるように +- Enhance: 管理しているチャンネルの見分けがつきやすくなるように +- Enhance: プロフィールへのリンクをユーザーポップアップのアバターに追加 +- Enhance: ユーザーのノート、フォロー、フォロワーページへのリンクをユーザーポップアップに追加 +- Enhance: プッシュ通知を行うための権限確認をより確実に行うように +- Enhance: 投稿フォームのチュートリアルを追加 +- Enhance: 「自動でもっと見る」をほとんどの箇所で利用可能に +- Enhance: アンテナ・リスト設定画面とタイムラインの動線を改善 + - アンテナ・リスト一覧画面の項目を選択すると、設定画面ではなくタイムラインに移動するようになりました + - アンテナ・リストの設定画面の右上にタイムラインに移動するボタンを追加しました +- Fix: 紙吹雪エフェクトがアニメーション設定を考慮せず常に表示される問題を修正 +- Fix: ナビゲーションバーのリアルタイムモード切替ボタンの状態をよりわかりやすく表示するように +- Fix: ページのタイトルが長いとき、はみ出る問題を修正 +- Fix: 投稿フォームのアバターが正しく表示されない問題を修正 #16789 +- FIx: カスタム絵文字(β)画面で変更行が正しくハイライトされない問題を修正 #16626 + +### Server +- Enhance: Remote Notes Cleaningが複雑度が高いノートの処理を中断せずに次のノートから再開するように +- Fix: チャンネルの説明欄の最小文字数制約を除去 + +## 2025.10.2 + +### Client +- Fix: アプリ内からキャッシュをクリアするとテーマ再適用するまでレンダリングが正しく行われない問題を修正 +- Fix: 期限が無期限のアンケートに投票できない問題を修正 + +## 2025.10.1 + +### General +- Enhance: リモートユーザーに付与したロールバッジを表示できるように(オプトイン) + パフォーマンス上の問題からデフォルトで無効化されています。「コントロールパネル > パフォーマンス」から有効化できます。 +- 依存関係の更新 + +### Client +- Enhance: デッキのメインカラムのヘッダをクリックしてページ上部/下部にスクロールできるように +- Enhance: 下書き/予約投稿一覧は投稿フォームのアカウントメニュー内に移動し、下書き保存は「...」メニュー内に移動されました +- Fix: カスタム絵文字画面(beta)のaliasesで使用される区切り文字が一致していないのを修正 #15614 +- Fix: バナー画像の幅が表示領域と一致していない問題を修正 +- Fix: 一部のブラウザでバナー画像が上下中央に表示されない問題を修正 +- Fix: ナビゲーションバーの設定で削除した項目をその場で再追加できない問題を修正 +- Fix: ロールポリシーによりダイレクトメッセージが無効化されている際のデッキのダイレクトメッセージカラムの挙動を改善 +- Fix: 画像のマスクでタッチ操作が不安定な問題を修正 +- Fix: ウォーターマークの各種挙動修正 + - ウォーターマークを回転させると歪む問題を修正 + - ウォーターマークを敷き詰めると上下左右反転した画像/文字が表示される問題を修正 + - ウォーターマークを回転させた際に画面からはみ出た部分を考慮できるように +- Fix: 投票が終了した後に投票結果が正しく表示されない問題を修正 +- Fix: ダークモードの同期が機能しない場合がある問題を修正 +- Fix: iOSで動画の圧縮を行うと音声トラックが失われる問題を修正 + +### Server +- Enhance: 管理者/モデレーターはファイルのアップロード制限をバイパスするように +- Enhance: セキュリティの向上 + +## 2025.10.0 + +### NOTE +- pnpm 10.16.0 が必要です +- ロールのインポート機能の利用可否ポリシーのデフォルト値が「いいえ」に変わったため、デフォルトから変更していないサーバーでは適宜設定を変更してください。 +- ロールのアップロード可能なファイル種別ポリシーのデフォルト値に「text/*」が追加されたため、デフォルトから変更していないサーバーでは適宜設定を変更してください。 + +### General +- Feat: 予約投稿ができるようになりました + - デフォルトで作成可能数は1になっています。適宜ロールのポリシーで設定を行ってください。 +- Enhance: 広告ごとにセンシティブフラグを設定できるようになりました +- Enhance: 依存関係の更新 +- Enhance: 翻訳の更新 + +### Client +- Feat: アカウントのQRコードを表示・読み取りできるようになりました +- Feat: 動画を圧縮してアップロードできるようになりました +- Feat: (実験的) ブラウザ上でノートの翻訳を行えるように +- Enhance: チャットの日本語名称がダイレクトメッセージに戻るとともに、ベータ版機能ではなくなりました +- Enhance: 画像編集にマスクエフェクト(塗りつぶし、ぼかし、モザイク)を追加 +- Enhance: 画像編集の集中線エフェクトを強化 +- Enhance: ウォーターマークにアカウントのQRコードを追加できるように +- Enhance: テーマをドラッグ&ドロップできるように +- Enhance: 絵文字ピッカーのサイズをより大きくできるように +- Enhance: カスタム絵文字が多い場合にサーバーの絵文字一覧ページがフリーズしないように +- Enhance: 時刻計算のための基準値を一か所で管理するようにし、パフォーマンスを向上 +- Enhance: 「お問い合わせ」ページから、バグの調査等に役立つ情報(OSやブラウザのバージョン等)を取得・コピーできるように +- Fix: iOSで、デバイスがダークモードだと初回読み込み時にエラーになる問題を修正 +- Fix: アクティビティウィジェットのグラフモードが動作しない問題を修正 +- Fix: ユニコード絵文字の追加辞書をインストールするとユニコード絵文字が絵文字ピッカーで検索できなくなる絵文字があるバグを修正 + +### Server +- Enhance: ユーザーIPを確実に取得できるために設定ファイルにFastifyOptions.trustProxyを追加しました + +## 2025.9.0 + +### Client +- Enhance: AiScriptAppウィジェットで構文エラーを検知してもダイアログではなくウィジェット内にエラーを表示するように +- Enhance: /flushページでサイトキャッシュをクリアできるようになりました +- Enhance: クリップ/リスト/アンテナ/ロール追加系メニュー項目において、表示件数を拡張 +- Enhance: 「キャッシュを削除」ボタンでブラウザの内部キャッシュの削除も行えるように +- Enhance: Ctrlキー(Commandキー)を押下しながらリンクをクリックすると新しいタブで開くように +- Fix: プッシュ通知を有効にできない問題を修正 +- Fix: RSSティッカーウィジェットが正しく動作しない問題を修正 +- Fix: プロファイルを復元後アカウントの切り替えができない問題を修正 +- Fix: エラー画像が横に引き伸ばされてしまう問題に対応 + +### Server +- Fix: webpなどの画像に対してセンシティブなメディアの検出が適用されていなかった問題を修正 + ## 2025.8.0 ### Note @@ -6,8 +239,8 @@ ### General - ノートを削除した際、関連するノートが同時に削除されないようになりました - APIで、「replyIdが存在しているのにreplyがnull」や「renoteIdが存在しているのにrenoteがnull」であるという、今までにはなかったパターンが表れることになります -- 定期的に参照されていない古いリモートの投稿を削除する機能が実装されました(コントロールパネル→パフォーマンス→Remote Notes Cleaning) - - 既存のサーバーでは**デフォルトでオフ**、新規サーバーでは**デフォルトでオン**になります +- 定期的に古いリモートの投稿を削除する機能が実装されました + - コントロールパネル→パフォーマンス→Remote Notes Cleaning で有効化できます - データベースの肥大化を防止することが可能です - 既存のサーバーで当機能を有効化した場合は、処理量が多くなるため、一時的にストレージ使用量が増加する可能性があります。 - 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。 @@ -15,7 +248,8 @@ - サーバーの初期設定が完了するまでは連合がオンにならないようになりました - 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました - 実際の動作に即した名称になり、馴染みのない人でも理解しやすくなりました - - 他サービスにおける「ダイレクトメッセージ」に相当するMisskeyの機能は「チャット」ですが、「ダイレクト投稿」という名称の機能が存在するとそちらがダイレクトメッセージ機能であるような誤解を生んでいました + - 他サービスにおける「ダイレクトメッセージ」に相当するMisskeyの機能は「チャット」ですが(過去のバージョンのMisskeyでも、当該機能は「チャット」ではなく「ダイレクトメッセージ」でした)、「ダイレクト投稿」という名称の機能が存在するとそちらがダイレクトメッセージ機能であるような誤解を生んでいました + - 今後、「チャット」の名称を「ダイレクトメッセージ」に戻す可能性があります - mfm.jsをアップデートしました - Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応 - Enhance: acctに `.` が入っているユーザーのメンションに対応 @@ -33,6 +267,7 @@ - プラグインは1.xに対応したものが必要です - Playはそのまま動作しますが、新規に作られるプリセットは1.xになります - 以前のバージョンから無効化されていた note_view_interruptor が有効になりました + - ハンドラは同期的である必要があります - Feat: セーフモード - プラグイン・テーマ・カスタムCSSの使用でクライアントの起動に問題が発生した際に、これらを無効にして起動できます - 以下の方法でセーフモードを起動できます @@ -43,13 +278,16 @@ - コントロールパネル→ブランディング→エントランスページのスタイル - Feat: ページのタブバーを下部に表示できるように - Feat: (実験的)iOSでの触覚フィードバックを有効にできるように +- Feat: コントロールパネルを検索できるように - Enhance: 「自動でもっと見る」オプションが有効になり、安定性が向上しました -- Enhance: コントロールパネルを検索できるように - Enhance: トルコ語 (tr-TR) に対応 - Enhance: 不必要な翻訳データを読み込まなくなり、パフォーマンスが向上しました - Enhance: 画像エフェクトのパラメータ名の多言語対応 -- Enhance: 依存ソフトウェアの更新 - Enhance: ノートを非表示にする相対期間を1ヶ月単位で自由に指定できるように +- Enhance: メールアドレス確認画面のUIを改善 +- Enhance: アイコンのスクロール追従を無効化する際の適用範囲を強化 +- Enhance: レンダリングパフォーマンスの向上 +- Enhance: 依存ソフトウェアの更新 - Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正 - Fix: 一部の設定検索結果が存在しないパスになる問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171) @@ -58,12 +296,27 @@ - Fix: カラムの名前が正しくリスト/チャンネルの名前にならない問題を修正 - Fix: 複数のメンションを1行に記述した場合に、サジェストが正しく表示されない問題を修正 - Fix: メンションとしての条件を満たしていても、特定の条件(`-`が含まれる場合など)で正しくサジェストされない問題を一部修正 +- Fix: ユーザーの前後ノートを閲覧する機能が動作しない問題を修正 +- Fix: 照会ダイアログでap/showでローカルユーザーを解決した際@username@nullに飛ばされる問題を修正 +- Fix: アイコンのデコレーションを付ける際にデコレーションが表示されなくなる問題を修正 +- Fix: タッチ操作時にマウスホバー時のユーザープレビューが開くことがある問題を修正 +- Fix: 管理中アカウント一覧で正しい表示が行われない問題を修正 +- Fix: lookupページでリモートURLを指定した際に正しく動作しない問題を修正 ### Server +- Feat: サーバー管理コマンド + - `pnpm cli foo` の形式で実行可能です + - 現在以下のコマンドが利用可能です + - `reset-captcha` - CAPTCHA設定をリセットします - Enhance: ノートの削除処理の効率化 - Enhance: 全体的なパフォーマンスの向上 - Enhance: 依存ソフトウェアの更新 +- Enhance: `clips/list` APIがページネーションに対応しました +- Fix: `notes/mentions` で場合によっては並び順が正しく返されない問題を修正 - Fix: SystemWebhook設定でsecretを空に出来ない問題を修正 +- Fix: 削除されたユーザーがチャットメッセージにリアクションしている場合`chat/history`などでエラーになる問題を修正 +- Fix: Pageのアイキャッチ画像をドライブから消してもPageごと消えないように +- Fix: タイムラインAPIの withRenotes: false 時のレスポンスを修正 ## 2025.7.0 diff --git a/COPYING b/COPYING index 7635bfc913..a17c82c002 100644 --- a/COPYING +++ b/COPYING @@ -1,5 +1,5 @@ Unless otherwise stated this repository is -Copyright © 2014-2025 syuilo and contributors +Copyright © 2014-2026 syuilo and contributors And is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as LICENSE. diff --git a/Dockerfile b/Dockerfile index 370bed5751..c1ac81f09e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ -# syntax = docker/dockerfile:1.4 +# syntax = docker/dockerfile:1.20 -ARG NODE_VERSION=22.15.0-bookworm +ARG NODE_VERSION=22.21.1-bookworm # build assets & compile TypeScript @@ -24,6 +24,7 @@ COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-share COPY --link ["packages/frontend/package.json", "./packages/frontend/"] COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"] COPY --link ["packages/frontend-builder/package.json", "./packages/frontend-builder/"] +COPY --link ["packages/i18n/package.json", "./packages/i18n/"] COPY --link ["packages/icons-subsetter/package.json", "./packages/icons-subsetter/"] COPY --link ["packages/sw/package.json", "./packages/sw/"] COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"] @@ -101,6 +102,8 @@ COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/ COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/src-js ./packages/backend/src-js +COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built COPY --chown=misskey:misskey --from=native-builder /misskey/fluent-emojis /misskey/fluent-emojis COPY --chown=misskey:misskey . ./ diff --git a/README.md b/README.md index 92e8fef639..e3261d13c2 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ become a patron +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/misskey-dev/misskey) + + + ## Thanks @@ -47,3 +51,13 @@ Thanks to [Crowdin](https://crowdin.com/) for providing the localization platfor Docker Thanks to [Docker](https://hub.docker.com/) for providing the container platform that helps us run Misskey in production. + +--- + +
+ +Support us with a ⭐ ! + +[![Star History Chart](https://api.star-history.com/svg?repos=misskey-dev/misskey&type=Date)](https://star-history.com/#misskey-dev/misskey&Date) + +
diff --git a/ROADMAP.md b/ROADMAP.md index 509ecb9fe7..7f5f268f2b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ Also, the later tasks are more indefinite and are subject to change as developme This is the phase we are at now. We need to make a high-maintenance environment that can withstand future development. - ~~Make the number of type errors zero (backend)~~ → Done ✔️ -- Make the number of type errors zero (frontend) +- ~~Make the number of type errors zero (frontend)~~ → Done ✔️ - Improve CI - ~~Fix tests~~ → Done ✔️ - Fix random test failures - https://github.com/misskey-dev/misskey/issues/7985 and https://github.com/misskey-dev/misskey/issues/7986 diff --git a/chart/templates/Deployment.yml b/chart/templates/Deployment.yml index 3c73837801..280bf9a597 100644 --- a/chart/templates/Deployment.yml +++ b/chart/templates/Deployment.yml @@ -27,7 +27,7 @@ spec: ports: - containerPort: 3000 - name: postgres - image: postgres:15-alpine + image: postgres:18-alpine env: - name: POSTGRES_USER value: "example-misskey-user" diff --git a/compose.local-db.yml b/compose.local-db.yml index 3835cb23db..2b69cabf5a 100644 --- a/compose.local-db.yml +++ b/compose.local-db.yml @@ -15,13 +15,13 @@ services: db: restart: always - image: postgres:15-alpine + image: postgres:18-alpine ports: - "5432:5432" env_file: - .config/docker.env volumes: - - ./db:/var/lib/postgresql/data + - ./db:/var/lib/postgresql healthcheck: test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" interval: 5s diff --git a/compose_example.yml b/compose_example.yml index 336bd814a7..efc7d6624d 100644 --- a/compose_example.yml +++ b/compose_example.yml @@ -37,13 +37,13 @@ services: db: restart: always - image: postgres:15-alpine + image: postgres:18-alpine networks: - internal_network env_file: - .config/docker.env volumes: - - ./db:/var/lib/postgresql/data + - ./db:/var/lib/postgresql healthcheck: test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB" interval: 5s diff --git a/idea/MkAbuseReport.stories.impl.ts b/idea/MkAbuseReport.stories.impl.ts index 717bceb23d..138e5cd0cd 100644 --- a/idea/MkAbuseReport.stories.impl.ts +++ b/idea/MkAbuseReport.stories.impl.ts @@ -4,8 +4,8 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import { action } from 'storybook/actions'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { abuseUserReport } from '../packages/frontend/.storybook/fakes.js'; import { commonHandlers } from '../packages/frontend/.storybook/mocks.js'; diff --git a/idea/MkAnimatedBg.dotted-ripples.vue b/idea/MkAnimatedBg.dotted-ripples.vue new file mode 100644 index 0000000000..f8f809c8ce --- /dev/null +++ b/idea/MkAnimatedBg.dotted-ripples.vue @@ -0,0 +1,232 @@ + + + + + + + diff --git a/idea/MkAnimatedBg.dotted.vue b/idea/MkAnimatedBg.dotted.vue new file mode 100644 index 0000000000..7f68b8972a --- /dev/null +++ b/idea/MkAnimatedBg.dotted.vue @@ -0,0 +1,190 @@ + + + + + + + diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index 24a2caea35..a72429e941 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -1010,6 +1010,15 @@ postForm: "أنشئ ملاحظة" information: "عن" inMinutes: "د" inDays: "ي" +widgets: "التطبيقات المُصغّرة" +presets: "إعدادات مسبقة" +_imageEditing: + _vars: + filename: "اسم الملف" +_imageFrameEditor: + font: "الخط" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" _chat: invitations: "دعوة" noHistory: "السجل فارغ" @@ -1356,6 +1365,14 @@ _widgets: userList: "قائمة المستخدمين" _userList: chooseList: "اختر قائمة" +_widgetOptions: + height: "الإرتفاع" + _button: + colored: "ملوّن" + _clock: + size: "الحجم" + _birthdayFollowings: + period: "المدة" _cw: hide: "إخفاء" show: "عرض المزيد" @@ -1396,6 +1413,9 @@ _postForm: replyPlaceholder: "رد على هذه الملاحظة…" quotePlaceholder: "اقتبس هذه الملاحظة…" channelPlaceholder: "انشر في قناة..." + _howToUse: + visibility_title: "الظهور" + menu_title: "القائمة" _placeholders: a: "ما الذي تنوي فعله؟" b: "ماذا يحدث حولك ؟" @@ -1603,5 +1623,9 @@ _imageEffector: _fxProps: scale: "الحجم" size: "الحجم" + offset: "الموضع" color: "اللون" opacity: "الشفافية" +_qr: + showTabTitle: "المظهر" + raw: "نص" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 2b0645b77d..9af4fc0ec4 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -850,6 +850,15 @@ postForm: "নোট লিখুন" information: "আপনার সম্পর্কে" inMinutes: "মিনিট" inDays: "দিন" +widgets: "উইজেটগুলি" +_imageEditing: + _vars: + filename: "ফাইলের নাম" +_imageFrameEditor: + header: "হেডার" + font: "ফন্ট" + fontSerif: "সেরিফ" + fontSansSerif: "স্যান্স সেরিফ" _chat: invitations: "আমন্ত্রণ" noHistory: "কোনো ইতিহাস নেই" @@ -1128,6 +1137,14 @@ _widgets: aichan: "আই চান" _userList: chooseList: "লিস্ট নির্বাচন করুন" +_widgetOptions: + height: "উচ্চতা" + _button: + colored: "রঙ্গিন" + _clock: + size: "আকার" + _birthdayFollowings: + period: "ব্যাপ্তিকাল" _cw: hide: "লুকান" show: "আরও দেখুন" @@ -1168,6 +1185,9 @@ _postForm: replyPlaceholder: "নোটটির জবাব দিন..." quotePlaceholder: "নোটটিকে উদ্ধৃত করুন..." channelPlaceholder: "চ্যানেলে পোস্ট করুন..." + _howToUse: + visibility_title: "দৃশ্যমানতা" + menu_title: "মেনু" _placeholders: a: "আপনি এখন কি করছেন?" b: "আপনার আশে পাশে কি হচ্ছে?" @@ -1364,3 +1384,6 @@ _imageEffector: color: "রং" opacity: "অস্বচ্ছতা" lightness: "উজ্জ্বল করুন" +_qr: + showTabTitle: "প্রদর্শন" + raw: "লেখা" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 2eb8ab33c9..edaa0647ad 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -83,6 +83,8 @@ files: "Fitxers" download: "Descarregar" driveFileDeleteConfirm: "Estàs segur que vols suprimir el fitxer \"{name}\"? Les notes associades a aquest fitxer també seran esborrades." unfollowConfirm: "Segur que vols deixar de seguir a {name}?" +cancelFollowRequestConfirm: "Vols cancel·lar la teva sol·licitud de seguiment a {name}?" +rejectFollowRequestConfirm: "Vols rebutjar la sol·licitud de seguiment de {name}?" exportRequested: "Has sol·licitat una exportació de dades. Això pot trigar una estona. S'afegirà a la teva unitat de disc un cop estigui completada." importRequested: "Has sol·licitat una importació de dades. Això pot trigar una estona." lists: "Llistes" @@ -253,6 +255,7 @@ noteDeleteConfirm: "Segur que voleu eliminar aquesta publicació?" pinLimitExceeded: "No podeu fixar més publicacions" done: "Fet" processing: "S'està processant..." +preprocessing: "Preparant" preview: "Vista prèvia" default: "Per defecte" defaultValueIs: "Per defecte: {value}" @@ -301,6 +304,7 @@ uploadFromUrlMayTakeTime: "La càrrega des de l'enllaç pot trigar un temps" uploadNFiles: "Pujar {n} arxius" explore: "Explora" messageRead: "Vist" +readAllChatMessages: "Marcar tots els missatges com a llegits" noMoreHistory: "No hi ha res més per veure" startChat: "Comença a xatejar " nUsersRead: "Vist per {n}" @@ -333,6 +337,7 @@ fileName: "Nom del Fitxer" selectFile: "Selecciona un fitxer" selectFiles: "Selecciona fitxers" selectFolder: "Selecció de carpeta" +unselectFolder: "Deixa de seleccionar la carpeta" selectFolders: "Selecció de carpetes" fileNotSelected: "Cap fitxer seleccionat" renameFile: "Canvia el nom del fitxer" @@ -345,6 +350,7 @@ addFile: "Afegeix un fitxer" showFile: "Mostrar fitxer" emptyDrive: "El teu Disc és buit" emptyFolder: "La carpeta està buida" +dropHereToUpload: "Arrossega els arxius fins aquí per pujar-los al servidor" unableToDelete: "No es pot eliminar" inputNewFileName: "Introduïu el nom de fitxer nou" inputNewDescription: "Escriu el peu de foto." @@ -648,7 +654,7 @@ disablePlayer: "Tanca el reproductor de vídeo" expandTweet: "Expandir post" themeEditor: "Editor de temes" description: "Descripció" -describeFile: "Afegeix una descripció " +describeFile: "Afegir text alternatiu" enterFileDescription: "Escriu un peu de foto" author: "Autor" leaveConfirm: "Hi ha canvis sense guardar. Els vols descartar?" @@ -772,6 +778,7 @@ lockedAccountInfo: "Tret que establiu la visibilitat de la nota a \"Només segui alwaysMarkSensitive: "Marcar com a sensible per defecte" loadRawImages: "Carregar les imatges originals en comptes de miniatures " disableShowingAnimatedImages: "No reproduir imatges animades" +disableShowingAnimatedImages_caption: "Si les imatges animades no es reprodueixen, independentment d'aquesta configuració, és possible que la configuració d'accessibilitat del navegador i el sistema operatiu, els modes d'estalvi d'energia i similars estiguin interferint." highlightSensitiveMedia: "Ressalta els medis marcats com a sensibles" verificationEmailSent: "S'ha enviat un correu electrònic de verificació. Fes clic a l'enllaç per completar la verificació." notSet: "Sense definir" @@ -1018,6 +1025,9 @@ pushNotificationAlreadySubscribed: "L'enviament de notificacions ja és activat" pushNotificationNotSupported: "El teu navegador o la teva instància no suporta l'enviament de notificacions " sendPushNotificationReadMessage: "Esborrar les notificacions enviades quan s'hagin llegit" sendPushNotificationReadMessageCaption: "Això pot fer que el teu dispositiu consumeixi més bateria" +pleaseAllowPushNotification: "Si us plau, permet les notificacions del navegador" +browserPushNotificationDisabled: "No s'ha pogut obtenir permisos per les notificacions" +browserPushNotificationDisabledDescription: "No tens permisos per enviar notificacions des de {serverName}. Activa les notificacions a la configuració del teu navegador i tornar-ho a intentar." windowMaximize: "Maximitzar " windowMinimize: "Minimitzar" windowRestore: "Restaurar" @@ -1054,6 +1064,7 @@ permissionDeniedError: "Operació no permesa " permissionDeniedErrorDescription: "Aquest compte no té suficients permisos per dur a terme aquesta acció " preset: "Predefinit" selectFromPresets: "Escull des dels predefinits" +custom: "Personalitzat" achievements: "Assoliments" gotInvalidResponseError: "Resposta del servidor invàlida " gotInvalidResponseErrorDescription: "No es pot contactar amb el servidor o potser es troba fora de línia per manteniment. Provar-ho de nou més tard." @@ -1167,6 +1178,7 @@ installed: "Instal·lats " branding: "Marca" enableServerMachineStats: "Publicar estadístiques del maquinari del servidor" enableIdenticonGeneration: "Activar la generació d'icones d'identificació " +showRoleBadgesOfRemoteUsers: "Mostrar insígnies de rols d'instàncies remotes " turnOffToImprovePerformance: "Desactivant aquesta opció es pot millorar el rendiment." createInviteCode: "Crear codi d'invitació " createWithOptions: "Crear invitació amb opcions" @@ -1244,7 +1256,7 @@ releaseToRefresh: "Deixar anar per actualitzar" refreshing: "Recarregant..." pullDownToRefresh: "Llisca cap a baix per recarregar" useGroupedNotifications: "Mostrar les notificacions agrupades " -signupPendingError: "Hi ha hagut un problema verificant l'adreça de correu electrònic. L'enllaç pot haver caducat." +emailVerificationFailedError: "Hem tingut un problema en verificar la teva adreça de correu electrònic. És probable que l'enllaç estigui caducat." cwNotationRequired: "Si està activat \"Amagar contingut\" s'ha d'escriure una descripció " doReaction: "Afegeix una reacció " code: "Codi" @@ -1315,6 +1327,7 @@ acknowledgeNotesAndEnable: "Activa'l després de comprendre els possibles perill federationSpecified: "Aquest servidor treballa amb una federació de llistes blanques. No pot interactuar amb altres servidors que no siguin els especificats per l'administrador." federationDisabled: "La unió es troba deshabilitada en aquest servidor. No es pot interactuar amb usuaris d'altres servidors." draft: "Esborrany " +draftsAndScheduledNotes: "Esborranys i publicacions programades" confirmOnReact: "Confirmar en reaccionar" reactAreYouSure: "Vols reaccionar amb \"{emoji}\"?" markAsSensitiveConfirm: "Vols marcar aquest contingut com a sensible?" @@ -1342,6 +1355,8 @@ postForm: "Formulari de publicació" textCount: "Nombre de caràcters " information: "Informació" chat: "Xat" +directMessage: "Xateja amb aquest usuari" +directMessage_short: "Missatge" migrateOldSettings: "Migrar la configuració anterior" migrateOldSettings_description: "Normalment això es fa automàticament, però si la transició no es fa, el procés es pot iniciar manualment. S'esborrarà la configuració actual." compress: "Comprimir " @@ -1369,16 +1384,83 @@ redisplayAllTips: "Torna ha mostrat tots els trucs i consells" hideAllTips: "Amagar tots els trucs i consells" defaultImageCompressionLevel: "Nivell de comprensió de la imatge per defecte" defaultImageCompressionLevel_description: "Baixa, conserva la qualitat de la imatge però la mida de l'arxiu és més gran.
Alta, redueix la mida de l'arxiu però també la qualitat de la imatge." +defaultCompressionLevel: "Nivell de compressió predeterminat" +defaultCompressionLevel_description: "Si el redueixes augmentaràs la qualitat de la imatge, però la mida de l'arxiu serà més gran.
Si augmentes l'opció redueixes la mida de l'arxiu i la qualitat de la imatge és pitjor." inMinutes: "Minut(s)" inDays: "Di(a)(es)" safeModeEnabled: "Mode segur activat" pluginsAreDisabledBecauseSafeMode: "Els afegits no estan activats perquè el mode segur està activat." customCssIsDisabledBecauseSafeMode: "El CSS personalitzat no s'aplica perquè el mode segur es troba activat." themeIsDefaultBecauseSafeMode: "El tema predeterminat es farà servir mentre el mode segur estigui activat. Una vegada es desactivi el mode segur es restablirà el tema escollit." +thankYouForTestingBeta: "Gràcies per ajudar-nos a provar la versió beta!" +createUserSpecifiedNote: "Crear notes especificades per l'usuari " +schedulePost: "Programar una nota" +scheduleToPostOnX: "Programar una nota per {x}" +scheduledToPostOnX: "S'ha programat la nota per {x}" +schedule: "Programa" +scheduled: "Programat" +widgets: "Ginys" +deviceInfo: "Informació del dispositiu" +deviceInfoDescription: "En fer consultes tècniques influir la següent informació pot ajudar a resoldre'l més ràpidament." +youAreAdmin: "Ets l'administrador " +frame: "Marc" +presets: "Predefinit" +zeroPadding: "Sense omplir" +nothingToConfigure: "No hi ha res a configurar" +_imageEditing: + _vars: + caption: "Títol de l'arxiu" + filename: "Nom del Fitxer" + filename_without_ext: "Nom de l'arxiu sense extensió " + year: "Any" + month: "Mes" + day: "Dia" + hour: "Hora" + minute: "Minut" + second: "Segon" + camera_model: "Nom de la càmera " + camera_lens_model: "Nom de la lent" + camera_mm: "Distància focal" + camera_mm_35: "Distància focal (equivalent a 35 mm)" + camera_f: "Obertura" + camera_s: "Velocitat d'obturació" + camera_iso: "Sensibilitat ISO" + gps_lat: "Latitud " + gps_long: "Longitud " +_imageFrameEditor: + title: "Edició de fotogrames " + tip: "Pots decorar les imatges afegint etiquetes que continguin marcs i metadades." + header: "Capçalera" + footer: "Peu de pàgina " + borderThickness: "Amplada de la vora" + labelThickness: "Amplada de l'etiqueta " + labelScale: "Mida de l'etiqueta " + centered: "Alinea al centre" + captionMain: "Peu de foto (gran)" + captionSub: "Peu de foto (petit)" + availableVariables: "Variables disponibles" + withQrCode: "Codi QR" + backgroundColor: "Color del fons" + textColor: "Color del text" + font: "Lletra tipogràfica" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + quitWithoutSaveConfirm: "Sortir sense desar?" + failedToLoadImage: "Error en carregar la imatge" +_compression: + _quality: + high: "Qualitat alta" + medium: "Qualitat mitjana" + low: "Qualitat baixa" + _size: + large: "Mida gran" + medium: "Mida mitjana" + small: "Mida petita" _order: newest: "Més recent" oldest: "Antigues primer" _chat: + messages: "Missatge" noMessagesYet: "Encara no tens missatges " newMessage: "Missatge nou" individualChat: "Xat individual " @@ -1467,6 +1549,11 @@ _settings: showUrlPreview: "Mostrar vista prèvia d'URL" showAvailableReactionsFirstInNote: "Mostra les reacciones que pots fer servir al damunt" showPageTabBarBottom: "Mostrar les pestanyes de les línies de temps a la part inferior" + emojiPaletteBanner: "Pots registrar ajustos preestablerts com paletes perquè es mostrin permanentment al selector d'emojis, o personalitzar la configuració de visió del selector." + enableAnimatedImages: "Activar imatges animades" + settingsPersistence_title: "Persistència de la configuració " + settingsPersistence_description1: "Habilitar la persistència de la configuració permet que no es perdi la informació de la configuració " + settingsPersistence_description2: "Depenent de l'entorn pot ser que no puguis habilitar aquesta opció." _chat: showSenderName: "Mostrar el nom del remitent" sendOnEnter: "Introdueix per enviar" @@ -1475,6 +1562,8 @@ _preferencesProfile: profileNameDescription: "Estableix un nom que identifiqui aquest dispositiu." profileNameDescription2: "Per exemple: \"PC Principal\", \"Smartphone\", etc" manageProfiles: "Gestionar perfils" + shareSameProfileBetweenDevicesIsNotRecommended: "No recomanem compartir el mateix perfil en diferents dispositius." + useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "Si hi ha ajustos que vols sincronitzar entre diferents dispositius activa l'opció \"Sincronitza entre diferents dispositius\" individualment per cada una de les diferents opcions." _preferencesBackup: autoBackup: "Còpia de seguretat automàtica " restoreFromBackup: "Restaurar des d'una còpia de seguretat" @@ -1484,6 +1573,7 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "Has de posar-li un nom al teu perfil per poder activar les còpies de seguretat automàtiques." autoPreferencesBackupIsNotEnabledForThisDevice: "La còpia de seguretat automàtica no es troba activada en aquest dispositiu." backupFound: "Còpia de seguretat de la configuració trobada" + forceBackup: "Còpia de seguretat forçada de la configuració " _accountSettings: requireSigninToViewContents: "És obligatori l'inici de sessió per poder veure el contingut" requireSigninToViewContentsDescription1: "Es requereix l'inici de sessió per poder veure totes les notes i el contingut que has creat. Amb això esperem evitar que els rastrejadors recopilin informació." @@ -1523,8 +1613,8 @@ _bubbleGame: highScore: "Millor puntuació " maxChain: "Nombre màxim de combos" yen: "{yen}Ien" - estimatedQty: "{qty}peces" - scoreSweets: "{onigiriQtyWithUnit}ongiris" + estimatedQty: "{qty} Peces" + scoreSweets: "{onigiriQtyWithUnit} Boles d'arròs " _howToPlay: section1: "Ajusta la posició i deixa caure l'objecte dintre la caixa." section2: "Quan dos objectes del mateix tipus es toquen, canviaran en un objecte diferent i guanyares punts." @@ -1535,7 +1625,7 @@ _announcement: needConfirmationToRead: "Es necessita confirmació de lectura de la notificació " needConfirmationToReadDescription: "Si s'activa es mostrarà un diàleg per confirmar la lectura d'aquesta notificació. A més aquesta notificació serà exclosa de qualsevol funcionalitat com \"Marcar tot com a llegit\"." end: "Final de la notificació " - tooManyActiveAnnouncementDescription: "Tenir massa notificacions actives pot empitjorar l'experiència de l'usuari. Considera finalitzar els avisos que siguin antics." + tooManyActiveAnnouncementDescription: "Tenir masses notificacions actives pot empitjorar l'experiència de l'usuari. Considera finalitzar els avisos que siguin antics." readConfirmTitle: "Marcar com llegida?" readConfirmText: "Això marcarà el contingut de \"{title}\" com llegit." shouldNotBeUsedToPresentPermanentInfo: "Ja que l'ús de notificacions pot impactar l'experiència dels nous usuaris, és recomanable fer servir les notificacions amb el flux d'informació en comptes de fer-les servir en un únic bloc." @@ -1642,7 +1732,7 @@ _serverSettings: reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat." remoteNotesCleaning: "Neteja automàtica de notes remotes" remoteNotesCleaning_description: "Quan activis aquesta opció, periòdicament es netejaran les notes remotes que no es consultin, això evitarà que la base de dades se" - remoteNotesCleaningMaxProcessingDuration: "D'oració màxima del temps de funcionament del procés de neteja" + remoteNotesCleaningMaxProcessingDuration: "Duració màxima del temps de funcionament del procés de neteja" remoteNotesCleaningExpiryDaysForEachNotes: "Duració mínima de conservació de les notes" inquiryUrl: "URL de consulta " inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació." @@ -1664,6 +1754,9 @@ _serverSettings: userGeneratedContentsVisibilityForVisitor_description2: "La publicació incondicional de tots els continguts del servidor a internet, incloent-hi els continguts remots rebuts pel servidor, comporta riscos. Això és extremadament important per els espectadors que desconeixen el caràcter descentralitzat dels continguts, ja que poden percebre erroneament els continguts remots com contingut generat per el propi servidor." restartServerSetupWizardConfirm_title: "Vols tornar a executar l'assistent de configuració inicial del servidor?" restartServerSetupWizardConfirm_text: "Algunes configuracions actuals seran restablertes." + entrancePageStyle: "Estil de la pàgina d'inici" + showTimelineForVisitor: "Mostrar la línia de temps" + showActivitiesForVisitor: "Mostrar activitat" _userGeneratedContentsVisibilityForVisitor: all: "Tot obert al públic " localOnly: "Només es publiquen els continguts locals, el contingut remot es manté privat" @@ -1986,6 +2079,7 @@ _role: canManageAvatarDecorations: "Gestiona les decoracions dels avatars " driveCapacity: "Capacitat del disc" maxFileSize: "Mida màxima de l'arxiu que es pot carregar" + maxFileSize_caption: "Pot haver-hi la possibilitat que existeixin altres opcions de configuració de l'etapa anterior, com podria ser el proxy invers i la CDN." alwaysMarkNsfw: "Marca sempre els fitxers com a sensibles" canUpdateBioMedia: "Permet l'edició d'una icona o un bàner" pinMax: "Nombre màxim de notes fixades" @@ -2013,6 +2107,7 @@ _role: uploadableFileTypes_caption: "Especifica el tipus MIME. Es poden especificar diferents tipus MIME separats amb una nova línia, i es poden especificar comodins amb asteriscs (*). (Per exemple: image/*)" uploadableFileTypes_caption2: "Pot que no sigui possible determinar el tipus MIME d'alguns arxius. Per permetre aquests tipus d'arxius afegeix {x} a les especificacions." noteDraftLimit: "Nombre possible d'esborranys de notes al servidor" + scheduledNoteLimit: "Màxim nombre de notes programades que es poden crear simultàniament" watermarkAvailable: "Pots fer servir la marca d'aigua" _condition: roleAssignedTo: "Assignat a rols manuals" @@ -2071,7 +2166,7 @@ _ad: timezoneinfo: "El dia de la setmana ve determinat del fus horari del servidor." adsSettings: "Configurar la publicitat" notesPerOneAd: "Interval d'emplaçament publicitari en temps real (Notes per anuncis)" - setZeroToDisable: "Ajusta aquest valor a 0 per deshabilitar l'actualització d'anuncis en temps real" + setZeroToDisable: "Ajusta aquest valor a 0 per deshabilitar l'actualització de publicitat en temps real" adsTooClose: "L'interval actual pot fer que l'experiència de l'usuari sigui dolenta perquè l'interval és molt baix." _forgotPassword: enterEmail: "Escriu l'adreça de correu electrònic amb la que et vas registrar. S'enviarà un correu electrònic amb un enllaç perquè puguis canviar-la." @@ -2273,6 +2368,7 @@ _time: minute: "Minut(s)" hour: "Hor(a)(es)" day: "Di(a)(es)" + month: "Mes(os)" _2fa: alreadyRegistered: "J has registrat un dispositiu d'autenticació de doble factor." registerTOTP: "Registrar una aplicació autenticadora" @@ -2402,6 +2498,7 @@ _auth: scopeUser: "Opera com si fossis aquest usuari" pleaseLogin: "Si us plau, identificat per autoritzar l'aplicació." byClickingYouWillBeRedirectedToThisUrl: "Si es garanteix l'accés, seràs redirigit automàticament a la següent adreça URL" + alreadyAuthorized: "Aquesta aplicació ja té accés." _antennaSources: all: "Totes les publicacions" homeTimeline: "Publicacions dels usuaris seguits" @@ -2447,7 +2544,45 @@ _widgets: chooseList: "Tria una llista" clicker: "Clicker" birthdayFollowings: "Usuaris que fan l'aniversari avui" - chat: "Xat" + chat: "Xateja amb aquest usuari" +_widgetOptions: + showHeader: "Mostrar la capçalera" + transparent: "Fons transparent" + height: "Alçada " + _button: + colored: "Colorit" + _clock: + size: "Mida" + thickness: "Amplada de l'agulla " + thicknessThin: "Esvelt " + thicknessMedium: "Normal" + thicknessThick: "Gruixut " + graduations: "Marques de l'esfera " + graduationDots: "Punt" + graduationArabic: "Nombres àrabs " + fadeGraduations: "Efecte gradient " + sAnimation: "Animació de la maneta dels segons" + sAnimationElastic: "Real" + sAnimationEaseOut: "Suau" + twentyFour: "Format 24 hores" + labelTime: "Temps" + labelTz: "Fus horari" + labelTimeAndTz: "Hora i fus horari" + timezone: "Fus horari" + showMs: "Mostrar mil·lisegons" + showLabel: "Mostrar etiqueta" + _jobQueue: + sound: "Reprodueix so" + _rss: + url: "URL del canal RSS" + refreshIntervalSec: "Interval d'actualitzacions (segons)" + maxEntries: "Nombre màxim d'entrades a mostrar" + _rssTicker: + shuffle: "Visualització aleatòria " + duration: "Velocitat desplaçament bàner informatiu " + reverse: "Desplaçament contrari" + _birthdayFollowings: + period: "Període" _cw: hide: "Amagar" show: "Carregar més" @@ -2492,6 +2627,20 @@ _postForm: replyPlaceholder: "Contestar..." quotePlaceholder: "Citar..." channelPlaceholder: "Publicar a un canal..." + showHowToUse: "Mostrar les instruccions" + _howToUse: + content_title: "Cos principal" + content_description: "Introdueix el contingut que vols publicar." + toolbar_title: "Barra d'eines " + toolbar_description: "Pots adjuntar arxius o enquestes, afegir anotacions o etiquetes i inserir emojis o mencions." + account_title: "Menú del compte" + account_description: "Pots anar canviant de comptes per publicar o veure una llista d'esborranys i les publicacions programades del teu compte." + visibility_title: "Visibilitat" + visibility_description: "Pots configurar la visibilitat de les teves notes." + menu_title: "Menú" + menu_description: "Pots fer altres accions com desar esborranys, programar publicacions i configurar reaccions." + submit_title: "Botó per publicar" + submit_description: "Publica les teves notes. També pots fer servir Ctrl + Enter / Cmd + Enter" _placeholders: a: "Que vols dir?..." b: "Alguna cosa interessant al teu voltant?..." @@ -2637,6 +2786,8 @@ _notification: youReceivedFollowRequest: "Has rebut una petició de seguiment" yourFollowRequestAccepted: "La teva petició de seguiment ha sigut acceptada" pollEnded: "Ja pots veure els resultats de l'enquesta " + scheduledNotePosted: "Una nota programada ha sigut publicada" + scheduledNotePostFailed: "Ha fallat la publicació d'una nota programada" newNote: "Nota nova" unreadAntennaNote: "Antena {name}" roleAssigned: "Rol assignat " @@ -2666,6 +2817,8 @@ _notification: quote: "Citar" reaction: "Reaccions" pollEnded: "Enquesta terminada" + scheduledNotePosted: "Nota programada amb èxit " + scheduledNotePostFailed: "Ha fallat la programació de la nota" receiveFollowRequest: "Rebuda una petició de seguiment" followRequestAccepted: "Petició de seguiment acceptada" roleAssigned: "Rol donat" @@ -2705,6 +2858,14 @@ _deck: usedAsMinWidthWhenFlexible: "L'amplada mínima es farà servir quan \"Ajust automàtic de l'amplada\" estigui activat" flexible: "Ajust automàtic de l'amplada" enableSyncBetweenDevicesForProfiles: "Activar la sincronització de la informació de perfils de dispositiu a dispositiu" + showHowToUse: "Veure la descripció de la interfície d'usuari " + _howToUse: + addColumn_title: "Afegir columna" + addColumn_description: "Pots seleccionar i afegir tipus de columnes." + settings_title: "Configuració de la interfície d'usuari " + settings_description: "Pots configurar la interfície d'usuari amb detall." + switchProfile_title: "Canviar perfil" + switchProfile_description: "Pots desar el disseny de la interfície d'usuari com un perfil i anar canviant entre ells quan vulguis." _columns: main: "Principal" widgets: "Ginys" @@ -2716,7 +2877,7 @@ _deck: mentions: "Mencions" direct: "Publicacions directes" roleTimeline: "Línia de temps dels rols" - chat: "Xat" + chat: "Xateja amb aquest usuari" _dialog: charactersExceeded: "Has arribat al màxim de caràcters! Actualment és {current} de {max}" charactersBelow: "Ets per sota del mínim de caràcters! Actualment és {current} de {min}" @@ -2765,6 +2926,8 @@ _abuseReport: notifiedWebhook: "Webhook que s'ha de fer servir" deleteConfirm: "Segur que vols esborrar el destinatari de l'informe de moderació?" _moderationLogTypes: + clearQueue: "Esborra la cua de feina" + promoteQueue: "Tornar a intentar la feina de la cua" createRole: "Rol creat" deleteRole: "Rol esborrat" updateRole: "Rol actualitzat" @@ -3159,10 +3322,13 @@ _watermarkEditor: title: "Editar la marca d'aigua " cover: "Cobrir-ho tot" repeat: "Repetir" + preserveBoundingRect: "Ajusta'l per evitar que sobresortir en fer la rotació " opacity: "Opacitat" scale: "Mida" text: "Text" + qr: "Codi QR" position: "Posició " + margin: "Marge" type: "Tipus" image: "Imatges" advanced: "Avançat" @@ -3177,17 +3343,21 @@ _watermarkEditor: polkadotSubDotOpacity: "Opacitat del lunar secundari" polkadotSubDotRadius: "Mida del lunar secundari" polkadotSubDotDivisions: "Nombre de punts secundaris" + leaveBlankToAccountUrl: "Si deixes aquest camp buit, es farà servir l'URL del teu compte" + failedToLoadImage: "Error en carregar la imatge" _imageEffector: title: "Efecte" addEffect: "Afegeix un efecte" discardChangesConfirm: "Vols descartar els canvis i sortir?" - nothingToConfigure: "No hi ha opcions de configuració disponibles" + failedToLoadImage: "Error en carregar la imatge" _fxs: chromaticAberration: "Aberració cromàtica" glitch: "Glitch" mirror: "Mirall" invert: "Inversió cromàtica " grayscale: "Monocrom " + blur: "Desenfocament" + pixelate: "Mosaic" colorAdjust: "Correcció de color" colorClamp: "Compressió cromàtica " colorClampAdvanced: "Compressió de cromàtica avançada " @@ -3199,10 +3369,14 @@ _imageEffector: checker: "Escacs" blockNoise: "Bloqueig de soroll" tearing: "Trencament d'imatge " + fill: "Omplir" _fxProps: angle: "Angle" scale: "Mida" size: "Mida" + radius: "Radi" + samples: "Mida de la mostra" + offset: "Posició " color: "Color" opacity: "Opacitat" normalize: "Normalitzar" @@ -3219,7 +3393,7 @@ _imageEffector: frequency: "Freqüència " strength: "Intensitat" glitchChannelShift: "Canvi de canal " - seed: "Llindar" + seed: "Llavors" redComponent: "Component vermell" greenComponent: "Component verd" blueComponent: "Component blau" @@ -3231,6 +3405,7 @@ _imageEffector: zoomLinesThreshold: "Amplada de línia a l'augmentar " zoomLinesMaskSize: "Diàmetre del centre" zoomLinesBlack: "Obscurir" + circle: "Cercle" drafts: "Esborrany " _drafts: select: "Seleccionar esborrany" @@ -3246,3 +3421,22 @@ _drafts: restoreFromDraft: "Restaurar des dels esborranys" restore: "Restaurar esborrany" listDrafts: "Llistat d'esborranys" + schedule: "Programació esborranys" + listScheduledNotes: "Llista de notes programades" + cancelSchedule: "Cancel·lar la programació" +qr: "Codi QR" +_qr: + showTabTitle: "Veure" + readTabTitle: "Escanejar " + shareTitle: "{name} {acct}" + shareText: "Segueix-me al Fediverse" + chooseCamera: "Seleccionar càmera " + cannotToggleFlash: "No es pot activar el flaix" + turnOnFlash: "Activar el flaix" + turnOffFlash: "Apagar el flaix" + startQr: "Reiniciar el lector de codis QR" + stopQr: "Parar el lector de codis QR" + noQrCodeFound: "No s'ha trobat cap codi QR" + scanFile: "Escanejar la imatge des del dispositiu" + raw: "Text" + mfm: "MFM" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 21be386e26..22962a4243 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -1109,6 +1109,16 @@ postForm: "Formulář pro odeslání" information: "Informace" inMinutes: "Minut" inDays: "Dnů" +widgets: "Widgety" +presets: "Předvolba" +_imageEditing: + _vars: + filename: "Název souboru" +_imageFrameEditor: + header: "Nadpis" + font: "Písmo" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" _chat: invitations: "Pozvat" noHistory: "Žádná historie" @@ -1779,6 +1789,14 @@ _widgets: _userList: chooseList: "Vybrat seznam" clicker: "Clicker" +_widgetOptions: + height: "Výška" + _button: + colored: "Barevné" + _clock: + size: "Velikost" + _birthdayFollowings: + period: "Trvání" _cw: hide: "Skrýt" show: "Zobrazit více" @@ -1821,6 +1839,9 @@ _postForm: replyPlaceholder: "Odpovědět na tuto poznámku..." quotePlaceholder: "Citovat tuto poznámku..." channelPlaceholder: "Zveřejnit příspěvek do kanálu..." + _howToUse: + visibility_title: "Viditelnost" + menu_title: "Menu" _placeholders: a: "Co máte v plánu?" b: "Co se děje kolem vás?" @@ -2057,6 +2078,10 @@ _imageEffector: _fxProps: scale: "Velikost" size: "Velikost" + offset: "Pozice" color: "Barva" opacity: "Průhlednost" lightness: "Zesvětlit" +_qr: + showTabTitle: "Zobrazit" + raw: "Text" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 51ae0642aa..beebba8292 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -83,6 +83,8 @@ files: "Dateien" download: "Herunterladen" driveFileDeleteConfirm: "Möchtest du die Datei „{name}“ wirklich löschen? Einige Inhalte, die diese Datei verwenden, werden auch verschwinden." unfollowConfirm: "Möchtest du {name} wirklich nicht mehr folgen?" +cancelFollowRequestConfirm: "Möchten Sie die Voll-Anfrage an {name} zurückziehen?" +rejectFollowRequestConfirm: "Möchtest du die Follow-Anfrage von {name} ablehnen?" exportRequested: "Du hast einen Export angefragt. Dies kann etwas Zeit in Anspruch nehmen. Sobald der Export abgeschlossen ist, wird er deiner Drive hinzugefügt." importRequested: "Du hast einen Import angefragt. Dies kann etwas Zeit in Anspruch nehmen." lists: "Listen" @@ -253,6 +255,7 @@ noteDeleteConfirm: "Möchtest du diese Notiz wirklich löschen?" pinLimitExceeded: "Du kannst nicht noch mehr Notizen anheften." done: "Fertig" processing: "In Bearbeitung …" +preprocessing: "In Vorbereitung" preview: "Vorschau" default: "Standard" defaultValueIs: "Standardwert: {value}" @@ -301,6 +304,7 @@ uploadFromUrlMayTakeTime: "Es kann eine Weile dauern, bis das Hochladen abgeschl uploadNFiles: "Lade {n} Dateien hoch" explore: "Erkunden" messageRead: "Gelesen" +readAllChatMessages: "Alle Nachrichten als gelesen markieren" noMoreHistory: "Kein weiterer Verlauf vorhanden" startChat: "Chat starten" nUsersRead: "Von {n} Benutzern gelesen" @@ -333,6 +337,7 @@ fileName: "Dateiname" selectFile: "Datei auswählen" selectFiles: "Dateien auswählen" selectFolder: "Ordner auswählen" +unselectFolder: "Ordnerauswahl aufheben" selectFolders: "Ordner auswählen" fileNotSelected: "Keine Datei ausgewählt" renameFile: "Datei umbenennen" @@ -345,6 +350,7 @@ addFile: "Datei hinzufügen" showFile: "Datei anzeigen" emptyDrive: "Deine Drive ist leer" emptyFolder: "Dieser Ordner ist leer" +dropHereToUpload: "Dateien hier ablegen, um sie hochzuladen." unableToDelete: "Nicht löschbar" inputNewFileName: "Gib einen neuen Dateinamen ein" inputNewDescription: "Gib eine neue Beschreibung ein" @@ -772,6 +778,7 @@ lockedAccountInfo: "Auch wenn du Follow-Anfragen auf manuelle Bestätigung setzt alwaysMarkSensitive: "Medien standardmäßig als sensibel markieren" loadRawImages: "Anstatt Vorschaubilder immer Originalbilder anzeigen" disableShowingAnimatedImages: "Animierte Bilder nicht abspielen" +disableShowingAnimatedImages_caption: "Unabhängig von dieser Einstellung kann es vorkommen, dass animierte Bilder nicht abgespielt werden, wenn z. B. die Barrierefreiheits- oder Energiespareinstellungen des Browsers oder des Betriebssystems eingreifen." highlightSensitiveMedia: "Sensitive Medien markieren" verificationEmailSent: "Eine Bestätigungsmail wurde an deine Email-Adresse versendet. Besuche den dort enthaltenen Link, um die Verifizierung abzuschließen." notSet: "Nicht konfiguriert" @@ -1018,6 +1025,9 @@ pushNotificationAlreadySubscribed: "Push-Benachrichtigungen sind bereits aktivie pushNotificationNotSupported: "Entweder dein Browser oder deine Instanz unterstützt Push-Benachrichtigungen nicht" sendPushNotificationReadMessage: "Push-Benachrichtigungen löschen, sobald sie gelesen wurden" sendPushNotificationReadMessageCaption: "Dies kann gegebenenfalls den Batterieverbrauch deines Gerätes erhöhen." +pleaseAllowPushNotification: "Bitte erlauben Sie Benachrichtigungen in Ihrem Browser." +browserPushNotificationDisabled: "Das Abrufen der Berechtigung zum Senden von Benachrichtigungen ist fehlgeschlagen." +browserPushNotificationDisabledDescription: "Sie haben keine Berechtigung, Benachrichtigungen von {serverName} zu senden. Bitte erlauben Sie Benachrichtigungen in den Browser-Einstellungen und versuchen Sie es erneut." windowMaximize: "Maximieren" windowMinimize: "Minimieren" windowRestore: "Wiederherstellen" @@ -1054,6 +1064,7 @@ permissionDeniedError: "Aktion verweigert" permissionDeniedErrorDescription: "Dieses Benutzerkonto besitzt nicht die Berechtigung, um diese Aktion auszuführen." preset: "Vorlage" selectFromPresets: "Aus Vorlagen wählen" +custom: "Benutzerdefiniert" achievements: "Errungenschaften" gotInvalidResponseError: "Ungültige Antwort des Servers" gotInvalidResponseErrorDescription: "Eventuell ist der Server momentan nicht erreichbar oder untergeht Wartungsarbeiten. Bitte versuche es später noch einmal." @@ -1092,6 +1103,7 @@ prohibitedWordsDescription2: "Durch die Verwendung von Leerzeichen können AND-V hiddenTags: "Ausgeblendete Hashtags" hiddenTagsDescription: "Die hier eingestellten Tags werden nicht mehr in den Trends angezeigt. Mit der Umschalttaste können mehrere ausgewählt werden." notesSearchNotAvailable: "Die Notizsuche ist nicht verfügbar." +usersSearchNotAvailable: "Die Benutzersuche ist nicht verfügbar." license: "Lizenz" unfavoriteConfirm: "Wirklich aus Favoriten entfernen?" myClips: "Meine Clips" @@ -1166,6 +1178,7 @@ installed: "Installiert" branding: "Branding" enableServerMachineStats: "Hardwareinformationen des Servers veröffentlichen" enableIdenticonGeneration: "Generierung von Benutzer-Identicons aktivieren" +showRoleBadgesOfRemoteUsers: "Rollensymbole anzeigen, die Remote-Benutzern zugewiesen wurden." turnOffToImprovePerformance: "Deaktivierung kann zu höherer Leistung führen." createInviteCode: "Einladung erstellen" createWithOptions: "Einladung mit Optionen erstellen" @@ -1243,7 +1256,7 @@ releaseToRefresh: "Zum Aktualisieren loslassen" refreshing: "Wird aktualisiert..." pullDownToRefresh: "Zum Aktualisieren ziehen" useGroupedNotifications: "Benachrichtigungen gruppieren" -signupPendingError: "Beim Überprüfen der Mailadresse ist etwas schiefgelaufen. Der Link könnte abgelaufen sein." +emailVerificationFailedError: "Es gab ein Problem bei der Überprüfung Ihrer E-Mail-Adresse. Der Link ist möglicherweise abgelaufen." cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Beschreibung gegeben werden." doReaction: "Reagieren" code: "Code" @@ -1314,6 +1327,7 @@ acknowledgeNotesAndEnable: "Schalten Sie dies erst ein, wenn Sie die Vorsichtsma federationSpecified: "Dieser Server arbeitet mit Whitelist-Föderation. Er kann nicht mit anderen als den vom Administrator angegebenen Servern interagieren." federationDisabled: "Föderation ist auf diesem Server deaktiviert. Es ist nicht möglich, mit Benutzern auf anderen Servern zu interagieren." draft: "Entwurf" +draftsAndScheduledNotes: "Entwürfe und geplante Beiträge" confirmOnReact: "Reagieren bestätigen" reactAreYouSure: "Willst du eine \"{emoji}\"-Reaktion hinzufügen?" markAsSensitiveConfirm: "Möchtest du dieses Medium als sensibel kennzeichnen?" @@ -1341,6 +1355,8 @@ postForm: "Notizfenster" textCount: "Zeichenanzahl" information: "Über" chat: "Chat" +directMessage: "Mit dem Benutzer chatten" +directMessage_short: "Nachrichten" migrateOldSettings: "Alte Client-Einstellungen migrieren" migrateOldSettings_description: "Dies sollte normalerweise automatisch geschehen, aber wenn die Migration aus irgendeinem Grund nicht erfolgreich war, kannst du den Migrationsprozess selbst manuell auslösen. Die aktuellen Konfigurationsinformationen werden dabei überschrieben." compress: "Komprimieren" @@ -1368,12 +1384,54 @@ redisplayAllTips: "Alle „Tipps und Tricks“ wieder anzeigen" hideAllTips: "Alle „Tipps und Tricks“ ausblenden" defaultImageCompressionLevel: "Standard-Bildkomprimierungsstufe" defaultImageCompressionLevel_description: "Ein niedrigerer Wert erhält die Bildqualität, erhöht aber die Dateigröße.
Höhere Werte reduzieren die Dateigröße, verringern aber die Bildqualität." +defaultCompressionLevel: "Standard-Kompressionsgrad" +defaultCompressionLevel_description: "Bei einem niedrigeren Wert bleibt die Qualität erhalten, aber die Dateigröße nimmt zu.
Bei einem höheren Wert lässt sich die Dateigröße verringern, aber die Qualität nimmt ab." inMinutes: "Minute(n)" inDays: "Tag(en)" +safeModeEnabled: "Der abgesicherte Modus ist aktiviert." +pluginsAreDisabledBecauseSafeMode: "Da der abgesicherte Modus aktiviert ist, sind alle Plugins deaktiviert." +customCssIsDisabledBecauseSafeMode: "Da der abgesicherte Modus aktiviert ist, wird benutzerdefiniertes CSS nicht angewendet." +themeIsDefaultBecauseSafeMode: "Solange der abgesicherte Modus aktiviert ist, wird das Standard-Theme verwendet. Wenn Sie den abgesicherten Modus deaktivieren, wird es wieder zurückgesetzt." +thankYouForTestingBeta: "Vielen Dank für Ihre Unterstützung beim Testen der Beta-Version!" +createUserSpecifiedNote: "Benutzerdefinierte Notiz erstellen" +schedulePost: "Beitrag planen" +scheduleToPostOnX: "Der Beitrag wird für {x} geplant.x" +scheduledToPostOnX: "Der Beitrag ist für {x} geplant." +schedule: "Planen" +scheduled: "Geplant" +widgets: "Widgets" +deviceInfo: "Geräteinformation" +deviceInfoDescription: "Bei technischen Anfragen kann es hilfreich sein, die folgenden Informationen anzugeben, da dies zur Lösung des Problems beitragen kann." +youAreAdmin: "Sie sind ein Administrator" +frame: "Rahmen" +presets: "Vorlage" +zeroPadding: "Nullauffüllung" +_imageEditing: + _vars: + caption: "Dateibeschriftung" + filename: "Dateiname" + filename_without_ext: "Dateiname ohne Erweiterung" + year: "Jahr der Aufnahme" + month: "Monat der Aufnahme" + day: "Tag der Aufnahme" + hour: "Stunde der Aufnahmezeit" + minute: "Minute der Aufnahmezeit" + second: "Sekunde der Aufnahmezeit" + camera_model: "Kameraname" + camera_lens_model: "Objektivname" + camera_mm: "Brennweite" + camera_mm_35: "Brennweite (35-mm-Äquivalent)" +_imageFrameEditor: + header: "Kopfzeile" + font: "Schriftart" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + quitWithoutSaveConfirm: "Nicht gespeicherte Änderungen verwerfen?" _order: newest: "Neueste zuerst" oldest: "Älteste zuerst" _chat: + messages: "Nachrichten" noMessagesYet: "Noch keine Nachrichten" newMessage: "Neue Nachricht" individualChat: "Privater Chat" @@ -2434,7 +2492,16 @@ _widgets: chooseList: "Liste auswählen" clicker: "Klickzähler" birthdayFollowings: "Nutzer, die heute Geburtstag haben" - chat: "Chat" + chat: "Mit dem Benutzer chatten" +_widgetOptions: + showHeader: "Kopfzeile anzeigen" + height: "Höhe" + _button: + colored: "Farbig" + _clock: + size: "Größe" + _birthdayFollowings: + period: "Dauer" _cw: hide: "Inhalt verbergen" show: "Inhalt anzeigen" @@ -2479,6 +2546,9 @@ _postForm: replyPlaceholder: "Dieser Notiz antworten …" quotePlaceholder: "Diese Notiz zitieren …" channelPlaceholder: "In einen Kanal senden" + _howToUse: + visibility_title: "Sichtbarkeit" + menu_title: "Menü" _placeholders: a: "Was machst du momentan?" b: "Was ist um dich herum los?" @@ -2703,7 +2773,7 @@ _deck: mentions: "Erwähnungen" direct: "Direktnachrichten" roleTimeline: "Rollenchronik" - chat: "Chat" + chat: "Mit dem Benutzer chatten" _dialog: charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" @@ -3177,6 +3247,7 @@ _imageEffector: angle: "Winkel" scale: "Größe" size: "Größe" + offset: "Position" color: "Farbe" opacity: "Transparenz" lightness: "Erhellen" @@ -3194,3 +3265,6 @@ _drafts: restoreFromDraft: "Aus Entwurf wiederherstellen" restore: "Wiederherstellen" listDrafts: "Liste der Entwürfe" +_qr: + showTabTitle: "Anzeigeart" + raw: "Text" diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 5fc2bd7221..693e2b93c5 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -288,6 +288,10 @@ replies: "Απάντηση" renotes: "Κοινοποίηση σημειώματος" postForm: "Φόρμα δημοσίευσης" information: "Πληροφορίες" +widgets: "Μαραφέτια" +_imageEditing: + _vars: + filename: "Όνομα αρχείου" _chat: members: "Μέλη" home: "Κεντρικό" diff --git a/locales/en-US.yml b/locales/en-US.yml index 395540ea01..17268af1cb 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -83,6 +83,8 @@ files: "Files" download: "Download" driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted." unfollowConfirm: "Are you sure you want to unfollow {name}?" +cancelFollowRequestConfirm: "Are you sure that you want to cancel your follow request to {name}?" +rejectFollowRequestConfirm: "Are you sure that you want to reject the follow request from {name}?" exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed." importRequested: "You've requested an import. This may take a while." lists: "Lists" @@ -253,6 +255,7 @@ noteDeleteConfirm: "Are you sure you want to delete this note?" pinLimitExceeded: "You cannot pin any more notes" done: "Done" processing: "Processing..." +preprocessing: "Preparing..." preview: "Preview" default: "Default" defaultValueIs: "Default: {value}" @@ -301,6 +304,7 @@ uploadFromUrlMayTakeTime: "It may take some time until the upload is complete." uploadNFiles: "Upload {n} files" explore: "Explore" messageRead: "Read" +readAllChatMessages: "Mark all messages as read" noMoreHistory: "There is no further history" startChat: "Start chat" nUsersRead: "read by {n}" @@ -333,6 +337,7 @@ fileName: "Filename" selectFile: "Select a file" selectFiles: "Select files" selectFolder: "Select a folder" +unselectFolder: "Deselect folder" selectFolders: "Select folders" fileNotSelected: "No file selected" renameFile: "Rename file" @@ -345,6 +350,7 @@ addFile: "Add a file" showFile: "Show files" emptyDrive: "Your Drive is empty" emptyFolder: "This folder is empty" +dropHereToUpload: "Drop files here to upload" unableToDelete: "Unable to delete" inputNewFileName: "Enter a new filename" inputNewDescription: "Enter new alt text" @@ -772,6 +778,7 @@ lockedAccountInfo: "Unless you set your note visiblity to \"Followers only\", yo alwaysMarkSensitive: "Mark as sensitive by default" loadRawImages: "Load original images instead of showing thumbnails" disableShowingAnimatedImages: "Don't play animated images" +disableShowingAnimatedImages_caption: "If animated images do not play even if this setting is disabled, it may be due to browser or OS accessibility settings, power-saving settings, or similar factors." highlightSensitiveMedia: "Highlight sensitive media" verificationEmailSent: "A verification email has been sent. Please follow the included link to complete verification." notSet: "Not set" @@ -1018,6 +1025,9 @@ pushNotificationAlreadySubscribed: "Push notifications are already enabled" pushNotificationNotSupported: "Your browser or instance does not support push notifications" sendPushNotificationReadMessage: "Delete push notifications once they have been read" sendPushNotificationReadMessageCaption: "This may increase the power consumption of your device." +pleaseAllowPushNotification: "Please enable push notifications in your browser" +browserPushNotificationDisabled: "Failed to acquire permission to send notifications" +browserPushNotificationDisabledDescription: "You do not have permission to send notifications from {serverName}. Please allow notifications in your browser settings and try again." windowMaximize: "Maximize" windowMinimize: "Minimize" windowRestore: "Restore" @@ -1054,6 +1064,7 @@ permissionDeniedError: "Operation denied" permissionDeniedErrorDescription: "This account does not have the permission to perform this action." preset: "Preset" selectFromPresets: "Choose from presets" +custom: "Custom" achievements: "Achievements" gotInvalidResponseError: "Invalid server response" gotInvalidResponseErrorDescription: "The server may be unreachable or undergoing maintenance. Please try again later." @@ -1167,6 +1178,7 @@ installed: "Installed" branding: "Branding" enableServerMachineStats: "Publish server hardware stats" enableIdenticonGeneration: "Enable user identicon generation" +showRoleBadgesOfRemoteUsers: "Display the role badges assigned to remote users" turnOffToImprovePerformance: "Turning this off can increase performance." createInviteCode: "Generate invite" createWithOptions: "Generate with options" @@ -1217,8 +1229,8 @@ showRepliesToOthersInTimeline: "Show replies to others in timeline" hideRepliesToOthersInTimeline: "Hide replies to others from timeline" showRepliesToOthersInTimelineAll: "Show replies to others from everyone you follow in timeline" hideRepliesToOthersInTimelineAll: "Hide replies to others from everyone you follow in timeline" -confirmShowRepliesAll: "This operation is irreversible. Would you really like to show replies to others from everyone you follow in your timeline?" -confirmHideRepliesAll: "This operation is irreversible. Would you really like to hide replies to others from everyone you follow in your timeline?" +confirmShowRepliesAll: "Are you sure you want to show replies from everyone you follow in your timeline? This action is irreversible." +confirmHideRepliesAll: "Are you sure you want to hide replies from everyone you follow in your timeline? This action is irreversible." externalServices: "External Services" sourceCode: "Source code" sourceCodeIsNotYetProvided: "Source code is not yet available. Contact the administrator to fix this problem." @@ -1244,7 +1256,7 @@ releaseToRefresh: "Release to refresh" refreshing: "Refreshing..." pullDownToRefresh: "Pull down to refresh" useGroupedNotifications: "Display grouped notifications" -signupPendingError: "There was a problem verifying the email address. The link may have expired." +emailVerificationFailedError: "A problem occurred while verifying your email address. The link may have expired." cwNotationRequired: "If \"Hide content\" is enabled, a description must be provided." doReaction: "Add reaction" code: "Code" @@ -1315,6 +1327,7 @@ acknowledgeNotesAndEnable: "Turn on after understanding the precautions." federationSpecified: "This server is operated in a whitelist federation. Interacting with servers other than those designated by the administrator is not allowed." federationDisabled: "Federation is disabled on this server. You cannot interact with users on other servers." draft: "Drafts" +draftsAndScheduledNotes: "Drafts and scheduled notes" confirmOnReact: "Confirm when reacting" reactAreYouSure: "Would you like to add a \"{emoji}\" reaction?" markAsSensitiveConfirm: "Do you want to set this media as sensitive?" @@ -1342,6 +1355,8 @@ postForm: "Posting form" textCount: "Character count" information: "About" chat: "Chat" +directMessage: "Chat with user" +directMessage_short: "Message" migrateOldSettings: "Migrate old client settings" migrateOldSettings_description: "This should be done automatically but if for some reason the migration was not successful, you can trigger the migration process yourself manually. The current configuration information will be overwritten." compress: "Compress" @@ -1369,16 +1384,83 @@ redisplayAllTips: "Show all “Tips & Tricks” again" hideAllTips: "Hide all \"Tips & Tricks\"" defaultImageCompressionLevel: "Default image compression level" defaultImageCompressionLevel_description: "Lower level preserves image quality but increases file size.
Higher level reduce file size, but reduce image quality." +defaultCompressionLevel: "Default compression level" +defaultCompressionLevel_description: "Lower compression preserves quality but increases file size.
Higher compression reduces file size but lowers quality." inMinutes: "Minute(s)" inDays: "Day(s)" safeModeEnabled: "Safe mode is enabled" pluginsAreDisabledBecauseSafeMode: "All plugins are disabled because safe mode is enabled." customCssIsDisabledBecauseSafeMode: "Custom CSS is not applied because safe mode is enabled." themeIsDefaultBecauseSafeMode: "While safe mode is active, the default theme is used. Disabling safe mode will revert these changes." +thankYouForTestingBeta: "Thank you for helping us test the beta version!" +createUserSpecifiedNote: "Create a direct note" +schedulePost: "Schedule note" +scheduleToPostOnX: "Scheduled to note on {x}" +scheduledToPostOnX: "Note is scheduled for {x}" +schedule: "Schedule" +scheduled: "Scheduled" +widgets: "Widgets" +deviceInfo: "Device information" +deviceInfoDescription: "When making technical inquiries, including the following information may help resolve the issue." +youAreAdmin: "You are admin" +frame: "Frame" +presets: "Preset" +zeroPadding: "Zero padding" +nothingToConfigure: "No configurable options available" +_imageEditing: + _vars: + caption: "File caption" + filename: "Filename" + filename_without_ext: "Filename without extension" + year: "Year of photography" + month: "Month of photogrphy" + day: "Date of photography" + hour: "Time the photo was taken (hour)" + minute: "Time the photo was taken (minute)" + second: "Time the photo was taken (second)" + camera_model: "Camera Name" + camera_lens_model: "Lens model" + camera_mm: "Focal length" + camera_mm_35: "Focal length (in 35 mm format)" + camera_f: "Aperture (f-number)" + camera_s: "Shutter speed" + camera_iso: "ISO" + gps_lat: "Latitude" + gps_long: "Longitude" +_imageFrameEditor: + title: "Edit frame" + tip: "You can decorate images by adding labels that include frames and metadata." + header: "Header" + footer: "Footer" + borderThickness: "Frame width" + labelThickness: "Label width" + labelScale: "Label scale" + centered: "Centered" + captionMain: "Caption (Big)" + captionSub: "Caption (Small)" + availableVariables: "Supported variables" + withQrCode: "QR Code" + backgroundColor: "Background color" + textColor: "Text color" + font: "Font" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + quitWithoutSaveConfirm: "Discard unsaved changes?" + failedToLoadImage: "Failed to load image" +_compression: + _quality: + high: "High quality" + medium: "Medium quality" + low: "Low quality" + _size: + large: "Large size" + medium: "Medium size" + small: "Small size" _order: newest: "Newest First" oldest: "Oldest First" _chat: + messages: "Messages" noMessagesYet: "No messages yet" newMessage: "New message" individualChat: "Private Chat" @@ -1467,6 +1549,11 @@ _settings: showUrlPreview: "Show URL preview" showAvailableReactionsFirstInNote: "Show available reactions at the top." showPageTabBarBottom: "Show page tab bar at the bottom" + emojiPaletteBanner: "You can register presets as palettes to display prominently in the emoji picker or customize the appearance of the picker." + enableAnimatedImages: "Enable animated images" + settingsPersistence_title: "Persistence of Settings" + settingsPersistence_description1: "Enabling setting persistence prevents configuration information from being lost." + settingsPersistence_description2: "It may not be possible to enable this depending on the environment." _chat: showSenderName: "Show sender's name" sendOnEnter: "Press Enter to send" @@ -1475,6 +1562,8 @@ _preferencesProfile: profileNameDescription: "Set a name that identifies this device." profileNameDescription2: "Example: \"Main PC\", \"Smartphone\"" manageProfiles: "Manage Profiles" + shareSameProfileBetweenDevicesIsNotRecommended: "We do not recommend sharing the same profile across multiple devices." + useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "If there are settings you wish to synchronize across multiple devices, enable the “Synchronize across multiple devices” option individually for each device." _preferencesBackup: autoBackup: "Auto backup" restoreFromBackup: "Restore from backup" @@ -1484,6 +1573,7 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "A profile name must be set to enable auto backup." autoPreferencesBackupIsNotEnabledForThisDevice: "Settings auto backup is not enabled on this device." backupFound: "Settings backup is found" + forceBackup: "Force a backup of settings" _accountSettings: requireSigninToViewContents: "Require sign-in to view contents" requireSigninToViewContentsDescription1: "Require login to view all notes and other content you have created. This will have the effect of preventing crawlers from collecting your information." @@ -1664,6 +1754,9 @@ _serverSettings: userGeneratedContentsVisibilityForVisitor_description2: "Unconditionally publishing all content on the server to the Internet, including remote content received by the server is risky. This is especially important for guests who are unaware of the distributed nature of the content, as they may mistakenly believe that even remote content is content created by users on the server." restartServerSetupWizardConfirm_title: "Restart server setup wizard?" restartServerSetupWizardConfirm_text: "Some current settings will be reset." + entrancePageStyle: "Entrance page style" + showTimelineForVisitor: "Show timeline" + showActivitiesForVisitor: "Show activities" _userGeneratedContentsVisibilityForVisitor: all: "Everything is public" localOnly: "Only local content is published, remote content is kept private" @@ -1986,6 +2079,7 @@ _role: canManageAvatarDecorations: "Manage avatar decorations" driveCapacity: "Drive capacity" maxFileSize: "Upload-able max file size" + maxFileSize_caption: "Reverse proxies, CDNs, and other front-end components may have their own configuration settings." alwaysMarkNsfw: "Always mark files as NSFW" canUpdateBioMedia: "Can edit an icon or a banner image" pinMax: "Maximum number of pinned notes" @@ -2013,6 +2107,7 @@ _role: uploadableFileTypes_caption: "Specifies the allowed MIME/file types. Multiple MIME types can be specified by separating them with a new line, and wildcards can be specified with an asterisk (*). (e.g., image/*)" uploadableFileTypes_caption2: "Some files types might fail to be detected. To allow such files, add {x} to the specification." noteDraftLimit: "Number of possible drafts of server notes" + scheduledNoteLimit: "Maximum number of simultaneous scheduled notes" watermarkAvailable: "Watermark function" _condition: roleAssignedTo: "Assigned to manual roles" @@ -2273,6 +2368,7 @@ _time: minute: "Minute(s)" hour: "Hour(s)" day: "Day(s)" + month: "Month(s)" _2fa: alreadyRegistered: "You have already registered a 2-factor authentication device." registerTOTP: "Register authenticator app" @@ -2402,6 +2498,7 @@ _auth: scopeUser: "Operate as the following user" pleaseLogin: "Please log in to authorize applications." byClickingYouWillBeRedirectedToThisUrl: "When access is granted, you will automatically be redirected to the following URL" + alreadyAuthorized: "This application already has access permission." _antennaSources: all: "All notes" homeTimeline: "Notes from followed users" @@ -2447,7 +2544,45 @@ _widgets: chooseList: "Select a list" clicker: "Clicker" birthdayFollowings: "Today's Birthdays" - chat: "Chat" + chat: "Chat with user" +_widgetOptions: + showHeader: "Show header" + transparent: "Make background transparent" + height: "Height" + _button: + colored: "Colored" + _clock: + size: "Size" + thickness: "Needle thickness" + thicknessThin: "Thin" + thicknessMedium: "Normal" + thicknessThick: "Thick" + graduations: "Dial markings" + graduationDots: "Dot" + graduationArabic: "Arabic numbers" + fadeGraduations: "Fade the scale" + sAnimation: "Second hand animation" + sAnimationElastic: "Real" + sAnimationEaseOut: "Smooth" + twentyFour: "24 Hour Format" + labelTime: "Time" + labelTz: "Timezone" + labelTimeAndTz: "Time and time zone" + timezone: "Timezone" + showMs: "Show Miliseconds" + showLabel: "Show Label" + _jobQueue: + sound: "Play Sounds" + _rss: + url: "RSS Feed Url" + refreshIntervalSec: "Update interval (in seconds)" + maxEntries: "Maximum number of items to display" + _rssTicker: + shuffle: "Random display order" + duration: "Banner scroll speed (in seconds)" + reverse: "Scroll in the opposite direction" + _birthdayFollowings: + period: "Duration" _cw: hide: "Hide" show: "Show content" @@ -2492,6 +2627,20 @@ _postForm: replyPlaceholder: "Reply to this note..." quotePlaceholder: "Quote this note..." channelPlaceholder: "Post to a channel..." + showHowToUse: "Show how to use this form" + _howToUse: + content_title: "Body" + content_description: "Enter the content you wish to post here." + toolbar_title: "Toolbars" + toolbar_description: "You can attach files or poll, add annotations or hashtags, and insert emojis or mentions." + account_title: "Account menu" + account_description: "You can switch between accounts for posting, or view a list of drafts and scheduled posts saved to your account." + visibility_title: "Visibility" + visibility_description: "You can configure the visibility of your notes." + menu_title: "Menu" + menu_description: "You can save current content to drafts, schedule posts, set reactions, and perform other actions." + submit_title: "Post button" + submit_description: "Post your notes by pressing this button. You can also post using Ctrl + Enter / Cmd + Enter." _placeholders: a: "What are you up to?" b: "What's happening around you?" @@ -2637,6 +2786,8 @@ _notification: youReceivedFollowRequest: "You've received a follow request" yourFollowRequestAccepted: "Your follow request was accepted" pollEnded: "Poll results have become available" + scheduledNotePosted: "Scheduled note has been posted" + scheduledNotePostFailed: "Failed to post scheduled note" newNote: "New note" unreadAntennaNote: "Antenna {name}" roleAssigned: "Role given" @@ -2666,6 +2817,8 @@ _notification: quote: "Quotes" reaction: "Reactions" pollEnded: "Polls ending" + scheduledNotePosted: "Scheduled note was successful" + scheduledNotePostFailed: "Scheduled note failed" receiveFollowRequest: "Received follow requests" followRequestAccepted: "Accepted follow requests" roleAssigned: "Role given" @@ -2705,6 +2858,14 @@ _deck: usedAsMinWidthWhenFlexible: "Minimum width will be used for this when the \"Auto-adjust width\" option is enabled" flexible: "Auto-adjust width" enableSyncBetweenDevicesForProfiles: "Enable profile information sync between devices" + showHowToUse: "" + _howToUse: + addColumn_title: "Add column" + addColumn_description: "You can select and add column types." + settings_title: "UI Settings" + settings_description: "You can configure detailed settings for the deck UI." + switchProfile_title: "Profile Switching" + switchProfile_description: "You can save UI layouts as profiles and switch between them at any time." _columns: main: "Main" widgets: "Widgets" @@ -2716,7 +2877,7 @@ _deck: mentions: "Mentions" direct: "Direct notes" roleTimeline: "Role Timeline" - chat: "Chat" + chat: "Chat with user" _dialog: charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." @@ -2765,6 +2926,8 @@ _abuseReport: notifiedWebhook: "Webhook to use" deleteConfirm: "Are you sure that you want to delete the notification recipient?" _moderationLogTypes: + clearQueue: "Clear queue" + promoteQueue: "Promote queue" createRole: "Role created" deleteRole: "Role deleted" updateRole: "Role updated" @@ -3159,10 +3322,13 @@ _watermarkEditor: title: "Edit Watermark" cover: "Cover everything" repeat: "spread all over" + preserveBoundingRect: "Adjust to prevent overflow when rotating" opacity: "Opacity" scale: "Size" text: "Text" + qr: "QR Code" position: "Position" + margin: "Margin" type: "Type" image: "Images" advanced: "Advanced" @@ -3177,17 +3343,21 @@ _watermarkEditor: polkadotSubDotOpacity: "Opacity of the secondary dot" polkadotSubDotRadius: "Size of the secondary dot" polkadotSubDotDivisions: "Number of sub-dots." + leaveBlankToAccountUrl: "Leave blank to use account URL" + failedToLoadImage: "Failed to load image" _imageEffector: title: "Effects" addEffect: "Add Effects" discardChangesConfirm: "Are you sure you want to leave? You have unsaved changes." - nothingToConfigure: "No configurable options available" + failedToLoadImage: "Failed to load image" _fxs: chromaticAberration: "Chromatic Aberration" glitch: "Glitch" mirror: "Mirror" invert: "Invert Colors" grayscale: "Grayscale" + blur: "Blur" + pixelate: "Pixelate" colorAdjust: "Color Correction" colorClamp: "Color Compression" colorClampAdvanced: "Color Compression (Advanced)" @@ -3199,10 +3369,14 @@ _imageEffector: checker: "Checker" blockNoise: "Block Noise" tearing: "Tearing" + fill: "Fill" _fxProps: angle: "Angle" scale: "Size" size: "Size" + radius: "Radius" + samples: "Sample count" + offset: "Position" color: "Color" opacity: "Opacity" normalize: "Normalize" @@ -3231,6 +3405,7 @@ _imageEffector: zoomLinesThreshold: "Zoom line width" zoomLinesMaskSize: "Center diameter" zoomLinesBlack: "Make black" + circle: "Circular" drafts: "Drafts" _drafts: select: "Select Draft" @@ -3246,3 +3421,22 @@ _drafts: restoreFromDraft: "Restore from Draft" restore: "Restore" listDrafts: "List of Drafts" + schedule: "Schedule note" + listScheduledNotes: "Scheduled notes list" + cancelSchedule: "Cancel schedule" +qr: "QR Code" +_qr: + showTabTitle: "Display" + readTabTitle: "Scan" + shareTitle: "{name} {acct}" + shareText: "Follow me on the Fediverse!" + chooseCamera: "Choose camera" + cannotToggleFlash: "Unable to toggle flashlight" + turnOnFlash: "Turn on flashlight" + turnOffFlash: "Turn off flashlight" + startQr: "Resume QR code reader" + stopQr: "Stop QR code reader" + noQrCodeFound: "No QR code found" + scanFile: "Scan image from device" + raw: "Text" + mfm: "MFM" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 51b377d49f..a243e678c2 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -5,11 +5,11 @@ introMisskey: "¡Bienvenido/a! Misskey es un servicio de microblogging descentra poweredByMisskeyDescription: "{name} es uno de los servicios (también llamado instancia) que usa la plataforma de código abierto Misskey" monthAndDay: "{day}/{month}" search: "Buscar" -reset: "Reiniciar" +reset: "Restablecer" notifications: "Notificaciones" username: "Nombre de usuario" password: "Contraseña" -initialPasswordForSetup: "Contraseña para iniciar la inicialización" +initialPasswordForSetup: "Contraseña de configuración inicial" initialPasswordIsIncorrect: "La contraseña para iniciar la configuración inicial es incorrecta." initialPasswordForSetupDescription: "Si ha instalado Misskey usted mismo, utilice la contraseña introducida en el archivo de configuración.\nSi utiliza un servicio de alojamiento de Misskey o similar, utilice la contraseña proporcionada.\nSi no ha establecido una contraseña, déjela en blanco para continuar." forgotPassword: "Olvidé mi contraseña" @@ -43,7 +43,7 @@ favorite: "Añadir a favoritos" favorites: "Favoritos" unfavorite: "Quitar de favoritos" favorited: "Añadido a favoritos." -alreadyFavorited: "Ya había sido añadido a favoritos" +alreadyFavorited: "Ya añadido a favoritos." cantFavorite: "No se puede añadir a favoritos." pin: "Fijar al perfil" unpin: "Desfijar" @@ -83,11 +83,13 @@ files: "Archivos" download: "Descargar" driveFileDeleteConfirm: "¿Desea borrar el archivo \"{name}\"? Las notas que tengan este archivo como adjunto serán eliminadas" unfollowConfirm: "¿Desea dejar de seguir a {name}?" +cancelFollowRequestConfirm: "¿Desea cancelar su solicitud de seguimiento a {name}?" +rejectFollowRequestConfirm: "¿Desea rechazar la solicitud de seguimiento de {name}?" exportRequested: "Has solicitado la exportación. Puede llevar un tiempo. Cuando termine la exportación, se añadirá al drive" importRequested: "Has solicitado la importación. Puede llevar un tiempo." lists: "Listas" noLists: "No tienes ninguna lista" -note: "Notas" +note: "Nota" notes: "Notas" following: "Siguiendo" followers: "Seguidores" @@ -126,10 +128,10 @@ pinnedNote: "Nota fijada" pinned: "Fijar al perfil" you: "Tú" clickToShow: "Haz clic para verlo" -sensitive: "Marcado como sensible" +sensitive: "Marcado como sensible (NSFW)" add: "Agregar" reaction: "Reacción" -reactions: "Reacción" +reactions: "Reacciones" emojiPicker: "Selector de emojis" pinnedEmojisForReactionSettingDescription: "Puedes seleccionar reacciones para fijarlos en el selector" pinnedEmojisSettingDescription: "Puedes seleccionar emojis para fijarlos en el selector" @@ -141,7 +143,7 @@ rememberNoteVisibility: "Recordar visibilidad" attachCancel: "Quitar adjunto" deleteFile: "Eliminar archivo" markAsSensitive: "Marcar como sensible" -unmarkAsSensitive: "Desmarcar como sensible" +unmarkAsSensitive: "No marcar como sensible" enterFileName: "Introduce el nombre del archivo" mute: "Silenciar" unmute: "Dejar de silenciar" @@ -251,8 +253,9 @@ noUsers: "No hay usuarios" editProfile: "Editar perfil" noteDeleteConfirm: "¿Quieres borrar esta nota?" pinLimitExceeded: "Ya no se pueden fijar más notas" -done: "Terminado" +done: "Hecho" processing: "Procesando..." +preprocessing: "Preparando" preview: "Vista previa" default: "Predeterminado" defaultValueIs: "Por defecto: {value}" @@ -301,6 +304,7 @@ uploadFromUrlMayTakeTime: "Subir el fichero puede tardar un tiempo." uploadNFiles: "Subir {n} archivos" explore: "Explorar" messageRead: "Ya leído" +readAllChatMessages: "Marcar todos los mensajes como leídos" noMoreHistory: "El historial se ha acabado" startChat: "Nuevo Chat" nUsersRead: "Leído por {n} personas" @@ -315,10 +319,10 @@ remoteUserCaution: "Para el usuario remoto, la información está incompleta" activity: "Actividad" images: "Imágenes" image: "Imágenes" -birthday: "Fecha de nacimiento" +birthday: "Cumpleaños" yearsOld: "{age} años" registeredDate: "Fecha de registro" -location: "Lugar" +location: "Ubicación" theme: "Tema" themeForLightMode: "Tema para usar en Modo Linterna" themeForDarkMode: "Tema para usar en Modo Oscuro" @@ -333,6 +337,7 @@ fileName: "Nombre de archivo" selectFile: "Elegir archivo" selectFiles: "Elegir archivos" selectFolder: "Seleccione una carpeta" +unselectFolder: "Deseleccionar carpeta" selectFolders: "Seleccione carpetas" fileNotSelected: "Archivo no seleccionado." renameFile: "Renombrar archivo" @@ -345,9 +350,10 @@ addFile: "Agregar archivo" showFile: "Examinar archivos" emptyDrive: "El drive está vacío" emptyFolder: "La carpeta está vacía" +dropHereToUpload: "Arrastra los archivos aquí para subirlos." unableToDelete: "No se puede borrar" inputNewFileName: "Ingrese un nuevo nombre de archivo" -inputNewDescription: "Ingrese nueva descripción" +inputNewDescription: "Introducir un nuevo texto alternativo" inputNewFolderName: "Ingrese un nuevo nombre de la carpeta" circularReferenceFolder: "La carpeta de destino es una sub-carpeta de la carpeta que quieres mover." hasChildFilesOrFolders: "No se puede borrar esta carpeta. No está vacía." @@ -573,7 +579,7 @@ objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir " s3ForcePathStyleDesc: "Si s3ForcePathStyle esta habilitado el nombre del bucket debe ser especificado como parte de la URL en lugar del nombre de host en la URL. Puede ser necesario activar esta opción cuando se utilice, por ejemplo, Minio en un servidor propio." serverLogs: "Registros del servidor" deleteAll: "Eliminar todos" -showFixedPostForm: "Mostrar el formulario de las entradas encima de la línea de tiempo" +showFixedPostForm: "Visualizar la ventana de publicación en la parte superior de la línea de tiempo." showFixedPostFormInChannel: "Mostrar el formulario de publicación por encima de la cronología (Canales)" withRepliesByDefaultForNewlyFollowed: "Incluir por defecto respuestas de usuarios recién seguidos en la línea de tiempo" newNoteRecived: "Tienes una nota nueva" @@ -648,7 +654,7 @@ disablePlayer: "Cerrar reproductor" expandTweet: "Expandir tweet" themeEditor: "Editor de temas" description: "Descripción" -describeFile: "Añade una descripción" +describeFile: "Añadir texto alternativo" enterFileDescription: "Introducir un título" author: "Autor" leaveConfirm: "Hay modificaciones sin guardar. ¿Desea descartarlas?" @@ -700,7 +706,7 @@ userSaysSomethingAbout: "{name} dijo algo sobre {word}" makeActive: "Activar" display: "Apariencia" copy: "Copiar" -copiedToClipboard: "Texto copiado al portapapeles" +copiedToClipboard: "Copiado al portapapeles" metrics: "Métricas" overview: "Resumen" logs: "Registros" @@ -709,7 +715,7 @@ database: "Base de datos" channel: "Canal" create: "Crear" notificationSetting: "Ajustes de Notificaciones" -notificationSettingDesc: "Por favor elija el tipo de notificación a mostrar" +notificationSettingDesc: "Por favor elige el tipo de notificación a mostrar" useGlobalSetting: "Usar ajustes globales" useGlobalSettingDesc: "Al activarse, se usará la configuración de notificaciones de la cuenta, al desactivarse se pueden hacer configuraciones particulares." other: "Otro" @@ -741,7 +747,7 @@ system: "Sistema" switchUi: "Cambiar interfaz de usuario" desktop: "Escritorio" clip: "Clip" -createNew: "Crear" +createNew: "Crear Nuevo" optional: "Opcional" createNewClip: "Crear clip nuevo" unclip: "Quitar clip" @@ -772,6 +778,7 @@ lockedAccountInfo: "A menos que configures la visibilidad de tus notas como \"S alwaysMarkSensitive: "Marcar los medios de comunicación como contenido sensible por defecto" loadRawImages: "Cargar las imágenes originales en lugar de mostrar las miniaturas" disableShowingAnimatedImages: "No reproducir imágenes animadas" +disableShowingAnimatedImages_caption: "Si las imágenes animadas no se reproducen independientemente de esta configuración, es posible que la configuración de accesibilidad del navegador o del sistema operativo, los modos de ahorro de energía o funciones similares estén interfiriendo." highlightSensitiveMedia: "Resaltar medios marcados como sensibles" verificationEmailSent: "Se le ha enviado un correo electrónico de confirmación. Por favor, acceda al enlace proporcionado en el correo electrónico para completar la configuración." notSet: "Sin especificar" @@ -837,7 +844,7 @@ jumpToSpecifiedDate: "Saltar a una fecha específica" showingPastTimeline: "Mostrar líneas de tiempo antiguas" clear: "Limpiar" markAllAsRead: "Marcar todo como leído" -goBack: "Deseleccionar" +goBack: "Anterior" unlikeConfirm: "¿Quitar como favorito?" fullView: "Vista completa" quitFullView: "quitar vista completa" @@ -910,8 +917,8 @@ pubSub: "Cuentas Pub/Sub" lastCommunication: "Última comunicación" resolved: "Resuelto" unresolved: "Sin resolver" -breakFollow: "Dejar de seguir" -breakFollowConfirm: "¿Quieres dejar de seguir?" +breakFollow: "Eliminar seguidor" +breakFollowConfirm: "¿De verdad quieres eliminar a este seguidor?" itsOn: "¡Está encendido!" itsOff: "¡Está apagado!" on: "Activado" @@ -962,7 +969,7 @@ threeDays: "Tres días" reflectMayTakeTime: "Puede pasar un tiempo hasta que se reflejen los cambios" failedToFetchAccountInformation: "No se pudo obtener información de la cuenta" rateLimitExceeded: "Se excedió el límite de peticiones" -cropImage: "Recortar imágen" +cropImage: "Recortar Imagen" cropImageAsk: "¿Desea recortar la imagen?" cropYes: "Recortar" cropNo: "Usar como está" @@ -981,7 +988,7 @@ typeToConfirm: "Ingrese {x} para confirmar" deleteAccount: "Borrar cuenta" document: "Documento" numberOfPageCache: "Cantidad de páginas cacheadas" -numberOfPageCacheDescription: "Al aumentar el número mejora la conveniencia pero tambien puede aumentar la carga y la memoria a usarse" +numberOfPageCacheDescription: "Al aumentar el número mejora la conveniencia pero también puede aumentar la carga y la memoria a usarse" logoutConfirm: "¿Cerrar sesión?" logoutWillClearClientData: "Al cerrar la sesión, la información de configuración del cliente se borra del navegador. Para garantizar que la información de configuración se pueda restaurar al volver a iniciar sesión, active la copia de seguridad automática de la configuración." lastActiveDate: "Utilizado por última vez el" @@ -1018,10 +1025,13 @@ pushNotificationAlreadySubscribed: "Notificaciones emergentes ya activadas" pushNotificationNotSupported: "El navegador o la instancia no admiten notificaciones push" sendPushNotificationReadMessage: "Eliminar las notificaciones push después de leer las notificaciones y los mensajes" sendPushNotificationReadMessageCaption: "La notificación \"{emptyPushNotificationMessage}\" aparecerá momentáneamente. Esto puede aumentar el consumo de batería del dispositivo." +pleaseAllowPushNotification: "Por favor, permita las notificaciones y la configuración del navegador." +browserPushNotificationDisabled: "No se ha podido obtener permiso para enviar notificaciones." +browserPushNotificationDisabledDescription: "No tienes permiso para enviar notificaciones desde {serverName}. Permite las notificaciones en la configuración de tu navegador y vuelve a intentarlo." windowMaximize: "Maximizar" windowMinimize: "Minimizar" windowRestore: "Regresar" -caption: "Pie de foto" +caption: "Texto alternativo" loggedInAsBot: "Inicio sesión como cuenta bot." tools: "Utilidades" cannotLoad: "No se puede cargar." @@ -1054,6 +1064,7 @@ permissionDeniedError: "Operación denegada" permissionDeniedErrorDescription: "Esta cuenta no tiene permisos para hacer esa acción." preset: "Predefinido" selectFromPresets: "Escoger desde predefinidos" +custom: "Personalizado" achievements: "Logros" gotInvalidResponseError: "Respuesta del servidor inválida" gotInvalidResponseErrorDescription: "Puede que el servidor esté caído o en mantenimiento. Favor de intentar más tarde" @@ -1148,7 +1159,7 @@ initialAccountSetting: "Configración inicial de su cuenta" youFollowing: "Siguiendo" preventAiLearning: "Rechazar el uso en el Aprendizaje de Máquinas. (IA Generativa)" preventAiLearningDescription: "Pedirle a las arañas (crawlers) no usar los textos publicados o imágenes en el aprendizaje automático (IA Predictiva / Generativa). Ésto se logra añadiendo una marca respuesta HTML con la cadena \"noai\" al cantenido. Una prevención total no podría lograrse sólo usando ésta marca, ya que puede ser simplemente ignorada." -options: "Opción" +options: "Opciones" specifyUser: "Especificar usuario" lookupConfirm: "¿Quiere informarse?" openTagPageConfirm: "¿Quieres abrir la página de etiquetas?" @@ -1167,6 +1178,7 @@ installed: "Instalado" branding: "Marca" enableServerMachineStats: "Publicar estadísticas de hardware del servidor" enableIdenticonGeneration: "Activar generación de identicon por usuario" +showRoleBadgesOfRemoteUsers: "Mostrar la insignia de rol asignada a los usuarios remotos." turnOffToImprovePerformance: "Desactivar esto puede aumentar el rendimiento." createInviteCode: "Generar invitación" createWithOptions: "Generar con opciones" @@ -1191,8 +1203,8 @@ iHaveReadXCarefullyAndAgree: "He leído el texto {x} y estoy de acuerdo" dialog: "Diálogo" icon: "Avatar" forYou: "Para ti" -currentAnnouncements: "Anuncios actuales" -pastAnnouncements: "Anuncios anteriores" +currentAnnouncements: "Avisos actuales" +pastAnnouncements: "Avisos anteriores" youHaveUnreadAnnouncements: "Hay anuncios sin leer" useSecurityKey: "Por favor, sigue las instrucciones de tu dispositivo o navegador para usar tu clave de seguridad o tu clave de paso." replies: "Responder" @@ -1240,11 +1252,11 @@ detachAll: "Quitar todo" angle: "Ángulo" flip: "Echar de un capirotazo" showAvatarDecorations: "Mostrar decoraciones de avatar" -releaseToRefresh: "Soltar para recargar" +releaseToRefresh: "Suelta para recargar" refreshing: "Recargando..." pullDownToRefresh: "Tira hacia abajo para recargar" useGroupedNotifications: "Mostrar notificaciones agrupadas" -signupPendingError: "Ha habido un problema al verificar tu dirección de correo electrónico. Es posible que el enlace haya caducado." +emailVerificationFailedError: "Se ha producido un error al confirmar tu dirección de correo electrónico. Es posible que el enlace haya caducado." cwNotationRequired: "Si se ha activado \"ocultar contenido\", es necesario proporcionar una descripción." doReaction: "Añadir reacción" code: "Código" @@ -1257,7 +1269,7 @@ addMfmFunction: "Añadir función MFM" enableQuickAddMfmFunction: "Activar acceso rápido para añadir funciones MFM" bubbleGame: "Bubble Game" sfx: "Efectos de sonido" -soundWillBePlayed: "Se reproducirán efectos sonoros" +soundWillBePlayed: "Con música y efectos sonoros" showReplay: "Ver reproducción" replay: "Reproducir" replaying: "Reproduciendo" @@ -1315,6 +1327,7 @@ acknowledgeNotesAndEnable: "Activar después de comprender las precauciones" federationSpecified: "Este servidor opera en una federación de listas blancas. No puede interactuar con otros servidores que no sean los especificados por el administrador." federationDisabled: "La federación está desactivada en este servidor. No puede interactuar con usuarios de otros servidores" draft: "Borrador" +draftsAndScheduledNotes: "Borradores y notas programadas" confirmOnReact: "Confirmar la reacción" reactAreYouSure: "¿Quieres añadir una reacción «{emoji}»?" markAsSensitiveConfirm: "¿Desea establecer este medio multimedia(Imagen,vídeo...) como sensible?" @@ -1342,9 +1355,11 @@ postForm: "Formulario" textCount: "caracteres" information: "Información" chat: "Chat" +directMessage: "Chatear" +directMessage_short: "Mensajes" migrateOldSettings: "Migrar la configuración anterior" migrateOldSettings_description: "Esto debería hacerse automáticamente, pero si por alguna razón la migración no ha tenido éxito, puede activar usted mismo el proceso de migración manualmente. Se sobrescribirá la información de configuración actual." -compress: "Comprimir" +compress: "Compresión de la imagen" right: "Derecha" bottom: "Abajo" top: "Arriba" @@ -1369,16 +1384,83 @@ redisplayAllTips: "Volver a mostrar todos \"Trucos y consejos\"" hideAllTips: "Ocultar todos los \"Trucos y consejos\"" defaultImageCompressionLevel: "Nivel de compresión de la imagen por defecto" defaultImageCompressionLevel_description: "Baja, conserva la calidad de la imagen pero la medida del archivo es más grande.
Alta, reduce la medida del archivo pero también la calidad de la imagen." +defaultCompressionLevel: "Nivel de compresión predeterminado" +defaultCompressionLevel_description: "Al reducir el ajuste se conserva la calidad, pero aumenta el tamaño del archivo.
Al aumentar el ajuste se reduce el tamaño del archivo, pero disminuye la calidad." inMinutes: "Minutos" inDays: "Días" safeModeEnabled: "El modo seguro está activado" pluginsAreDisabledBecauseSafeMode: "El modo seguro está activado, por lo que todos los plugins están desactivados." customCssIsDisabledBecauseSafeMode: "El modo seguro está activado, por lo que no se aplica el CSS personalizado." themeIsDefaultBecauseSafeMode: "Mientras el modo seguro esté activado, se utilizará el tema predeterminado. Cuando se desactive el modo seguro, se volverá al tema original." +thankYouForTestingBeta: "¡Gracias por tu colaboración en la prueba de la versión beta!" +createUserSpecifiedNote: "Mencionar al usuario (Nota Directa)" +schedulePost: "Programar una nota" +scheduleToPostOnX: "Programar una nota para {x}" +scheduledToPostOnX: "La nota está programada para el {x}." +schedule: "Programar" +scheduled: "Programado" +widgets: "Widgets" +deviceInfo: "Información del dispositivo" +deviceInfoDescription: "Al realizar consultas técnicas, incluir la siguiente información puede ayudar a resolver el problema." +youAreAdmin: "Eres administrador." +frame: "Marco" +presets: "Predefinido" +zeroPadding: "Relleno cero" +nothingToConfigure: "No hay nada que configurar" +_imageEditing: + _vars: + caption: "Título del archivo" + filename: "Nombre de archivo" + filename_without_ext: "Nombre del archivo sin la extensión" + year: "Año de rodaje" + month: "Mes de la fotografía" + day: "Día de la fotografía" + hour: "Hora" + minute: "Minuto" + second: "Segundo" + camera_model: "Nombre de la cámara" + camera_lens_model: "Modelo de lente" + camera_mm: "Distancia focal" + camera_mm_35: "Distancia Focal (Equivalente a formato de 35mm)" + camera_f: "Apertura de diafragma" + camera_s: "Velocidad de Obturación" + camera_iso: "Sensibilidad ISO" + gps_lat: "Latitud" + gps_long: "Longitud" +_imageFrameEditor: + title: "Edición de Fotos" + tip: "Decora tus imágenes con marcos y etiquetas que contengan metadatos." + header: "Título" + footer: "Pie de página" + borderThickness: "Ancho del borde" + labelThickness: "Ancho de la etiqueta" + labelScale: "Escala de la Etiqueta" + centered: "Alinear al centro" + captionMain: "Pie de foto (Grande)" + captionSub: "Pie de foto (Pequeño)" + availableVariables: "Variables disponibles" + withQrCode: "Código QR" + backgroundColor: "Color de fondo" + textColor: "Color del texto" + font: "Fuente" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + quitWithoutSaveConfirm: "¿Descartar cambios no guardados?" + failedToLoadImage: "Error al cargar la imagen" +_compression: + _quality: + high: "Calidad alta" + medium: "Calidad media" + low: "Calidad baja" + _size: + large: "Tamaño grande" + medium: "Tamaño mediano" + small: "Tamaño pequeño" _order: - newest: "Los más recientes primero" - oldest: "Los más antiguos primero" + newest: "Más reciente primero" + oldest: "Más antiguos primero" _chat: + messages: "Mensajes" noMessagesYet: "Aún no hay mensajes" newMessage: "Mensajes nuevos" individualChat: "Chat individual" @@ -1430,7 +1512,7 @@ _emojiPalette: palettes: "Paleta\n" enableSyncBetweenDevicesForPalettes: "Activar la sincronización de paletas entre dispositivos" paletteForMain: "Paleta principal" - paletteForReaction: "Paleta de reacción" + paletteForReaction: "Paleta utilizada para las reacciones" _settings: driveBanner: "Puedes gestionar y configurar la unidad, comprobar su uso y configurar los ajustes de carga de archivos." pluginBanner: "Puedes ampliar las funciones del cliente con plugins. Puedes instalar plugins, configurarlos y gestionarlos individualmente." @@ -1442,7 +1524,7 @@ _settings: accountData: "Datos de la cuenta" accountDataBanner: "Exportación e importación para gestionar los datos de la cuenta." muteAndBlockBanner: "Puedes configurar y gestionar ajustes para ocultar contenidos y restringir acciones a usuarios específicos." - accessibilityBanner: "Puedes personalizar los visuales y el comportamiento del cliente, y configurar los ajustes para optimizar el uso." + accessibilityBanner: "Puedes personalizar el aspecto y el comportamiento del cliente y configurar los ajustes para optimizar su uso." privacyBanner: "Puedes configurar opciones relacionadas con la privacidad de la cuenta, como la visibilidad del contenido, la posibilidad de descubrir la cuenta y la aprobación de seguimiento." securityBanner: "Puedes configurar opciones relacionadas con la seguridad de la cuenta, como la contraseña, los métodos de inicio de sesión, las aplicaciones de autenticación y Passkeys." preferencesBanner: "Puedes configurar el comportamiento general del cliente según tus preferencias." @@ -1459,7 +1541,7 @@ _settings: ifOff: "Si está desactivado" enableSyncThemesBetweenDevices: "Sincronizar los temas instalados entre dispositivos." enablePullToRefresh: "Tirar para actualizar" - enablePullToRefresh_description: "Si utiliza un ratón, arrastre mientras pulsa la rueda de desplazamiento." + enablePullToRefresh_description: "Si utilizas un ratón, arrastra mientras pulsas la rueda de desplazamiento." realtimeMode_description: "Establece una conexión con el servidor y actualiza el contenido en tiempo real. Esto puede aumentar el tráfico y el consumo de memoria." contentsUpdateFrequency: "Frecuencia de adquisición del contenido." contentsUpdateFrequency_description: "Cuanto mayor sea el valor, más se actualiza el contenido, pero disminuye el rendimiento y aumenta el tráfico y el consumo de memoria." @@ -1467,6 +1549,11 @@ _settings: showUrlPreview: "Mostrar la vista previa de la URL" showAvailableReactionsFirstInNote: "Mostrar las reacciones disponibles en la parte superior." showPageTabBarBottom: "Mostrar la barra de pestañas de la página en la parte inferior." + emojiPaletteBanner: "Puedes registrar ajustes preestablecidos como paletas para que se muestren permanentemente en el selector de emojis, o personalizar el método de visualización del selector." + enableAnimatedImages: "Habilitar imágenes animadas" + settingsPersistence_title: "Persistencia de la configuración" + settingsPersistence_description1: "Habilitar la persistencia de la configuración evita que se pierda la información de configuración." + settingsPersistence_description2: "Es posible que no se pueda habilitar esta función dependiendo del entorno." _chat: showSenderName: "Mostrar el nombre del remitente" sendOnEnter: "Intro para enviar" @@ -1475,6 +1562,8 @@ _preferencesProfile: profileNameDescription: "Establece un nombre que identifique al dispositivo" profileNameDescription2: "Por ejemplo: \"PC Principal\",\"Teléfono\"" manageProfiles: "Administrar perfiles" + shareSameProfileBetweenDevicesIsNotRecommended: "No recomendamos compartir el mismo perfil en varios dispositivos." + useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "Si hay ajustes que deseas sincronizar en varios dispositivos, activa la opción «Sincronizar en varios dispositivos» individualmente para cada uno de ellos." _preferencesBackup: autoBackup: "Respaldo automático" restoreFromBackup: "Restaurar desde copia de seguridad" @@ -1484,6 +1573,7 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "Se debe establecer un nombre de perfil para activar la copia de seguridad automática." autoPreferencesBackupIsNotEnabledForThisDevice: "La copia de seguridad automática de los ajustes no está activada en este dispositivo." backupFound: "Copia de seguridad de los ajustes encontrada " + forceBackup: "Forzar una copia de seguridad de la configuración" _accountSettings: requireSigninToViewContents: "Se requiere iniciar sesión para ver el contenido" requireSigninToViewContentsDescription1: "Requiere iniciar sesión para ver todas las notas y otros contenidos que hayas creado. Se espera que esto evite que los rastreadores recopilen información." @@ -1521,7 +1611,7 @@ _bubbleGame: score: "Puntos" scoreYen: "Cantidad de dinero ganada" highScore: "Puntuación más alta" - maxChain: "Número máximo de cadenas" + maxChain: "Número máximo de combos" yen: "{yen} Yenes" estimatedQty: "{qty} Piezas" scoreSweets: "{onigiriQtyWithUnit} Onigiris" @@ -1584,9 +1674,9 @@ _initialTutorial: title: "El concepto de Línea de tiempo" description1: "Misskey proporciona múltiples líneas de tiempo basadas en su uso (algunas pueden no estar disponibles dependiendo de las políticas de la instancia)." home: "Puedes ver los posts de las cuentas que sigues." - local: "Puedes ver los posts de todos los usuarios de este servidor." + local: "Puedes ver los posts de todos los usuarios de este servidor (también llamado instancia)." social: "Se ven los posts de la línea de tiempo de inicio junto con los de la línea de tiempo local." - global: "Puedes ver notas de todos los servidores conectados." + global: "Puedes ver notas de todos los servidores (instancias) conectados." description2: "Puedes cambiar la línea de tiempo en la parte superior de la pantalla cuando quieras." description3: "Además, hay listas de líneas de tiempo y listas de canales. Para más detalle, por favor visita este enlace: {link}" _postNote: @@ -1596,14 +1686,14 @@ _initialTutorial: description: "Puedes limitar quién puede ver tu nota." public: "Tu nota será visible para todos los usuarios." home: "Publicar solo en la línea de tiempo de Inicio. La nota se verá en tu perfil, la verán tus seguidores y también cuando sea renotada." - followers: "Visible solo para seguidores. Sólo tus seguidores podrán ver la nota, y no podrá ser renotada por otras personas." + followers: "Visible solo para seguidores. Solo tus seguidores podrán ver la nota, y no podrá ser renotada por otras personas." direct: "Visible sólo para usuarios específicos, y el destinatario será notificado. Puede usarse como alternativa a la mensajería directa." doNotSendConfidencialOnDirect1: "¡Ten cuidado cuando vayas a enviar información sensible!" - doNotSendConfidencialOnDirect2: "Los administradores del servidor pueden leer lo que escribes. Ten cuidado cuando envíes información sensible en notas directas en servidores no confiables." + doNotSendConfidencialOnDirect2: "Los administradores del servidor, también llamado instancia, pueden leer lo que escribes. Ten cuidado cuando envíes información sensible en notas directas en servidores o instancias no confiables." localOnly: "Publicando con esta opción seleccionada, la nota no se federará hacia otros servidores. Los usuarios de otros servidores no podrán ver estas notas directamente, sin importar los ajustes seleccionados más arriba." _cw: title: "Alerta de contenido (CW)" - description: "En lugar de mostrarse el contenido de la nota, se mostrará lo que escribas en el campo \"comentarios\". Pulsando en \"leer más\" desplegará el contenido de la nota." + description: "En lugar de mostrarse el contenido de la nota, se mostrará lo que escribas en el campo \"comentarios\". Pulsando en \"Ver más\" desplegará el contenido de la nota." _exampleNote: cw: "¡Esto te hará tener hambre!" note: "Acabo de comerme un donut de chocolate glaseado 🍩😋" @@ -1664,6 +1754,9 @@ _serverSettings: userGeneratedContentsVisibilityForVisitor_description2: "Publicar incondicionalmente todo el contenido del servidor en Internet, incluido el contenido remoto recibido por el servidor, es arriesgado. Esto es especialmente importante para los invitados que desconocen la naturaleza distribuida del contenido, ya que pueden creer erróneamente que incluso el contenido remoto es contenido creado por usuarios en el servidor." restartServerSetupWizardConfirm_title: "¿Reiniciar el asistente de configuración del servidor?" restartServerSetupWizardConfirm_text: "Algunas configuraciones actuales se restablecerán" + entrancePageStyle: "Estilo de la página de inicio" + showTimelineForVisitor: "Mostrar la línea de tiempo" + showActivitiesForVisitor: "Mostrar actividades" _userGeneratedContentsVisibilityForVisitor: all: "Todo es público." localOnly: "Sólo se publica el contenido local, el remoto se mantiene privado" @@ -1952,7 +2045,7 @@ _role: isConditionalRole: "Esto es un rol condicional" isPublic: "Publicar rol" descriptionOfIsPublic: "Cualquiera puede ver los usuarios asignados a este rol. También, el perfil del usuario mostrará este rol." - options: "Opción" + options: "Opciones" policies: "Política" baseRole: "Rol base" useBaseValue: "Usar los valores del rol base" @@ -1986,6 +2079,7 @@ _role: canManageAvatarDecorations: "Administrar decoraciones de avatar" driveCapacity: "Capacidad del drive" maxFileSize: "Tamaño máximo de archivo que se puede cargar." + maxFileSize_caption: "Los proxies inversos o las CDN pueden tener diferentes valores de configuración aguas arriba." alwaysMarkNsfw: "Siempre marcar archivos como NSFW" canUpdateBioMedia: "Puede editar un icono o una imagen de fondo (banner)" pinMax: "Máximo de notas fijadas" @@ -2013,6 +2107,7 @@ _role: uploadableFileTypes_caption: "Especifica los tipos MIME/archivos permitidos. Se pueden especificar varios tipos MIME separándolos con una nueva línea, y se pueden especificar comodines con un asterisco (*). (por ejemplo, image/*)" uploadableFileTypes_caption2: "Es posible que no se detecten algunos tipos de archivos. Para permitir estos archivos, añade {x} a la especificación." noteDraftLimit: "Número de posibles borradores de notas del servidor" + scheduledNoteLimit: "Máximo número de notas programadas que se pueden crear simultáneamente." watermarkAvailable: "Disponibilidad de la función de marca de agua" _condition: roleAssignedTo: "Asignado a roles manuales" @@ -2061,11 +2156,11 @@ _accountDelete: accountDelete: "Eliminar Cuenta" mayTakeTime: "La eliminación de la cuenta es un proceso que precisa de carga. Puede pasar un tiempo hasta que se complete si es mucho el contenido creado y los archivos subidos." sendEmail: "Cuando se termine de borrar la cuenta, se enviará un correo a la dirección usada para el registro." - requestAccountDelete: "Pedir la eliminación de la cuenta." + requestAccountDelete: "Solicitar la eliminación de la cuenta." started: "El proceso de eliminación ha comenzado." inProgress: "La eliminación está en proceso." _ad: - back: "Deseleccionar" + back: "Anterior" reduceFrequencyOfThisAd: "Mostrar menos este anuncio." hide: "No mostrar" timezoneinfo: "El día de la semana está determidado por la zona horaria del servidor." @@ -2116,7 +2211,7 @@ _registry: key: "Clave" keys: "Clave" domain: "Dominio" - createKey: "Crear una llave" + createKey: "Crear una clave" _aboutMisskey: about: "Misskey es un software de código abierto, desarrollado por syuilo desde el 2014" contributors: "Principales colaboradores" @@ -2132,7 +2227,7 @@ _aboutMisskey: _displayOfSensitiveMedia: respect: "Esconder medios marcados como sensibles" ignore: "Mostrar medios marcados como sensibles" - force: "Esconder todala multimedia" + force: "Esconder toda la multimedia" _instanceTicker: none: "No mostrar" remote: "Mostrar a usuarios remotos" @@ -2208,7 +2303,7 @@ _theme: indicator: "Indicador" panel: "Panel" shadow: "Sombra" - header: "Cabezal" + header: "Título" navBg: "Fondo de la barra lateral" navFg: "Texto de la barra lateral" navActive: "Texto de la barra lateral (activo)" @@ -2273,6 +2368,7 @@ _time: minute: "Minutos" hour: "Horas" day: "Días" + month: "Mes(es)" _2fa: alreadyRegistered: "Ya has completado la configuración." registerTOTP: "Registrar aplicación autenticadora" @@ -2402,6 +2498,7 @@ _auth: scopeUser: "Operar como el siguiente usuario" pleaseLogin: "Se requiere un inicio de sesión para darle permisos a la aplicación" byClickingYouWillBeRedirectedToThisUrl: "Cuando el acceso es concedido, serás automáticamente redireccionado a la siguiente URL" + alreadyAuthorized: "Esta aplicación ya ha obtenido acceso." _antennaSources: all: "Todas las notas" homeTimeline: "Notas de los usuarios que sigues" @@ -2432,7 +2529,7 @@ _widgets: digitalClock: "Reloj digital" unixClock: "Reloj UNIX" federation: "Federación" - instanceCloud: "Nube de palabras de la instancia" + instanceCloud: "Nube de Instancias Federadas" postForm: "Formulario" slideshow: "Diapositivas" button: "Botón" @@ -2447,7 +2544,45 @@ _widgets: chooseList: "Seleccione una lista" clicker: "Cliqueador" birthdayFollowings: "Hoy cumplen años" - chat: "Chat" + chat: "Chatear" +_widgetOptions: + showHeader: "Mostrar encabezados" + transparent: "Hacer fondo transparente" + height: "Altura" + _button: + colored: "Color" + _clock: + size: "Tamaño" + thickness: "Grosor de la aguja" + thicknessThin: "Delgada" + thicknessMedium: "Normal" + thicknessThick: "Gruesa" + graduations: "Marcas del dial" + graduationDots: "Puntos" + graduationArabic: "Números decimales" + fadeGraduations: "Desvanecer la escala" + sAnimation: "Animación de la manecilla de los segundos" + sAnimationElastic: "Real" + sAnimationEaseOut: "Suave" + twentyFour: "Formato 24 horas" + labelTime: "Hora" + labelTz: "Zona horaria" + labelTimeAndTz: "Hora y zona horaria" + timezone: "Zona horaria" + showMs: "Mostrar milisegundos" + showLabel: "Mostrar etiqueta" + _jobQueue: + sound: "Reproducir sonido" + _rss: + url: "URL del canal RSS" + refreshIntervalSec: "Intervalo de actualización (En segundos)" + maxEntries: "Número máximo de elementos a mostrar" + _rssTicker: + shuffle: "Orden de visualización aleatorio" + duration: "Velocidad de desplazamiento del baner (En segundos)" + reverse: "Desplázate en la dirección opuesta." + _birthdayFollowings: + period: "Duración" _cw: hide: "Ocultar" show: "Ver más" @@ -2457,7 +2592,7 @@ _poll: noOnlyOneChoice: "Se necesitan al menos 2 opciones" choiceN: "Opción {n}" noMore: "No se pueden agregar más" - canMultipleVote: "Permitir más de una respuesta" + canMultipleVote: "Permitir seleccionar varias opciones" expiration: "Termina el" infinite: "Sin límite de tiempo" at: "Elegir fecha y hora" @@ -2488,10 +2623,24 @@ _visibility: disableFederationDescription: "No enviar a otras instancias" _postForm: quitInspiteOfThereAreUnuploadedFilesConfirm: "Hay archivos que no se han cargado, ¿deseas descartarlos y cerrar el formulario?" - uploaderTip: "El archivo aún no se ha cargado. Desde el menú Archivo, puedes cambiar el nombre, recortar imágenes, poner marcas de agua y comprimir o no el archivo. Los archivos se cargan automáticamente al publicar una nota." + uploaderTip: "El archivo aún no se ha cargado. Desde el menú de archivos, puedes cambiar el nombre, recortar la imagen, añadir una marca de agua y configurar la compresión, entre otras opciones. Los archivos se suben automáticamente al publicar una nota." replyPlaceholder: "Responder a esta nota" quotePlaceholder: "Citar esta nota" channelPlaceholder: "Publicar en el canal" + showHowToUse: "Mostrar el tutorial de este formulario" + _howToUse: + content_title: "Cuerpo" + content_description: "Introduce aquí el contenido que deseas publicar." + toolbar_title: "Barras de herramientas" + toolbar_description: "Puedes adjuntar archivos o realizar encuestas, añadir anotaciones o hashtags e insertar emojis o menciones." + account_title: "Menú de la cuenta" + account_description: "Puedes cambiar entre cuentas para publicar o ver una lista de borradores y publicaciones programadas guardadas en tu cuenta." + visibility_title: "Visibilidad" + visibility_description: "Puedes configurar la visibilidad de tus notas." + menu_title: "Menú" + menu_description: "Puedes realizar otras acciones, como guardar borradores, programar publicaciones y configurar reacciones." + submit_title: "Botón de publicar" + submit_description: "Publica tus notas pulsando este botón. También puedes publicar utilizando Ctrl + Intro / Cmd + Intro." _placeholders: a: "¿Qué haces?" b: "¿Te pasó algo?" @@ -2503,10 +2652,10 @@ _profile: name: "Nombre" username: "Nombre de usuario" description: "Descripción" - youCanIncludeHashtags: "Puedes añadir hashtags" + youCanIncludeHashtags: "También puedes incluir hashtags en tu biografía" metadata: "información adicional" metadataEdit: "Editar información adicional" - metadataDescription: "Muestra la información adicional en el perfil" + metadataDescription: "Usando esto puedes mostrar campos de información adicionales en tu perfil." metadataLabel: "Etiqueta" metadataContent: "Contenido" changeAvatar: "Cambiar avatar" @@ -2526,7 +2675,7 @@ _exportOrImport: userLists: "Listas" excludeMutingUsers: "Excluir usuarios silenciados" excludeInactiveUsers: "Excluir usuarios inactivos" - withReplies: "Incluir respuestas de los usuarios importados en la línea de tiempo" + withReplies: "Si el archivo no incluye información sobre si las respuestas deben incluirse en la línea de tiempo, las respuestas realizadas por el importador deben incluirse en la línea de tiempo." _charts: federation: "Federación" apRequest: "Pedidos" @@ -2637,6 +2786,8 @@ _notification: youReceivedFollowRequest: "Has mandado una solicitud de seguimiento" yourFollowRequestAccepted: "Tu solicitud de seguimiento fue aceptada" pollEnded: "Estan disponibles los resultados de la encuesta" + scheduledNotePosted: "Una nota programada ha sido publicada" + scheduledNotePostFailed: "Ha fallado la publicación de una nota programada" newNote: "Nueva nota" unreadAntennaNote: "Antena {name}" roleAssigned: "Rol asignado" @@ -2662,10 +2813,12 @@ _notification: follow: "Siguiendo" mention: "Menciones" reply: "Respuestas" - renote: "Renotar" + renote: "Renotas" quote: "Citar" reaction: "Reacción" pollEnded: "La encuesta terminó" + scheduledNotePosted: "Publicación programada con éxito" + scheduledNotePostFailed: "Publicación programada fallida" receiveFollowRequest: "Recibió una solicitud de seguimiento" followRequestAccepted: "El seguimiento fue aceptado" roleAssigned: "Rol asignado" @@ -2705,6 +2858,14 @@ _deck: usedAsMinWidthWhenFlexible: "Se usará el ancho mínimo cuando la opción \"Autoajustar ancho\" esté habilitada" flexible: "Autoajustar ancho" enableSyncBetweenDevicesForProfiles: "Activar la sincronización de la información de perfiles entre dispositivos." + showHowToUse: "Ver la descripción de la interfaz de usuario" + _howToUse: + addColumn_title: "Añadir columna" + addColumn_description: "Puede seleccionar y añadir tipos de columnas." + settings_title: "Configuración de la interfaz de usuario" + settings_description: "Puedes configurar la interfaz de usuario en detalle." + switchProfile_title: "Cambiar de perfil" + switchProfile_description: "Puedes guardar diseños de interfaz de usuario como perfiles y cambiar entre ellos en cualquier momento." _columns: main: "Principal" widgets: "Widgets" @@ -2716,7 +2877,7 @@ _deck: mentions: "Menciones" direct: "Notas directas" roleTimeline: "Linea de tiempo del rol" - chat: "Chat" + chat: "Chatear" _dialog: charactersExceeded: "¡Has excedido el límite de caracteres! Actualmente {current} de {max}." charactersBelow: "¡Estás por debajo del límite de caracteres! Actualmente {current} de {min}." @@ -2765,6 +2926,8 @@ _abuseReport: notifiedWebhook: "Webhook a utilizar" deleteConfirm: "¿Estás seguro de que deseas borrar el destinatario del informe de moderación?" _moderationLogTypes: + clearQueue: "Borrar la cola de trabajos" + promoteQueue: "Reintentar el trabajo en la cola" createRole: "Rol creado" deleteRole: "Rol eliminado" updateRole: "Rol actualizado" @@ -2917,7 +3080,7 @@ _reversi: loopedMap: "Mapa en bucle" canPutEverywhere: "Las fichas se pueden poner a cualquier lugar\n" timeLimitForEachTurn: "Tiempo límite por jugada." - freeMatch: "Partida libre." + freeMatch: "Partida libre" lookingForPlayer: "Buscando oponente" gameCanceled: "La partida ha sido cancelada." shareToTlTheGameWhenStart: "Compartir la partida en la línea de tiempo cuando comience " @@ -3032,8 +3195,8 @@ _selfXssPrevention: description2: "Si no entiendes que estás pegando exactamente, %cdetente ahora mismo y cierra esta ventana" description3: "Para más información visita esto {link}" _followRequest: - recieved: "Petición de seguimiento recibida" - sent: "Petición de seguimiento enviada" + recieved: "Solicitud de seguimiento recibida" + sent: "Solicitud de seguimiento enviada" _remoteLookupErrors: _federationNotAllowed: title: "Incapaz de comunicarse con este servidor." @@ -3159,10 +3322,13 @@ _watermarkEditor: title: "Editar la marca de agua" cover: "Cubrir todo" repeat: "Repetir" + preserveBoundingRect: "Ajuste para evitar que se desborde al rotar." opacity: "Opacidad" scale: "Tamaño" text: "Texto" + qr: "Código QR" position: "Posición" + margin: "Margen" type: "Tipo" image: "Imágenes" advanced: "Avanzado" @@ -3170,39 +3336,47 @@ _watermarkEditor: stripe: "Rayas" stripeWidth: "Anchura de línea" stripeFrequency: "Número de líneas." - polkadot: "Lunares" - checker: "verificador" + polkadot: "Patrón de Lunares" + checker: "Patrón de Damas / Tablero de Ajedrez" polkadotMainDotOpacity: "Opacidad del círculo principal" polkadotMainDotRadius: "Tamaño del círculo principal." polkadotSubDotOpacity: "Opacidad del círculo secundario" polkadotSubDotRadius: "Tamaño del círculo secundario." polkadotSubDotDivisions: "Número de subpuntos." + leaveBlankToAccountUrl: "Si dejas este campo en blanco, se utilizará la URL de tu cuenta." + failedToLoadImage: "Error al cargar la imagen" _imageEffector: title: "Efecto" addEffect: "Añadir Efecto" discardChangesConfirm: "¿Ignorar cambios y salir?" - nothingToConfigure: "No hay opciones configurables disponibles." + failedToLoadImage: "Error al cargar la imagen" _fxs: chromaticAberration: "Aberración Cromática" glitch: "Glitch" mirror: "Espejo" invert: "Invertir colores" grayscale: "Blanco y negro" + blur: "Difuminar" + pixelate: "Pixelar" colorAdjust: "Corrección de Color" - colorClamp: "Compresión cromática" - colorClampAdvanced: "Compresión cromática avanzada" + colorClamp: "Ajuste de Tono" + colorClampAdvanced: "Ajuste de Tono avanzado" distort: "Distorsión" - threshold: "umbral" - zoomLines: "Saturación de Líneas" + threshold: "Binarización" + zoomLines: "Líneas de Impacto" stripe: "Rayas" - polkadot: "Lunares" - checker: "Corrector" - blockNoise: "Bloquear Ruido" + polkadot: "Patrón de Lunares" + checker: "Patrón de Damas / Tablero de Ajedrez" + blockNoise: "Ruido de Bloque" tearing: "Rasgado de Imagen (Tearing)" + fill: "Relleno de color" _fxProps: angle: "Ángulo" scale: "Tamaño" size: "Tamaño" + radius: "Radio" + samples: "Tamaño de muestra" + offset: "Posición" color: "Color" opacity: "Opacidad" normalize: "Normalización" @@ -3210,7 +3384,7 @@ _imageEffector: lightness: "Brillo" contrast: "Contraste" hue: "Tonalidad" - brightness: "Brillo" + brightness: "Luminancia" saturation: "Saturación" max: "Valor máximo" min: "Valor mínimo" @@ -3218,11 +3392,11 @@ _imageEffector: phase: "Fase" frequency: "Frecuencia" strength: "Intensidad" - glitchChannelShift: "cambio de canal de imagen" + glitchChannelShift: "Desfase" seed: "Valor de la semilla" - redComponent: "Componente rojo" - greenComponent: "Componente Verde" - blueComponent: "Componente Azul" + redComponent: "Canal Rojo" + greenComponent: "Canal Verde" + blueComponent: "Canal Azul" threshold: "Umbral" centerX: "Centrar X" centerY: "Centrar Y" @@ -3230,7 +3404,8 @@ _imageEffector: zoomLinesSmoothingDescription: "El suavizado y el ancho de línea de zoom no se pueden utilizar juntos." zoomLinesThreshold: "Ancho de línea del zoom" zoomLinesMaskSize: "Diámetro del centro" - zoomLinesBlack: "Hacer oscuro" + zoomLinesBlack: "Cambiar color de las líneas de impacto a negro." + circle: "Círculo" drafts: "Borrador" _drafts: select: "Seleccionar borradores" @@ -3246,3 +3421,22 @@ _drafts: restoreFromDraft: "Restaurar desde los borradores" restore: "Restaurar" listDrafts: "Listar los borradores" + schedule: "Programar Nota" + listScheduledNotes: "Lista de notas programadas" + cancelSchedule: "Cancelar programación" +qr: "Código QR" +_qr: + showTabTitle: "Apariencia" + readTabTitle: "Escanear" + shareTitle: "{name} {acct}" + shareText: "¡Sígueme en el Fediverso!" + chooseCamera: "Seleccione cámara" + cannotToggleFlash: "No se puede activar el flash" + turnOnFlash: "Encender el flash" + turnOffFlash: "Apagar el flash" + startQr: "Reiniciar el lector de códigos QR" + stopQr: "Detener el lector de códigos QR" + noQrCodeFound: "No se encontró el código QR" + scanFile: "Escanear imagen desde un dispositivo" + raw: "Texto" + mfm: "MFM" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index f47a350369..63b8f3bb55 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -5,11 +5,12 @@ introMisskey: "Bienvenue ! Misskey est un service de microblogage décentralis poweredByMisskeyDescription: "{name} est l'un des services propulsés par la plateforme ouverte Misskey (appelée \"instance Misskey\")." monthAndDay: "{day}/{month}" search: "Rechercher" +reset: "Réinitialiser" notifications: "Notifications" username: "Nom d’utilisateur·rice" password: "Mot de passe" initialPasswordForSetup: "Mot de passe initial pour la configuration" -initialPasswordIsIncorrect: "Mot de passe initial pour la configuration est incorrecte" +initialPasswordIsIncorrect: "Le mot de passe initial pour la configuration est incorrect" initialPasswordForSetupDescription: "Utilisez le mot de passe que vous avez entré pour le fichier de configuration si vous avez installé Misskey vous-même.\nSi vous utilisez un service d'hébergement Misskey, utilisez le mot de passe fourni.\nSi vous n'avez pas défini de mot de passe, laissez le champ vide pour continuer." forgotPassword: "Mot de passe oublié" fetchingAsApObject: "Récupération depuis le fédiverse …" @@ -48,6 +49,7 @@ pin: "Épingler sur le profil" unpin: "Désépingler" copyContent: "Copier le contenu" copyLink: "Copier le lien" +copyRemoteLink: "Copier le lien de la note" copyLinkRenote: "Copier le lien de la renote" delete: "Supprimer" deleteAndEdit: "Supprimer et réécrire" @@ -62,8 +64,8 @@ copyNoteId: "Copier l'identifiant de la note" copyFileId: "Copier l'identifiant du fichier" copyFolderId: "Copier l'identifiant du dossier" copyProfileUrl: "Copier l'URL du profil" -searchUser: "Chercher un·e utilisateur·rice" -searchThisUsersNotes: "Cherchez les notes de cet·te utilisateur·rice" +searchUser: "Chercher un utilisateur" +searchThisUsersNotes: "Cherchez les notes de cet utilisateur" reply: "Répondre" loadMore: "Afficher plus …" showMore: "Voir plus" @@ -81,6 +83,8 @@ files: "Fichiers" download: "Télécharger" driveFileDeleteConfirm: "Êtes-vous sûr·e de vouloir supprimer le fichier « {name} » ? Les notes avec ce fichier joint seront aussi supprimées." unfollowConfirm: "Désirez-vous vous désabonner de {name} ?" +cancelFollowRequestConfirm: "Est-te vous sur de vouloir annuler la demande de suivi de {name} ?" +rejectFollowRequestConfirm: "Refuser la demande de suivi de {name} ?" exportRequested: "Vous avez demandé une exportation. L’opération pourrait prendre un peu de temps. Une fois terminée, le fichier sera ajouté au Drive." importRequested: "Vous avez initié un import. Cela pourrait prendre un peu de temps." lists: "Listes" @@ -118,6 +122,8 @@ cantReRenote: "Impossible de renoter une Renote." quote: "Citer" inChannelRenote: "Renoter dans le canal" inChannelQuote: "Citer dans le canal" +renoteToChannel: "Renoter sur le canal" +renoteToOtherChannel: "Renoter sur un autre canal" pinnedNote: "Note épinglée" pinned: "Épingler sur le profil" you: "Vous" @@ -212,6 +218,7 @@ blockThisInstance: "Bloquer cette instance" silenceThisInstance: "Mettre cette instance en sourdine" operations: "Opérations" software: "Logiciel" +softwareName: "Nom du logiciel" version: "Version" metadata: "Métadonnées" withNFiles: "{n} fichier(s)" @@ -231,6 +238,9 @@ blockedInstances: "Instances bloquées" blockedInstancesDescription: "Listez les instances que vous désirez bloquer, une par ligne. Ces instances ne seront plus en capacité d'interagir avec votre instance." silencedInstances: "Instances mises en sourdine" silencedInstancesDescription: "Énumérer les noms d'hôte des instances à mettre en sourdine. Tous les comptes des instances énumérées seront traités comme mis en sourdine, ne peuvent faire que des demandes de suivi et ne peuvent pas mentionner les comptes locaux s'ils ne sont pas suivis. Cela n'affectera pas les instances bloquées." +mediaSilencedInstances: "Médias silencieux sur ces instances" +mediaSilencedInstancesDescription: "Liste des noms de serveurs où vous voulez que les médias soient silencieux, séparés par un retour à la ligne.\nTous les comptes des instances listées seront considérés comme sensibles, et ne peuvent pas utilisés d'émojis personnalisés. Ceci n'affectera pas les serveurs bloquées." +federationAllowedHosts: "Serveurs qui autorisent la fédération" muteAndBlock: "Masqué·e·s / Bloqué·e·s" mutedUsers: "Utilisateur·rice·s en sourdine" blockedUsers: "Utilisateur·rice·s bloqué·e·s" @@ -1208,7 +1218,6 @@ releaseToRefresh: "Relâcher pour rafraîchir" refreshing: "Rafraîchissement..." pullDownToRefresh: "Tirer vers le bas pour rafraîchir" useGroupedNotifications: "Grouper les notifications" -signupPendingError: "Un problème est survenu lors de la vérification de votre adresse e-mail. Le lien a peut-être expiré." cwNotationRequired: "Si « Masquer le contenu » est activé, une description doit être fournie." doReaction: "Réagir" code: "Code" @@ -1274,6 +1283,16 @@ postForm: "Formulaire de publication" information: "Informations" inMinutes: "min" inDays: "j" +widgets: "Widgets" +presets: "Préréglage" +_imageEditing: + _vars: + filename: "Nom du fichier" +_imageFrameEditor: + header: "Entête" + font: "Police de caractères" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" _chat: invitations: "Inviter" noHistory: "Pas d'historique" @@ -1996,6 +2015,14 @@ _widgets: _userList: chooseList: "Sélectionner une liste" birthdayFollowings: "Utilisateurs qui fêtent l'anniversaire aujourd'hui" +_widgetOptions: + height: "Hauteur" + _button: + colored: "Coloré" + _clock: + size: "Taille" + _birthdayFollowings: + period: "Durée" _cw: hide: "Masquer" show: "Afficher le contenu" @@ -2037,6 +2064,9 @@ _postForm: replyPlaceholder: "Répondre à cette note ..." quotePlaceholder: "Citez cette note ..." channelPlaceholder: "Publier au canal…" + _howToUse: + visibility_title: "Visibilité" + menu_title: "Menu" _placeholders: a: "Quoi de neuf ?" b: "Il s'est passé quelque chose ?" @@ -2377,6 +2407,10 @@ _imageEffector: angle: "Angle" scale: "Taille" size: "Taille" + offset: "Position" color: "Couleur" opacity: "Transparence" lightness: "Clair" +_qr: + showTabTitle: "Affichage" + raw: "Texte" diff --git a/locales/generateDTS.js b/locales/generateDTS.js deleted file mode 100644 index ab0613cc82..0000000000 --- a/locales/generateDTS.js +++ /dev/null @@ -1,232 +0,0 @@ -import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; -import * as yaml from 'js-yaml'; -import ts from 'typescript'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const parameterRegExp = /\{(\w+)\}/g; - -function createMemberType(item) { - if (typeof item !== 'string') { - return ts.factory.createTypeLiteralNode(createMembers(item)); - } - const parameters = Array.from( - item.matchAll(parameterRegExp), - ([, parameter]) => parameter, - ); - return parameters.length - ? ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ParameterizedString'), - [ - ts.factory.createUnionTypeNode( - parameters.map((parameter) => - ts.factory.createStringLiteral(parameter), - ), - ), - ], - ) - : ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); -} - -function createMembers(record) { - return Object.entries(record).map(([k, v]) => { - const node = ts.factory.createPropertySignature( - undefined, - ts.factory.createStringLiteral(k), - undefined, - createMemberType(v), - ); - if (typeof v === 'string') { - ts.addSyntheticLeadingComment( - node, - ts.SyntaxKind.MultiLineCommentTrivia, - `* - * ${v.replace(/\n/g, '\n * ')} - `, - true, - ); - } - return node; - }); -} - -export default function generateDTS() { - const locale = yaml.load(fs.readFileSync(`${__dirname}/ja-JP.yml`, 'utf-8')); - const members = createMembers(locale); - const elements = [ - ts.factory.createVariableStatement( - [ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - ts.factory.createIdentifier('kParameters'), - undefined, - ts.factory.createTypeOperatorNode( - ts.SyntaxKind.UniqueKeyword, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.SymbolKeyword), - ), - undefined, - ), - ], - ts.NodeFlags.Const, - ), - ), - ts.factory.createTypeAliasDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('ParameterizedString'), - [ - ts.factory.createTypeParameterDeclaration( - undefined, - ts.factory.createIdentifier('T'), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ), - ], - ts.factory.createIntersectionTypeNode([ - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createTypeLiteralNode([ - ts.factory.createPropertySignature( - undefined, - ts.factory.createComputedPropertyName( - ts.factory.createIdentifier('kParameters'), - ), - undefined, - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('T'), - undefined, - ), - ), - ]) - ]), - ), - ts.factory.createInterfaceDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('ILocale'), - undefined, - undefined, - [ - ts.factory.createIndexSignature( - undefined, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - ts.factory.createIdentifier('_'), - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - undefined, - ), - ], - ts.factory.createUnionTypeNode([ - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ParameterizedString'), - ), - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('ILocale'), - undefined, - ), - ]), - ), - ], - ), - ts.factory.createInterfaceDeclaration( - [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], - ts.factory.createIdentifier('Locale'), - undefined, - [ - ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [ - ts.factory.createExpressionWithTypeArguments( - ts.factory.createIdentifier('ILocale'), - undefined, - ), - ]), - ], - members, - ), - ts.factory.createVariableStatement( - [ts.factory.createToken(ts.SyntaxKind.DeclareKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - ts.factory.createIdentifier('locales'), - undefined, - ts.factory.createTypeLiteralNode([ - ts.factory.createIndexSignature( - undefined, - [ - ts.factory.createParameterDeclaration( - undefined, - undefined, - ts.factory.createIdentifier('lang'), - undefined, - ts.factory.createKeywordTypeNode( - ts.SyntaxKind.StringKeyword, - ), - undefined, - ), - ], - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('Locale'), - undefined, - ), - ), - ]), - undefined, - ), - ], - ts.NodeFlags.Const, - ), - ), - ts.factory.createFunctionDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - ts.factory.createIdentifier('build'), - undefined, - [], - ts.factory.createTypeReferenceNode( - ts.factory.createIdentifier('Locale'), - undefined, - ), - undefined, - ), - ts.factory.createExportDefault(ts.factory.createIdentifier('locales')), - ]; - ts.addSyntheticLeadingComment( - elements[0], - ts.SyntaxKind.MultiLineCommentTrivia, - ' eslint-disable ', - true, - ); - ts.addSyntheticLeadingComment( - elements[0], - ts.SyntaxKind.SingleLineCommentTrivia, - ' This file is generated by locales/generateDTS.js', - true, - ); - ts.addSyntheticLeadingComment( - elements[0], - ts.SyntaxKind.SingleLineCommentTrivia, - ' Do not edit this file directly.', - true, - ); - const printed = ts - .createPrinter({ - newLine: ts.NewLineKind.LineFeed, - }) - .printList( - ts.ListFormat.MultiLine, - ts.factory.createNodeArray(elements), - ts.createSourceFile( - 'index.d.ts', - '', - ts.ScriptTarget.ESNext, - true, - ts.ScriptKind.TS, - ), - ); - - fs.writeFileSync(`${__dirname}/index.d.ts`, printed, 'utf-8'); -} diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 93683508b5..dbb5d63da6 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -240,6 +240,8 @@ blockedInstances: "Instansi terblokir" blockedInstancesDescription: "Daftar nama host dari instansi yang diperlukan untuk diblokir. Instansi yang didaftarkan tidak akan dapat berkomunikasi dengan instansi ini." silencedInstances: "Instansi yang disenyapkan" silencedInstancesDescription: "Daftar nama host dari instansi yang ingin kamu senyapkan. Semua akun dari instansi yang terdaftar akan diperlakukan sebagai disenyapkan. Hal ini membuat akun hanya dapat membuat permintaan mengikuti, dan tidak dapat menyebutkan akun lokal apabila tidak mengikuti. Hal ini tidak akan mempengaruhi instansi yang diblokir." +mediaSilencedInstances: "Server dengan media dibisukan" +mediaSilencedInstancesDescription: "Masukkan host server yang medianya ingin Anda bisukan, pisahkan dengan baris baru. Semua berkas dari akun di server ini akan dianggap sebagai sensitif dan emoji kustom tidak akan tersedia. Ini tidak akan membengaruhi server yang diblokir." federationAllowedHosts: "Server yang membolehkan federasi" muteAndBlock: "Bisukan / Blokir" mutedUsers: "Pengguna yang dibisukan" @@ -298,6 +300,7 @@ uploadFromUrlMayTakeTime: "Membutuhkan beberapa waktu hingga pengunggahan selesa explore: "Jelajahi" messageRead: "Telah dibaca" noMoreHistory: "Tidak ada sejarah lagi" +startChat: "Kirim pesan" nUsersRead: "Dibaca oleh {n}" agreeTo: "Saya setuju kepada {0}" agree: "Setuju" @@ -397,7 +400,7 @@ enableHcaptcha: "Nyalakan hCaptcha" hcaptchaSiteKey: "Site Key" hcaptchaSecretKey: "Secret Key" mcaptcha: "mCaptcha" -enableMcaptcha: "Nyalakan mCaptcha" +enableMcaptcha: "" mcaptchaSiteKey: "Site key" mcaptchaSecretKey: "Secret Key" mcaptchaInstanceUrl: "URL instansi mCaptcha" @@ -510,6 +513,7 @@ emojiStyle: "Gaya emoji" native: "Native" menuStyle: "Gaya menu" style: "Gaya" +popup: "Pemunculan" showNoteActionsOnlyHover: "Hanya tampilkan aksi catatan saat ditunjuk" showReactionsCount: "Lihat jumlah reaksi dalam catatan" noHistory: "Tidak ada riwayat" @@ -566,6 +570,7 @@ showFixedPostForm: "Tampilkan form posting di atas lini masa" showFixedPostFormInChannel: "Tampilkan form posting di atas lini masa (Kanal)" withRepliesByDefaultForNewlyFollowed: "Termasuk balasan dari pengguna baru yang diikuti pada lini masa secara bawaan" newNoteRecived: "Kamu mendapat catatan baru" +newNote: "Catatan baru" sounds: "Bunyi" sound: "Bunyi" listen: "Dengarkan" @@ -1028,6 +1033,7 @@ permissionDeniedError: "Operasi ditolak" permissionDeniedErrorDescription: "Akun ini tidak memiliki izin untuk melakukan aksi ini." preset: "Prasetel" selectFromPresets: "Pilih dari prasetel" +custom: "Penyesuaian" achievements: "Pencapaian" gotInvalidResponseError: "Respon peladen tidak valid" gotInvalidResponseErrorDescription: "Peladen tidak dapat dijangkau atau sedang dalam perawatan. Mohon coba lagi nanti." @@ -1110,6 +1116,7 @@ preservedUsernamesDescription: "Daftar nama pengguna yang dicadangkan dipisah de createNoteFromTheFile: "Buat catatan dari berkas ini" archive: "Arsipkan" archived: "Diarsipkan" +unarchive: "Batalkan pengarsipan" channelArchiveConfirmTitle: "Yakin untuk mengarsipkan {name}?" channelArchiveConfirmDescription: "Kanal yang diarsipkan tidak akan muncul pada daftar kanal atau hasil pencarian. Postingan baru juga tidak dapat ditambahkan lagi." thisChannelArchived: "Kanal ini telah diarsipkan." @@ -1212,7 +1219,6 @@ releaseToRefresh: "Lepaskan untuk memuat ulang" refreshing: "Sedang memuat ulang..." pullDownToRefresh: "Tarik ke bawah untuk memuat ulang" useGroupedNotifications: "Tampilkan notifikasi secara dikelompokkan" -signupPendingError: "Terdapat masalah ketika memverifikasi alamat surel. Tautan kemungkinan telah kedaluwarsa." cwNotationRequired: "Jika \"Sembunyikan konten\" diaktifkan, deskripsi harus disediakan." doReaction: "Tambahkan reaksi" code: "Kode" @@ -1252,6 +1258,7 @@ noDescription: "Tidak ada deskripsi" alwaysConfirmFollow: "Selalu konfirmasi ketika mengikuti" inquiry: "Hubungi kami" tryAgain: "Silahkan coba lagi." +sensitiveMediaRevealConfirm: "Media sensitif. Apakah ingin melihat?" createdLists: "Senarai yang dibuat" createdAntennas: "Antena yang dibuat" fromX: "Dari {x}" @@ -1259,20 +1266,52 @@ noteOfThisUser: "Catatan oleh pengguna ini" clipNoteLimitExceeded: "Klip ini tak bisa ditambahi lagi catatan." performance: "Kinerja" modified: "Diubah" +discard: "Buang" thereAreNChanges: "Ada {n} perubahan" +signinWithPasskey: "Masuk dengan kunci sandi" +unknownWebAuthnKey: "Kunci sandi tidak terdaftar." +passkeyVerificationFailed: "Verifikasi kunci sandi gagal." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "Verifikasi kunci sandi berhasil, namun pemasukan tanpa sandi dinonaktifkan." +messageToFollower: "Pesan kepada pengikut" prohibitedWordsForNameOfUser: "Kata yang dilarang untuk nama pengguna" +lockdown: "Kuncitara" +noName: "Tidak ada nama" +skip: "Lewati" +paste: "Tempel" +emojiPalette: "Palet emoji" postForm: "Buat catatan" information: "Informasi" +chat: "Obrolan" +directMessage: "Obrolan pengguna" +right: "Kanan" +bottom: "Bawah" +top: "Atas" +advice: "Saran" inMinutes: "menit" inDays: "hari" +widgets: "Widget" +presets: "Prasetel" +_imageEditing: + _vars: + filename: "Nama berkas" +_imageFrameEditor: + header: "Header" + font: "Font" + fontSerif: "Serif" + fontSansSerif: "Sans-serif" _chat: invitations: "Undang" + history: "Riwayat obrolan" noHistory: "Tidak ada riwayat" members: "Anggota" home: "Beranda" send: "Kirim" + chatWithThisUser: "Obrolan pengguna" _settings: webhook: "Webhook" + contentsUpdateFrequency: "Frekuensi pembaruan konten" +_preferencesProfile: + profileName: "Nama profil" _abuseUserReport: accept: "Setuju" reject: "Tolak" @@ -1966,6 +2005,7 @@ _sfx: noteMy: "Catatan (Saya)" notification: "Notifikasi" reaction: "Ketika memilih reaksi" + chatMessage: "Obrolan pengguna" _soundSettings: driveFile: "Menggunakan berkas audio dalam Drive" driveFileWarn: "Pilih berkas audio dari Drive" @@ -2168,6 +2208,15 @@ _widgets: chooseList: "Pilih daftar" clicker: "Pengeklik" birthdayFollowings: "Pengguna yang merayakan hari ulang tahunnya hari ini" + chat: "Obrolan pengguna" +_widgetOptions: + height: "Tinggi" + _button: + colored: "Diwarnai" + _clock: + size: "Ukuran" + _birthdayFollowings: + period: "Durasi" _cw: hide: "Sembunyikan" show: "Lihat konten" @@ -2210,6 +2259,9 @@ _postForm: replyPlaceholder: "Balas ke catatan ini..." quotePlaceholder: "Kutip catatan ini..." channelPlaceholder: "Posting ke kanal" + _howToUse: + visibility_title: "Visibilitas" + menu_title: "Menu" _placeholders: a: "Sedang apa kamu saat ini?" b: "Apa yang terjadi di sekitarmu?" @@ -2416,6 +2468,7 @@ _deck: mentions: "Sebutan" direct: "Langsung" roleTimeline: "Lini masa peran" + chat: "Obrolan pengguna" _dialog: charactersExceeded: "Kamu telah melebihi batas karakter maksimum! Saat ini pada {current} dari {max}." charactersBelow: "Kamu berada di bawah batas minimum karakter! Saat ini pada {current} dari {min}." @@ -2632,6 +2685,10 @@ _imageEffector: angle: "Sudut" scale: "Ukuran" size: "Ukuran" + offset: "Posisi" color: "Warna" opacity: "Opasitas" lightness: "Menerangkan" +_qr: + showTabTitle: "Tampilkan" + raw: "Teks" diff --git a/locales/index.js b/locales/index.js deleted file mode 100644 index 6d9cf4796b..0000000000 --- a/locales/index.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Languages Loader - */ - -import * as fs from 'node:fs'; -import * as yaml from 'js-yaml'; - -const merge = (...args) => args.reduce((a, c) => ({ - ...a, - ...c, - ...Object.entries(a) - .filter(([k]) => c && typeof c[k] === 'object') - .reduce((a, [k, v]) => (a[k] = merge(v, c[k]), a), {}) -}), {}); - -const languages = [ - 'ar-SA', - 'ca-ES', - 'cs-CZ', - 'da-DK', - 'de-DE', - 'en-US', - 'es-ES', - 'fr-FR', - 'id-ID', - 'it-IT', - 'ja-JP', - 'ja-KS', - 'kab-KAB', - 'kn-IN', - 'ko-KR', - 'nl-NL', - 'no-NO', - 'pl-PL', - 'pt-PT', - 'ru-RU', - 'sk-SK', - 'th-TH', - 'tr-TR', - 'ug-CN', - 'uk-UA', - 'vi-VN', - 'zh-CN', - 'zh-TW', -]; - -const primaries = { - 'en': 'US', - 'ja': 'JP', - 'zh': 'CN', -}; - -// 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く -const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); - -export function build() { - // 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) => { - for (const [k, v] of Object.entries(obj)) { - if (v === '') { - delete obj[k]; - } else if (typeof v === 'object') { - removeEmpty(v); - } - } - return obj; - }; - removeEmpty(locales); - - return Object.entries(locales) - .reduce((a, [k, v]) => (a[k] = (() => { - const [lang] = k.split('-'); - switch (k) { - case 'ja-JP': return v; - case 'ja-KS': - case 'en-US': return merge(locales['ja-JP'], v); - default: return merge( - locales['ja-JP'], - locales['en-US'], - locales[`${lang}-${primaries[lang]}`] ?? {}, - v - ); - } - })(), a), {}); -} - -export default build(); diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 81487c4d79..47f2bbdb72 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -83,6 +83,8 @@ files: "Allegati" download: "Scarica" driveFileDeleteConfirm: "Vuoi davvero eliminare il file \"{name}\", e le Note a cui è stato allegato?" unfollowConfirm: "Vuoi davvero togliere il Following a {name}?" +cancelFollowRequestConfirm: "Vuoi annullare la tua richiesta di follow inviata a {name}?" +rejectFollowRequestConfirm: "Vuoi rifiutare la richiesta di follow ricevuta da {name}?" exportRequested: "Hai richiesto un'esportazione, e potrebbe volerci tempo. Quando sarà compiuta, il file verrà aggiunto direttamente al Drive." importRequested: "Hai richiesto un'importazione. Potrebbe richiedere un po' di tempo." lists: "Liste" @@ -139,12 +141,12 @@ overwriteFromPinnedEmojis: "Sovrascrivi con le impostazioni globali" reactionSettingDescription2: "Trascina per riorganizzare, clicca per cancellare, usa il pulsante \"+\" per aggiungere." rememberNoteVisibility: "Ricordare le impostazioni di visibilità delle note" attachCancel: "Rimuovi allegato" -deleteFile: "File da Drive eliminato" +deleteFile: "Elimina un file dal Drive" markAsSensitive: "Segna come esplicito" unmarkAsSensitive: "Non segnare come esplicito " enterFileName: "Nome del file" mute: "Silenziare" -unmute: "Riattiva l'audio" +unmute: "Dai voce" renoteMute: "Silenziare le Rinota" renoteUnmute: "Non silenziare le Rinota" block: "Bloccare" @@ -253,6 +255,7 @@ noteDeleteConfirm: "Vuoi davvero eliminare questa Nota?" pinLimitExceeded: "Non puoi fissare altre note " done: "Fine" processing: "In elaborazione" +preprocessing: "In preparazione" preview: "Anteprima" default: "Predefinito" defaultValueIs: "Predefinito: {value}" @@ -301,6 +304,7 @@ uploadFromUrlMayTakeTime: "Il caricamento del file può richiedere tempo." uploadNFiles: "Caricare {n} file singolarmente" explore: "Esplora" messageRead: "Visualizzato" +readAllChatMessages: "Segna tutti i messaggi come già letti" noMoreHistory: "Non c'è più cronologia da visualizzare" startChat: "Inizia a chattare" nUsersRead: "Letto da {n} persone" @@ -333,6 +337,7 @@ fileName: "Nome dell'allegato" selectFile: "Scelta allegato" selectFiles: "Scelta allegato" selectFolder: "Seleziona cartella" +unselectFolder: "Deseleziona la cartella" selectFolders: "Seleziona cartella" fileNotSelected: "Nessun file selezionato" renameFile: "Rinomina file" @@ -345,6 +350,7 @@ addFile: "Allega" showFile: "Visualizza file" emptyDrive: "Il Drive è vuoto" emptyFolder: "La cartella è vuota" +dropHereToUpload: "Trascina qui il tuo file per caricarlo" unableToDelete: "Eliminazione impossibile" inputNewFileName: "Inserisci nome del nuovo file" inputNewDescription: "Inserisci una nuova descrizione" @@ -455,7 +461,7 @@ setupOf2fa: "Impostare l'autenticazione a due fattori" totp: "App di autenticazione a due fattori (2FA/MFA)" totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App di autenticazione a due fattori (2FA/MFA)" moderator: "Moderatore" -moderation: "moderazione" +moderation: "Moderazione" moderationNote: "Promemoria di moderazione" moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori." addModerationNote: "Aggiungi promemoria di moderazione" @@ -496,7 +502,7 @@ attachAsFileQuestion: "Il testo copiato eccede le dimensioni, vuoi allegarlo?" onlyOneFileCanBeAttached: "È possibile allegare al messaggio soltanto uno file" signinRequired: "Occorre avere un profilo registrato su questa istanza" signinOrContinueOnRemote: "Per continuare, devi accedere alla tua istanza o registrarti su questa e poi accedere" -invitations: "Invita" +invitations: "Inviti" invitationCode: "Codice di invito" checking: "Confermando" available: "Disponibile" @@ -522,7 +528,7 @@ style: "Stile" drawer: "Drawer" popup: "Popup" showNoteActionsOnlyHover: "Mostra le azioni delle Note solo al passaggio del mouse" -showReactionsCount: "Visualizza il numero di reazioni su una nota" +showReactionsCount: "Visualizza la quantità di reazioni su una nota" noHistory: "Nessuna cronologia" signinHistory: "Storico degli accessi al profilo" enableAdvancedMfm: "Attivare i Misskey Flavoured Markdown (MFM) avanzati" @@ -577,7 +583,7 @@ showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timel showFixedPostFormInChannel: "Per i canali, mostra il modulo di pubblicazione in cima alla timeline" withRepliesByDefaultForNewlyFollowed: "Quando segui nuovi profili, includi le risposte in TL come impostazione predefinita" newNoteRecived: "Nuove Note da leggere" -newNote: "Nuova Nota" +newNote: "Nuove Note" sounds: "Impostazioni suoni" sound: "Suono" notificationSoundSettings: "Preferenze di notifica" @@ -607,7 +613,7 @@ descendingOrder: "Diminuisce" scratchpad: "ScratchPad" scratchpadDescription: "Lo Scratchpad offre un ambiente per esperimenti di AiScript. È possibile scrivere, eseguire e confermare i risultati dell'interazione del codice con Misskey." uiInspector: "UI Inspector" -uiInspectorDescription: "Puoi visualizzare un elenco di elementi UI presenti in memoria. I componenti dell'interfaccia utente vengono generati dalle funzioni Ui:C:." +uiInspectorDescription: "Puoi visualizzare un elenco di elementi grafici presenti in memoria. I componenti dell'interfaccia grafica vengono generati dalle funzioni Ui:C:." output: "Output" script: "Script" disablePagesScript: "Disabilitare AiScript nelle pagine" @@ -687,16 +693,16 @@ emptyToDisableSmtpAuth: "Lasciare i campi vuoti se non c'è autenticazione SMTP" smtpSecure: "Usare SSL/TLS implicito per le connessioni SMTP" smtpSecureInfo: "Disabilitare quando è attivo STARTTLS." testEmail: "Verifica il funzionamento" -wordMute: "Filtri parole" -wordMuteDescription: "Contrae le Note con la parola o la frase specificata. Permette di espandere le Note, cliccandole." -hardWordMute: "Filtro parole forte" +wordMute: "Parole silenziate" +wordMuteDescription: "Comprimi le Note che hanno la parola o la regola specificata. Cliccale per espanderle e leggerne comunque il contenuto." +hardWordMute: "Filtro per parole" showMutedWord: "Elenca le parole silenziate" -hardWordMuteDescription: "Nasconde le Note con la parola o la frase specificata. A differenza delle parole silenziate, la Nota non verrà federata." +hardWordMuteDescription: "Ignora le Note con la parola o la regola specificata. A differenza delle \"Parole Silenziate\", queste Note non ti verranno proprio recapitate." regexpError: "errore regex" regexpErrorDescription: "Si è verificato un errore nell'espressione regolare alla riga {line} della parola muta {tab}:" instanceMute: "Silenziare l'istanza" userSaysSomething: "{name} ha detto qualcosa" -userSaysSomethingAbout: "{name} ha Notato a riguardo di \"{word}\"" +userSaysSomethingAbout: "{name} ha anNotato qualcosa su \"{word}\"" makeActive: "Attiva" display: "Visualizza" copy: "Copia" @@ -752,19 +758,19 @@ i18nInfo: "Misskey è tradotto in diverse lingue da volontari. Anche tu puoi con manageAccessTokens: "Gestisci token di accesso" accountInfo: "Informazioni profilo" notesCount: "Conteggio note" -repliesCount: "Numero di risposte inviate" -renotesCount: "Numero di note che hai ricondiviso" -repliedCount: "Numero di risposte ricevute" -renotedCount: "Numero delle tue note ricondivise" -followingCount: "Numero di Following" -followersCount: "Numero di profili che ti seguono" -sentReactionsCount: "Numero di reazioni inviate" -receivedReactionsCount: "Numero di reazioni ricevute" -pollVotesCount: "Numero di voti inviati" -pollVotedCount: "Numero di voti ricevuti" +repliesCount: "Quantità di risposte" +renotesCount: "Quantità di Note ricondivise" +repliedCount: "Quantità di risposte" +renotedCount: "Quantità di tue Note ricondivise" +followingCount: "Quantità di Following" +followersCount: "Quantità di Follower" +sentReactionsCount: "Quantità di reazioni" +receivedReactionsCount: "Quantità di reazioni ricevute" +pollVotesCount: "Quantità di voti" +pollVotedCount: "Quantità di voti ricevuti" yes: "Sì" no: "No" -driveFilesCount: "Numero di file nel Drive" +driveFilesCount: "Quantità di file nel Drive" driveUsage: "Utilizzazione del Drive" noCrawle: "Rifiuta l'indicizzazione dai robot." noCrawleDescription: "Richiedi che i motori di ricerca non indicizzino la tua pagina di profilo, le tue note, pagine, ecc." @@ -772,12 +778,13 @@ lockedAccountInfo: "A meno che non imposti la visibilità delle tue note su \"So alwaysMarkSensitive: "Segnare automaticamente come espliciti gli allegati" loadRawImages: "Visualizza le intere immagini allegate invece delle miniature." disableShowingAnimatedImages: "Disabilitare le immagini animate" +disableShowingAnimatedImages_caption: "L'attivazione delle animazioni immagini potrebbe interferire sull'accessibilità e sul risparmio energetico nel dispositivo." highlightSensitiveMedia: "Evidenzia i media espliciti" verificationEmailSent: "Una mail di verifica è stata inviata. Si prega di accedere al collegamento per compiere la verifica." notSet: "Non impostato" emailVerified: "Il tuo indirizzo email è stato verificato" noteFavoritesCount: "Conteggio note tra i preferiti" -pageLikesCount: "Numero di pagine che ti piacciono" +pageLikesCount: "Quantità di pagine che ti piacciono" pageLikedCount: "Numero delle tue pagine che hanno ricevuto \"Mi piace\"" contact: "Contatti" useSystemFont: "Usa il carattere predefinito del sistema" @@ -822,9 +829,9 @@ currentVersion: "Versione attuale" latestVersion: "Ultima versione" youAreRunningUpToDateClient: "Stai usando la versione più recente del client." newVersionOfClientAvailable: "Una nuova versione del tuo client è disponibile." -usageAmount: "In uso" +usageAmount: "Quantità utilizzata" capacity: "Capacità" -inUse: "In uso" +inUse: "Usata da" editCode: "Modifica codice" apply: "Applica" receiveAnnouncementFromInstance: "Ricevi i messaggi informativi dall'istanza" @@ -899,7 +906,7 @@ useBlurEffect: "Utilizza effetto sfocatura" learnMore: "Per saperne di più" misskeyUpdated: "Misskey è stato aggiornato!" whatIsNew: "Informazioni sull'aggiornamento" -translate: "Traduci" +translate: "Traduzione" translatedFrom: "Traduzione da {x}" accountDeletionInProgress: "È in corso l'eliminazione del profilo" usernameInfo: "Un nome per identificare univocamente il tuo profilo sull'istanza. Puoi utilizzare caratteri alfanumerici maiuscoli, minuscoli e il trattino basso (_). Non potrai cambiare nome utente in seguito." @@ -910,7 +917,7 @@ pubSub: "Publish/Subscribe del profilo" lastCommunication: "La comunicazione più recente" resolved: "Risolto" unresolved: "Non risolto" -breakFollow: "Rimuovi Follower" +breakFollow: "Rimuovere Follower" breakFollowConfirm: "Vuoi davvero togliere questo Follower?" itsOn: "Abilitato" itsOff: "Disabilitato" @@ -943,7 +950,7 @@ tablet: "Tablet" auto: "Automatico" themeColor: "Colore del tema" size: "Dimensioni" -numberOfColumn: "Numero di colonne" +numberOfColumn: "Quantità di colonne" searchByGoogle: "Cerca" instanceDefaultLightTheme: "Istanza, tema luminoso predefinito." instanceDefaultDarkTheme: "Istanza, tema scuro predefinito." @@ -980,7 +987,7 @@ isSystemAccount: "Si tratta di un profilo creato e gestito automaticamente dal s typeToConfirm: "Digita {x} per continuare" deleteAccount: "Eliminazione profilo" document: "Documentazione" -numberOfPageCache: "Numero di pagine cache" +numberOfPageCache: "Quantità di pagine in cache" numberOfPageCacheDescription: "Aumenta l'usabilità, ma aumenta anche il carico e l'utilizzo della memoria." logoutConfirm: "Vuoi davvero uscire da Misskey? " logoutWillClearClientData: "All'uscita, la configurazione del client viene rimossa dal browser. Per ripristinarla quando si effettua nuovamente l'accesso, abilitare il backup automatico." @@ -1018,6 +1025,9 @@ pushNotificationAlreadySubscribed: "Le notifiche push sono già attivate" pushNotificationNotSupported: "Il client o il server non supporta le notifiche push" sendPushNotificationReadMessage: "Eliminare le notifiche push dopo la relativa lettura" sendPushNotificationReadMessageCaption: "Se possibile, verrà mostrata brevemente una notifica con il testo \"{emptyPushNotificationMessage}\". Potrebbe influire negativamente sulla durata della batteria." +pleaseAllowPushNotification: "Per favore, acconsenti alla ricezione di notifiche nel browser" +browserPushNotificationDisabled: "Non è stato possibile ottenere il consenso alla ricezione di notifche" +browserPushNotificationDisabledDescription: "Non hai concesso a {serverName} di spedire notifiche. Per favore, acconsenti alla ricezione nelle impostazioni del browser e riprova." windowMaximize: "Ingrandisci" windowMinimize: "Contrai finestra" windowRestore: "Ripristina" @@ -1028,7 +1038,7 @@ cannotLoad: "Caricamento impossibile" numberOfProfileView: "Visualizzazioni profilo" like: "Mi piace!" unlike: "Non mi piace" -numberOfLikes: "Numero di Like" +numberOfLikes: "Quantità di Like" show: "Visualizza" neverShow: "Non mostrare più" remindMeLater: "Rimanda" @@ -1054,6 +1064,7 @@ permissionDeniedError: "Errore, attività non autorizzata" permissionDeniedErrorDescription: "Non si dispone dell'autorizzazione per eseguire questa operazione." preset: "Preimpostato" selectFromPresets: "Seleziona preimpostato" +custom: "Personalizzato" achievements: "Conquiste" gotInvalidResponseError: "Risposta del server non valida" gotInvalidResponseErrorDescription: "Il server potrebbe essere irraggiungibile o in manutenzione. Riprova più tardi." @@ -1167,12 +1178,13 @@ installed: "Installazione avvenuta" branding: "Branding" enableServerMachineStats: "Pubblicare le informazioni sul server" enableIdenticonGeneration: "Generazione automatica delle Identicon" +showRoleBadgesOfRemoteUsers: "Visualizza i badge per i ruoli concessi ai profili remoti" turnOffToImprovePerformance: "Disattiva, per migliorare le prestazioni" createInviteCode: "Genera codice di invito" createWithOptions: "Genera con opzioni" createCount: "Conteggio inviti" inviteCodeCreated: "Inviti generati" -inviteLimitExceeded: "Hai raggiunto il numero massimo di codici invito generabili." +inviteLimitExceeded: "Hai raggiunto la quantità massima di codici invito generabili." createLimitRemaining: "Inviti generabili: {limit} rimanenti" inviteLimitResetCycle: "Alle {time}, il limite verrà ripristinato a {limit}" expirationDate: "Scadenza" @@ -1244,7 +1256,7 @@ releaseToRefresh: "Rilascia per aggiornare" refreshing: "Aggiornamento..." pullDownToRefresh: "Trascinare per aggiornare" useGroupedNotifications: "Mostra le notifiche raggruppate" -signupPendingError: "Si è verificato un problema durante la verifica del tuo indirizzo email. Potrebbe essere scaduto il collegamento temporaneo." +emailVerificationFailedError: "La verifica dell'indirizzo e-mail non è andata a buon fine. Il link potrebbe essere scaduto." cwNotationRequired: "Devi indicare perché il contenuto è indicato come esplicito." doReaction: "Reagisci" code: "Codice" @@ -1289,7 +1301,7 @@ sensitiveMediaRevealConfirm: "Questo allegato è esplicito, vuoi vederlo?" createdLists: "Liste create" createdAntennas: "Antenne create" fromX: "Da {x}" -genEmbedCode: "Ottieni il codice di incorporamento" +genEmbedCode: "Ottieni il codice per incorporare" noteOfThisUser: "Elenco di Note di questo profilo" clipNoteLimitExceeded: "Non è possibile aggiungere ulteriori Note a questa Clip." performance: "Prestazioni" @@ -1315,13 +1327,14 @@ acknowledgeNotesAndEnable: "Attivare dopo averne compreso il comportamento." federationSpecified: "Questo server è federato solo con istanze specifiche del Fediverso. Puoi interagire solo con quelle scelte dall'amministrazione." federationDisabled: "Questo server ha la federazione disabilitata. Non puoi interagire con profili provenienti da altri server." draft: "Bozza" +draftsAndScheduledNotes: "Bozze e Note pianificate" confirmOnReact: "Confermare le reazioni" reactAreYouSure: "Vuoi davvero reagire con {emoji} ?" markAsSensitiveConfirm: "Vuoi davvero indicare questo contenuto multimediale come esplicito?" unmarkAsSensitiveConfirm: "Vuoi davvero indicare come non esplicito il contenuto multimediale?" preferences: "Preferenze" accessibility: "Accessibilità" -preferencesProfile: "Profilo preferenze" +preferencesProfile: "Preferenze del profilo" copyPreferenceId: "Copia ID preferenze" resetToDefaultValue: "Ripristina a predefinito" overrideByAccount: "Sovrascrivere col profilo" @@ -1339,9 +1352,11 @@ preferenceSyncConflictChoiceCancel: "Annulla la sincronizzazione" paste: "Incolla" emojiPalette: "Tavolozza emoji" postForm: "Finestra di pubblicazione" -textCount: "Il numero di caratteri" +textCount: "Quantità di caratteri" information: "Informazioni" chat: "Chat" +directMessage: "Chattare insieme" +directMessage_short: "Messaggio" migrateOldSettings: "Migrare le vecchie impostazioni" migrateOldSettings_description: "Di solito, viene fatto automaticamente. Se per qualche motivo non fossero migrate con successo, è possibile avviare il processo di migrazione manualmente, sovrascrivendo le configurazioni attuali." compress: "Compressione" @@ -1369,16 +1384,83 @@ redisplayAllTips: "Mostra tutti i suggerimenti" hideAllTips: "Nascondi tutti i suggerimenti" defaultImageCompressionLevel: "Livello predefinito di compressione immagini" defaultImageCompressionLevel_description: "La compressione diminuisce la qualità dell'immagine, poca compressione mantiene alta qualità delle immagini. Aumentandola, si riducono le dimensioni del file, a discapito della qualità dell'immagine." +defaultCompressionLevel: "Compressione predefinita" +defaultCompressionLevel_description: "Diminuisci per mantenere la qualità aumentando le dimensioni del file.
Aumenta per ridurre le dimensioni del file e anche la qualità." inMinutes: "min" inDays: "giorni" safeModeEnabled: "La modalità sicura è attiva" pluginsAreDisabledBecauseSafeMode: "Tutti i plugin sono disattivati, poiché la modalità sicura è attiva." customCssIsDisabledBecauseSafeMode: "Il CSS personalizzato non è stato applicato, poiché la modalità sicura è attiva." themeIsDefaultBecauseSafeMode: "Quando la modalità sicura è attiva, viene utilizzato il tema predefinito. Quando la modalità sicura viene disattivata, il tema torna a essere quello precedente." +thankYouForTestingBeta: "Grazie per la tua collaborazione nella verifica delle versioni beta!" +createUserSpecifiedNote: "Crea Nota privata" +schedulePost: "Pianificare la pubblicazione" +scheduleToPostOnX: "Pianificare la pubblicazione {x}" +scheduledToPostOnX: "Pubblicazione pianificata {x}" +schedule: "Pianificare" +scheduled: "Pianificata" +widgets: "Riquadri" +deviceInfo: "Informazioni sul dispositivo" +deviceInfoDescription: "Se ci contatti per ricevere supporto tecnico, ti preghiamo di includere le seguenti informazioni per aiutarci a risolvere il tuo problema." +youAreAdmin: "Sei un amministratore" +frame: "Cornice" +presets: "Preimpostato" +zeroPadding: "Al vivo" +nothingToConfigure: "Niente da configurare" +_imageEditing: + _vars: + caption: "Didascalia dell'immagine" + filename: "Nome dell'allegato" + filename_without_ext: "Nome file senza estensione" + year: "Anno di scatto" + month: "Mese dello scatto" + day: "Giorno dello scatto" + hour: "Ora dello scatto" + minute: "Minuto dello scatto" + second: "Secondi dello scatto" + camera_model: "Modello di fotocamera" + camera_lens_model: "Modello della lente" + camera_mm: "Lunghezza focale" + camera_mm_35: "Lunghezza focale (equivalente a 35 mm)" + camera_f: "Diaframma" + camera_s: "Velocità otturatore" + camera_iso: "Sensibilità ISO" + gps_lat: "Latitudine" + gps_long: "Longitudine" +_imageFrameEditor: + title: "Modifica fotogramma" + tip: "Puoi decorare le immagini aggiungendo etichette con cornici e metadati." + header: "Intestazione" + footer: "Piè di pagina" + borderThickness: "Larghezza del bordo" + labelThickness: "Spessore etichetta" + labelScale: "Dimensione etichetta" + centered: "Allinea al centro" + captionMain: "Didascalia (grande)" + captionSub: "Didascalia (piccola)" + availableVariables: "Variabili disponibili" + withQrCode: "QR Code" + backgroundColor: "Colore dello sfondo" + textColor: "Colore del testo" + font: "Tipo di carattere" + fontSerif: "Serif" + fontSansSerif: "Sans serif" + quitWithoutSaveConfirm: "Uscire senza salvare?" + failedToLoadImage: "Impossibile caricare l'immagine" +_compression: + _quality: + high: "Alta qualità" + medium: "Media qualità" + low: "Bassa qualità" + _size: + large: "Taglia grande" + medium: "Taglia media" + small: "Taglia piccola" _order: - newest: "Prima i più recenti" - oldest: "Meno recenti prima" + newest: "Più recenti" + oldest: "Meno recenti" _chat: + messages: "Messaggi" noMessagesYet: "Ancora nessun messaggio" newMessage: "Nuovo messaggio" individualChat: "Chat individuale" @@ -1389,12 +1471,12 @@ _chat: inviteUserToChat: "Invita a chattare altre persone" yourRooms: "Le tue stanze" joiningRooms: "Stanze a cui partecipi" - invitations: "Invita" + invitations: "Inviti" noInvitations: "Nessun invito" history: "Cronologia" noHistory: "Nessuna cronologia" noRooms: "Nessuna stanza" - inviteUser: "Invita" + inviteUser: "Invita persona" sentInvitations: "Inviti spediti" join: "Entra" ignore: "Ignora" @@ -1467,6 +1549,11 @@ _settings: showUrlPreview: "Mostra anteprima dell'URL" showAvailableReactionsFirstInNote: "Mostra le reazioni disponibili in alto" showPageTabBarBottom: "Visualizza le schede della pagina nella parte inferiore" + emojiPaletteBanner: "Puoi salvare i le emoji predefinite da appuntare in alto nel raccoglitore emoji come tavolozza e personalizzare in che modo visualizzare il raccoglitore." + enableAnimatedImages: "Attivare le immagini animate" + settingsPersistence_title: "Configurazione persistente" + settingsPersistence_description1: "Attivando le impostazioni persistenti si può evitare di riconfigurare il client successivamente." + settingsPersistence_description2: "Potrebbe non essere possibile attivare, dipende dall'ambiente." _chat: showSenderName: "Mostra il nome del mittente" sendOnEnter: "Invio spedisce" @@ -1475,6 +1562,8 @@ _preferencesProfile: profileNameDescription: "Impostare il nome che indentifica questo dispositivo." profileNameDescription2: "Es: \"PC principale\" o \"Cellulare\"" manageProfiles: "Gestione profili" + shareSameProfileBetweenDevicesIsNotRecommended: "Si sconsiglia di condividere lo stesso profilo su più dispositivi." + useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "Se intendi sincronizzare solo alcuni parametri di configurazione su più dispositivi, devi attivare l'opzione \"Sincronizzazione tra dispositivi\" per ogni parametro interessato." _preferencesBackup: autoBackup: "Backup automatico" restoreFromBackup: "Ripristinare da backup" @@ -1484,6 +1573,7 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "Per abilitare i backup automatici, è necessario indicare il nome del profilo." autoPreferencesBackupIsNotEnabledForThisDevice: "Su questo dispositivo non è stato attivato il backup automatico delle preferenze." backupFound: "Esiste il Backup delle preferenze" + forceBackup: "Backup forzato delle impostazioni" _accountSettings: requireSigninToViewContents: "Per vedere il contenuto, è necessaria l'iscrizione" requireSigninToViewContentsDescription1: "Richiedere l'iscrizione per visualizzare tutte le Note e gli altri contenuti che hai creato. Probabilmente l'effetto è impedire la raccolta di informazioni da parte dei bot crawler." @@ -1651,7 +1741,7 @@ _serverSettings: thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo." deliverSuspendedSoftware: "Software fuori produzione" deliverSuspendedSoftwareDescription: "A causa di vulnerabilità o altri motivi, puoi interrompere la distribuzione di un software da un server specificandone il nome e la versione. Le informazioni sono fornite dall'altro server e l'autenticità non è garantita. Puoi indicare un intervallo di versione semantica, ma specificando >= 2024.3.1 non verranno incluse le versioni personalizzate come ad esempio 2024.3.1-custom.0, pertanto ti consigliamo di specificare una versione come >= 2024.3.1-0." - singleUserMode: "Modalità utente singolo" + singleUserMode: "Modalità utenza singola" singleUserMode_description: "Se sei l'unica persona a utilizzare questo server, l'abilitazione di questa modalità ottimizzerà le prestazioni." signToActivityPubGet: "Firma delle richieste GET" signToActivityPubGet_description: "Normalmente questa opzione dovrebbe essere abilitata. Se si verificano problemi con la comunicazione federata, disabilitarla potrebbe migliorare la situazione, ma d'altro canto potrebbe rendere impossibile la comunicazione, a seconda del server." @@ -1664,6 +1754,9 @@ _serverSettings: userGeneratedContentsVisibilityForVisitor_description2: "Esistono dei rischi nell'esporre incondizionatamente su internet tutto il contenuto del tuo server, incluso il contenuto remoto ricevuto da altri server. In particolare, occorre prestare attenzione, perché le persone non consapevoli della federazione potrebbero erroneamente credere che il contenuto remoto sia stato invece creato all'interno del proprio server." restartServerSetupWizardConfirm_title: "Vuoi ripetere la procedura guidata di configurazione iniziale del server?" restartServerSetupWizardConfirm_text: "Verranno ripristinate alcune tue impostazioni personalizzate." + entrancePageStyle: "Stile della pagina di ingresso" + showTimelineForVisitor: "Mostra la Timeline a visitatori non autenticati" + showActivitiesForVisitor: "Mostrare la propria attività" _userGeneratedContentsVisibilityForVisitor: all: "Tutto pubblico" localOnly: "Pubblica solo contenuti locali, mantieni privati ​​i contenuti remoti" @@ -1986,6 +2079,7 @@ _role: canManageAvatarDecorations: "Gestisce le decorazioni di immagini del profilo" driveCapacity: "Capienza del Drive" maxFileSize: "Dimensione massima del file caricabile" + maxFileSize_caption: "Potrebbero esserci altre impostazioni nella fase precedente, come reverse proxy o CDN." alwaysMarkNsfw: "Impostare sempre come esplicito (NSFW)" canUpdateBioMedia: "Può aggiornare foto profilo e di testata" pinMax: "Quantità massima di Note in primo piano" @@ -2013,6 +2107,7 @@ _role: uploadableFileTypes_caption: "Specifica il tipo MIME. Puoi specificare più valori separandoli andando a capo, oppure indicare caratteri jolly con un asterisco (*). Ad esempio: image/*" uploadableFileTypes_caption2: "A seconda del file, il tipo potrebbe non essere determinato. Se si desidera consentire tali file, aggiungere {x} alla specifica." noteDraftLimit: "Numero massimo di Note in bozza, lato server" + scheduledNoteLimit: "Quantità di Note pianificabili contemporaneamente" watermarkAvailable: "Disponibilità della funzione filigrana" _condition: roleAssignedTo: "Assegnato a ruoli manualmente" @@ -2217,7 +2312,7 @@ _theme: hashtag: "Hashtag" mention: "Menzioni" mentionMe: "Menzioni (di me)" - renote: "Renota" + renote: "Rinota" modalBg: "Sfondo modale." divider: "Interruzione di linea" scrollbarHandle: "Maniglie della barra di scorrimento" @@ -2261,18 +2356,19 @@ _ago: yearsAgo: "{n} anni fa" invalid: "Niente da visualizzare" _timeIn: - seconds: "Dopo {n} secondi" - minutes: "Dopo {n} minuti" - hours: "Dopo {n} ore" - days: "Dopo {n} giorni" - weeks: "Dopo {n} settimane" - months: "Dopo {n} mesi" - years: "Dopo {n} anni" + seconds: "Tra {n} secondi" + minutes: "Tra {n} minuti" + hours: "Tra {n} ore" + days: "Tra {n} giorni" + weeks: "Tra {n} settimane" + months: "Tra {n} mesi" + years: "Tra {n} anni" _time: second: "s" minute: "min" hour: "ore" day: "giorni" + month: "Mese" _2fa: alreadyRegistered: "La configurazione è stata già completata." registerTOTP: "Registra una App di autenticazione a due fattori (2FA/MFA)" @@ -2402,6 +2498,7 @@ _auth: scopeUser: "Sto funzionando per il seguente profilo" pleaseLogin: "Per favore accedi al tuo account per cambiare i permessi dell'applicazione" byClickingYouWillBeRedirectedToThisUrl: "Consentendo l'accesso, si verrà reindirizzati presso questo indirizzo URL" + alreadyAuthorized: "Questa applicazione è già autorizzata ad accedere." _antennaSources: all: "Tutte le note" homeTimeline: "Note dai tuoi Following" @@ -2447,7 +2544,45 @@ _widgets: chooseList: "Seleziona una lista" clicker: "Cliccheria" birthdayFollowings: "Compleanni del giorno" - chat: "Chat" + chat: "Chatta con questa persona" +_widgetOptions: + showHeader: "Mostra la testata" + transparent: "Sfondo trasparente" + height: "Altezza" + _button: + colored: "Colorato" + _clock: + size: "Dimensioni" + thickness: "Spessore lancette" + thicknessThin: "Sottili" + thicknessMedium: "Medie" + thicknessThick: "Larghe" + graduations: "Quadrante" + graduationDots: "Punti" + graduationArabic: "Numeri" + fadeGraduations: "Sfumatura" + sAnimation: "Animazione dei secondi" + sAnimationElastic: "Realistica" + sAnimationEaseOut: "Morbida" + twentyFour: "Formato 24 ore" + labelTime: "Orario" + labelTz: "Fuso orario" + labelTimeAndTz: "Orario e fuso orario" + timezone: "Fuso orario" + showMs: "Millisecondi visibili" + showLabel: "Etichetta visibile" + _jobQueue: + sound: "Emetti un suono" + _rss: + url: "URL del Feed RSS" + refreshIntervalSec: "Intervallo di aggiornamento (in secondi)" + maxEntries: "Quantità massima visualizzabile" + _rssTicker: + shuffle: "Ordine casuale" + duration: "Velocità di scorrimento del ticker (in secondi)" + reverse: "Direzione inversa" + _birthdayFollowings: + period: "Durata" _cw: hide: "Nascondere" show: "Continua la lettura..." @@ -2484,7 +2619,7 @@ _visibility: followersDescription: "Visibile solo ai tuoi follower" specified: "Nota diretta" specifiedDescription: "Visibile solo ai profili menzionati" - disableFederation: "Senza federazione" + disableFederation: "Gestisci la federazione" disableFederationDescription: "Non spedire attività alle altre istanze remote" _postForm: quitInspiteOfThereAreUnuploadedFilesConfirm: "Alcuni file non sono stati caricati. Vuoi annullare l'operazione?" @@ -2492,6 +2627,20 @@ _postForm: replyPlaceholder: "Rispondi a questa nota..." quotePlaceholder: "Cita questa nota..." channelPlaceholder: "Pubblica sul canale..." + showHowToUse: "Mostra il tutorial" + _howToUse: + content_title: "Testo" + content_description: "Inserisci il contenuto che desideri pubblicare." + toolbar_title: "Barra degli Strumenti" + toolbar_description: "Puoi allegare file e sondaggi, aggiungere Note, hashtag, inserire emoji e menzioni." + account_title: "Menu profilo" + account_description: "Puoi cambiare il profilo col quale vuoi pubblicare, elencare bozze e pianificare le Note." + visibility_title: "Visibilità" + visibility_description: "Puoi impostare il grado di visibilità delle Note." + menu_title: "Menù" + menu_description: "Puoi svolgere varie azioni, come salvare in bozza, pianificare le annotazioni, regolare le reazioni ricevute e altro." + submit_title: "Bottone invia" + submit_description: "Pubblica la Nota. Funziona anche con \"Ctrl + Invio\", oppure \"Cmd + Invio\"." _placeholders: a: "Come va?" b: "Hai qualcosa da raccontare? Inizia pure..." @@ -2637,6 +2786,8 @@ _notification: youReceivedFollowRequest: "Hai ricevuto una richiesta di follow" yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata" pollEnded: "Risultati del sondaggio." + scheduledNotePosted: "Pubblicazione Nota pianificata" + scheduledNotePostFailed: "Impossibile pubblicare la Nota pianificata" newNote: "Nuove Note" unreadAntennaNote: "Antenna {name}" roleAssigned: "Ruolo assegnato" @@ -2657,7 +2808,7 @@ _notification: createToken: "È stato creato un token di accesso" createTokenDescription: "In caso contrario, eliminare il token di accesso tramite ({text})." _types: - all: "Tutto" + all: "Tutte" note: "Nuove Note" follow: "Follower" mention: "Menzioni" @@ -2665,7 +2816,9 @@ _notification: renote: "Rinota" quote: "Cita" reaction: "Reazioni" - pollEnded: "Sondaggio chiuso." + pollEnded: "Sondaggio terminato" + scheduledNotePosted: "Nota pianificata correttamente" + scheduledNotePostFailed: "La pianificazione della Nota è fallita" receiveFollowRequest: "Richieste di follow in arrivo" followRequestAccepted: "Richieste di follow accettate" roleAssigned: "Ruolo concesso" @@ -2673,7 +2826,7 @@ _notification: achievementEarned: "Risultato raggiunto" exportCompleted: "Esportazione completata" login: "Accessi" - createToken: "Creare un token di accesso" + createToken: "Aggiunto un token di accesso" test: "Notifiche di test" app: "Notifiche da applicazioni" _actions: @@ -2681,7 +2834,7 @@ _notification: reply: "Rispondi" renote: "Rinota" _deck: - alwaysShowMainColumn: "Mostra sempre la colonna principale" + alwaysShowMainColumn: "Mostrare sempre la colonna Principale" columnAlign: "Allineamento delle colonne" columnGap: "Spessore del margine tra colonne" deckMenuPosition: "Posizione del menu Deck" @@ -2698,13 +2851,21 @@ _deck: profile: "Profilo" newProfile: "Nuovo profilo" deleteProfile: "Cancellare il profilo." - introduction: "Combinate le colonne per creare la vostra interfaccia!" - introduction2: "È possibile aggiungere colonne in qualsiasi momento premendo + sulla destra dello schermo." + introduction: "Crea la tua interfaccia combinando le colonne!" + introduction2: "Per aggiungere una colonna, cliccare il bottone + (più) visibile al margine dello schermo." widgetsIntroduction: "Dal menu della colonna, selezionare \"Modifica i riquadri\" per aggiungere un un riquadro con funzionalità" useSimpleUiForNonRootPages: "Visualizza sotto pagine con interfaccia web semplice" usedAsMinWidthWhenFlexible: "Se \"larghezza flessibile\" è abilitato, questa diventa la larghezza minima" flexible: "Larghezza flessibile" enableSyncBetweenDevicesForProfiles: "Abilita la sincronizzazione delle informazioni profilo tra dispositivi" + showHowToUse: "Guarda la spiegazione dell'interfaccia grafica" + _howToUse: + addColumn_title: "Aggiungere colonne" + addColumn_description: "Puoi selezionare un tipo di colonna e aggiungerlo." + settings_title: "Configurazione interfaccia grafica" + settings_description: "Puoi personalizzare i dettagli dell'interfaccia grafica." + switchProfile_title: "Selettore profilo" + switchProfile_description: "Puoi salvare la disposizione dell'interfaccia grafica nel tuo profilo, affinché cambi con comodità." _columns: main: "Principale" widgets: "Riquadri" @@ -2716,7 +2877,7 @@ _deck: mentions: "Menzioni" direct: "Note Dirette" roleTimeline: "Timeline Ruolo" - chat: "Chat" + chat: "Chatta con questa persona" _dialog: charactersExceeded: "Hai superato il limite di {max} caratteri! ({current})" charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({current})" @@ -2765,56 +2926,58 @@ _abuseReport: notifiedWebhook: "Webhook da usare" deleteConfirm: "Vuoi davvero rimuovere il destinatario della notifica?" _moderationLogTypes: - createRole: "Ruolo creato" - deleteRole: "Ruolo eliminato" - updateRole: "Ruolo aggiornato" - assignRole: "Ruolo assegnato" - unassignRole: "Ruolo disassegnato" - suspend: "Sospensione" - unsuspend: "Sospensione rimossa" - addCustomEmoji: "Emoji personalizzata aggiunta" - updateCustomEmoji: "Emoji personalizzata aggiornata" - deleteCustomEmoji: "Emoji personalizzata eliminata" - updateServerSettings: "Impostazioni del server aggiornate" - updateUserNote: "Promemoria di moderazione aggiornato" - deleteDriveFile: "File da Drive eliminato" - deleteNote: "Nota eliminata" - createGlobalAnnouncement: "Annuncio globale creato" - createUserAnnouncement: "Annuncio ai profili iscritti creato" - updateGlobalAnnouncement: "Annuncio globale aggiornato" - updateUserAnnouncement: "Annuncio ai profili iscritti aggiornato" - deleteGlobalAnnouncement: "Annuncio globale eliminato" - deleteUserAnnouncement: "Annuncio ai profili iscritti eliminato" - resetPassword: "Password azzerata" - suspendRemoteInstance: "Istanza remota sospesa" - unsuspendRemoteInstance: "Istanza remota riattivata" - updateRemoteInstanceNote: "Aggiornamento del promemoria di moderazione per il server remoto" - markSensitiveDriveFile: "File nel Drive segnato come esplicito" - unmarkSensitiveDriveFile: "File nel Drive segnato come non esplicito" - resolveAbuseReport: "Segnalazione risolta" - forwardAbuseReport: "Segnalazione inoltrata" - updateAbuseReportNote: "Ha aggiornato la segnalazione" - createInvitation: "Genera codice di invito" - createAd: "Banner creato" - deleteAd: "Banner eliminato" - updateAd: "Banner aggiornato" - createAvatarDecoration: "Creazione decorazione della foto profilo" - updateAvatarDecoration: "Aggiornamento decorazione foto profilo" - deleteAvatarDecoration: "Eliminazione decorazione della foto profilo" - unsetUserAvatar: "Rimossa foto profilo" - unsetUserBanner: "Rimossa intestazione profilo" - createSystemWebhook: "Crea un SystemWebhook" - updateSystemWebhook: "Modifica SystemWebhook" - deleteSystemWebhook: "Elimina SystemWebhook" + clearQueue: "Ha cancellato la coda di Attività" + promoteQueue: "Ripeti le attività in coda" + createRole: "Crea un Ruolo" + deleteRole: "Elimina un Ruolo" + updateRole: "Modifica un ruolo" + assignRole: "Assegna un Ruolo" + unassignRole: "Toglie un Ruolo al Profilo" + suspend: "Sospende" + unsuspend: "Solleva la sospensione" + addCustomEmoji: "Aggiunge Emoji personalizzata" + updateCustomEmoji: "Modifica Emoji personalizzata" + deleteCustomEmoji: "Elimina Emoji personalizzata" + updateServerSettings: "Modifica le impostazioni del server" + updateUserNote: "Modifica un promemoria di moderazione" + deleteDriveFile: "Elimina un file dal Drive" + deleteNote: "Elimina una Nota" + createGlobalAnnouncement: "Crea un annuncio globale" + createUserAnnouncement: "Crea un annuncio ai profili già iscritti" + updateGlobalAnnouncement: "Modifica un annuncio globale" + updateUserAnnouncement: "Modifica un annuncio ai profili già iscritti" + deleteGlobalAnnouncement: "Elimina un annuncio globale" + deleteUserAnnouncement: "Elimina un annuncio ai profili già iscritti" + resetPassword: "Azzera la password" + suspendRemoteInstance: "Sospende una istanza remota" + unsuspendRemoteInstance: "Riattiva una istanza remota" + updateRemoteInstanceNote: "Modifica il promemoria di moderazione per il server remoto" + markSensitiveDriveFile: "Aggiunge NSFW a un file nel Drive" + unmarkSensitiveDriveFile: "Toglie NSFW da un file nel Drive" + resolveAbuseReport: "Risolve una segnalazione" + forwardAbuseReport: "Inoltra una segnalazione" + updateAbuseReportNote: "Modifica una segnalazione" + createInvitation: "Genera un codice di invito" + createAd: "Aggiunge un Banner" + deleteAd: "Elimina un Banner" + updateAd: "Modifica un Banner" + createAvatarDecoration: "Crea una decorazione della foto profilo" + updateAvatarDecoration: "Modifica una decorazione della foto profilo" + deleteAvatarDecoration: "Elimina una decorazione della foto profilo" + unsetUserAvatar: "Toglie una foto profilo" + unsetUserBanner: "Toglie una immagine di intestazione profilo" + createSystemWebhook: "Aggiunge un System Webhook" + updateSystemWebhook: "Modifica un System Webhook" + deleteSystemWebhook: "Elimina un System Webhook" createAbuseReportNotificationRecipient: "Crea destinatario per le notifiche di segnalazioni" - updateAbuseReportNotificationRecipient: "Aggiorna destinatario notifiche di segnalazioni" - deleteAbuseReportNotificationRecipient: "Elimina destinatario notifiche di segnalazioni" - deleteAccount: "Quando viene eliminato un profilo" - deletePage: "Pagina eliminata" - deleteFlash: "Play eliminato" - deleteGalleryPost: "Eliminazione pubblicazione nella Galleria" - deleteChatRoom: "Elimina chat" - updateProxyAccountDescription: "Aggiornata la descrizione del profilo proxy" + updateAbuseReportNotificationRecipient: "Modifica un destinatario per le notifiche di segnalazioni" + deleteAbuseReportNotificationRecipient: "Elimina un destinatario per le notifiche di segnalazioni" + deleteAccount: "Elimina un profilo" + deletePage: "Elimina una Pagina" + deleteFlash: "Elimina un Play" + deleteGalleryPost: "Elimina pubblicazione nella Galleria" + deleteChatRoom: "Elimina una Chat" + updateProxyAccountDescription: "Aggiorna la descrizione del profilo proxy" _fileViewer: title: "Dettagli del file" type: "Tipo di file" @@ -2954,7 +3117,7 @@ _contextMenu: title: "Menu contestuale" app: "Applicazione" appWithShift: "Applicazione Shift+Tasto" - native: "Interfaccia utente del browser" + native: "Interfaccia grafica del browser" _gridComponent: _error: requiredValue: "Campo obbligatorio" @@ -3012,7 +3175,7 @@ _customEmojisManager: confirmClearEmojisDescription: "Annullare le modifiche e cancella le emoji nell'elenco. Confermi?" confirmUploadEmojisDescription: "Caricamento sul Drive di {count} file locali. Vuoi davvero procedere?" _embedCodeGen: - title: "Personalizza il codice di incorporamento" + title: "Personalizza il codice per incorporare" header: "Mostra la testata" autoload: "Carica automaticamente di più (sconsigliato)" maxHeight: "Altezza massima" @@ -3021,8 +3184,8 @@ _embedCodeGen: previewIsNotActual: "Poiché supera l'intervallo che può essere visualizzato in anteprima, la visualizzazione vera e propria sarà diversa quando effettivamente incorporata." rounded: "Bordo arrotondato" border: "Aggiungi un bordo al contenitore" - applyToPreview: "Applica all'anteprima" - generateCode: "Crea il codice di incorporamento" + applyToPreview: "Aggiorna l'anteprima" + generateCode: "Crea il codice per incorporare" codeGenerated: "Codice generato" codeGeneratedDescription: "Incolla il codice appena generato sul tuo sito web." _selfXssPrevention: @@ -3079,7 +3242,7 @@ _bootErrors: _search: searchScopeAll: "Tutte" searchScopeLocal: "Locale" - searchScopeServer: "Specifiche del server" + searchScopeServer: "Server specifico" searchScopeUser: "Profilo specifico" pleaseEnterServerHost: "Inserire il nome host" pleaseSelectUser: "Per favore, seleziona un profilo" @@ -3159,10 +3322,13 @@ _watermarkEditor: title: "Modifica la filigrana" cover: "Coprire tutto" repeat: "Disposizione" + preserveBoundingRect: "Fai in modo da non eccedere durante la rotazione" opacity: "Opacità" scale: "Dimensioni" text: "Testo" + qr: "QR Code" position: "Posizione" + margin: "Margine" type: "Tipo" image: "Immagini" advanced: "Avanzato" @@ -3177,17 +3343,21 @@ _watermarkEditor: polkadotSubDotOpacity: "Opacità del punto secondario" polkadotSubDotRadius: "Dimensione del punto secondario" polkadotSubDotDivisions: "Quantità di punti secondari" + leaveBlankToAccountUrl: "Il valore vuoto indica la URL dell'account" + failedToLoadImage: "Impossibile caricare l'immagine" _imageEffector: title: "Effetto" addEffect: "Aggiungi effetto" discardChangesConfirm: "Scarta le modifiche ed esci?" - nothingToConfigure: "Nessuna impostazione configurabile." + failedToLoadImage: "Impossibile caricare l'immagine" _fxs: chromaticAberration: "Aberrazione cromatica" glitch: "Glitch" mirror: "Specchio" invert: "Inversione colore" grayscale: "Bianco e nero" + blur: "Sfocatura" + pixelate: "Mosaico" colorAdjust: "Correzione Colore" colorClamp: "Compressione del colore" colorClampAdvanced: "Compressione del colore (avanzata)" @@ -3199,10 +3369,14 @@ _imageEffector: checker: "revisore" blockNoise: "Attenua rumore" tearing: "Strappa immagine" + fill: "Riempimento" _fxProps: angle: "Angolo" scale: "Dimensioni" size: "Dimensioni" + radius: "Raggio" + samples: "Quantità di campioni" + offset: "Posizione" color: "Colore" opacity: "Opacità" normalize: "Normalizza" @@ -3231,7 +3405,8 @@ _imageEffector: zoomLinesThreshold: "Limite delle linee zoom" zoomLinesMaskSize: "Ampiezza del diametro" zoomLinesBlack: "Bande nere" -drafts: "Bozza" + circle: "Circolare" +drafts: "Bozze" _drafts: select: "Selezionare bozza" cannotCreateDraftAnymore: "Hai superato il numero massimo di bozze ammissibili." @@ -3246,3 +3421,22 @@ _drafts: restoreFromDraft: "Recuperare dalle bozze" restore: "Ripristina" listDrafts: "Elenco bozze" + schedule: "Pianifica pubblicazione" + listScheduledNotes: "Elenca Note pianificate" + cancelSchedule: "Annulla pianificazione" +qr: "QR Code" +_qr: + showTabTitle: "Visualizza" + readTabTitle: "Leggere" + shareTitle: "{name} {acct}" + shareText: "Seguimi nel Fediverso!" + chooseCamera: "Seleziona fotocamera" + cannotToggleFlash: "Flash non controllabile" + turnOnFlash: "Accendi il flash" + turnOffFlash: "Spegni il flash" + startQr: "Inizia lettura QR Code" + stopQr: "Interrompi lettura QR Code" + noQrCodeFound: "Non trovo alcun QR Code" + scanFile: "Scansiona immagine nel dispositivo" + raw: "Testo" + mfm: "MFM" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 4dd21345a8..61b2d6c3c9 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -83,6 +83,8 @@ files: "ファイル" download: "ダウンロード" driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?このファイルを使用した一部のコンテンツも削除されます。" unfollowConfirm: "{name}のフォローを解除しますか?" +cancelFollowRequestConfirm: "{name}へのフォロー申請をキャンセルしますか?" +rejectFollowRequestConfirm: "{name}からのフォロー申請を拒否しますか?" exportRequested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、「ドライブ」に追加されます。" importRequested: "インポートをリクエストしました。これには時間がかかる場合があります。" lists: "リスト" @@ -182,7 +184,7 @@ flagAsCat: "にゃああああああああああああああ!!!!!! flagAsCatDescription: "にゃにゃにゃ??" flagShowTimelineReplies: "タイムラインにノートへの返信を表示する" flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーのノート以外にもそのユーザーの他のノートへの返信を表示します。" -autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" +autoAcceptFollowed: "フォロー中ユーザーからのフォロー申請を自動承認" addAccount: "アカウントを追加" reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗しました" @@ -253,6 +255,7 @@ noteDeleteConfirm: "このノートを削除しますか?" pinLimitExceeded: "これ以上ピン留めできません" done: "完了" processing: "処理中" +preprocessing: "準備中" preview: "プレビュー" default: "デフォルト" defaultValueIs: "デフォルト: {value}" @@ -301,8 +304,9 @@ uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がか uploadNFiles: "{n}個のファイルをアップロード" explore: "みつける" messageRead: "既読" +readAllChatMessages: "すべてのメッセージを既読にする" noMoreHistory: "これより過去の履歴はありません" -startChat: "チャットを始める" +startChat: "メッセージを送る" nUsersRead: "{n}人が読みました" agreeTo: "{0}に同意" agree: "同意する" @@ -333,6 +337,7 @@ fileName: "ファイル名" selectFile: "ファイルを選択" selectFiles: "ファイルを選択" selectFolder: "フォルダーを選択" +unselectFolder: "フォルダーの選択を解除" selectFolders: "フォルダーを選択" fileNotSelected: "ファイルが選択されていません" renameFile: "ファイル名を変更" @@ -345,6 +350,7 @@ addFile: "ファイルを追加" showFile: "ファイルを表示" emptyDrive: "ドライブは空です" emptyFolder: "フォルダーは空です" +dropHereToUpload: "ここにファイルをドロップしてアップロード" unableToDelete: "削除できません" inputNewFileName: "新しいファイル名を入力してください" inputNewDescription: "新しいキャプションを入力してください" @@ -477,7 +483,7 @@ notFoundDescription: "指定されたURLに該当するページはありませ uploadFolder: "既定アップロード先" markAsReadAllNotifications: "すべての通知を既読にする" markAsReadAllUnreadNotes: "すべての投稿を既読にする" -markAsReadAllTalkMessages: "すべてのチャットを既読にする" +markAsReadAllTalkMessages: "すべてのダイレクトメッセージを既読にする" help: "ヘルプ" inputMessageHere: "ここにメッセージを入力" close: "閉じる" @@ -537,6 +543,7 @@ regenerate: "再生成" fontSize: "フォントサイズ" mediaListWithOneImageAppearance: "画像が1枚のみのメディアリストの高さ" limitTo: "{x}を上限に" +showMediaListByGridInWideArea: "画面幅が広いときはメディアリストを横並びで表示する" noFollowRequests: "フォロー申請はありません" openImageInNewTab: "画像を新しいタブで開く" dashboard: "ダッシュボード" @@ -772,6 +779,7 @@ lockedAccountInfo: "フォローを承認制にしても、ノートの公開範 alwaysMarkSensitive: "デフォルトでメディアをセンシティブ設定にする" loadRawImages: "添付画像のサムネイルをオリジナル画質にする" disableShowingAnimatedImages: "アニメーション画像を再生しない" +disableShowingAnimatedImages_caption: "この設定に関わらずアニメーション画像が再生されないときは、ブラウザ・OSのアクセシビリティ設定や省電力設定等が干渉している場合があります。" highlightSensitiveMedia: "メディアがセンシティブであることを分かりやすく表示" verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。" notSet: "未設定" @@ -1018,6 +1026,9 @@ pushNotificationAlreadySubscribed: "プッシュ通知は有効です" pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に非対応" sendPushNotificationReadMessage: "通知が既読になったらプッシュ通知を削除する" sendPushNotificationReadMessageCaption: "端末の電池消費量が増加する可能性があります。" +pleaseAllowPushNotification: "ブラウザの通知設定を許可してください" +browserPushNotificationDisabled: "通知の送信権限の取得に失敗しました" +browserPushNotificationDisabledDescription: "{serverName}から通知を送信する権限がありません。ブラウザの設定から通知を許可して再度お試しください。" windowMaximize: "最大化" windowMinimize: "最小化" windowRestore: "元に戻す" @@ -1168,6 +1179,7 @@ installed: "インストール済み" branding: "ブランディング" enableServerMachineStats: "サーバーのマシン情報を公開する" enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" +showRoleBadgesOfRemoteUsers: "リモートユーザーに付与したロールバッジを表示する" turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。" createInviteCode: "招待コードを作成" createWithOptions: "オプションを指定して作成" @@ -1245,7 +1257,7 @@ releaseToRefresh: "離してリロード" refreshing: "リロード中" pullDownToRefresh: "引っ張ってリロード" useGroupedNotifications: "通知をグルーピング" -signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" +emailVerificationFailedError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。" doReaction: "リアクションする" code: "コード" @@ -1316,6 +1328,7 @@ acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。" federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。" draft: "下書き" +draftsAndScheduledNotes: "下書きと予約投稿" confirmOnReact: "リアクションする際に確認する" reactAreYouSure: "\" {emoji} \" をリアクションしますか?" markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?" @@ -1343,6 +1356,8 @@ postForm: "投稿フォーム" textCount: "文字数" information: "情報" chat: "チャット" +directMessage: "ダイレクトメッセージ" +directMessage_short: "メッセージ" migrateOldSettings: "旧設定情報を移行" migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。" compress: "圧縮" @@ -1370,6 +1385,8 @@ redisplayAllTips: "全ての「ヒントとコツ」を再表示" hideAllTips: "全ての「ヒントとコツ」を非表示" defaultImageCompressionLevel: "デフォルトの画像圧縮度" defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。
高くするとファイルサイズを減らせますが、画質は低下します。" +defaultCompressionLevel: "デフォルトの圧縮度" +defaultCompressionLevel_description: "低くすると品質を保てますが、ファイルサイズは増加します。
高くするとファイルサイズを減らせますが、品質は低下します。" inMinutes: "分" inDays: "日" safeModeEnabled: "セーフモードが有効です" @@ -1377,54 +1394,121 @@ pluginsAreDisabledBecauseSafeMode: "セーフモードが有効なため、プ customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カスタムCSSは適用されていません。" themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。" thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!" +createUserSpecifiedNote: "ユーザー指定ノートを作成" +schedulePost: "投稿を予約" +scheduleToPostOnX: "{x}に投稿を予約します" +scheduledToPostOnX: "{x}に投稿が予約されています" +schedule: "予約" +scheduled: "予約" +widgets: "ウィジェット" +deviceInfo: "デバイス情報" +deviceInfoDescription: "技術的なお問い合わせの際に、以下の情報を併記すると問題の解決に役立つことがあります。" +youAreAdmin: "あなたは管理者です" +frame: "フレーム" +presets: "プリセット" +zeroPadding: "ゼロ埋め" +nothingToConfigure: "設定項目はありません" youAreNotAllowedToCreateNote: "お使いのアカウントにはノートを投稿する権限がありません。" +_imageEditing: + _vars: + caption: "ファイルのキャプション" + filename: "ファイル名" + filename_without_ext: "拡張子無しファイル名" + year: "撮影年" + month: "撮影月" + day: "撮影日" + hour: "撮影した時刻(時)" + minute: "撮影した時刻(分)" + second: "撮影した時刻(秒)" + camera_model: "カメラ名" + camera_lens_model: "レンズ名" + camera_mm: "焦点距離" + camera_mm_35: "焦点距離(35mm判換算)" + camera_f: "絞り" + camera_s: "シャッタースピード" + camera_iso: "ISO感度" + gps_lat: "緯度" + gps_long: "経度" + +_imageFrameEditor: + title: "フレームの編集" + tip: "画像にフレームやメタデータを含んだラベルを追加して装飾できます。" + header: "ヘッダー" + footer: "フッター" + borderThickness: "フチの幅" + labelThickness: "ラベルの幅" + labelScale: "ラベルのスケール" + centered: "中央揃え" + captionMain: "キャプション(大)" + captionSub: "キャプション(小)" + availableVariables: "利用可能な変数" + withQrCode: "二次元コード" + backgroundColor: "背景色" + textColor: "文字色" + font: "フォント" + fontSerif: "セリフ" + fontSansSerif: "サンセリフ" + quitWithoutSaveConfirm: "保存せずに終了しますか?" + failedToLoadImage: "画像の読み込みに失敗しました" + +_compression: + _quality: + high: "高品質" + medium: "中品質" + low: "低品質" + _size: + large: "サイズ大" + medium: "サイズ中" + small: "サイズ小" + _order: newest: "新しい順" oldest: "古い順" _chat: + messages: "メッセージ" noMessagesYet: "まだメッセージはありません" newMessage: "新しいメッセージ" - individualChat: "個人チャット" - individualChat_description: "特定ユーザーとの一対一のチャットができます。" - roomChat: "ルームチャット" - roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。" - createRoom: "ルームを作成" - inviteUserToChat: "ユーザーを招待してチャットを始めましょう" - yourRooms: "作成したルーム" - joiningRooms: "参加中のルーム" + individualChat: "個別" + individualChat_description: "特定ユーザーと個別にメッセージのやりとりができます。" + roomChat: "グループ" + roomChat_description: "複数人でメッセージのやりとりができます。\nまた、個別のメッセージを許可していないユーザーとでも、相手が受け入れればやりとりできます。" + createRoom: "グループを作成" + inviteUserToChat: "ユーザーを招待してメッセージを送信しましょう" + yourRooms: "作成したグループ" + joiningRooms: "参加中のグループ" invitations: "招待" noInvitations: "招待はありません" history: "履歴" noHistory: "履歴はありません" - noRooms: "ルームはありません" + noRooms: "グループはありません" inviteUser: "ユーザーを招待" sentInvitations: "送信した招待" join: "参加" ignore: "無視" - leave: "ルームから退出" + leave: "グループから退出" members: "メンバー" searchMessages: "メッセージを検索" home: "ホーム" send: "送信" newline: "改行" - muteThisRoom: "このルームをミュート" - deleteRoom: "ルームを削除" - chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。" - chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。" - chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。" - cannotChatWithTheUser: "このユーザーとのチャットを開始できません" - cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。" - youAreNotAMemberOfThisRoomButInvited: "あなたはこのルームの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。" + muteThisRoom: "このグループをミュート" + deleteRoom: "グループを削除" + chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでダイレクトメッセージは有効化されていません。" + chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでダイレクトメッセージは読み取り専用となっています。新たに書き込んだり、グループを作成・参加したりすることはできません。" + chatNotAvailableInOtherAccount: "相手のアカウントでダイレクトメッセージが使えない状態になっています。" + cannotChatWithTheUser: "このユーザーとのダイレクトメッセージを開始できません" + cannotChatWithTheUser_description: "ダイレクトメッセージが使えない状態になっているか、相手がダイレクトメッセージを開放していません。" + youAreNotAMemberOfThisRoomButInvited: "あなたはこのグループの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。" doYouAcceptInvitation: "招待を承認しますか?" - chatWithThisUser: "チャットする" - thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。" - thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。" - thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみチャットを受け付けています。" - thisUserNotAllowedChatAnyone: "このユーザーは誰からもチャットを受け付けていません。" - chatAllowedUsers: "チャットを許可する相手" - chatAllowedUsers_note: "自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。" + chatWithThisUser: "ダイレクトメッセージ" + thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみメッセージを受け付けています。" + thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみメッセージを受け付けています。" + thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみメッセージを受け付けています。" + thisUserNotAllowedChatAnyone: "このユーザーは誰からもメッセージを受け付けていません。" + chatAllowedUsers: "メッセージを許可する相手" + chatAllowedUsers_note: "自分からメッセージを送った相手とはこの設定に関わらずメッセージの送受信が可能です。" _chatAllowedUsers: everyone: "誰でも" followers: "自分のフォロワーのみ" @@ -1474,6 +1558,11 @@ _settings: showUrlPreview: "URLプレビューを表示する" showAvailableReactionsFirstInNote: "利用できるリアクションを先頭に表示" showPageTabBarBottom: "ページのタブバーを下部に表示" + emojiPaletteBanner: "絵文字ピッカーに固定表示するプリセットをパレットとして登録したり、ピッカーの表示方法をカスタマイズしたりできます。" + enableAnimatedImages: "アニメーション画像を有効にする" + settingsPersistence_title: "設定の永続化" + settingsPersistence_description1: "設定の永続化を有効にすると、設定情報が失われるのを防止できます。" + settingsPersistence_description2: "環境によっては有効化できない場合があります。" _chat: showSenderName: "送信者の名前を表示" @@ -1484,6 +1573,8 @@ _preferencesProfile: profileNameDescription: "このデバイスを識別する名前を設定してください。" profileNameDescription2: "例: 「メインPC」、「スマホ」など" manageProfiles: "プロファイルの管理" + shareSameProfileBetweenDevicesIsNotRecommended: "複数のデバイスで同一のプロファイルを共有することは推奨しません。" + useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "複数のデバイスで同期したい設定項目が存在する場合は、個別に「複数のデバイスで同期」オプションを有効にしてください。" _preferencesBackup: autoBackup: "自動バックアップ" @@ -1494,6 +1585,7 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。" autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。" backupFound: "設定のバックアップが見つかりました" + forceBackup: "設定の強制バックアップ" _accountSettings: requireSigninToViewContents: "コンテンツの表示にログインを必須にする" @@ -1661,7 +1753,7 @@ _serverSettings: fanoutTimelineDbFallbackDescription: "有効にすると、タイムラインがキャッシュされていない場合にDBへ追加で問い合わせを行うフォールバック処理を行います。無効にすると、フォールバック処理を行わないことでさらにサーバーの負荷を軽減することができますが、タイムラインが取得できる範囲に制限が生じます。" reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" remoteNotesCleaning: "リモート投稿の自動クリーニング" - remoteNotesCleaning_description: "有効にすると、参照されていない古いリモートの投稿を定期的にクリーンアップしてデータベースの肥大化を抑制します。" + remoteNotesCleaning_description: "有効にすると、一定期間経過したリモートの投稿を定期的にクリーンアップしてデータベースの肥大化を抑制します。" remoteNotesCleaningMaxProcessingDuration: "最大クリーニング処理継続時間" remoteNotesCleaningExpiryDaysForEachNotes: "最低ノート保持日数" inquiryUrl: "問い合わせ先URL" @@ -1686,7 +1778,7 @@ _serverSettings: restartServerSetupWizardConfirm_text: "現在の一部の設定はリセットされます。" entrancePageStyle: "エントランスページのスタイル" showTimelineForVisitor: "タイムラインを表示する" - showActivityiesForVisitor: "アクティビティを表示する" + showActivitiesForVisitor: "アクティビティを表示する" _userGeneratedContentsVisibilityForVisitor: all: "全て公開" @@ -2013,6 +2105,7 @@ _role: canManageAvatarDecorations: "アバターデコレーションの管理" driveCapacity: "ドライブ容量" maxFileSize: "アップロード可能な最大ファイルサイズ" + maxFileSize_caption: "リバースプロキシやCDNなど、前段で別の設定値が存在する場合があります。" alwaysMarkNsfw: "ファイルにNSFWを常に付与" canUpdateBioMedia: "アイコンとバナーの更新を許可" pinMax: "ノートのピン留めの最大数" @@ -2035,11 +2128,12 @@ _role: canImportFollowing: "フォローのインポートを許可" canImportMuting: "ミュートのインポートを許可" canImportUserLists: "リストのインポートを許可" - chatAvailability: "チャットを許可" + chatAvailability: "ダイレクトメッセージを許可" uploadableFileTypes: "アップロード可能なファイル種別" uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)" uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。" noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数" + scheduledNoteLimit: "予約投稿の同時作成可能数" watermarkAvailable: "ウォーターマーク機能の使用可否" canNote: "ノートの投稿を許可" renotePolicy: "リノート・引用を許可" @@ -2290,7 +2384,7 @@ _theme: buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" badge: "バッジ" - messageBg: "チャットの背景" + messageBg: "メッセージの背景" fgHighlighted: "強調された文字" _sfx: @@ -2298,7 +2392,7 @@ _sfx: noteMy: "ノート(自分)" notification: "通知" reaction: "リアクション選択時" - chatMessage: "チャットのメッセージ" + chatMessage: "ダイレクトメッセージ" _soundSettings: driveFile: "ドライブの音声を使用" @@ -2378,8 +2472,8 @@ _permissions: "write:favorites": "お気に入りを操作する" "read:following": "フォローの情報を見る" "write:following": "フォロー・フォロー解除する" - "read:messaging": "チャットを見る" - "write:messaging": "チャットを操作する" + "read:messaging": "ダイレクトメッセージを見る" + "write:messaging": "ダイレクトメッセージを操作する" "read:mutes": "ミュートを見る" "write:mutes": "ミュートを操作する" "write:notes": "ノートを作成・削除する" @@ -2452,8 +2546,8 @@ _permissions: "read:clip-favorite": "クリップのいいねを見る" "read:federation": "連合に関する情報を取得する" "write:report-abuse": "違反を報告する" - "write:chat": "チャットを操作する" - "read:chat": "チャットを閲覧する" + "write:chat": "ダイレクトメッセージを操作する" + "read:chat": "ダイレクトメッセージを閲覧する" _auth: shareAccessTitle: "アプリへのアクセス許可" @@ -2468,6 +2562,7 @@ _auth: scopeUser: "以下のユーザーとして操作しています" pleaseLogin: "アプリケーションにアクセス許可を与えるには、ログインが必要です。" byClickingYouWillBeRedirectedToThisUrl: "アクセスを許可すると、自動で以下のURLに遷移します" + alreadyAuthorized: "このアプリケーションは既にアクセスが許可されています。" _antennaSources: all: "全てのノート" @@ -2515,8 +2610,47 @@ _widgets: _userList: chooseList: "リストを選択" clicker: "クリッカー" - birthdayFollowings: "今日誕生日のユーザー" - chat: "チャット" + birthdayFollowings: "もうすぐ誕生日のユーザー" + chat: "ダイレクトメッセージ" + +_widgetOptions: + showHeader: "ヘッダーを表示" + transparent: "背景を透明にする" + height: "高さ" + _button: + colored: "色付き" + _clock: + size: "サイズ" + thickness: "針の太さ" + thicknessThin: "細い" + thicknessMedium: "普通" + thicknessThick: "太い" + graduations: "文字盤の目盛り" + graduationDots: "ドット" + graduationArabic: "アラビア数字" + fadeGraduations: "目盛りをフェード" + sAnimation: "秒針のアニメーション" + sAnimationElastic: "リアル" + sAnimationEaseOut: "滑らか" + twentyFour: "24時間表示" + labelTime: "時刻" + labelTz: "タイムゾーン" + labelTimeAndTz: "時刻とタイムゾーン" + timezone: "タイムゾーン" + showMs: "ミリ秒を表示" + showLabel: "ラベルを表示" + _jobQueue: + sound: "音を鳴らす" + _rss: + url: "RSSフィードのURL" + refreshIntervalSec: "更新間隔(秒)" + maxEntries: "最大表示件数" + _rssTicker: + shuffle: "表示順をシャッフル" + duration: "ティッカーのスクロール速度(秒)" + reverse: "逆方向にスクロール" + _birthdayFollowings: + period: "期間" _cw: hide: "隠す" @@ -2565,6 +2699,20 @@ _postForm: replyPlaceholder: "このノートに返信..." quotePlaceholder: "このノートを引用..." channelPlaceholder: "チャンネルに投稿..." + showHowToUse: "フォームの説明を表示" + _howToUse: + content_title: "本文" + content_description: "投稿する内容を入力します。" + toolbar_title: "ツールバー" + toolbar_description: "ファイルやアンケートの添付、注釈やハッシュタグの設定、絵文字やメンションの挿入などが行えます。" + account_title: "アカウントメニュー" + account_description: "投稿するアカウントを切り替えたり、アカウントに保存した下書き・予約投稿を一覧できます。" + visibility_title: "公開範囲" + visibility_description: "ノートを公開する範囲の設定が行えます。" + menu_title: "メニュー" + menu_description: "下書きへの保存、投稿の予約、リアクションの設定など、その他のアクションが行えます。" + submit_title: "投稿ボタン" + submit_description: "ノートを投稿します。Ctrl + Enter / Cmd + Enter でも投稿できます。" _placeholders: a: "いまどうしてる?" b: "何かありましたか?" @@ -2720,10 +2868,12 @@ _notification: youReceivedFollowRequest: "フォローリクエストが来ました" yourFollowRequestAccepted: "フォローリクエストが承認されました" pollEnded: "アンケートの結果が出ました" + scheduledNotePosted: "予約ノートが投稿されました" + scheduledNotePostFailed: "予約ノートの投稿に失敗しました" newNote: "新しい投稿" unreadAntennaNote: "アンテナ {name}" roleAssigned: "ロールが付与されました" - chatRoomInvitationReceived: "チャットルームへ招待されました" + chatRoomInvitationReceived: "ダイレクトメッセージのグループへ招待されました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" testNotification: "通知テスト" @@ -2750,10 +2900,12 @@ _notification: quote: "引用" reaction: "リアクション" pollEnded: "アンケートが終了" + scheduledNotePosted: "予約投稿が成功した" + scheduledNotePostFailed: "予約投稿が失敗した" receiveFollowRequest: "フォロー申請を受け取った" followRequestAccepted: "フォローが受理された" roleAssigned: "ロールが付与された" - chatRoomInvitationReceived: "チャットルームへ招待された" + chatRoomInvitationReceived: "ダイレクトメッセージのグループへ招待された" achievementEarned: "実績の獲得" exportCompleted: "エクスポートが完了した" login: "ログイン" @@ -2791,6 +2943,15 @@ _deck: usedAsMinWidthWhenFlexible: "「幅を自動調整」が有効の場合、これが幅の最小値となります" flexible: "幅を自動調整" enableSyncBetweenDevicesForProfiles: "プロファイル情報のデバイス間同期を有効にする" + showHowToUse: "UIの説明を見る" + + _howToUse: + addColumn_title: "カラム追加" + addColumn_description: "カラムの種類を選んで追加できます。" + settings_title: "UI設定" + settings_description: "デッキUIの詳細設定を行えます。" + switchProfile_title: "プロファイル切り替え" + switchProfile_description: "UIのレイアウトをプロファイルとして保存し、いつでも切り替えられるようにできます。" _columns: main: "メイン" @@ -2803,7 +2964,7 @@ _deck: mentions: "メンション" direct: "指名" roleTimeline: "ロールタイムライン" - chat: "チャット" + chat: "ダイレクトメッセージ" _dialog: charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" @@ -2858,6 +3019,8 @@ _abuseReport: deleteConfirm: "通知先を削除しますか?" _moderationLogTypes: + clearQueue: "ジョブキューをクリア" + promoteQueue: "キューのジョブを再試行" createRole: "ロールを作成" deleteRole: "ロールを削除" updateRole: "ロールを更新" @@ -2906,7 +3069,7 @@ _moderationLogTypes: deletePage: "ページを削除" deleteFlash: "Playを削除" deleteGalleryPost: "ギャラリーの投稿を削除" - deleteChatRoom: "チャットルームを削除" + deleteChatRoom: "ダイレクトメッセージのグループを削除" updateProxyAccountDescription: "プロキシアカウントの説明を更新" _fileViewer: @@ -3225,8 +3388,8 @@ _serverSetupWizard: doYouConnectToFediverse_description1: "分散型サーバーで構成されるネットワーク(Fediverse)に接続すると、他のサーバーと相互にコンテンツのやり取りが可能です。" doYouConnectToFediverse_description2: "Fediverseと接続することは「連合」とも呼ばれます。" youCanConfigureMoreFederationSettingsLater: "連合可能なサーバーの指定など、高度な設定も後ほど可能です。" - remoteContentsCleaning: "受信コンテンツの自動クリーニング" - remoteContentsCleaning_description: "連合を行うと、継続して多くのコンテンツを受信します。自動クリーニングを有効にすると、参照されていない古くなったコンテンツを自動でサーバーから削除し、ストレージを節約できます。" + remoteContentsCleaning: "リモートコンテンツの自動クリーニング" + remoteContentsCleaning_description: "連合を行うと、継続して多くのコンテンツを受信します。自動クリーニングを有効にすると、一定期間経過したリモートコンテンツを自動でサーバーから削除し、ストレージを節約できます。" adminInfo: "管理者情報" adminInfo_description: "問い合わせを受け付けるために使用される管理者情報を設定します。" adminInfo_mustBeFilled: "オープンサーバー、または連合がオンの場合は必ず入力が必要です。" @@ -3270,17 +3433,20 @@ _userLists: watermark: "ウォーターマーク" defaultPreset: "デフォルトのプリセット" _watermarkEditor: - tip: "画像にクレジット情報などのウォーターマークを追加することができます。" + tip: "画像にクレジット情報などのウォーターマークを追加できます。" quitWithoutSaveConfirm: "保存せずに終了しますか?" driveFileTypeWarn: "このファイルは対応していません" driveFileTypeWarnDescription: "画像ファイルを選択してください" title: "ウォーターマークの編集" cover: "全体に被せる" repeat: "敷き詰める" + preserveBoundingRect: "回転時はみ出ないように調整する" opacity: "不透明度" scale: "サイズ" text: "テキスト" + qr: "二次元コード" position: "位置" + margin: "マージン" type: "タイプ" image: "画像" advanced: "高度" @@ -3295,12 +3461,14 @@ _watermarkEditor: polkadotSubDotOpacity: "サブドットの不透明度" polkadotSubDotRadius: "サブドットの大きさ" polkadotSubDotDivisions: "サブドットの数" + leaveBlankToAccountUrl: "空欄にするとアカウントのURLになります" + failedToLoadImage: "画像の読み込みに失敗しました" _imageEffector: title: "エフェクト" addEffect: "エフェクトを追加" discardChangesConfirm: "変更を破棄して終了しますか?" - nothingToConfigure: "設定項目はありません" + failedToLoadImage: "画像の読み込みに失敗しました" _fxs: chromaticAberration: "色収差" @@ -3308,6 +3476,8 @@ _imageEffector: mirror: "ミラー" invert: "色の反転" grayscale: "白黒" + blur: "ぼかし" + pixelate: "モザイク" colorAdjust: "色調補正" colorClamp: "色の圧縮" colorClampAdvanced: "色の圧縮(高度)" @@ -3319,11 +3489,15 @@ _imageEffector: checker: "チェッカー" blockNoise: "ブロックノイズ" tearing: "ティアリング" + fill: "塗りつぶし" _fxProps: angle: "角度" scale: "サイズ" size: "サイズ" + radius: "半径" + samples: "サンプル数" + offset: "位置" color: "色" opacity: "不透明度" normalize: "正規化" @@ -3352,6 +3526,7 @@ _imageEffector: zoomLinesThreshold: "集中線の幅" zoomLinesMaskSize: "中心径" zoomLinesBlack: "黒色にする" + circle: "円形" drafts: "下書き" _drafts: @@ -3368,3 +3543,23 @@ _drafts: restoreFromDraft: "下書きから復元" restore: "復元" listDrafts: "下書き一覧" + schedule: "投稿予約" + listScheduledNotes: "予約投稿一覧" + cancelSchedule: "予約解除" + +qr: "二次元コード" +_qr: + showTabTitle: "表示" + readTabTitle: "読み取る" + shareTitle: "{name} {acct}" + shareText: "Fediverseで私をフォローしてください!" + chooseCamera: "カメラを選択" + cannotToggleFlash: "ライト選択不可" + turnOnFlash: "ライトをオンにする" + turnOffFlash: "ライトをオフにする" + startQr: "コードリーダーを再開" + stopQr: "コードリーダーを停止" + noQrCodeFound: "QRコードが見つかりません" + scanFile: "端末の画像をスキャン" + raw: "テキスト" + mfm: "MFM" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 05113500a3..4a2cc3a9b8 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -220,6 +220,7 @@ silenceThisInstance: "サーバーサイレンスすんで?" mediaSilenceThisInstance: "サーバーをメディアサイレンス" operations: "操作" software: "ソフトウェア" +softwareName: "ソフトウェア名" version: "バージョン" metadata: "メタデータ" withNFiles: "{n}個のファイル" @@ -252,6 +253,7 @@ noteDeleteConfirm: "このノートをほかしてええか?" pinLimitExceeded: "これ以上ピン留めできひん" done: "でけた" processing: "処理しとる" +preprocessing: "準備中" preview: "プレビュー" default: "デフォルト" defaultValueIs: "デフォルト: {value}" @@ -299,13 +301,14 @@ uploadFromUrlRequested: "アップロードしたい言うといたで" uploadFromUrlMayTakeTime: "アップロード終わるんにちょい時間かかるかもしれへんわ。" explore: "みつける" messageRead: "もう読んだ" +readAllChatMessages: "メッセージを全部読んだことにしとく" noMoreHistory: "これより昔のんはあらへんで" startChat: "チャットを始めよか" nUsersRead: "{n}人が読んでもうた" agreeTo: "{0}に同意したで" agree: "せやな" -agreeBelow: "下記に同意したる" -basicNotesBeforeCreateAccount: "よう読んでやってや" +agreeBelow: "下記に同意するわ" +basicNotesBeforeCreateAccount: "よう読んどいてや" termsOfService: "使うための決め事" start: "始める" home: "ホーム" @@ -325,12 +328,13 @@ dark: "ダーク" lightThemes: "デイゲーム" darkThemes: "ナイトゲーム" syncDeviceDarkMode: "デバイスのダークモードと一緒にする" -switchDarkModeManuallyWhenSyncEnabledConfirm: "「{x}」がオンになってるで。同期をオフにして手動でモードを切り替えることにします?" +switchDarkModeManuallyWhenSyncEnabledConfirm: "「{x}」がオンになってるで。同期切って手動でモード切り替える?" drive: "ドライブ" fileName: "ファイル名" selectFile: "ファイル選んでや" selectFiles: "ファイル選んでや" selectFolder: "フォルダ選んでや" +unselectFolder: "フォルダーの選択を解除" selectFolders: "フォルダ選んでや" fileNotSelected: "ファイルが選択されてへんで" renameFile: "ファイル名をいらう" @@ -421,14 +425,13 @@ antennaSource: "受信ソース(このソースは食われへん)" antennaKeywords: "受信キーワード" antennaExcludeKeywords: "除外キーワード" antennaExcludeBots: "Botアカウントを除外" -antennaKeywordsDescription: "スペースで区切ったるとAND指定で、改行で区切ったるとOR指定や" +antennaKeywordsDescription: "スペースで区切ったらAND指定で、改行で区切ったらOR指定や" notifyAntenna: "新しいノートを通知すんで" withFileAntenna: "なんか添付されたノートだけ" excludeNotesInSensitiveChannel: "センシティブなチャンネルのノートは入れんとくわ" enableServiceworker: "ブラウザにプッシュ通知が行くようにする" antennaUsersDescription: "ユーザー名を改行で区切ったってな" caseSensitive: "大文字と小文字は別もんや" -withReplies: "返信も入れたって" connectedTo: "次のアカウントに繋がっとるで" notesAndReplies: "投稿と返信" withFiles: "ファイル付いとる" @@ -471,9 +474,9 @@ newPasswordIs: "今度のパスワードは「{password}」や" reduceUiAnimation: "UIの動きやアニメーションを少なする" share: "わけわけ" notFound: "見つからへんね" -notFoundDescription: "言われたURLにはまるページはなかったで。" +notFoundDescription: "言われたURLのページはなかったで。" uploadFolder: "とりあえずアップロードしたやつ置いとく所" -markAsReadAllNotifications: "通知はもう全て読んだわっ" +markAsReadAllNotifications: "通知はもう全部読んだわ" markAsReadAllUnreadNotes: "投稿は全て読んだわっ" markAsReadAllTalkMessages: "チャットはもうぜんぶ読んだわっ" help: "ヘルプ" @@ -554,7 +557,7 @@ showFeaturedNotesInTimeline: "タイムラインにおすすめのノートを objectStorage: "オブジェクトストレージ" useObjectStorage: "オブジェクトストレージを使う" objectStorageBaseUrl: "Base URL" -objectStorageBaseUrlDesc: "参照に使うにURLやで。CDNやProxyを使用してるんならそのURL、S3: 'https://.s3.amazonaws.com'、GCSとかなら: 'https://storage.googleapis.com/'。" +objectStorageBaseUrlDesc: "参照に使うURLやで。CDNやProxyを使用してるんならそのURL、S3: 'https://.s3.amazonaws.com'、GCSとかなら: 'https://storage.googleapis.com/'。" objectStorageBucket: "Bucket" objectStorageBucketDesc: "使ってるサービスのbucket名を選んでな" objectStoragePrefix: "Prefix" @@ -571,17 +574,19 @@ objectStorageSetPublicRead: "アップロードした時に'public-read'を設 s3ForcePathStyleDesc: "s3ForcePathStyleを使たらバケット名をURLのホスト名やなくてパスの一部として必ず指定させるようになるで。セルフホストされたMinioとかを使うてるんやったら有効にせなあかん場合があるで。" serverLogs: "サーバーログ" deleteAll: "全部ほかす" -showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?" +showFixedPostForm: "タイムラインの上の方で投稿できるようにするわ" showFixedPostFormInChannel: "タイムラインの上の方で投稿できるようにするわ(チャンネル)" withRepliesByDefaultForNewlyFollowed: "フォローする時、デフォルトで返信をタイムラインに含むようにしよか" newNoteRecived: "新しいノートがあるで" +newNote: "新しいノートがあるで" sounds: "音" sound: "音" +notificationSoundSettings: "通知音の設定" listen: "聴く" none: "なし" showInPage: "ページで表示" popout: "ポップアウト" -volume: "やかましさ" +volume: "音のでかさ" masterVolume: "全体のやかましさ" notUseSound: "音出さへん" useSoundOnlyWhenActive: "Misskeyがアクティブなときだけ音出す" @@ -597,7 +602,7 @@ nothing: "あらへん" installedDate: "インストールした日時" lastUsedDate: "最後に使った日時" state: "状態" -sort: "仕分ける" +sort: "並び替え" ascendingOrder: "小さい順" descendingOrder: "大きい順" scratchpad: "スクラッチパッド" @@ -657,9 +662,9 @@ useBlurEffectForModal: "モーダルにぼかし効果を使用" useFullReactionPicker: "フルフルのツッコミピッカーを使う" width: "幅" height: "高さ" -large: "大" -medium: "中" -small: "小" +large: "でかい" +medium: "ふつう" +small: "ちいさい" generateAccessToken: "アクセストークンの発行" permission: "権限" adminPermission: "管理者権限" @@ -684,7 +689,7 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" smtpSecureInfo: "STARTTLS使っとる時はオフにしてや。" testEmail: "配信テスト" wordMute: "ワードミュート" -wordMuteDescription: "指定した語句が入ってるノートを最小化するで。最小化されたノートをクリックしたら、表示できるようになるで。" +wordMuteDescription: "指定した語句が入ってるノートをちっさくするで。ちっさくなったノートをクリックしたら中身を見れるで。" hardWordMute: "ハードワードミュート" showMutedWord: "ミュートされたワードを表示するで" hardWordMuteDescription: "指定した語句が入ってるノートを隠すで。ワードミュートとちゃうて、ノートは完全に表示されんようになるで。" @@ -718,7 +723,7 @@ behavior: "動作" sample: "サンプル" abuseReports: "通報" reportAbuse: "通報" -reportAbuseRenote: "リノート苦情だすで?" +reportAbuseRenote: "リノートの苦情出す" reportAbuseOf: "{name}を通報する" fillAbuseReportDescription: "細かい通報理由を書いてなー。対象ノートがある時はそのURLも書いといてなー。" abuseReported: "無事内容が送信されたみたいやで。おおきに〜。" @@ -768,6 +773,7 @@ lockedAccountInfo: "フォローを承認制にしとっても、ノートの公 alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にするで" loadRawImages: "添付画像のサムネイルをオリジナル画質にするで" disableShowingAnimatedImages: "アニメーション画像を再生せんとくで" +disableShowingAnimatedImages_caption: "この設定を変えてもアニメーション画像が再生されへん時は、ブラウザとかOSのアクセシビリティ設定とか省電力設定の方が悪さしてるかもしれへんで。" highlightSensitiveMedia: "きわどいことをめっっちゃわかりやすくする" verificationEmailSent: "無事確認のメールを送れたで。メールに書いてあるリンクにアクセスして、設定を完了してなー。" notSet: "未設定" @@ -877,7 +883,7 @@ startingperiod: "始めた期間" memo: "メモ" priority: "優先度" high: "高い" -middle: "中" +middle: "ふつう" low: "低い" emailNotConfiguredWarning: "メアドの設定がされてへんで。" ratio: "比率" @@ -1014,6 +1020,9 @@ pushNotificationAlreadySubscribed: "プッシュ通知はオンになってる pushNotificationNotSupported: "ブラウザかサーバーがプッシュ通知に対応してないみたいやで。" sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を消すで" sendPushNotificationReadMessageCaption: "あんたの端末の電池使う量が増えるかもしれん。" +pleaseAllowPushNotification: "ブラウザの通知設定を許可してな" +browserPushNotificationDisabled: "通知の送信権限が取れんかったわ" +browserPushNotificationDisabledDescription: "今 {serverName} から通知を送るための権限が無いから、ブラウザの設定で通知を許可してもっかい試してな。" windowMaximize: "最大化" windowMinimize: "最小化" windowRestore: "元に戻す" @@ -1050,6 +1059,7 @@ permissionDeniedError: "操作が拒否されてもうた。" permissionDeniedErrorDescription: "このアカウントはこれやったらアカンって。" preset: "プリセット" selectFromPresets: "プリセットから選ぶ" +custom: "カスタム" achievements: "実績" gotInvalidResponseError: "サーバー黙っとるわ、知らんけど" gotInvalidResponseErrorDescription: "サーバーいま日曜日。またきて月曜日。" @@ -1088,6 +1098,7 @@ prohibitedWordsDescription2: "スペースで区切るとAND指定、キーワ hiddenTags: "見えてへんハッシュタグ" hiddenTagsDescription: "設定したタグを最近流行りのとこに見えんようにすんで。複数設定するときは改行で区切ってな。" notesSearchNotAvailable: "なんかノート探せへん。" +usersSearchNotAvailable: "ユーザーを探すことはできへんみたいや。" license: "ライセンス" unfavoriteConfirm: "ほんまに気に入らんの?" myClips: "自分のクリップ" @@ -1239,7 +1250,7 @@ releaseToRefresh: "離したらリロード" refreshing: "リロードしとる" pullDownToRefresh: "引っ張ってリロードするで" useGroupedNotifications: "通知をグループ分けして出すで" -signupPendingError: "メアド確認してたらなんか変なことなったわ。リンクの期限切れてるかもしれん。" +emailVerificationFailedError: "メアド確認してたらなんか変なことなったわ。リンクの期限切れてるかもしれん。" cwNotationRequired: "「内容を隠す」んやったら注釈書かなアカンで。" doReaction: "ツッコむで" code: "コード" @@ -1320,6 +1331,7 @@ preferenceSyncConflictChoiceMerge: "ガッチャンコしよか" preferenceSyncConflictChoiceCancel: "同期の有効化はやめとくわ" postForm: "投稿フォーム" information: "情報" +directMessage: "チャットしよか" migrateOldSettings: "旧設定情報をお引っ越し" migrateOldSettings_description: "通常これは自動で行われるはずなんやけど、なんかの理由で上手く移行できへんかったときは手動で移行処理をポチっとできるで。今の設定情報は上書きされるで。" settingsMigrating: "設定を移行しとるで。ちょっと待っとってな... (後で、設定→その他→旧設定情報を移行 で手動で移行することもできるで)" @@ -1331,21 +1343,41 @@ unmuteX: "{x}のミュートやめたる" redisplayAllTips: "全部の「ヒントとコツ」をもっかい見して" hideAllTips: "「ヒントとコツ」は全部表示せんでええ" defaultImageCompressionLevel_description: "低くすると画質は保てるんやけど、ファイルサイズが増えるで。
高くするとファイルサイズは減らせるんやけど、画質が落ちるで。" +defaultCompressionLevel_description: "低くすると品質は保てるんやけど、ファイルサイズが増えるで。
高くするとファイルサイズは減らせるんやけど、品質が落ちるで。" inMinutes: "分" inDays: "日" safeModeEnabled: "セーフモードがオンになってるで" pluginsAreDisabledBecauseSafeMode: "セーフモードがオンやから、プラグインは全部無効化されてるで。" customCssIsDisabledBecauseSafeMode: "セーフモードがオンやから、カスタムCSSは適用されてへんで。" themeIsDefaultBecauseSafeMode: "セーフモードがオンの間はデフォルトのテーマを使うで。セーフモードをオフにれば元に戻るで。" +thankYouForTestingBeta: "ベータ版使うてくれておおきに!" +widgets: "ウィジェット" +deviceInfoDescription: "なんか技術的なことで分からんこと聞くときは、下の情報も一緒に書いてもらえると、こっちも分かりやすいし、はよ直ると思います。" +youAreAdmin: "あんた、管理者やで" +presets: "プリセット" +_imageEditing: + _vars: + filename: "ファイル名" +_imageFrameEditor: + tip: "画像にフレームとかメタデータを入れたラベルとかを付け足していい感じにできるで。" + header: "ヘッダー" + font: "フォント" + fontSerif: "セリフ" + fontSansSerif: "サンセリフ" + quitWithoutSaveConfirm: "保存せずに終わってもええんか?" + failedToLoadImage: "あかん、画像読み込まれへんわ" _chat: noMessagesYet: "まだメッセージはあらへんで" - individualChat_description: "特定のユーザーと一対一でチャットができるで。" + individualChat_description: "特定のユーザーとサシでチャットできるで。" roomChat_description: "複数人でチャットできるで。\nあと、個人チャットを許可してへんユーザーとでも、相手がええって言うならチャットできるで。" inviteUserToChat: "ユーザーを招待してチャットを始めてみ" invitations: "来てや" noInvitations: "招待はあらへんで" noHistory: "履歴はないわ。" noRooms: "ルームはあらへんで" + join: "入る" + ignore: "ほっとく" + leave: "グループから抜ける" members: "メンバーはん" home: "ホーム" send: "送信" @@ -1388,11 +1420,13 @@ _settings: appearanceBanner: "好みに応じた、クライアントの見た目・表示方法に関わる設定ができるで。" soundsBanner: "クライアントで流すサウンドの設定ができるで。" makeEveryTextElementsSelectable: "全部のテキスト要素を選択できるようにする" - makeEveryTextElementsSelectable_description: "これをつけると、一部のシチュエーションでユーザビリティが低下するかもしれん。" + makeEveryTextElementsSelectable_description: "これをつけると、場面によったら使いにくくなるかもしれん。" + useStickyIcons: "アイコンがスクロールにひっつくようにする" enablePullToRefresh_description: "マウスやったら、ホイールを押し込みながらドラッグしてな。" realtimeMode_description: "サーバーと接続を確立して、リアルタイムでコンテンツを更新するで。通信量とバッテリーの消費が多くなるかもしれへん。" - contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されるんやけど、そのぶんパフォーマンスが低くなるし、通信量とバッテリーの消費も増えるねん。" + contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されるんやけど、そのぶんパフォーマンスが落ちるし、通信量とバッテリーの消費も増えるねん。" contentsUpdateFrequency_description2: "リアルタイムモードをつけてるんやったら、この設定がどうであれリアルタイムでコンテンツが更新されるで。" + emojiPaletteBanner: "絵文字ピッカーに置いとくプリセットをパレットっていうので登録したり、ピッカーの見た目を変えたりできるで。" _preferencesProfile: profileNameDescription: "このデバイスはなんて呼んだらええんや?" _preferencesBackup: @@ -1410,7 +1444,7 @@ _accountSettings: makeNotesFollowersOnlyBefore: "昔のノートをフォロワーだけに見てもらう" makeNotesFollowersOnlyBeforeDescription: "この機能が有効になってる間は、設定された日時より前、それか設定された時間が経ったノートがフォロワーのみ見れるようになるで。無効に戻すと、ノートの公開状態も戻るで。" makeNotesHiddenBefore: "昔のノートを見れんようにする" - makeNotesHiddenBeforeDescription: "この機能が有効になってる間は、設定された日時より前、それか設定された時間が経ったノートがフォロワーのみ見れるようになるで。無効に戻すと、ノートの公開状態も戻るで。" + makeNotesHiddenBeforeDescription: "この機能が有効になってる間は、設定された日時より前、それか設定された時間が経ったノートがあんただけ見れるようになるで。無効に戻すと、ノートの公開状態も戻るで。" mayNotEffectForFederatedNotes: "リモートサーバーに連合されたノートには効果が及ばんかもしれん。" mayNotEffectSomeSituations: "これらの制限は簡易的なものやで。リモートサーバーでの閲覧とかモデレーション時とか、一部のシチュエーションでは適用されへんかもしれん。" notesHavePassedSpecifiedPeriod: "決めた時間が経ったノート" @@ -1486,7 +1520,7 @@ _initialTutorial: description: "ここでは、Misskeyのカンタンな使い方とか機能を確かめれんで。" _note: title: "ノートってなんや?" - description: "Misskeyでの投稿は「ノート」って呼ばれてんで。ノートは順々にタイムラインに載ってて、リアルタイムで新しくなってってんで。" + description: "Misskeyでの投稿は「ノート」って呼ばれてんで。ノートは順々にタイムラインに載ってて、リアルタイムで新しくなってくで。" reply: "返信もできるで。返信の返信もできるから、スレッドっぽく会話をそのまま続けれもするで。" renote: "そのノートを自分のタイムラインに流して共有できるで。テキスト入れて引用してもええな。" reaction: "ツッコミをつけることもできるで。細かいことは次のページや。" @@ -1506,7 +1540,7 @@ _initialTutorial: social: "ホームタイムラインの投稿もローカルタイムラインのも一緒に見れるで。" global: "繋がってる他の全サーバーからの投稿が見れるで。" description2: "それぞれのタイムラインは、いつでも画面上で切り替えられんねん。覚えとき。" - description3: "その他にも、リストタイムラインとかチャンネルタイムラインとかがあんねん。詳しいのは{link}を見とき。" + description3: "その他にも、リストタイムラインとかチャンネルタイムラインとかがあんねん。詳しいのは{link}を見ときや。" _postNote: title: "ノートの投稿設定" description1: "Misskeyにノートを投稿するとき、いろんなオプションが付けれるで。投稿画面はこんな感じや。" @@ -1542,7 +1576,7 @@ _timelineDescription: home: "ホームタイムラインは、あんたがフォローしとるアカウントの投稿だけ見れるで。" local: "ローカルタイムラインは、このサーバーにおる全員の投稿を見れるで。" social: "ソーシャルタイムラインは、ホームタイムラインの投稿もローカルタイムラインのも一緒に見れるで。" - global: "グローバルタイムラインは、繋がっとる他のサーバーの投稿、全部ひっくるめて見れんで。" + global: "グローバルタイムラインは、繋がっとる他のサーバーの投稿、全部ひっくるめて見れるで。" _serverRules: description: "新規登録前に見せる、サーバーのカンタンなルールを決めるで。内容は使うための決め事の要約がええと思うわ。" _serverSettings: @@ -1562,7 +1596,7 @@ _serverSettings: inquiryUrl: "問い合わせ先URL" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定するで。" openRegistration: "アカウントの作成をオープンにする" - openRegistrationWarning: "登録を解放するのはリスクが伴うで。サーバーをいっつも監視して、なんか起きたらすぐに対応できるんやったら、オンにしてもええと思う。" + openRegistrationWarning: "登録を解放するのはリスクあるで。サーバーをいっつも監視して、なんか起きたらすぐに対応できるんやったら、オンにしてもええと思うけどな。" thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターがおらんかったら、スパムを防ぐためにこの設定は勝手に切られるで。" deliverSuspendedSoftwareDescription: "脆弱性とかの理由で、サーバーのソフトウェアの名前とバージョンの範囲を決めて配信を止められるで。このバージョン情報はサーバーが提供したものやから、信頼性は保証されへん。バージョン指定には semver の範囲指定が使えるねんけど、>= 2024.3.1と指定すると 2024.3.1-custom.0 みたいなカスタムバージョンが含まれへんから、>= 2024.3.1-0 みたいに prerelease を指定するとええかもしれへんな。" singleUserMode_description: "このサーバーを使うとるんが自分だけなんやったら、このモードを有効にすると動作がええ感じになるで。" @@ -1957,7 +1991,7 @@ _signup: emailSent: "さっき入れたメアド({email})宛に確認メールを送ったで。メールに書かれたリンク押してアカウント作るの終わらしてな。\nメールの認証リンクの期限は30分や。" _accountDelete: accountDelete: "アカウントの削除" - mayTakeTime: "アカウント消すんはサーバーが重いんやって。やから作ったコンテンツとか上げたファイルの数が多いと消し終わるまでに時間がかかるかもしれへん。" + mayTakeTime: "アカウント消すんはサーバーに負荷かかるんやって。やから、作ったコンテンツとか上げたファイルの数が多いと消し終わるまでに時間がかかるかもしれんわ。" sendEmail: "アカウントの消し終わるときは、登録してたメアドに通知するで。" requestAccountDelete: "アカウント削除頼む" started: "削除処理が始まったで。" @@ -2136,6 +2170,7 @@ _sfx: noteMy: "ノート(自分)" notification: "通知" reaction: "ツッコミ選んどるとき" + chatMessage: "チャットしよか" _soundSettings: driveFile: "ドライブん中の音使う" driveFileWarn: "ドライブん中のファイル選びや" @@ -2296,6 +2331,7 @@ _auth: scopeUser: "以下のユーザーとしていじってるで" pleaseLogin: "アプリにアクセスさせるんやったら、ログインしてや。" byClickingYouWillBeRedirectedToThisUrl: "アクセスを許したら、自動で下のURLに遷移するで" + alreadyAuthorized: "このアプリはもうアクセスを許可してるみたいやで。" _antennaSources: all: "みんなのノート" homeTimeline: "フォローしとるユーザーのノート" @@ -2341,6 +2377,16 @@ _widgets: chooseList: "リストを選ぶ" clicker: "クリッカー" birthdayFollowings: "今日誕生日のツレ" + chat: "チャットしよか" +_widgetOptions: + showHeader: "ヘッダー出す" + height: "高さ" + _button: + colored: "色付き" + _clock: + size: "大きさ" + _birthdayFollowings: + period: "期間" _cw: hide: "隠す" show: "続き見して!" @@ -2385,6 +2431,14 @@ _postForm: replyPlaceholder: "このノートに返信..." quotePlaceholder: "このノートを引用..." channelPlaceholder: "チャンネルに投稿..." + _howToUse: + toolbar_description: "ファイルとかアンケートを付けたり、注釈とかハッシュタグを書いたり、絵文字とかメンションとかを付け足したりできるで。" + account_description: "投稿するアカウントを変えたり、アカウントに保存した下書きとか予約投稿とかを見れるで。" + visibility_title: "公開範囲" + visibility_description: "ノートを誰に見せたいかはここで切り替えてな。" + menu_title: "メニュー" + menu_description: "下書きに保存したり、投稿の予約したり、リアクションの受け入れ設定とか…なんか色々できるで。" + submit_description: "ノートを投稿するときはここ押してな。Ctrl + Enter / Cmd + Enter でも投稿できるで。" _placeholders: a: "いまどないしとるん?" b: "何かあったん?" @@ -2530,6 +2584,8 @@ _notification: youReceivedFollowRequest: "フォロー許可してほしいみたいやな" yourFollowRequestAccepted: "フォローさせてもろたで" pollEnded: "アンケートの結果が出たみたいや" + scheduledNotePosted: "予約ノートが投稿されたで" + scheduledNotePostFailed: "予約ノート投稿できんかったで" newNote: "さらの投稿" unreadAntennaNote: "アンテナ {name}" roleAssigned: "ロールが付与されたで" @@ -2603,6 +2659,7 @@ _deck: mentions: "あんた宛て" direct: "ダイレクト" roleTimeline: "ロールタイムライン" + chat: "チャットしよか" _dialog: charactersExceeded: "最大の文字数を上回っとるで!今は {current} / 最大でも {max}" charactersBelow: "最小の文字数を下回っとるで!今は {current} / 最低でも {min}" @@ -2995,6 +3052,7 @@ _uploader: maxFileSizeIsX: "アップロードできるファイルサイズは{x}までやで。" tip: "ファイルはまだアップロードされてへんで。このダイアログで、アップロードする前に確認・リネーム・圧縮・クロッピングとかをできるで。準備が出来たら、「アップロード」ボタンを押してアップロードしてな。" _clientPerformanceIssueTip: + title: "バッテリーようさん食うなぁと思ったら" makeSureDisabledAdBlocker: "アドブロッカーを切ってみてや" makeSureDisabledAdBlocker_description: "アドブロッカーはパフォーマンスに影響があるかもしれへん。OSの機能とかブラウザの機能・アドオンとかでアドブロッカーが有効になってないか確認してや。" makeSureDisabledCustomCss: "カスタムCSSを無効にしてみてや" @@ -3018,12 +3076,15 @@ _watermarkEditor: image: "画像" advanced: "高度" angle: "角度" + failedToLoadImage: "あかん、画像読み込まれへんわ" _imageEffector: discardChangesConfirm: "変更をせんで終わるか?" + failedToLoadImage: "あかん、画像読み込まれへんわ" _fxProps: angle: "角度" scale: "大きさ" size: "大きさ" + offset: "位置" color: "色" opacity: "不透明度" lightness: "明るさ" @@ -3033,3 +3094,7 @@ _drafts: delete: "下書きをほかす" deleteAreYouSure: "下書きをほかしてもええか?" noDrafts: "下書きはあらへん" +_qr: + showTabTitle: "表示" + shareText: "Fediverseでフォローしてな!" + raw: "テキスト" diff --git a/locales/kab-KAB.yml b/locales/kab-KAB.yml index d4aa36fa70..27d20f47d1 100644 --- a/locales/kab-KAB.yml +++ b/locales/kab-KAB.yml @@ -57,6 +57,10 @@ searchByGoogle: "Nadi" file: "Ifuyla" account: "Imiḍan" replies: "Err" +_imageFrameEditor: + font: "Tasefsit" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" _email: _follow: title: "Yeṭṭafaṛ-ik·em-id" diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index 59fe63be6c..b44bd2f482 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -651,6 +651,9 @@ renotes: "리노트" attach: "옇기" surrender: "아이예" information: "정보" +_imageEditing: + _vars: + filename: "파일 이럼" _chat: invitations: "초대하기" noHistory: "기록이 없십니다" @@ -852,3 +855,5 @@ _search: searchScopeUser: "사용자 지정" _watermarkEditor: image: "이미지" +_qr: + showTabTitle: "보기" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index ff9bb32788..09ffe75f82 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -83,6 +83,8 @@ files: "파일" download: "다운로드" driveFileDeleteConfirm: "‘{name}’ 파일을 삭제하시겠습니까? 이 파일을 사용하는 일부 콘텐츠도 삭제됩니다." unfollowConfirm: "{name}님을 언팔로우하시겠습니까?" +cancelFollowRequestConfirm: "{name}(으)로의 팔로우 신청을 취소하시겠습니까?" +rejectFollowRequestConfirm: "{name}(으)로부터의 팔로우 신청을 거부하시겠습니까?" exportRequested: "내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 \"드라이브\"에 추가됩니다." importRequested: "가져오기를 요청하였습니다. 이 작업에는 시간이 걸릴 수 있습니다." lists: "리스트" @@ -253,6 +255,7 @@ noteDeleteConfirm: "이 노트를 삭제하시겠습니까?" pinLimitExceeded: "더 이상 고정할 수 없습니다." done: "완료" processing: "처리중" +preprocessing: "준비중" preview: "미리보기" default: "기본값" defaultValueIs: "기본값: {value}" @@ -301,6 +304,7 @@ uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 uploadNFiles: "{n}개의 파일을 업로" explore: "둘러보기" messageRead: "읽음" +readAllChatMessages: "모든 메시지를 읽은 상태로 표시" noMoreHistory: "이것보다 과거의 기록이 없습니다" startChat: "채팅을 시작하기" nUsersRead: "{n}명이 읽음" @@ -333,6 +337,7 @@ fileName: "파일명" selectFile: "파일 선택" selectFiles: "파일 선택" selectFolder: "폴더 선택" +unselectFolder: "폴더 선택 해제" selectFolders: "폴더 선택" fileNotSelected: "파일을 선택하지 않았습니다" renameFile: "파일 이름 변경" @@ -345,6 +350,7 @@ addFile: "파일 추가" showFile: "파일 표시하기" emptyDrive: "드라이브가 비어 있습니다" emptyFolder: "폴더가 비어 있습니다" +dropHereToUpload: "업로드할 파일을 여기로 드롭하십시오" unableToDelete: "삭제할 수 없습니다" inputNewFileName: "바꿀 파일명을 입력해 주세요" inputNewDescription: "새 캡션을 입력해 주세요" @@ -772,6 +778,7 @@ lockedAccountInfo: "팔로우를 승인으로 승인받더라도 노트의 공 alwaysMarkSensitive: "미디어를 항상 열람 주의로 설정" loadRawImages: "첨부한 이미지의 썸네일을 원본화질로 표시" disableShowingAnimatedImages: "움직이는 이미지를 자동으로 재생하지 않음" +disableShowingAnimatedImages_caption: "이 설정에 상관없이 애니메이션 이미지가 재생되지 않을 때는 브라우저·OS의 액티비티 설정이나 절전 모드 설정 등이 간섭하고 있는 경우가 있습니다." highlightSensitiveMedia: "미디어가 민감한 내용이라는 것을 알기 쉽게 표시" verificationEmailSent: "확인 메일을 발송하였습니다. 설정을 완료하려면 메일에 첨부된 링크를 확인해 주세요." notSet: "설정되지 않음" @@ -1018,6 +1025,9 @@ pushNotificationAlreadySubscribed: "푸시 알림이 이미 켜져 있습니다" pushNotificationNotSupported: "브라우저나 서버에서 푸시 알림이 지원되지 않습니다" sendPushNotificationReadMessage: "푸시 알림이나 메시지를 읽은 뒤 푸시 알림을 삭제" sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」이라는 알림이 잠깐 표시됩니다. 기기의 전력 소비량이 증가할 수 있습니다." +pleaseAllowPushNotification: "브라우저의 알림 설정을 허가해 주십시오." +browserPushNotificationDisabled: "알림 송신 권한 얻기에 실패했습니다." +browserPushNotificationDisabledDescription: "{serverName}에서의 알림 송신 권한이 없습니다. 브라우저의 설정에서 알림을 허가해 다시 시도해 주십시오." windowMaximize: "최대화" windowMinimize: "최소화" windowRestore: "복구" @@ -1054,6 +1064,7 @@ permissionDeniedError: "작업이 거부되었습니다" permissionDeniedErrorDescription: "이 작업을 수행할 권한이 없습니다." preset: "프리셋" selectFromPresets: "프리셋에서 선택" +custom: "커스텀" achievements: "도전 과제" gotInvalidResponseError: "서버의 응답이 올바르지 않습니다" gotInvalidResponseErrorDescription: " 서버가 다운되었거나 점검중일 가능성이 있습니다. 잠시후에 다시 시도해 주십시오." @@ -1167,6 +1178,7 @@ installed: "설치됨" branding: "브랜딩" enableServerMachineStats: "서버의 머신 사양을 공개하기" enableIdenticonGeneration: "유저마다의 Identicon 생성 유효화" +showRoleBadgesOfRemoteUsers: "리모트 유저의 역할 배지 표시" turnOffToImprovePerformance: "이 기능을 끄면 성능이 향상될 수 있습니다." createInviteCode: "초대 코드 생성" createWithOptions: "옵션을 지정하여 생성" @@ -1244,7 +1256,7 @@ releaseToRefresh: "놓아서 새로고침" refreshing: "새로고침 중" pullDownToRefresh: "아래로 내려서 새로고침" useGroupedNotifications: "알림을 그룹화하고 표시" -signupPendingError: "메일 주소 확인중에 문제가 발생했습니다. 링크의 유효기간이 지났을 가능성이 있습니다." +emailVerificationFailedError: "메일 주소 확인에 실패했습니다. 확인에 필요한 URL의 유효기간이 지났을 가능성이 있습니다." cwNotationRequired: "'내용을 숨기기'를 체크한 경우 주석을 써야 합니다." doReaction: "리액션 추가" code: "문자열" @@ -1315,6 +1327,7 @@ acknowledgeNotesAndEnable: "활성화 하기 전에 주의 사항을 확인했 federationSpecified: "이 서버는 화이트 리스트 제도로 운영 중 입니다. 정해진 리모트 서버가 아닌 경우 연합되지 않습니다." federationDisabled: "이 서버는 연합을 하지 않고 있습니다. 리모트 서버 유저와 통신을 할 수 없습니다." draft: "초안" +draftsAndScheduledNotes: "초안과 예약 게시물" confirmOnReact: "리액션할 때 확인" reactAreYouSure: "\" {emoji} \"로 리액션하시겠습니까?" markAsSensitiveConfirm: "이 미디어를 민감한 미디어로 설정하시겠습니까?" @@ -1342,6 +1355,8 @@ postForm: "글 입력란" textCount: "문자 수" information: "정보" chat: "채팅" +directMessage: "채팅하기" +directMessage_short: "메시지" migrateOldSettings: "기존 설정 정보를 이전" migrateOldSettings_description: "보통은 자동으로 이루어지지만, 어떤 이유로 인해 성공적으로 이전이 이루어지지 않는 경우 수동으로 이전을 실행할 수 있습니다. 현재 설정 정보는 덮어쓰게 됩니다." compress: "압축" @@ -1369,16 +1384,83 @@ redisplayAllTips: "모든 '팁과 유용한 정보'를 재표시" hideAllTips: "모든 '팁과 유용한 정보'를 비표시" defaultImageCompressionLevel: "기본 이미지 압축 정도" defaultImageCompressionLevel_description: "낮추면 화질을 유지합니다만 파일 크기는 증가합니다.
높이면 파일 크기를 줄일 수 있습니다만 화질은 저하됩니다." +defaultCompressionLevel: "기본 압축 정도 " +defaultCompressionLevel_description: "낮추면 품질을 유지합니다만 파일 크기는 증가합니다.
높이면 파일 크기를 줄일 수 있습니다만 품질은 저하됩니다." inMinutes: "분" inDays: "일" safeModeEnabled: "세이프 모드가 활성화돼있습니다" pluginsAreDisabledBecauseSafeMode: "세이프 모드가 활성화돼있기에 플러그인은 전부 비활성화됩니다." customCssIsDisabledBecauseSafeMode: "세이프 모드가 활성화돼있기에 커스텀 CSS는 적용되지 않습니다." themeIsDefaultBecauseSafeMode: "세이프 모드가 활성화돼있는 동안에는 기본 테마가 사용됩니다. 세이프 모드를 끄면 원래대로 돌아옵니다." +thankYouForTestingBeta: "베타 버전의 검증에 협력해 주셔서 감사합니다!" +createUserSpecifiedNote: "사용자 지정 노트를 작성" +schedulePost: "게시 예약" +scheduleToPostOnX: "{x}에 게시를 예약합니다." +scheduledToPostOnX: "{x}에 게시가 예약돼있습니다." +schedule: "예약" +scheduled: "예약" +widgets: "위젯" +deviceInfo: "장치 정보" +deviceInfoDescription: "기술적 문의의 경우 아래의 정보를 병기하면 문제의 해결에 도움이 됩니다." +youAreAdmin: "당신은 관리자입니다." +frame: "프레임" +presets: "프리셋" +zeroPadding: "0으로 채우기" +nothingToConfigure: "설정 항목이 없습니다." +_imageEditing: + _vars: + caption: "파일 설명" + filename: "파일명" + filename_without_ext: "확장자가 없는 파일명" + year: "촬영한 해" + month: "촬영한 달" + day: "촬영한 날" + hour: "촬영한 시각(시)" + minute: "촬영한 시각(분)" + second: "촬영한 시각(초)" + camera_model: "카메라 이름" + camera_lens_model: "렌즈 이름" + camera_mm: "초점 거리" + camera_mm_35: "초점 거리(35m판 환산)" + camera_f: "조리개 조절" + camera_s: "셔터 속도" + camera_iso: "ISO 감도" + gps_lat: "위도" + gps_long: "경도" +_imageFrameEditor: + title: "프레임 편집" + tip: "이미지에 프레임이나 메타 데이터를 포함한 라벨을 추가해 장식할 수 있습니다." + header: "헤더" + footer: "꼬리말" + borderThickness: "테두리의 폭" + labelThickness: "라벨의 폭" + labelScale: "라벨의 스케일" + centered: "중앙 정렬" + captionMain: "캡션(대)" + captionSub: "캡션(소)" + availableVariables: "이용 가능한 변수" + withQrCode: "QR 코드" + backgroundColor: "배경색" + textColor: "글꼴 색상" + font: "폰트" + fontSerif: "명조체" + fontSansSerif: "고딕체" + quitWithoutSaveConfirm: "보존하지 않고 종료하시겠습니까?" + failedToLoadImage: "이미지 로드에 실패했습니다." +_compression: + _quality: + high: "고품질" + medium: "중간 품질" + low: "저품질" + _size: + large: "대형" + medium: "중형" + small: "소형" _order: newest: "최신 순" oldest: "오래된 순" _chat: + messages: "메시지" noMessagesYet: "아직 메시지가 없습니다" newMessage: "새로운 메시지" individualChat: "개인 대화" @@ -1467,6 +1549,11 @@ _settings: showUrlPreview: "URL 미리보기 표시" showAvailableReactionsFirstInNote: "이용 가능한 리액션을 선두로 표시" showPageTabBarBottom: "페이지의 탭 바를 아래쪽에 표시" + emojiPaletteBanner: "이모티콘 선택기에 고정 표시되는 프리셋을 팔레트로 등록하거나 선택기의 표시 방법을 커스터마이징할 수 있습니다." + enableAnimatedImages: "애니메이션 이미지 활성화" + settingsPersistence_title: "설정 영구화" + settingsPersistence_description1: "설정 영구화를 활성화하면 설정 정보를 잃어버리는 것을 방지할 수 있습니다." + settingsPersistence_description2: "환경에 따라 활성화되지 않을 수 있습니다." _chat: showSenderName: "발신자 이름 표시" sendOnEnter: "엔터로 보내기" @@ -1475,6 +1562,8 @@ _preferencesProfile: profileNameDescription: "이 디바이스를 식별할 이름을 설정해 주세요." profileNameDescription2: "예: '메인PC', '스마트폰' 등" manageProfiles: "프로파일 관리" + shareSameProfileBetweenDevicesIsNotRecommended: "여러 장치에서 동일한 프로필을 공유하는 것은 권장하지 않습니다." + useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "여러 장치에서 동기화하고 싶은 설정 항목이 있는 경우에는 개별로 '여러 장치에서 동기화' 옵션을 활성화해 주십시오." _preferencesBackup: autoBackup: "자동 백업" restoreFromBackup: "백업으로 복구" @@ -1484,6 +1573,7 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "자동 백업을 활성화하려면 프로필 이름을 설정해야 합니다." autoPreferencesBackupIsNotEnabledForThisDevice: "이 장치에서 설정 자동 백업이 활성화되어 있지 않습니다." backupFound: "설정 백업이 발견되었습니다" + forceBackup: "설정 강제 백업" _accountSettings: requireSigninToViewContents: "콘텐츠 열람을 위해 로그인을 필수로 설정하기" requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다." @@ -1664,6 +1754,9 @@ _serverSettings: userGeneratedContentsVisibilityForVisitor_description2: "서버에서 받은 리모트 콘텐츠를 포함해 서버 내의 모든 콘텐츠를 무조건 인터넷에 공개하는 것에는 위험이 따릅니다. 특히, 분산형 특성에 대해 모르는 열람자에게는 리모트 콘텐츠여도 서버 내에서 작성된 콘텐츠라고 잘못 인식할 수 있기에 주의가 필요합니다." restartServerSetupWizardConfirm_title: "서버의 초기 설정 위자드를 재시도하시겠습니까?" restartServerSetupWizardConfirm_text: "현재 일부 설정은 리셋됩니다." + entrancePageStyle: "입구 페이지의 스타일" + showTimelineForVisitor: "타임라인 표시" + showActivitiesForVisitor: "액티비티 표시하기" _userGeneratedContentsVisibilityForVisitor: all: "모두 공개" localOnly: "로컬 콘텐츠만 공개하고 리모트 콘텐츠는 비공개" @@ -1986,6 +2079,7 @@ _role: canManageAvatarDecorations: "아바타 꾸미기 관리" driveCapacity: "드라이브 용량" maxFileSize: "업로드 가능한 최대 파일 크기" + maxFileSize_caption: "리버스 프록시나 CDN 등 전단에서 다른 설정값이 존재하는 경우가 있습니다." alwaysMarkNsfw: "파일을 항상 NSFW로 지정" canUpdateBioMedia: "아바타 및 배너 이미지 변경 허용" pinMax: "고정할 수 있는 노트 수" @@ -2013,6 +2107,7 @@ _role: uploadableFileTypes_caption: "MIME 유형을 " uploadableFileTypes_caption2: "파일에 따라서는 유형을 검사하지 못하는 경우가 있습니다. 그러한 파일을 허가하는 경우에는 {x}를 지정으로 추가해주십시오." noteDraftLimit: "서버측 노트 초안 작성 가능 수" + scheduledNoteLimit: "예약 게시물의 동시 작성 가능 수" watermarkAvailable: "워터마크 기능의 사용 여부" _condition: roleAssignedTo: "수동 역할에 이미 할당됨" @@ -2273,6 +2368,7 @@ _time: minute: "분" hour: "시간" day: "일" + month: "개월" _2fa: alreadyRegistered: "이미 설정이 완료되었습니다." registerTOTP: "인증 앱 설정 시작" @@ -2402,6 +2498,7 @@ _auth: scopeUser: "다음 유저로 활동하고 있습니다." pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인하십시오." byClickingYouWillBeRedirectedToThisUrl: "접근을 허용하면 자동으로 다음 URL로 이동합니다." + alreadyAuthorized: "이 애플리케이션은 이미 접근이 허가돼있습니다." _antennaSources: all: "모든 노트" homeTimeline: "팔로우중인 유저의 노트" @@ -2447,7 +2544,45 @@ _widgets: chooseList: "리스트 선택" clicker: "클리커" birthdayFollowings: "오늘이 생일인 유저" - chat: "채팅" + chat: "채팅하기" +_widgetOptions: + showHeader: "해더를 표시" + transparent: "배경을 투명하게 설정" + height: "높이" + _button: + colored: "색 입히기" + _clock: + size: "크기" + thickness: "시곗바늘의 두께" + thicknessThin: "얇게" + thicknessMedium: "보통" + thicknessThick: "굵게" + graduations: "문자반의 눈금" + graduationDots: "도트" + graduationArabic: "아라비아 숫자" + fadeGraduations: "눈금 페이드" + sAnimation: "초침 애니메이션" + sAnimationElastic: "사실적으로" + sAnimationEaseOut: "매끄럽게" + twentyFour: "24시간 표시" + labelTime: "시각" + labelTz: "시간대" + labelTimeAndTz: "시각과 시간대" + timezone: "시간대" + showMs: "밀리초 표시" + showLabel: "레이블 표시" + _jobQueue: + sound: "소리 재생" + _rss: + url: "RSS 필드의 URL" + refreshIntervalSec: "갱신 간격(초)" + maxEntries: "최대 표시 건수" + _rssTicker: + shuffle: "표시 순서 셔플" + duration: "티커 스크롤 속도(초)" + reverse: "역방향으로 스크롤" + _birthdayFollowings: + period: "기간" _cw: hide: "숨기기" show: "더 보기" @@ -2492,6 +2627,20 @@ _postForm: replyPlaceholder: "이 노트에 답글..." quotePlaceholder: "이 노트를 인용..." channelPlaceholder: "채널에 게시하기..." + showHowToUse: "입력란의 설명 표시" + _howToUse: + content_title: "본문" + content_description: "게시할 내용을 입력합니다." + toolbar_title: "도구 모음" + toolbar_description: "파일이나 설문의 첨부, 주석이나 해시태그 설정, 이모티콘이나 멘션의 삽입 등을 할 수 있습니다." + account_title: "계정 메뉴" + account_description: "게시할 계정을 교체하거나, 계정에 보존한 초안 및 예약 게시물을 목록으로 볼 수 있습니다." + visibility_title: "공개 범위" + visibility_description: "노트 공개 범위의 설정을 할 수 있습니다." + menu_title: "메뉴" + menu_description: "초안의 보존, 게시 예약, 리액션의 설정 등 그 외의 액션을 할 수 있습니다." + submit_title: "게시 버튼" + submit_description: "노트를 게시합니다. Ctrl + Enter / Cmd + Enter로도 게시할 수 있습니다." _placeholders: a: "지금 무엇을 하고 있나요?" b: "무슨 일이 일어나고 있나요?" @@ -2637,6 +2786,8 @@ _notification: youReceivedFollowRequest: "새로운 팔로우 요청이 있습니다" yourFollowRequestAccepted: "팔로우 요청이 수락되었습니다" pollEnded: "투표 결과가 발표되었습니다" + scheduledNotePosted: "예약 노트가 게시됐습니다." + scheduledNotePostFailed: "예약 노트의 게시에 실패했습니다." newNote: "새 게시물" unreadAntennaNote: "안테나 {name}" roleAssigned: "역할이 부여 되었습니다." @@ -2666,6 +2817,8 @@ _notification: quote: "인용" reaction: "리액션" pollEnded: "투표가 종료됨" + scheduledNotePosted: "예약 게시에 성공했습니다" + scheduledNotePostFailed: "예약 게시에 실패했습니다" receiveFollowRequest: "팔로우 요청을 받았을 때" followRequestAccepted: "팔로우 요청이 승인되었을 때" roleAssigned: "역할이 부여됨" @@ -2705,6 +2858,14 @@ _deck: usedAsMinWidthWhenFlexible: "'폭 자동 조정'이 활성화된 경우 최소 폭으로 사용됩니다" flexible: "폭 자동 조정" enableSyncBetweenDevicesForProfiles: "프로파일 정보의 디바이스 간 동기화를 활성화" + showHowToUse: "UI 설명 보기" + _howToUse: + addColumn_title: "칼럼 추가" + addColumn_description: "칼럼의 종류를 선택해 추가할 수 있습니다." + settings_title: "UI 설정" + settings_description: "덱 UI의 상세 설정을 할 수 있습니다." + switchProfile_title: "프로필 전환" + switchProfile_description: "UI의 레이아웃을 프로필로 저장하고, 언제나 전환할 수 있습니다." _columns: main: "메인" widgets: "위젯" @@ -2716,7 +2877,7 @@ _deck: mentions: "받은 멘션" direct: "다이렉트" roleTimeline: "역할 타임라인" - chat: "채팅" + chat: "채팅하기" _dialog: charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {max}" charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" @@ -2765,6 +2926,8 @@ _abuseReport: notifiedWebhook: "사용할 Webhook" deleteConfirm: "수신자를 삭제하시겠습니까?" _moderationLogTypes: + clearQueue: "작업 대기열 비우기" + promoteQueue: "큐의 작업을 재시도" createRole: "역할 생성" deleteRole: "역할 삭제" updateRole: "역할 수정" @@ -3159,10 +3322,13 @@ _watermarkEditor: title: "워터마크 편집" cover: "전체에 붙이기" repeat: "전면에 깔기" + preserveBoundingRect: "회전 시 빠져나오지 않도록 조정" opacity: "불투명도" scale: "크기" text: "텍스트" + qr: "QR 코드" position: "위치" + margin: "여백" type: "종류" image: "이미지" advanced: "고급" @@ -3177,17 +3343,21 @@ _watermarkEditor: polkadotSubDotOpacity: "서브 물방울의 불투명도" polkadotSubDotRadius: "서브 물방울의 크기" polkadotSubDotDivisions: "서브 물방울의 수" + leaveBlankToAccountUrl: "빈칸일 경우 계정의 URL로 됩니다." + failedToLoadImage: "이미지 로딩에 실패했습니다." _imageEffector: title: "이펙트" addEffect: "이펙트를 추가" discardChangesConfirm: "변경을 취소하고 종료하시겠습니까?" - nothingToConfigure: "설정 항목이 없습니다." + failedToLoadImage: "이미지 로딩에 실패했습니다." _fxs: chromaticAberration: "색수차" glitch: "글리치" mirror: "미러" invert: "색 반전" grayscale: "흑백" + blur: "흐림 효과" + pixelate: "모자이크" colorAdjust: "색조 보정" colorClamp: "색 압축" colorClampAdvanced: "색 압축(고급)" @@ -3199,10 +3369,14 @@ _imageEffector: checker: "체크 무늬" blockNoise: "노이즈 방지" tearing: "티어링" + fill: "채우기" _fxProps: angle: "각도" scale: "크기" size: "크기" + radius: "반지름" + samples: "샘플 수" + offset: "위치" color: "색" opacity: "불투명도" normalize: "노멀라이즈" @@ -3231,6 +3405,7 @@ _imageEffector: zoomLinesThreshold: "집중선 폭" zoomLinesMaskSize: "중앙 값" zoomLinesBlack: "검은색으로 하기" + circle: "원형" drafts: "초안" _drafts: select: "초안 선택" @@ -3246,3 +3421,22 @@ _drafts: restoreFromDraft: "초안에서 복원\n" restore: "복원" listDrafts: "초안 목록" + schedule: "게시 예약" + listScheduledNotes: "예약 게시물 목록" + cancelSchedule: "예약 해제" +qr: "QR 코드" +_qr: + showTabTitle: "보기" + readTabTitle: "읽어들이기" + shareTitle: "{name} {acct}" + shareText: "Fediverse로 저를 팔로우해 주세요!" + chooseCamera: "카메라 선택" + cannotToggleFlash: "플래시 선택 불가" + turnOnFlash: "플래시 켜기" + turnOffFlash: "플래시 끄기" + startQr: "코드 리더 재개" + stopQr: "코드 리더 정지" + noQrCodeFound: "QR 코드를 찾을 수 없습니다." + scanFile: "단말기의 이미지 스캔" + raw: "텍스트" + mfm: "MFM" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index aec49a2965..7017d81733 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -393,6 +393,9 @@ file: "ໄຟລ໌" replies: "ຕອບ​ກັບ" renotes: "Renote" information: "ກ່ຽວກັບ" +_imageEditing: + _vars: + filename: "ຊື່ໄຟລ໌" _chat: invitations: "ເຊີນ" noHistory: "​ບໍ່​ມີປະຫວັດ" @@ -428,12 +431,17 @@ _widgets: jobQueue: "ຄິວວຽກ" _userList: chooseList: "ເລືອກບັນຊີລາຍການ" +_widgetOptions: + height: "ຄວາມສູງ" _cw: show: "ໂຫຼດເພີ່ມເຕີມ" _visibility: home: "ໜ້າຫຼັກ" followers: "ຜູ້ຕິດຕາມ" specified: "ໂພສ Direct note" +_postForm: + _howToUse: + menu_title: "ເມນູ" _profile: name: "ຊື່" username: "ຊື່ຜູ້ໃຊ້" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index e4efbc7e39..32e021d1a1 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -970,6 +970,9 @@ renotes: "Herdelen" followingOrFollower: "Gevolgd of volger" confirmShowRepliesAll: "Dit is een onomkeerbare operatie. Weet je zeker dat reacties op anderen van iedereen die je volgt, wil weergeven in je tijdlijn?" information: "Over" +_imageEditing: + _vars: + filename: "Bestandsnaam" _chat: invitations: "Uitnodigen" noHistory: "Geen geschiedenis gevonden" @@ -1014,12 +1017,18 @@ _widgets: jobQueue: "Job Queue" _userList: chooseList: "Kies een lijst." +_widgetOptions: + height: "Hoogte" _cw: show: "Laad meer" _visibility: home: "Startpagina" followers: "Volgers" specified: "Directe notities" +_postForm: + _howToUse: + visibility_title: "Zichtbaarheid" + menu_title: "Menu" _profile: name: "Naam" username: "Gebruikersnaam" @@ -1083,3 +1092,5 @@ _search: _watermarkEditor: image: "Afbeeldingen" advanced: "Geavanceerd" +_qr: + showTabTitle: "Weergave" diff --git a/locales/no-NO.yml b/locales/no-NO.yml index 1eafd31c4f..7bf3f387b8 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -463,6 +463,12 @@ surrender: "Avbryt" information: "Informasjon" inMinutes: "Minutter" inDays: "Dager" +_imageEditing: + _vars: + filename: "Filnavn" +_imageFrameEditor: + fontSerif: "Serif" + fontSansSerif: "Sans Serif" _chat: invitations: "Inviter" members: "Medlemmer" @@ -633,6 +639,10 @@ _widgets: userList: "Brukerliste" _userList: chooseList: "Velg liste" +_widgetOptions: + height: "Høyde" + _clock: + size: "Størrelse" _cw: hide: "Skjul" show: "Vis mer" @@ -650,6 +660,8 @@ _visibility: home: "Hjem" followers: "Følgere" _postForm: + _howToUse: + menu_title: "Meny" _placeholders: a: "Hva skjer?" _profile: @@ -747,3 +759,5 @@ _imageEffector: scale: "Størrelse" size: "Størrelse" color: "Farge" +_qr: + raw: "Tekst" diff --git a/locales/package.json b/locales/package.json deleted file mode 100644 index bedb411a91..0000000000 --- a/locales/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index fbd898016e..34540239fb 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -1042,6 +1042,16 @@ postForm: "Formularz tworzenia wpisu" information: "Informacje" inMinutes: "minuta" inDays: "dzień" +widgets: "Widżety" +presets: "Konfiguracja" +_imageEditing: + _vars: + filename: "Nazwa pliku" +_imageFrameEditor: + header: "Nagłówek" + font: "Czcionka" + fontSerif: "Szeryfowa" + fontSansSerif: "Bezszeryfowa" _chat: invitations: "Zaproś" noHistory: "Brak historii" @@ -1351,6 +1361,14 @@ _widgets: _userList: chooseList: "Wybierz listę" clicker: "Clicker" +_widgetOptions: + height: "Wysokość" + _button: + colored: "Kolorowe" + _clock: + size: "Rozmiar" + _birthdayFollowings: + period: "Czas trwania" _cw: hide: "Ukryj" show: "Załaduj więcej" @@ -1392,6 +1410,9 @@ _postForm: replyPlaceholder: "Odpowiedz na ten wpis..." quotePlaceholder: "Zacytuj ten wpis…" channelPlaceholder: "Publikuj na kanale..." + _howToUse: + visibility_title: "Widoczność" + menu_title: "Menu" _placeholders: a: "Co się dzieje?" b: "Co się wydarzyło?" @@ -1600,3 +1621,6 @@ _imageEffector: color: "Kolor" opacity: "Przezroczystość" lightness: "Rozjaśnij" +_qr: + showTabTitle: "Wyświetlanie" + raw: "Tekst" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 64fec3957e..7552852c4e 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -253,6 +253,7 @@ noteDeleteConfirm: "Deseja excluir esta nota?" pinLimitExceeded: "Não é possível fixar novas notas" done: "Concluído" processing: "Em Progresso" +preprocessing: "Preparando..." preview: "Pré-visualizar" default: "Predefinição" defaultValueIs: "Predefinição: {value}" @@ -1054,6 +1055,7 @@ permissionDeniedError: "Operação recusada" permissionDeniedErrorDescription: "Esta conta não tem permissão para executar esta ação." preset: "Predefinições" selectFromPresets: "Escolher de predefinições" +custom: "Personalizado" achievements: "Conquistas" gotInvalidResponseError: "Resposta do servidor inválida" gotInvalidResponseErrorDescription: "Servidor fora do ar ou em manutenção. Favor tentar mais tarde." @@ -1092,6 +1094,7 @@ prohibitedWordsDescription2: "Utilizar espaços irá criar expressões aditivas hiddenTags: "Hashtags escondidas" hiddenTagsDescription: "Selecione tags que não serão exibidas na lista de destaques. Várias tags podem ser escolhidas, separadas por linha." notesSearchNotAvailable: "A pesquisa de notas está indisponível." +usersSearchNotAvailable: "Pesquisa de usuário está indisponível." license: "Licença" unfavoriteConfirm: "Deseja realmente remover dos favoritos?" myClips: "Meus clipes" @@ -1243,7 +1246,7 @@ releaseToRefresh: "Solte para atualizar" refreshing: "Atualizando..." pullDownToRefresh: "Puxe para baixo para atualizar" useGroupedNotifications: "Agrupar notificações" -signupPendingError: "Houve um problema ao verificar o endereço de email. O link pode ter expirado." +emailVerificationFailedError: "Houve um problema ao verificar seu endereço de email. O link pode ter expirado." cwNotationRequired: "Se \"Esconder conteúdo\" está habilitado, uma descrição deve ser adicionada." doReaction: "Adicionar reação" code: "Código" @@ -1314,6 +1317,7 @@ acknowledgeNotesAndEnable: "Ative após compreender as precauções." federationSpecified: "Esse servidor opera com uma lista branca de federação. Interagir com servidores diferentes daqueles designados pela administração não é permitido." federationDisabled: "Federação está desabilitada nesse servidor. Você não pode interagir com usuários de outros servidores." draft: "Rascunhos" +draftsAndScheduledNotes: "Rascunhos e notas agendadas." confirmOnReact: "Confirmar ao reagir" reactAreYouSure: "Você deseja adicionar uma reação \"{emoji}\"?" markAsSensitiveConfirm: "Você deseja definir essa mídia como sensível?" @@ -1341,6 +1345,8 @@ postForm: "Campo de postagem" textCount: "Contagem de caracteres" information: "Sobre" chat: "Conversas" +directMessage: "Conversar com usuário" +directMessage_short: "Mensagem" migrateOldSettings: "Migrar configurações antigas de cliente" migrateOldSettings_description: "Isso deve ser feito automaticamente. Caso o processo de migração tenha falhado, você pode acioná-lo manualmente. As informações atuais de migração serão substituídas." compress: "Comprimir" @@ -1368,12 +1374,47 @@ redisplayAllTips: "Mostrar todas as \"Dicas e Truques\" novamente" hideAllTips: "Ocultas todas as \"Dicas e Truques\"" defaultImageCompressionLevel: "Nível de compressão de imagem padrão" defaultImageCompressionLevel_description: "Alto, reduz o tamanho do arquivo mas, também, a qualidade da imagem.
Alto, reduz o tamanho do arquivo mas, também, a qualidade da imagem." +defaultCompressionLevel: "Nível padrão de compressão" +defaultCompressionLevel_description: "Menor compressão preserva a qualidade mas aumenta o tamanho do arquivo.
Maior compressão reduz o tamanho do arquivo mas diminui a qualidade." inMinutes: "Minuto(s)" inDays: "Dia(s)" +safeModeEnabled: "Modo seguro está habilitado" +pluginsAreDisabledBecauseSafeMode: "Todos os plugins estão desabilitados porque o modo seguro está habilitado." +customCssIsDisabledBecauseSafeMode: "CSS personalizado não está aplicado porque o modo seguro está habilitado." +themeIsDefaultBecauseSafeMode: "Enquanto o modo seguro estiver ativo, o tema padrão é utilizado. Desabilitar o modo seguro reverterá essas mudanças." +thankYouForTestingBeta: "Obrigado por nos ajudar a testar a versão beta!" +createUserSpecifiedNote: "Criar uma nota direta" +schedulePost: "Agendar publicação" +scheduleToPostOnX: "Agendar nota para {x}" +scheduledToPostOnX: "A nota está agendada para {x}" +schedule: "Agendar" +scheduled: "Agendado" +widgets: "Widgets" +presets: "Predefinições" +_imageEditing: + _vars: + filename: "Nome do Ficheiro" +_imageFrameEditor: + header: "Cabeçalho" + withQrCode: "Código QR" + font: "Fonte" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + quitWithoutSaveConfirm: "Descartar mudanças?" +_compression: + _quality: + high: "Qualidade alta" + medium: "Qualidade média" + low: "Qualidade baixa" + _size: + large: "Tamanho grande" + medium: "Tamanho médio" + small: "Tamanho pequeno" _order: newest: "Priorizar Mais Novos" oldest: "Priorizar Mais Antigos" _chat: + messages: "Mensagem" noMessagesYet: "Ainda não há mensagens" newMessage: "Nova mensagem" individualChat: "Conversa Particular" @@ -1461,6 +1502,7 @@ _settings: contentsUpdateFrequency_description2: "Quando o modo tempo-real está ativado, o conteúdo é atualizado em tempo real, ignorando essa opção." showUrlPreview: "Exibir prévia de URL" showAvailableReactionsFirstInNote: "Exibir reações disponíveis no topo." + showPageTabBarBottom: "Mostrar barra de aba da página inferiormente" _chat: showSenderName: "Exibir nome de usuário do remetente" sendOnEnter: "Pressionar Enter para enviar" @@ -1634,6 +1676,10 @@ _serverSettings: fanoutTimelineDbFallback: "\"Fallback\" ao banco de dados" fanoutTimelineDbFallbackDescription: "Quando habilitado, a linha do tempo irá recuar ao banco de dados caso consultas adicionais sejam feitas e ela não estiver em cache. Quando desabilitado, o impacto no servidor será reduzido ao eliminar o recuo, mas limita a quantidade de linhas do tempo que podem ser recebidas." reactionsBufferingDescription: "Quando ativado, o desempenho durante a criação de uma reação será melhorado substancialmente, reduzindo a carga do banco de dados. Porém, a o uso de memória do Redis irá aumentar." + remoteNotesCleaning: "Limpeza automática de notas remotas" + remoteNotesCleaning_description: "Quando habilitado, notas remotas obsoletas e não utilizadas serão periodicamente limpadas para previnir sobrecarga no banco de dados." + remoteNotesCleaningMaxProcessingDuration: "Maximizar tempo de processamento da limpeza" + remoteNotesCleaningExpiryDaysForEachNotes: "Mínimo de dias para retenção de notas" inquiryUrl: "URL de inquérito" inquiryUrlDescription: "Especifique um URL para um formulário de inquérito para a administração ou uma página web com informações de contato." openRegistration: "Abrir a criação de contas" @@ -1652,6 +1698,11 @@ _serverSettings: userGeneratedContentsVisibilityForVisitor: "Visibilidade de conteúdo dos usuários para visitantes" userGeneratedContentsVisibilityForVisitor_description: "Isso é útil para prevenir problemas causados por conteúdo inapropriado de usuários remotos de servidores com pouca ou nenhuma moderação, que pode ser hospedado na internet a partir desse servidor." userGeneratedContentsVisibilityForVisitor_description2: "Publicar todo o conteúdo do servidor para a internet pode ser arriscado. Isso é especialmente importante para visitantes que desconhecem a natureza distribuída do conteúdo na internet, pois eles podem acreditar que o conteúdo remoto é criado por usuários desse servidor." + restartServerSetupWizardConfirm_title: "Reiniciar o assistente de configuração?" + restartServerSetupWizardConfirm_text: "Algumas configurações atuais serão reiniciadas." + entrancePageStyle: "Estilo da página de entrada" + showTimelineForVisitor: "Mostrar linha do tempo" + showActivitiesForVisitor: "Mostrar atividades" _userGeneratedContentsVisibilityForVisitor: all: "Tudo é público" localOnly: "Conteúdo local é publicado, conteúdo remoto é privado" @@ -1988,6 +2039,7 @@ _role: descriptionOfRateLimitFactor: "Valores menores são menos restritivos, valores maiores são mais restritivos." canHideAds: "Permitir ocultar anúncios" canSearchNotes: "Permitir a busca de notas" + canSearchUsers: "Busca de usuário" canUseTranslator: "Uso do tradutor" avatarDecorationLimit: "Número máximo de decorações de avatar que podem ser aplicadas" canImportAntennas: "Permitir importação de antenas" @@ -2000,6 +2052,7 @@ _role: uploadableFileTypes_caption: "Especifica tipos MIME permitidos. Múltiplos tipos MIME podem ser especificados separando-os por linha. Curingas podem ser especificados com um asterisco (*). (exemplo, image/*)" uploadableFileTypes_caption2: "Alguns tipos de arquivos podem não ser detectados. Para permiti-los, adicione {x} à especificação." noteDraftLimit: "Limite de rascunhos possíveis" + scheduledNoteLimit: "Número máximo de notas agendadas simultâneas" watermarkAvailable: "Disponibilidade da função de marca d'água" _condition: roleAssignedTo: "Atribuído a cargos manuais" @@ -2260,6 +2313,7 @@ _time: minute: "Minuto(s)" hour: "Hora(s)" day: "Dia(s)" + month: "Mês(es)" _2fa: alreadyRegistered: "Você já cadastrou um dispositivo de autenticação de dois fatores." registerTOTP: "Cadastrar aplicativo autenticador" @@ -2434,7 +2488,16 @@ _widgets: chooseList: "Selecione uma lista" clicker: "Clicker" birthdayFollowings: "Usuários de aniversário hoje" - chat: "Conversas" + chat: "Conversar com usuário" +_widgetOptions: + showHeader: "Exibir cabeçalho" + height: "Altura" + _button: + colored: "Colorido" + _clock: + size: "Tamanho" + _birthdayFollowings: + period: "Duração" _cw: hide: "Esconder" show: "Carregar mais" @@ -2479,6 +2542,9 @@ _postForm: replyPlaceholder: "Responder a essa nota..." quotePlaceholder: "Citar essa nota..." channelPlaceholder: "Postar em canal..." + _howToUse: + visibility_title: "Visibilidade" + menu_title: "Menu\n" _placeholders: a: "Como vão as coisas?" b: "O que está rolando por aí?" @@ -2624,6 +2690,8 @@ _notification: youReceivedFollowRequest: "Você recebeu um pedido de seguidor" yourFollowRequestAccepted: "Seu pedido de seguidor foi aceito" pollEnded: "Os resultados da enquete agora estão disponíveis" + scheduledNotePosted: "Nota agendada foi publicada" + scheduledNotePostFailed: "Não foi possível publicar nota agendada" newNote: "Nova nota" unreadAntennaNote: "Antena {name}" roleAssigned: "Cargo dado" @@ -2703,7 +2771,7 @@ _deck: mentions: "Menções" direct: "Notas diretas" roleTimeline: "Linha do tempo do cargo" - chat: "Conversas" + chat: "Conversar com usuário" _dialog: charactersExceeded: "Você excedeu o limite de caracteres! Atualmente em {current} de {max}." charactersBelow: "Você está abaixo do limite mínimo de caracteres! Atualmente em {current} of {min}." @@ -3062,6 +3130,7 @@ _bootErrors: otherOption1: "Excluir ajustes de cliente e cache" otherOption2: "Iniciar o cliente simples" otherOption3: "Iniciar ferramenta de reparo" + otherOption4: "Abrir Misskey no modo seguro" _search: searchScopeAll: "Todos" searchScopeLocal: "Local" @@ -3098,6 +3167,8 @@ _serverSetupWizard: doYouConnectToFediverse_description1: "Quando conectado com uma rede distribuída de servidores (Fediverso), o conteúdo pode ser trocado com outros servidores." doYouConnectToFediverse_description2: "Conectar com o Fediverso também é chamado de \"federação\"" youCanConfigureMoreFederationSettingsLater: "Configurações adicionais como especificar servidores para conectar-se com podem ser feitas posteriormente" + remoteContentsCleaning: "Limpeza automática de conteúdos recebidos" + remoteContentsCleaning_description: "A federação pode resultar em uma entrada contínua de conteúdo. Habilitar a limpeza automática removerá conteúdo obsoleto e não referenciado do servidor para economizar armazenamento." adminInfo: "Informações da administração" adminInfo_description: "Define as informações do administrador usadas para receber consultas." adminInfo_mustBeFilled: "Deve ser preenchido se o servidor é público ou se a federação está ativa." @@ -3146,7 +3217,9 @@ _watermarkEditor: opacity: "Opacidade" scale: "Tamanho" text: "Texto" + qr: "Código QR" position: "Posição" + margin: "Margem" type: "Tipo" image: "imagem" advanced: "Avançado" @@ -3161,6 +3234,7 @@ _watermarkEditor: polkadotSubDotOpacity: "Opacidade da bolinha secundária" polkadotSubDotRadius: "Raio das bolinhas adicionais" polkadotSubDotDivisions: "Número de bolinhas adicionais" + leaveBlankToAccountUrl: "Deixe em branco para utilizar URL da conta" _imageEffector: title: "Efeitos" addEffect: "Adicionar efeitos" @@ -3171,6 +3245,8 @@ _imageEffector: mirror: "Espelho" invert: "Inverter Cores" grayscale: "Tons de Cinza" + blur: "Desfoque" + pixelate: "Pixelizar" colorAdjust: "Correção de Cores" colorClamp: "Compressão de Cores" colorClampAdvanced: "Compressão Avançada de Cores" @@ -3182,13 +3258,43 @@ _imageEffector: checker: "Xadrez" blockNoise: "Bloquear Ruído" tearing: "Descontinuidade" + fill: "Preencher" _fxProps: angle: "Ângulo" scale: "Tamanho" size: "Tamanho" + radius: "Raio" + samples: "Número de amostras" + offset: "Posição" color: "Cor" opacity: "Opacidade" + normalize: "Normalizar" + amount: "Quantidade" lightness: "Esclarecer" + contrast: "Contraste" + hue: "Matiz" + brightness: "Brilho" + saturation: "Saturação" + max: "Máximo" + min: "Mínimo" + direction: "Direção" + phase: "Fase" + frequency: "Frequência" + strength: "Força" + glitchChannelShift: "Mudança de canal" + seed: "Valor da semente" + redComponent: "Componente vermelho" + greenComponent: "Componente verde" + blueComponent: "Componente azul" + threshold: "Limiar" + centerX: "Centralizar X" + centerY: "Centralizar Y" + zoomLinesSmoothing: "Suavização" + zoomLinesSmoothingDescription: "Suavização e largura das linhas de zoom não podem ser utilizados simultaneamente." + zoomLinesThreshold: "Largura das linhas de zoom" + zoomLinesMaskSize: "Diâmetro do centro" + zoomLinesBlack: "Linhas pretas" + circle: "Circular" drafts: "Rascunhos" _drafts: select: "Selecionar Rascunho" @@ -3204,3 +3310,22 @@ _drafts: restoreFromDraft: "Restaurar de Rascunho" restore: "Redefinir" listDrafts: "Lista de Rascunhos" + schedule: "Agendar nota" + listScheduledNotes: "Lista de notas agendadas" + cancelSchedule: "Cancelar agendamento" +qr: "Código QR" +_qr: + showTabTitle: "Visualizar" + readTabTitle: "Escanear" + shareTitle: "{name} {acct}" + shareText: "Siga-me no Fediverso!" + chooseCamera: "Escolher câmera" + cannotToggleFlash: "Não foi possível ligar a lanterna" + turnOnFlash: "Ligar a lanterna" + turnOffFlash: "Desligar a lanterna" + startQr: "Retornar ao leitor de códigos QR" + stopQr: "Deixar o leitor de códigos QR" + noQrCodeFound: "Nenhum código QR encontrado" + scanFile: "Escanear imagem de dispositivo" + raw: "Texto" + mfm: "MFM" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index b08341711a..bf352b45c2 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -1215,6 +1215,10 @@ lastNDays: "Ultimele {n} zile" surrender: "Anulează" copyPreferenceId: "Copiază ID-ul preferințelor" information: "Despre" +presets: "Presetate" +_imageEditing: + _vars: + filename: "Nume fișier" _chat: invitations: "Invită" noHistory: "Nu există istoric" @@ -1297,6 +1301,12 @@ _widgets: jobQueue: "coada de job-uri" _userList: chooseList: "Selectează o listă" +_widgetOptions: + height: "Înălţime" + _button: + colored: "Colorat" + _clock: + size: "Dimensiune" _cw: show: "Incarcă mai mult" _visibility: @@ -1307,6 +1317,9 @@ _postForm: replyPlaceholder: "Răspunde la această notă..." quotePlaceholder: "Citează aceasta nota..." channelPlaceholder: "Postează pe un canal..." + _howToUse: + visibility_title: "Vizibilitate" + menu_title: "Meniu" _placeholders: a: "Ce mai faci?" b: "Ce se mai petrece in jurul tău?" @@ -1404,3 +1417,7 @@ _imageEffector: _fxProps: scale: "Dimensiune" size: "Dimensiune" + offset: "Poziție" +_qr: + showTabTitle: "Arată" + raw: "Text" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 3d3ecca531..3baf31c4d0 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -83,11 +83,13 @@ files: "Файлы" download: "Скачать" driveFileDeleteConfirm: "Удалить файл «{name}»? Заметки с ним также будут удалены." unfollowConfirm: "Отписаться от {name} ?" +cancelFollowRequestConfirm: "Вы уверены, что хотите отменить запрос на подписку пользователю {name}?" +rejectFollowRequestConfirm: "Отклонить запрос на подписку от {name}?" exportRequested: "Вы запросили экспорт. Это может занять некоторое время. Результат будет добавлен на «Диск»." importRequested: "Вы запросили импорт. Это может занять некоторое время." lists: "Списки" noLists: "Нет ни одного списка" -note: "Заметка" +note: "Пост" notes: "Заметки" following: "Подписки" followers: "Подписчики" @@ -122,7 +124,7 @@ inChannelRenote: "В канале" inChannelQuote: "Заметки в канале" renoteToChannel: "Репостнуть в канал" renoteToOtherChannel: "Репостнуть в другой канал" -pinnedNote: "Закреплённая заметка" +pinnedNote: "Закреплённый пост" pinned: "Закрепить в профиле" you: "Вы" clickToShow: "Нажмите для просмотра" @@ -199,7 +201,7 @@ searchWith: "Найденное «{q}»" youHaveNoLists: "У вас нет ни одного списка" followConfirm: "Подписаться на {name}?" proxyAccount: "Учётная запись прокси" -proxyAccountDescription: "Учетная запись прокси предназначена служить подписчиком на пользователей с других сайтов. Например, если пользователь добавит кого-то с другого сайта а список, деятельность того не отобразится, пока никто с этого же сайта не подписан на него. Чтобы это стало возможным, на него подписывается прокси." +proxyAccountDescription: "Учетная запись прокси предназначена служить подписчиком на пользователей с других сайтов. Например: если пользователь добавит кого-то с другого сайта в список, то деятельность того не отобразится, пока никто с этого же сайта не подписан на него. Чтобы это стало возможным, на него подписывается прокси." host: "Хост" selectSelf: "Выбрать себя" selectUser: "Выберите пользователя" @@ -253,6 +255,7 @@ noteDeleteConfirm: "Вы хотите удалить эту заметку?" pinLimitExceeded: "Нельзя закрепить ещё больше заметок" done: "Готово" processing: "Обработка" +preprocessing: "Подготовка..." preview: "Предпросмотр" default: "По умолчанию" defaultValueIs: "По умолчанию: {value}" @@ -301,6 +304,7 @@ uploadFromUrlMayTakeTime: "Загрузка может занять некото uploadNFiles: "Загрузить {n} файл" explore: "Обзор" messageRead: "Прочитали" +readAllChatMessages: "Отметить прочитанным" noMoreHistory: "История закончилась" startChat: "Начать чат" nUsersRead: "Прочитали {n}" @@ -327,11 +331,13 @@ dark: "Тёмный" lightThemes: "Светлые темы" darkThemes: "Тёмные темы" syncDeviceDarkMode: "Синхронизировать с тёмной темой системы" +switchDarkModeManuallyWhenSyncEnabledConfirm: "Включена функция \"{x}\". Отключить синхронизацию, чтобы переключать режим вручную?" drive: "Диск" fileName: "Имя файла" selectFile: "Выберите файл" selectFiles: "Выберите файлы" selectFolder: "Выберите папку" +unselectFolder: "Снять выбор" selectFolders: "Выберите папки" fileNotSelected: "Файл не выбран" renameFile: "Переименовать файл" @@ -344,6 +350,7 @@ addFile: "Добавить файл" showFile: "Посмотреть файл" emptyDrive: "Диск пуст" emptyFolder: "Папка пуста" +dropHereToUpload: "Переместите файл сюда" unableToDelete: "Удаление невозможно" inputNewFileName: "Введите имя нового файла" inputNewDescription: "Введите новую подпись" @@ -396,7 +403,7 @@ pinnedUsersDescription: "Перечислите по одному имени п pinnedPages: "Закрепленные страницы" pinnedPagesDescription: "Если хотите закрепить страницы на главной сайта, сюда можно добавить пути к ним, каждый в отдельной строке." pinnedClipId: "Идентификатор закреплённой подборки" -pinnedNotes: "Закреплённая заметка" +pinnedNotes: "Закреплённый пост" hcaptcha: "hCaptcha" enableHcaptcha: "Включить hCaptcha" hcaptchaSiteKey: "Ключ сайта" @@ -457,7 +464,7 @@ moderator: "Модератор" moderation: "Модерация" moderationNote: "Примечания модератора" moderationNoteDescription: "Вы можете заполнять заметки, которые будут доступны только модераторам." -addModerationNote: "" +addModerationNote: "Оставить заметку" moderationLogs: "Журнал модерации" nUsersMentioned: "Упомянуло пользователей: {n}" securityKeyAndPasskey: "Ключ безопасности и парольная фраза" @@ -601,7 +608,7 @@ installedDate: "Дата установки" lastUsedDate: "Дата использования" state: "Состояние" sort: "Сортировать" -ascendingOrder: "по возрастанию" +ascendingOrder: "По возрастанию" descendingOrder: "По убыванию" scratchpad: "Когтеточка" scratchpadDescription: "«Когтеточка» — это место для опытов с AiScript. Здесь можно писать программы, взаимодействующие с Misskey, запускать и смотреть что из этого получается." @@ -622,9 +629,9 @@ removeAllFollowingDescription: "Отменить все подписки с до userSuspended: "Эта учётная запись заморожена" userSilenced: "Этот пользователь был заглушен" yourAccountSuspendedTitle: "Эта учетная запись заблокирована" -yourAccountSuspendedDescription: "Эта учетная запись была заблокирована из-за нарушения условий предоставления услуг сервера. Свяжитесь с администратором, если вы хотите узнать более подробную причину. Пожалуйста, не создавайте новую учетную запись." +yourAccountSuspendedDescription: "Этот аккаунт нарушил ToS сервера, поэтому был заморожен. Свяжитесь с администратором, чтобы узнать подробности. Не пытайтесь создать новый аккаунт." tokenRevoked: "Токен недействителен" -tokenRevokedDescription: "Срок действия вашего токена входа истек. Пожалуйста, войдите снова." +tokenRevokedDescription: "Токен входа устарел. Пожалуйста, войдите снова." accountDeleted: "Учетная запись удалена" accountDeletedDescription: "Эта учетная запись удалена" menu: "Меню" @@ -683,9 +690,9 @@ smtpPort: "Порт" smtpUser: "Имя пользователя" smtpPass: "Пароль" emptyToDisableSmtpAuth: "Не заполняйте имя пользователя и пароль, чтобы отключить аутентификацию в SMTP." -smtpSecure: "Использовать SSL/TLS для SMTP-соединений" +smtpSecure: "Использовать SSL/TLS" smtpSecureInfo: "Выключите при использовании STARTTLS." -testEmail: "Проверка доставки электронной почты" +testEmail: "Отправить тестовое письмо" wordMute: "Скрытие слов" wordMuteDescription: "Сведите к минимуму записи, содержащие указанное утверждение. Нажмите на свернутую запись, чтобы отобразить ее." hardWordMute: "Строгое скрытие слов" @@ -771,6 +778,7 @@ lockedAccountInfo: "Даже если вы вручную подтверждае alwaysMarkSensitive: "Отмечать файлы как «содержимое не для всех» по умолчанию" loadRawImages: "Сразу показывать изображения в полном размере" disableShowingAnimatedImages: "Не проигрывать анимацию" +disableShowingAnimatedImages_caption: "Если анимации всё равно не работают, проверьте настройки специальных возможностей и режимы экономии заряда в браузере или системе" highlightSensitiveMedia: "Выделять содержимое не для всех" verificationEmailSent: "Вам отправлено письмо для подтверждения. Пройдите, пожалуйста, по ссылке из письма, чтобы завершить проверку." notSet: "Не настроено" @@ -778,7 +786,7 @@ emailVerified: "Адрес электронной почты подтвержд noteFavoritesCount: "Количество добавленного в избранное" pageLikesCount: "Количество понравившихся страниц" pageLikedCount: "Количество страниц, понравившихся другим" -contact: "Как связаться" +contact: "Почта для связи" useSystemFont: "Использовать шрифт, предлагаемый системой" clips: "Подборки" experimentalFeatures: "Экспериментальные функции" @@ -837,7 +845,7 @@ showingPastTimeline: "Отображается старая лента" clear: "Очистить" markAllAsRead: "Отметить всё как прочитанное" goBack: "Выход" -unlikeConfirm: "В самом деле отменить «нравится»?" +unlikeConfirm: "В самом деле убрать «нравится»?" fullView: "Полный вид" quitFullView: "Закрыть полный вид" addDescription: "Добавить описание" @@ -882,7 +890,7 @@ priority: "Приоритет" high: "Высокий" middle: "Средне" low: "Низкий" -emailNotConfiguredWarning: "Не указан адрес электронной почты" +emailNotConfiguredWarning: "Адрес почты пустует" ratio: "Соотношение" previewNoteText: "Предварительный просмотр" customCss: "Индивидуальный CSS" @@ -962,13 +970,13 @@ reflectMayTakeTime: "Изменения могут занять время дл failedToFetchAccountInformation: "Не удалось получить информацию об аккаунте" rateLimitExceeded: "Ограничение скорости превышено" cropImage: "Кадрирование" -cropImageAsk: "Нужно ли кадрировать изображение?" +cropImageAsk: "Обрезать изображение?" cropYes: "Обрезать" cropNo: "Не обрезать" file: "Файлы" recentNHours: "Последние {n} ч" recentNDays: "Последние {n} сут" -noEmailServerWarning: "Почтовый сервер не установлен " +noEmailServerWarning: "Отправка писем выключена" thereIsUnresolvedAbuseReportWarning: "Остались нерешённые жалобы" recommended: "Рекомендуем" check: "Проверить" @@ -982,7 +990,7 @@ document: "Документ" numberOfPageCache: "Количество сохранённых страниц в кэше" numberOfPageCacheDescription: "Описание количества страниц в кэше" logoutConfirm: "Вы хотите выйти из аккаунта?" -logoutWillClearClientData: "Когда вы выйдете из системы, информация о конфигурации клиента будет удалена из браузера.Чтобы иметь возможность восстановить информацию о вашей конфигурации при повторном входе в систему, пожалуйста, включите опцию автоматического резервного копирования в настройках." +logoutWillClearClientData: "Выход из аккаунта удалит настройки клиента из этого браузера. Включите автоматическое резервное копирование, чтобы иметь возможность восстановить настройки при повторном входе." lastActiveDate: "Последняя дата использования" statusbar: "Статусбар" pleaseSelect: "Пожалуйста, выберите" @@ -1001,6 +1009,7 @@ failedToUpload: "Сбой выгрузки" cannotUploadBecauseInappropriate: "Файл не может быть загружен, так как было установлено, что он может содержать неприемлемое содержимое." cannotUploadBecauseNoFreeSpace: "Файл не может быть загружен, так как не осталось места на диске" cannotUploadBecauseExceedsFileSizeLimit: "Файл не может быть загружен, так как он превышает лимит размера файла." +cannotUploadBecauseUnallowedFileType: "Формат файла не подходит" beta: "Бета" enableAutoSensitive: "Автоматическое определение содержимого не для всех" enableAutoSensitiveDescription: "Позволяет определять наличие содержимого не для всех при помощи искусственного интеллекта там, где это возможно. Даже если эту опцию отключить, она всё равно может быть включена на весь инстанс." @@ -1016,6 +1025,9 @@ pushNotificationAlreadySubscribed: "Push-уведомления уже вклю pushNotificationNotSupported: "Push-уведмления не поддерживаются инстансом или браузером" sendPushNotificationReadMessage: "Удалять push-уведомления когда сообщение или прочитано" sendPushNotificationReadMessageCaption: "На мгновение появится уведомление \"{emptyPushNotificationMessage}\". Расход заряда батареи может увеличиться " +pleaseAllowPushNotification: "Пожалуйста, разрешите уведомление в браузере от сайта" +browserPushNotificationDisabled: "Вы не дали разрешение на уведомления сайту" +browserPushNotificationDisabledDescription: "Разрешите уведомления в настройках браузера от {serverName}, чтобы включить PUSH уведомления" windowMaximize: "Развернуть" windowMinimize: "Свернуть" windowRestore: "Восстановить" @@ -1037,7 +1049,7 @@ roles: "Роли" role: "Роль" noRole: "Нет роли" normalUser: "Обычный пользователь" -undefined: "неопределён" +undefined: "неопределённо" assign: "Назначить" unassign: "Отменить назначение" color: "Цвет" @@ -1052,6 +1064,7 @@ permissionDeniedError: "Операция запрещена" permissionDeniedErrorDescription: "У этой учетной записи нет разрешения на выполнение этой операции." preset: "Шаблоны" selectFromPresets: "Выбрать из шаблонов" +custom: "Пользовательские" achievements: "Достижения" gotInvalidResponseError: "Сервер ответил ошибкой" gotInvalidResponseErrorDescription: "Сервер временно не доступен. Возможно проводятся технические работы, или сервер отключен." @@ -1090,6 +1103,7 @@ prohibitedWordsDescription2: "Разделение пробелом создаё hiddenTags: "Скрытые хештеги" hiddenTagsDescription: "Установленные теги не будут отображаться в тренде, можно установить несколько тегов." notesSearchNotAvailable: "Поиск заметок недоступен" +usersSearchNotAvailable: "Функция \"поиска пользователей\" отключена" license: "Лицензия" unfavoriteConfirm: "Удалить избранное?" myClips: "Мои подборки" @@ -1128,7 +1142,7 @@ vertical: "Вертикально" horizontal: "Горизонтально" position: "Позиция" serverRules: "Правила сервера" -pleaseConfirmBelowBeforeSignup: "Для регистрации на данном сервере, необходимо согласится с нижеследующими положениями." +pleaseConfirmBelowBeforeSignup: "Прочитайте и согласитесь с информацией ниже, чтобы продолжить" pleaseAgreeAllToContinue: "Чтобы продолжить, необходимо поставить отметки во всех полях \"согласен\"." continue: "Продолжить" preservedUsernames: "Зарезервированные имена пользователей" @@ -1164,6 +1178,7 @@ installed: "Установлено" branding: "Бренд" enableServerMachineStats: "Опубликовать характеристики сервера" enableIdenticonGeneration: "Включить генерацию иконки пользователя" +showRoleBadgesOfRemoteUsers: "Display the role badges assigned to remote users" turnOffToImprovePerformance: "Отключение этого параметра может повысить производительность." createInviteCode: "Создать код приглашения" createWithOptions: "Используйте параметры для создания" @@ -1176,6 +1191,9 @@ expirationDate: "Дата истечения" noExpirationDate: "Бессрочно" inviteCodeUsedAt: "Дата и время, когда был использован пригласительный код" registeredUserUsingInviteCode: "Пользователи, которые использовали пригласительный код" +waitingForMailAuth: "Подтвердите вашу электронную почту" +inviteCodeCreator: "Создатель приглашения" +usedAt: "Использовано" unused: "Неиспользованное" used: "Использован" expired: "Срок действия приглашения истёк" @@ -1184,43 +1202,58 @@ beSureToReadThisAsItIsImportant: "Это важно, поэтому, пожал iHaveReadXCarefullyAndAgree: "Я прочитал(а) и согласен(сна) с условиями \"{x}" dialog: "Диалог" icon: "Аватар" +forYou: "Для вас" currentAnnouncements: "Текущие новости" pastAnnouncements: "Предыдущие новости" youHaveUnreadAnnouncements: "У вас есть непрочитанные уведомления" +useSecurityKey: "Используйте ключ безопасности или Passkey, следуя подсказкам браузера" replies: "Ответы" renotes: "Репост" loadReplies: "Показать ответы" loadConversation: "Загрузить беседу" pinnedList: "Закреплённый список" keepScreenOn: "Держать экран включённым" +verifiedLink: "Эта ссылка принадлежит пользователю" +notifyNotes: "Оповещать о публикациях" unnotifyNotes: "Отписаться от сообщений" authentication: "Аутентификация" authenticationRequiredToContinue: "Пожалуйста, пройдите аутентификацию, чтобы продолжить" dateAndTime: "Дата и время" showRenotes: "Показывать репосты" edited: "Изменено" +notificationRecieveConfig: "Настроить оповещения" mutualFollow: "Взаимные подписки" followingOrFollower: "Подписки или подписчики" fileAttachedOnly: "Только заметки с файлами" showRepliesToOthersInTimeline: "Показывать ответы в ленте" +hideRepliesToOthersInTimeline: "Скрыть чужие ответы в ленте" showRepliesToOthersInTimelineAll: "Показывать в ленте ответы пользователей, на которых вы подписаны" hideRepliesToOthersInTimelineAll: "Скрывать в ленте ответы пользователей, на которых вы подписаны" +confirmShowRepliesAll: "Это нельзя будет отменить. Показать ответы от всех, на кого вы подписаны?" +confirmHideRepliesAll: "Это нельзя будет отменить. Скрыть ответы от всех, на кого вы подписаны?" sourceCode: "Исходный код" sourceCodeIsNotYetProvided: "Исходный код пока не доступен. Свяжитесь с администратором, чтобы исправить эту проблему." repositoryUrl: "Ссылка на репозиторий" repositoryUrlDescription: "Если вы используете Misskey как есть (без изменений в исходном коде), введите https://github.com/misskey-dev/misskey" +repositoryUrlOrTarballRequired: "Если репозиторий закрыт, необходимо предоставить ссылку на tarball. Подробности см. в файле \".config/example.yml\"" feedback: "Обратная связь" +feedbackUrl: "Ссылка для обратной связи" +impressum: "О владельце" privacyPolicy: "Политика Конфиденциальности" privacyPolicyUrl: "Ссылка на Политику Конфиденциальности" tosAndPrivacyPolicy: "Условия использования и политика конфиденциальности" avatarDecorations: "Украшения для аватара" attach: "Прикрепить" +detach: "Открепить" +detachAll: "Убрать всё" angle: "Угол" flip: "Переворот" showAvatarDecorations: "Показать украшения для аватара" +releaseToRefresh: "Отпустите, чтобы обновить" +refreshing: "Обновление..." pullDownToRefresh: "Опустите что бы обновить" useGroupedNotifications: "Отображать уведомления сгруппировано" -signupPendingError: "Возникла проблема с подтверждением вашего адреса электронной почты. Возможно, срок действия ссылки истёк." +emailVerificationFailedError: "Не смогли подтвердить почту. Вероятно, истек срок письма" cwNotationRequired: "Если включена опция «Скрыть содержимое», необходимо написать аннотацию." doReaction: "Добавить реакцию" code: "Код" @@ -1230,34 +1263,49 @@ overwriteContentConfirm: "Текущее содержимое будет пер seasonalScreenEffect: "Эффект времени года на экране" decorate: "Украсить" addMfmFunction: "Добавить MFM" +enableQuickAddMfmFunction: "Показывать расширенный выбор MFM" bubbleGame: "BubbleGame" sfx: "Звуковые эффекты" soundWillBePlayed: "Будет воспроизведен звук" showReplay: "Показать повтор" +replay: "Ответить" endReplay: "Конец повтора" lastNDays: "Последние {n} сут" hemisphere: "Место проживания" userSaysSomethingSensitive: "Сообщение, содержит конфиденциальные файлы от {name}" enableHorizontalSwipe: "Смахните в сторону, чтобы сменить вкладки" +loading: "Загрузка" surrender: "Этот пост не может быть отменен." gameRetry: "Повторить попытку" notUsePleaseLeaveBlank: "Если не используется, оставьте пустым" +useTotp: "Включить двухэтапную проверку" +useBackupCode: "Использовать резервные коды" +launchApp: "Запустить приложение" useNativeUIForVideoAudioPlayer: "Использовать интерфейс браузера при проигрывании видео и звука" keepOriginalFilename: "Сохранять исходное имя файла" keepOriginalFilenameDescription: "Если вы выключите данную настройку, имена файлов будут автоматически заменены случайной строкой при загрузке." +noDescription: "Нет описания" alwaysConfirmFollow: "Всегда подтверждать подписку" inquiry: "Связаться" +tryAgain: "Попробуйте еще раз позже" +confirmWhenRevealingSensitiveMedia: "Спрашивать перед открытием NSFW контента" +sensitiveMediaRevealConfirm: "Возможно, это NSFW контент. Показать?" +createdLists: "Созданные списки" +createdAntennas: "Созданные антенны" fromX: "Из {x}" genEmbedCode: "Сгенерировать код для " noteOfThisUser: "Список заметок этого пользователя" clipNoteLimitExceeded: "К этому клипу больше нельзя добавить заметки" performance: "Производительность" modified: "Изменено" +discard: "Отменить" +thereAreNChanges: "Изменено: {n}" signinWithPasskey: "Войдите в систему, используя свой пароль" -unknownWebAuthnKey: "Не известный ключ " +unknownWebAuthnKey: "Неизвестный ключ" passkeyVerificationFailed: "Ошибка проверка ключа доступа " +passkeyVerificationSucceededButPasswordlessLoginDisabled: "Проверка Passkey выполнена, но вход без пароля отключен" messageToFollower: "Сообщение подписчикам" -testCaptchaWarning: "Эта функция предназначена для тестирования CAPTCHA. Не использовать это в рабочей среде" +testCaptchaWarning: "Эта тестовая CAPTCHA. Не используйте её!" prohibitedWordsForNameOfUser: "Запрещенные слова (имя пользователя)" prohibitedWordsForNameOfUserDescription: "Если имя пользователя содержит строку из этого списка, изменение имени пользователя будет запрещено. На пользователей с правами модератора это ограничение не распространяется. Имена пользователей также проверяются путём замены всех букв в нижнем регистре" yourNameContainsProhibitedWords: "Имя, которое вы пытаетесь изменить, содержит запрещенную строку символов" @@ -1266,14 +1314,68 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "Автор сообщения у lockdown: "Доступ ограничен" pleaseSelectAccount: "Выберите свой аккаунт" availableRoles: "Доступные роли" +federationSpecified: "Сервер работает через белый список федерации. Связь с другими серверами ограничена" federationDisabled: "Федерация отключена для этого сервера. Вы не можете взаимодействовать с пользователями на других серверах." draft: "Черновик" +draftsAndScheduledNotes: "Черновики и отложенные публикации" +confirmOnReact: "Подтверждать добавление реакции" +reactAreYouSure: "Добавить {emoji}?" markAsSensitiveConfirm: "Отметить контент как чувствительный?" +unmarkAsSensitiveConfirm: "Снять пометку о NSFW контенте?" +preferences: "Основное" +accessibility: "Специальные возможности" +preferencesProfile: "Настройки профиля" +copyPreferenceId: "Копировать ID настройки" resetToDefaultValue: "Сбросить настройки до стандартных" +overrideByAccount: "Переопределить этим аккаунтом" +untitled: "Без названия" +noName: "Имя не указано" +skip: "Пропустить" +syncBetweenDevices: "Синхронизировать между устройствами" postForm: "Форма отправки" +textCount: "Количество символов" information: "Описание" inMinutes: "мин" inDays: "сут" +schedule: "Отложить" +scheduled: "Отложено" +widgets: "Виджеты" +deviceInfo: "Об устройстве" +deviceInfoDescription: "Эта информация может быть полезна при обращении в поддержку" +youAreAdmin: "Вы администратор" +frame: "Рамки" +presets: "Шаблоны" +zeroPadding: "Без отступов" +nothingToConfigure: "Нечего менять" +_imageEditing: + _vars: + caption: "Описание файла" + filename: "Имя файла" + filename_without_ext: "Имя файла без расширения" + year: "Год создания" + month: "Месяц создания" + day: "День создания" + hour: "Час создания" + minute: "Минуты создания" + second: "Секунды создания" + camera_model: "Модель камеры" + camera_lens_model: "Модель линзы" + camera_mm: "Фокусное расстояние" + camera_mm_35: "Фокусное расстояние (экв. 35 мм)" + camera_f: "Диафрагма" + camera_s: "Выдержка" + camera_iso: "ISO" + gps_lat: "Широта" + gps_long: "Долгота" +_imageFrameEditor: + title: "Редактировать рамку" + header: "Заголовок" + footer: "Нижняя часть" + borderThickness: "Толщина рамки" + labelThickness: "Толщина границ" + font: "Шрифт" + fontSerif: "Антиква (с засечками)" + fontSansSerif: "Гротеск (без засечек)" _chat: invitations: "Пригласить" noHistory: "История пока пуста" @@ -1282,6 +1384,11 @@ _chat: send: "Отправить" _settings: webhook: "Вебхук" + preferencesBanner: "Вы можете настроить общее поведение клиента по вашим предпочтениям" + timelineAndNote: "Лента и заметки" + _chat: + showSenderName: "Показывать имя отправителя" + sendOnEnter: "Использовать Enter для отправки" _delivery: stop: "Заморожено" _type: @@ -1530,7 +1637,7 @@ _achievements: description: "Нажато здесь" _justPlainLucky: title: "Чистая удача" - description: "Может достаться с вероятностью 0,01% каждые 10 секунд." + description: "Может достаться с вероятностью 0,005% каждые 10 секунд." _setNameToSyuilo: title: "Комплекс бога" description: "Установлено «syuilo» в качестве имени" @@ -1558,6 +1665,12 @@ _achievements: title: "Brain Diver" description: "Опубликована ссылка на песню «Brain Diver»" flavor: "Мисски-Мисски Ла-Ту-Ма" + _bubbleGameExplodingHead: + title: "🤯" + description: "Самый большой объект в Bubble game" + _bubbleGameDoubleExplodingHead: + title: "Двойной🤯" + description: "Два самых больших объекта в Bubble game одновременно!" _role: new: "Новая роль" edit: "Изменить роль" @@ -1635,6 +1748,7 @@ _emailUnavailable: disposable: "Временный адрес электронной почты не принимается" mx: "Неверный почтовый сервер" smtp: "Почтовый сервер не отвечает" + banned: "Этот адрес почты недоступен" _ffVisibility: public: "Общедоступны" followers: "Показываются только подписчикам" @@ -1895,6 +2009,7 @@ _permissions: "read:gallery-likes": "Просмотр списка понравившегося в галерее" "write:gallery-likes": "Изменение списка понравившегося в галерее" "write:admin:reset-password": "Сбросить пароль пользователю" + "write:admin:send-email": "Отправить письмо" "write:chat": "Писать и удалять сообщения" _auth: shareAccessTitle: "Разрешения для приложений" @@ -1950,6 +2065,14 @@ _widgets: chooseList: "Выберите список" clicker: "Счётчик щелчков" birthdayFollowings: "Пользователи, у которых сегодня день рождения" +_widgetOptions: + height: "Высота" + _button: + colored: "Выделена цветом" + _clock: + size: "Размер" + _birthdayFollowings: + period: "Длительность" _cw: hide: "Спрятать" show: "Показать" @@ -1992,6 +2115,9 @@ _postForm: replyPlaceholder: "Ответ на заметку..." quotePlaceholder: "Пояснение к цитате..." channelPlaceholder: "Отправить в канал" + _howToUse: + visibility_title: "Видимость" + menu_title: "Меню" _placeholders: a: "Как дела?" b: "Что интересного вокруг?" @@ -2216,6 +2342,7 @@ _abuseReport: mail: "Электронная почта" webhook: "Вебхук" _captions: + mail: "Уведомлять модераторов по почте (только при поступлении жалоб)" webhook: "Отправить уведомление Системному Вебхуку при получении или разрешении жалоб." notifiedWebhook: "Используемый Вебхук" _moderationLogTypes: @@ -2262,7 +2389,11 @@ _imageEffector: angle: "Угол" scale: "Размер" size: "Размер" + offset: "Позиция" color: "Цвет" opacity: "Непрозрачность" lightness: "Осветление" drafts: "Черновик" +_qr: + showTabTitle: "Отображение" + raw: "Текст" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index c9fd8e6ae9..b6289f4e04 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -915,6 +915,15 @@ postForm: "Napísať poznámku" information: "Informácie" inMinutes: "min" inDays: "dní" +widgets: "Widgety" +_imageEditing: + _vars: + filename: "Názov súboru" +_imageFrameEditor: + header: "Hlavička" + font: "Písmo" + fontSerif: "Pätkové" + fontSansSerif: "Bezpätkové" _chat: invitations: "Pozvať" noHistory: "Žiadna história" @@ -1223,6 +1232,14 @@ _widgets: aichan: "Ai" _userList: chooseList: "Vyberte zoznam" +_widgetOptions: + height: "Výška" + _button: + colored: "Farebné" + _clock: + size: "Veľkosť" + _birthdayFollowings: + period: "Trvanie" _cw: hide: "Skryť" show: "Zobraziť viac" @@ -1263,6 +1280,9 @@ _postForm: replyPlaceholder: "Odpoveď na túto poznámku..." quotePlaceholder: "Citovanie tejto poznámky..." channelPlaceholder: "Poslať do kanála..." + _howToUse: + visibility_title: "Viditeľnosť" + menu_title: "Menu" _placeholders: a: "Čo máte v pláne?" b: "Čo sa deje?" @@ -1466,3 +1486,6 @@ _imageEffector: color: "Farba" opacity: "Priehľadnosť" lightness: "Zosvetliť" +_qr: + showTabTitle: "Zobraziť" + raw: "Text" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index 96944706e9..560f4a187c 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -559,6 +559,9 @@ tryAgain: "Försök igen senare" signinWithPasskey: "Logga in med nyckel" unknownWebAuthnKey: "Okänd nyckel" information: "Om" +_imageEditing: + _vars: + filename: "Filnamn" _chat: invitations: "Inbjudan" members: "Medlemmar" @@ -636,6 +639,9 @@ _widgets: jobQueue: "Jobbkö" _userList: chooseList: "Välj lista" +_widgetOptions: + _clock: + size: "Storlek" _cw: hide: "Dölj" show: "Ladda mer" @@ -647,6 +653,9 @@ _visibility: home: "Hem" followers: "Följare" specified: "Direktnoter" +_postForm: + _howToUse: + menu_title: "Meny" _profile: name: "Namn" username: "Användarnamn" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 7ed6a3dbdc..48a92adae0 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -253,6 +253,7 @@ noteDeleteConfirm: "ต้องการลบโน้ตนี้ใช่ไ pinLimitExceeded: "คุณไม่สามารถปักหมุดโน้ตเพิ่มเติมใดๆได้อีก" done: "เสร็จสิ้น" processing: "กำลังประมวลผล..." +preprocessing: "กำลังจัดเตรียม..." preview: "แสดงตัวอย่าง" default: "ค่าเริ่มต้น" defaultValueIs: "ค่าเริ่มต้น: {value}" @@ -1054,6 +1055,7 @@ permissionDeniedError: "การดำเนินถูกปฏิเสธ" permissionDeniedErrorDescription: "บัญชีนี้ไม่มีสิทธิ์อนุญาตในการดำเนินการนี้" preset: "พรีเซ็ต" selectFromPresets: "เลือกจากการพรีเซ็ต" +custom: "แบบกำหนดเอง" achievements: "ความสำเร็จ" gotInvalidResponseError: "การตอบสนองเซิร์ฟเวอร์ไม่ถูกต้อง" gotInvalidResponseErrorDescription: "เซิร์ฟเวอร์อาจไม่สามารถเข้าถึงได้หรืออาจจะกำลังอยู่ในระหว่างปรับปรุง กรุณาลองใหม่อีกครั้งในภายหลังนะคะ" @@ -1244,7 +1246,7 @@ releaseToRefresh: "ปล่อยเพื่อรีเฟรช" refreshing: "กำลังรีเฟรช..." pullDownToRefresh: "ดึงลงเพื่อรีเฟรช" useGroupedNotifications: "แสดงผลการแจ้งเตือนแบบกลุ่มแล้ว" -signupPendingError: "มีปัญหาในการตรวจสอบที่อยู่อีเมลลิงก์อาจหมดอายุแล้ว" +emailVerificationFailedError: "เกิดปัญหาในขณะตรวจสอบอีเมล อาจเป็นไปได้ว่าลิงก์หมดอายุแล้ว" cwNotationRequired: "หากเปิดใช้งาน “ซ่อนเนื้อหา” จะต้องระบุคำอธิบาย" doReaction: "เพิ่มรีแอคชั่น" code: "โค้ด" @@ -1315,6 +1317,7 @@ acknowledgeNotesAndEnable: "เปิดใช้งานหลังจาก federationSpecified: "เซิร์ฟเวอร์นี้ดำเนินงานในระบบกลุ่มไวท์ลิสต์ ไม่สามารถติดต่อกับเซิร์ฟเวอร์อื่นที่ไม่ได้รับอนุญาตจากผู้ดูแลระบบได้" federationDisabled: "เซิร์ฟเวอร์นี้ปิดใช้งานสหพันธ์ ไม่สามารถติดต่อหรือแลกเปลี่ยนข้อมูลกับผู้ใช้จากเซิร์ฟเวอร์อื่นได้" draft: "ร่าง" +draftsAndScheduledNotes: "ร่างและกำหนดเวลาโพสต์" confirmOnReact: "ยืนยันเมื่อทำการรีแอคชั่น" reactAreYouSure: "ต้องการใส่รีแอคชั่นด้วย \"{emoji}\" หรือไม่?" markAsSensitiveConfirm: "ต้องการตั้งค่าสื่อนี้ว่าเป็นเนื้อหาละเอียดอ่อนหรือไม่?" @@ -1342,6 +1345,8 @@ postForm: "แบบฟอร์มการโพสต์" textCount: "จำนวนอักขระ" information: "เกี่ยวกับ" chat: "แชต" +directMessage: "แชตเลย" +directMessage_short: "ข้อความ" migrateOldSettings: "ย้ายข้อมูลการตั้งค่าเก่า" migrateOldSettings_description: "โดยปกติจะทำโดยอัตโนมัติ แต่หากด้วยเหตุผลบางประการที่ไม่สามารถย้ายได้สำเร็จ สามารถสั่งย้ายด้วยตนเองได้ การตั้งค่าปัจจุบันจะถูกเขียนทับ" compress: "บีบอัด" @@ -1367,18 +1372,49 @@ abort: "หยุดและยกเลิก" tip: "คำแนะนำและเคล็ดลับ" redisplayAllTips: "แสดงคำแนะนำและเคล็ดลับทั้งหมดอีกครั้ง" hideAllTips: "ซ่อนคำแนะนำและเคล็ดลับทั้งหมด" -defaultImageCompressionLevel: "ความละเอียดเริ่มต้นสำหรับการบีบอัดภาพ" +defaultImageCompressionLevel: "ค่าการบีบอัดภาพเริ่มต้น" defaultImageCompressionLevel_description: "หากตั้งค่าต่ำ จะรักษาคุณภาพภาพได้ดีขึ้นแต่ขนาดไฟล์จะเพิ่มขึ้น
หากตั้งค่าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพภาพจะลดลง" +defaultCompressionLevel: "ค่าการบีบอัดเริ่มต้น" +defaultCompressionLevel_description: "ถ้าต่ำ จะรักษาคุณภาพได้ แต่ขนาดไฟล์จะเพิ่มขึ้น
ถ้าสูง จะลดขนาดไฟล์ได้ แต่คุณภาพจะลดลง" inMinutes: "นาที" inDays: "วัน" safeModeEnabled: "โหมดปลอดภัยถูกเปิดใช้งาน" pluginsAreDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน ปลั๊กอินทั้งหมดจึงถูกปิดใช้งาน" customCssIsDisabledBecauseSafeMode: "เนื่องจากโหมดปลอดภัยถูกเปิดใช้งาน CSS แบบกำหนดเองจึงไม่ได้ถูกนำมาใช้" themeIsDefaultBecauseSafeMode: "ในระหว่างที่โหมดปลอดภัยถูกเปิดใช้งาน จะใช้ธีมเริ่มต้น เมื่อปิดโหมดปลอดภัยจะกลับคืนดังเดิม" +thankYouForTestingBeta: "ขอบคุณที่ให้ความร่วมมือในการทดสอบเวอร์ชันเบต้า!" +createUserSpecifiedNote: "สร้างโน้ตแบบไดเร็กต์" +schedulePost: "กำหนดเวลาให้โพสต์" +scheduleToPostOnX: "กำหนดเวลาให้โพสต์ไว้ที่ {x}" +scheduledToPostOnX: "มีการกำหนดเวลาให้โพสต์ไว้ที่ {x}" +schedule: "กำหนดเวลา" +scheduled: "กำหนดเวลา" +widgets: "วิดเจ็ต" +presets: "พรีเซ็ต" +_imageEditing: + _vars: + filename: "ชื่อไฟล์" +_imageFrameEditor: + header: "ส่วนหัว" + withQrCode: "QR โค้ด" + font: "แบบอักษร" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + quitWithoutSaveConfirm: "ต้องการออกโดยไม่บันทึกหรือไม่?" +_compression: + _quality: + high: "คุณภาพสูง" + medium: "คุณภาพปานกลาง" + low: "คุณภาพต่ำ" + _size: + large: "ขนาดใหญ่" + medium: "ขนาดปานกลาง" + small: "ขนาดเล็ก" _order: newest: "เรียงจากใหม่ไปเก่า" oldest: "เรียงจากเก่าไปใหม่" _chat: + messages: "ข้อความ" noMessagesYet: "ยังไม่มีข้อความ" newMessage: "ข้อความใหม่" individualChat: "แชตส่วนตัว" @@ -1664,6 +1700,9 @@ _serverSettings: userGeneratedContentsVisibilityForVisitor_description2: "การเปิดเผยเนื้อหาทั้งหมดในเซิร์ฟเวอร์รวมทั้งเนื้อหาที่รับมาจากระยะไกลสู่สาธารณะบนอินเทอร์เน็ตโดยไม่มีข้อจำกัดใดๆ มีความเสี่ยงโดยเฉพาะอย่างยิ่งสำหรับผู้ชมที่ไม่เข้าใจลักษณะของระบบแบบกระจาย อาจทำให้เกิดความเข้าใจผิดคิดว่าเนื้อหาที่มาจากระยะไกลนั้นเป็นเนื้อหาที่สร้างขึ้นภายในเซิร์ฟเวอร์นี้ จึงควรใช้ความระมัดระวังอย่างมาก" restartServerSetupWizardConfirm_title: "ต้องการเริ่มวิซาร์ดการตั้งค่าเซิร์ฟเวอร์ใหม่หรือไม่?" restartServerSetupWizardConfirm_text: "การตั้งค่าบางส่วนในปัจจุบันจะถูกรีเซ็ต" + entrancePageStyle: "สไตล์ของหน้าเพจทางเข้า" + showTimelineForVisitor: "แสดงไทม์ไลน์" + showActivitiesForVisitor: "แสดงกิจกรรม" _userGeneratedContentsVisibilityForVisitor: all: "ทั้งหมดสาธารณะ" localOnly: "เผยแพร่เป็นสาธารณะเฉพาะเนื้อหาท้องถิ่น เนื้อหาระยะไกลให้เป็นส่วนตัว" @@ -2013,6 +2052,7 @@ _role: uploadableFileTypes_caption: "สามารถระบุ MIME type ได้ โดยใช้การขึ้นบรรทัดใหม่เพื่อแยกหลายรายการ และสามารถใช้ดอกจัน (*) เพื่อระบุแบบไวลด์การ์ดได้ (เช่น: image/*)" uploadableFileTypes_caption2: "ไฟล์บางประเภทอาจไม่สามารถระบุชนิดได้ หากต้องการอนุญาตไฟล์ลักษณะนั้น กรุณาเพิ่ม {x} ลงในรายการที่อนุญาต" noteDraftLimit: "จำนวนโน้ตฉบับร่างที่สามารถสร้างได้บนฝั่งเซิร์ฟเวอร์" + scheduledNoteLimit: "จำนวนโพสต์กำหนดเวลาที่สร้างพร้อมกันได้" watermarkAvailable: "มีฟังก์ชั่นลายน้ำให้เลือกใช้" _condition: roleAssignedTo: "มอบหมายให้มีบทบาทแบบทำมือ" @@ -2273,6 +2313,7 @@ _time: minute: "นาที" hour: "ชั่วโมง" day: "วัน" + month: "เดือน" _2fa: alreadyRegistered: "คุณได้ลงทะเบียนอุปกรณ์ยืนยันตัวตนแบบ 2 ชั้นแล้ว" registerTOTP: "ลงทะเบียนแอพตัวตรวจสอบสิทธิ์" @@ -2447,7 +2488,16 @@ _widgets: chooseList: "เลือกรายชื่อ" clicker: "คลิกเกอร์" birthdayFollowings: "วันเกิดผู้ใช้ในวันนี้" - chat: "แชต" + chat: "แชตเลย" +_widgetOptions: + showHeader: "แสดงส่วนหัว" + height: "ความสูง" + _button: + colored: "สี" + _clock: + size: "ขนาด" + _birthdayFollowings: + period: "ระยะเวลา" _cw: hide: "ซ่อน" show: "โหลดเพิ่มเติม" @@ -2492,6 +2542,9 @@ _postForm: replyPlaceholder: "ตอบกลับโน้ตนี้..." quotePlaceholder: "อ้างโน้ตนี้..." channelPlaceholder: "โพสต์ลงช่อง..." + _howToUse: + visibility_title: "การมองเห็น" + menu_title: "เมนู" _placeholders: a: "ตอนนี้เป็นยังไงบ้าง?" b: "มีอะไรเกิดขึ้นหรือเปล่า?" @@ -2637,6 +2690,8 @@ _notification: youReceivedFollowRequest: "ได้รับคำขอติดตาม" yourFollowRequestAccepted: "คำขอติดตามได้รับการอนุมัติแล้ว" pollEnded: "ผลโพลออกมาแล้ว" + scheduledNotePosted: "โน้ตที่กำหนดเวลาไว้ได้ถูกโพสต์แล้ว" + scheduledNotePostFailed: "ล้มเหลวในการโพสต์โน้ตที่กำหนดเวลาไว้" newNote: "โพสต์ใหม่" unreadAntennaNote: "เสาอากาศ {name}" roleAssigned: "ได้รับบทบาท" @@ -2716,7 +2771,7 @@ _deck: mentions: "กล่าวถึงคุณ" direct: "ไดเร็กต์" roleTimeline: "บทบาทไทม์ไลน์" - chat: "แชต" + chat: "แชตเลย" _dialog: charactersExceeded: "คุณกำลังมีตัวอักขระเกินขีดจำกัดสูงสุดแล้วนะ! ปัจจุบันอยู่ที่ {current} จาก {max}" charactersBelow: "คุณกำลังใช้อักขระต่ำกว่าขีดจำกัดขั้นต่ำเลยนะ! ปัจจุบันอยู่ที่ {current} จาก {min}" @@ -3162,7 +3217,9 @@ _watermarkEditor: opacity: "ความทึบแสง" scale: "ขนาด" text: "ข้อความ" + qr: "QR โค้ด" position: "ตำแหน่ง" + margin: "ระยะขอบ" type: "รูปแบบ" image: "รูปภาพ" advanced: "ขั้นสูง" @@ -3177,17 +3234,19 @@ _watermarkEditor: polkadotSubDotOpacity: "ความทึบของจุดรอง" polkadotSubDotRadius: "ขนาดของจุดรอง" polkadotSubDotDivisions: "จำนวนจุดรอง" + leaveBlankToAccountUrl: "เว้นว่างไว้หากต้องการใช้ URL ของบัญชีแทน" _imageEffector: title: "เอฟเฟกต์" addEffect: "เพิ่มเอฟเฟกต์" discardChangesConfirm: "ต้องการทิ้งการเปลี่ยนแปลงแล้วออกหรือไม่?" - nothingToConfigure: "ไม่มีอะไรให้ตั้งค่า" _fxs: chromaticAberration: "ความคลาดสี" glitch: "กลิตช์" mirror: "กระจก" invert: "กลับสี" grayscale: "ขาวดำเทา" + blur: "มัว" + pixelate: "โมเสก" colorAdjust: "ปรับแก้สี" colorClamp: "บีบอัดสี" colorClampAdvanced: "บีบอัดสี (ขั้นสูง)" @@ -3199,10 +3258,14 @@ _imageEffector: checker: "ช่องตาราง" blockNoise: "บล็อกที่มีการรบกวน" tearing: "ฉีกขาด" + fill: "เติมเต็ม" _fxProps: angle: "แองเกิล" scale: "ขนาด" size: "ขนาด" + radius: "รัศสี" + samples: "จำนวนตัวอย่าง" + offset: "ตำแหน่ง" color: "สี" opacity: "ความทึบแสง" normalize: "นอร์มัลไลซ์" @@ -3231,6 +3294,7 @@ _imageEffector: zoomLinesThreshold: "ความกว้างเส้นรวมศูนย์" zoomLinesMaskSize: "ขนาดพื้นที่ตรงกลาง" zoomLinesBlack: "ทำให้ดำ" + circle: "ทรงกลม" drafts: "ร่าง" _drafts: select: "เลือกฉบับร่าง" @@ -3246,3 +3310,22 @@ _drafts: restoreFromDraft: "คืนค่าจากฉบับร่าง" restore: "กู้คืน" listDrafts: "รายการฉบับร่าง" + schedule: "โพสต์กำหนดเวลา" + listScheduledNotes: "รายการโน้ตที่กำหนดเวลาไว้" + cancelSchedule: "ยกเลิกกำหนดเวลา" +qr: "QR โค้ด" +_qr: + showTabTitle: "แสดงผล" + readTabTitle: "แสกน" + shareTitle: "{name}{acct}" + shareText: "โปรดติดตามฉันบน Fediverse ด้วย!" + chooseCamera: "เลือกกล้อง" + cannotToggleFlash: "ไม่สามารถเลือกแสงแฟลชได้" + turnOnFlash: "ปิดแสงแฟลช" + turnOffFlash: "เปิดแสงแฟลช" + startQr: "เริ่มตัวอ่าน QR โค้ด" + stopQr: "หยุดตัวอ่าน QR โค้ด" + noQrCodeFound: "ไม่พบ QR โค้ด" + scanFile: "สแกนภาพจากอุปกรณ์" + raw: "ข้อความ" + mfm: "MFM" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index cabd80e6e7..94e01df0fb 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -31,7 +31,7 @@ openInWindow: "Pencerede aç" profile: "Profil" timeline: "Pano" noAccountDescription: "Bu kullanıcı henüz biyografisini yazmamış." -login: "Giriş Yap" +login: "Oturum Aç" loggingIn: "Giriş Yapılıyor..." logout: "Çıkış Yap" signup: "Kaydol" @@ -310,7 +310,7 @@ agreeBelow: "Aşağıdakileri kabul ediyorum" basicNotesBeforeCreateAccount: "Önemli notlar" termsOfService: "Hizmet Şartları" start: "Başla" -home: "Ana sayfa" +home: "Pano" remoteUserCaution: "Bu kullanıcı uzak bir sunucudan geldiği için, gösterilen bilgiler eksik olabilir." activity: "Etkinlik" images: "Görseller" @@ -360,7 +360,7 @@ whenServerDisconnected: "Sunucu ile bağlantı kesildiğinde" disconnectedFromServer: "Sunucu bağlantısı kesildi" reload: "Yenile" doNothing: "Yoksay" -reloadConfirm: "Zaman çizelgesini yenilemek ister misin?" +reloadConfirm: "Panoyu yenilemek ister misin?" watch: "İzle" unwatch: "İzlemeyi bırak" accept: "Kabul et" @@ -573,9 +573,9 @@ objectStorageSetPublicRead: "Yükleme sırasında \"genel-okuma\" ayarını yap s3ForcePathStyleDesc: "s3ForcePathStyle etkinleştirilirse, kova adı URL'nin ana bilgisayar adı yerine URL yoluna eklenmelidir. Kendi kendine barındırılan bir Minio örneği gibi hizmetleri kullanırken bu ayarı etkinleştirmen gerekebilir." serverLogs: "Sunucu log kayıtları" deleteAll: "Tümünü sil" -showFixedPostForm: "Gönderi formunu zaman çizelgesinin en üstünde görüntüle" -showFixedPostFormInChannel: "Gönderi formunu zaman çizelgesinin en üstünde görüntüle (Kanallar)" -withRepliesByDefaultForNewlyFollowed: "Yeni takip edilen kullanıcıların yanıtlarını varsayılan olarak zaman çizelgesine dahil et" +showFixedPostForm: "Gönderi formunu pano üstünde görüntüle" +showFixedPostFormInChannel: "Gönderi formunu pano üstünde görüntüle (Kanallar)" +withRepliesByDefaultForNewlyFollowed: "Yeni takip edilen kullanıcıların yanıtlarını varsayılan olarak panoya dahil et" newNoteRecived: "Yeni Not'lar var" newNote: "Yeni Not" sounds: "Sesler" @@ -1054,11 +1054,12 @@ permissionDeniedError: "İşlem reddedildi" permissionDeniedErrorDescription: "Bu hesap bu işlemi gerçekleştirmek için gerekli izne sahip değildir." preset: "Ön ayar" selectFromPresets: "Ön ayarlardan seçim yapın" +custom: "Özel" achievements: "Başarılar" gotInvalidResponseError: "Geçersiz sunucu yanıtı" gotInvalidResponseErrorDescription: "Sunucu erişilemez durumda olabilir veya bakım çalışması yapılmaktadır. Lütfen daha sonra tekrar dene." thisPostMayBeAnnoying: "Bu not başkalarını rahatsız edebilir." -thisPostMayBeAnnoyingHome: "Ana zaman çizelgesine gönder" +thisPostMayBeAnnoyingHome: "Ana panoya gönder" thisPostMayBeAnnoyingCancel: "İptal" thisPostMayBeAnnoyingIgnore: "Yine de gönder" collapseRenotes: "Daha önce görüntülenen Renote'lari kısaltılmış olarak göster" @@ -1066,8 +1067,8 @@ collapseRenotesDescription: "Zaten yanıtladığın veya renote aldığın notla internalServerError: "İç Sunucu Hatası" internalServerErrorDescription: "Sunucu beklenmedik bir hatayla karşılaştı." copyErrorInfo: "Hata ayrıntılarını kopyala" -joinThisServer: "Bu sunucuda kaydolun" -exploreOtherServers: "Başka bir sunucu arayın" +joinThisServer: "Kaydol" +exploreOtherServers: "Diğer sunucuları keşfet" letsLookAtTimeline: "Pano'ya bir göz atın" disableFederationConfirm: "Federasyonu cidden devre dışı bırakmak istiyor musun?" disableFederationConfirmWarn: "Federasyondan ayrılsa bile, aksi belirtilmedikçe gönderiler herkese açık olmaya devam edecek. Genellikle bunu yapmanız gerekmez." @@ -1217,8 +1218,8 @@ showRepliesToOthersInTimeline: "Pano'da diğer kişilere verilen yanıtları gö hideRepliesToOthersInTimeline: "Pano'dan diğer kişilerin yanıtlarını gizle" showRepliesToOthersInTimelineAll: "Pano'da takip ettiğin herkesin diğerlerine verdiği yanıtları göster" hideRepliesToOthersInTimelineAll: "Pano'da takip ettiğin herkesten diğer kişilere verilen yanıtları gizle" -confirmShowRepliesAll: "Bu işlem geri alınamaz. Takip ettiğin herkesin yanıtlarını zaman çizelgende diğer kullanıcılara göstermek istiyor musun?" -confirmHideRepliesAll: "Bu işlem geri alınamaz. Şu anda takip ettiğin tüm kullanıcıların yanıtlarını zaman tünelinde cidden göstermeyecek misin?" +confirmShowRepliesAll: "Bu işlem geri alınamaz. Takip ettiğin herkesin yanıtlarını panoda diğer kullanıcılara göstermek istiyor musun?" +confirmHideRepliesAll: "Bu işlem geri alınamaz. Şu anda takip ettiğin tüm kullanıcıların yanıtlarını panoda cidden göstermeyecek misin?" externalServices: "Dış Hizmetler" sourceCode: "Kaynak kodu" sourceCodeIsNotYetProvided: "Kaynak kodu henüz mevcut değildir. Bu sorunu gidermek için yöneticiyle iletişime geçin." @@ -1244,7 +1245,7 @@ releaseToRefresh: "Yenilemek için serbest bırak" refreshing: "Yenileniyor..." pullDownToRefresh: "Yenilemek için aşağı çekin" useGroupedNotifications: "Gruplandırılmış bildirimleri göster" -signupPendingError: "E-posta adresini doğrulamada bir sorun oluştu. Bağlantının süresi dolmuş olabilir." +emailVerificationFailedError: "E-posta adresi doğrulanırken bir sorun oluştu. Bağlantının geçerlilik süresi dolmuş olabilir." cwNotationRequired: "“İçeriği gizle” seçeneği etkinleştirilirse, bir açıklama sağlanmalı." doReaction: "Tepki ekle" code: "Kod" @@ -1342,6 +1343,7 @@ postForm: "Gönderim formu" textCount: "Karakter sayısı" information: "Hakkında" chat: "Sohbet" +directMessage: "Kullanıcıyla sohbet et" migrateOldSettings: "Eski istemci ayarlarını taşıma" migrateOldSettings_description: "Bu işlem otomatik olarak yapılmalıdır, ancak herhangi bir nedenle geçiş başarısız olursa, geçiş işlemini manuel olarak kendin başlatabilirsin. Mevcut yapılandırma bilgileri üzerine yazılacaktır." compress: "Sıkıştır" @@ -1375,6 +1377,18 @@ safeModeEnabled: "Güvenli mod etkinleştirildi" pluginsAreDisabledBecauseSafeMode: "Güvenli mod etkinleştirildiği için tüm eklentiler devre dışı bırakılmıştır." customCssIsDisabledBecauseSafeMode: "Güvenli mod etkin olduğu için özel CSS uygulanmıyor." themeIsDefaultBecauseSafeMode: "Güvenli mod etkinken, varsayılan tema kullanılır. Güvenli modu devre dışı bırakmak bu değişiklikleri geri alır." +thankYouForTestingBeta: "Beta sürümünü test ettiğin için teşekkür ederiz!" +widgets: "Widget'lar" +presets: "Ön ayar" +_imageEditing: + _vars: + filename: "Dosya adı" +_imageFrameEditor: + header: "Başlık" + font: "Yazı tipi" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + quitWithoutSaveConfirm: "Kaydedilmemiş değişiklikleri silmek ister misin?" _order: newest: "Önce yeni" oldest: "Önce eski" @@ -1568,9 +1582,9 @@ _initialTutorial: description: "Burada, Misskey'i kullanmanın temellerini ve özelliklerini öğrenebilirsin." _note: title: "Not nedir?" - description: "Misskey'deki gönderiler “Notlar” olarak adlandırılır. Notlar zaman çizelgesinde kronolojik olarak düzenlenir ve gerçek zamanlı olarak güncellenir." + description: "Misskey'deki gönderiler “Notlar” olarak adlandırılır. Notlar panoda kronolojik olarak düzenlenir ve gerçek zamanlı olarak güncellenir." reply: "Bir mesaja yanıt vermek için bu düğmeye tıklayın. Yanıtlara yanıt vermek de mümkündür, böylece konuşma bir konu başlığı gibi devam eder." - renote: "Bu notu kendi zaman çizelgende paylaşabilirsiniz. Ayrıca yorumlarınızla birlikte alıntı da yapabilirsin." + renote: "Bu notu kendi panonda paylaşabilirsin. Ayrıca yorumlarınla birlikte alıntı da yapabilirsin." reaction: "Not'a tepkiler ekleyebilirsin. Daha fazla ayrıntı bir sonraki sayfada açıklanacak." menu: "Not ayrıntılarını görüntüleyebilir, bağlantıları kopyalayabilir ve çeşitli diğer işlemleri gerçekleştirebilirsin." _reaction: @@ -1623,7 +1637,7 @@ _initialTutorial: _timelineDescription: home: "Ana Pano'da, takip ettiğin hesapların notlarını görebilirsin." local: "Yerel Pano'da, bu sunucudaki tüm kullanıcıların notlarını görebilirsin." - social: "Sosyal Pano, Ana Sayfa ve Yerel Pano'dan gelen notları görüntüler." + social: "Pano, Sosyal Pano ve Yerel Pano'dan gelen notları görüntüler." global: "Global Pano'da, bağlı tüm sunuculardan gelen notları görebilirsin." _serverRules: description: "Kayıt öncesinde gösterilecek bir dizi kural. Hizmet Şartlarının özetini belirlemen önerilir." @@ -1638,7 +1652,7 @@ _serverSettings: shortNameDescription: "Resmi adın uzun olması durumunda görüntülenebilen, örneğin adının kısaltması." fanoutTimelineDescription: "Etkinleştirildiğinde Pano alma performansını büyük ölçüde artırır ve veritabanı yükünü azaltır. Bunun karşılığında Redis'in bellek kullanımı artacaktır. Sunucu belleği düşükse veya sunucu kararsızsa bunu devre dışı bırakmayı düşün." fanoutTimelineDbFallback: "Veritabanına geri dön" - fanoutTimelineDbFallbackDescription: "Etkinleştirildiğinde, Pano önbelleğe alınmamışsa ek sorgular için veritabanına geri döner. Bu özelliği devre dışı bırakmak, geri dönüş sürecini ortadan kaldırarak sunucu yükünü daha da azaltır, ancak alınabilecek zaman çizelgelerinin aralığını sınırlar." + fanoutTimelineDbFallbackDescription: "Etkinleştirildiğinde, Pano önbelleğe alınmamışsa ek sorgular için veritabanına geri döner. Bu özelliği devre dışı bırakmak, geri dönüş sürecini ortadan kaldırarak sunucu yükünü daha da azaltır, ancak alınabilecek panoların aralığını sınırlar." reactionsBufferingDescription: "Etkinleştirildiğinde, reaksiyon oluşturma sırasında performans büyük ölçüde artacak ve veritabanı üzerindeki yük azalacaktır. Ancak, Redis bellek kullanımı artacakt." remoteNotesCleaning: "Uzak notların otomatik olarak temizlenmesi" remoteNotesCleaning_description: "Etkinleştirildiğinde, kullanılmayan ve güncelliğini yitirmiş uzak notlar, veritabanının şişmesini önlemek için periyodik olarak temizlenecek." @@ -1664,6 +1678,9 @@ _serverSettings: userGeneratedContentsVisibilityForVisitor_description2: "Sunucu tarafından alınan uzak içerik dahil olmak üzere sunucudaki tüm içeriği koşulsuz olarak İnternet'e yayınlamak risklidir. Bu, içeriğin dağıtılmış yapısından haberdar olmayan misafirler için özellikle önemlidir, çünkü onlar yanlışlıkla uzak içeriğin bile sunucudaki kullanıcılar tarafından oluşturulan içerik olduğunu düşünebilirler." restartServerSetupWizardConfirm_title: "Sunucu kurulum sihirbazını yeniden başlatmak ister misin?" restartServerSetupWizardConfirm_text: "Bazı mevcut ayarlar sıfırlanacaktır." + entrancePageStyle: "Giriş sayfası stili" + showTimelineForVisitor: "Panoyu göster" + showActivitiesForVisitor: "Aktiviteleri göster" _userGeneratedContentsVisibilityForVisitor: all: "Her şey halka açıktır." localOnly: "Yalnızca yerel içerik yayınlanır, uzak içerik gizli tutulur." @@ -1872,7 +1889,7 @@ _achievements: title: "Öz Referans" description: "Kendi notunuzu alıntı yapın" _htl20npm: - title: "Akış Zaman Çizelgesi" + title: "Akış Panosu" description: "Ev zaman çizelgenizin hızı 20 npm'yi (dakika başına not sayısı) aşıyor mu?" _viewInstanceChart: title: "Analist" @@ -1961,7 +1978,7 @@ _role: asBadge: "Rozet olarak göster" descriptionOfAsBadge: "This role's icon will be displayed next to the username of users with this role if turned on." isExplorable: "Rolü keşfedilebilir hale getir" - descriptionOfIsExplorable: "Bu rolün zaman çizelgesi ve bu role sahip kullanıcıların listesi, etkinleştirilirse kamuya açık hale getirilecek." + descriptionOfIsExplorable: "Bu rolün panosu ve bu role sahip kullanıcıların listesi, etkinleştirilirse kamuya açık hale getirilecek." displayOrder: "Pozisyon" descriptionOfDisplayOrder: "Sayı ne kadar yüksekse, UI pozisyonu da o kadar yüksek olur." preserveAssignmentOnMoveAccount: "Geçiş sırasında rol atamalarını koruyun" @@ -1975,7 +1992,7 @@ _role: high: "Yüksek" _options: gtlAvailable: "Global Pano'yu görüntüleyebilir" - ltlAvailable: "Yerel zaman çizelgesini görüntüleyebilir" + ltlAvailable: "Yerel panoyu görüntüleyebilir" canPublicNote: "Halka açık notlar gönderebilir" mentionMax: "Bir notta maksimum bahsetme sayısı" canInvite: "Sunucu davet kodları oluşturabilir" @@ -2273,6 +2290,7 @@ _time: minute: "Dakika(lar)" hour: "Saat(ler)" day: "Gün(ler)" + month: "Ay" _2fa: alreadyRegistered: "2fa kimlik doğrulama cihazını zaten kaydettin." registerTOTP: "Kimlik doğrulama uygulamasını kaydet" @@ -2448,6 +2466,15 @@ _widgets: clicker: "Tıklayıcı" birthdayFollowings: "Bugünün Doğum Günleri" chat: "Sohbet" +_widgetOptions: + showHeader: "Başlığı göster" + height: "Yükseklik" + _button: + colored: "Renkli" + _clock: + size: "Boyut" + _birthdayFollowings: + period: "Süre" _cw: hide: "Gizle" show: "İçeriği göster" @@ -2478,8 +2505,8 @@ _poll: _visibility: public: "Halka açık" publicDescription: "Notunuz tüm kullanıcılar tarafından görülebilir olacaktır." - home: "Ana sayfa" - homeDescription: "Yalnızca ana zaman çizelgesine gönder" + home: "Pano" + homeDescription: "Yalnızca ana panoya gönder" followers: "Takipçiler" followersDescription: "Sadece takipçilerine görünür hale getir" specified: "Doğrudan" @@ -2492,6 +2519,9 @@ _postForm: replyPlaceholder: "Bu notu yanıtla..." quotePlaceholder: "Bu notu alıntı yap..." channelPlaceholder: "Bir kanala gönder..." + _howToUse: + visibility_title: "Görünürlük" + menu_title: "Menü" _placeholders: a: "Ne yapıyorsun?" b: "Çevrende neler oluyor?" @@ -2526,7 +2556,7 @@ _exportOrImport: userLists: "Kullanıcı listeleri" excludeMutingUsers: "Sessize alınan kullanıcıları hariç tut" excludeInactiveUsers: "Etkin olmayan kullanıcıları hariç tut" - withReplies: "İçe aktarılan kullanıcıların yanıtlarını zaman çizelgesine dahil edin" + withReplies: "İçe aktarılan kullanıcıların yanıtlarını panoya dahil edin" _charts: federation: "Federasyon" apRequest: "Talepler" @@ -2554,7 +2584,7 @@ _instanceCharts: files: "Dosya sayısındaki fark" filesTotal: "Toplam dosya sayısı" _timelines: - home: "Ana Sayfa" + home: "Pano" local: "Yerel" social: "Sosyal" global: "Global" @@ -2672,7 +2702,7 @@ _notification: chatRoomInvitationReceived: "Sohbet odasına davet edildi" achievementEarned: "Başarı kilidi açıldı" exportCompleted: "İhracat işlemi tamamlandı." - login: "Giriş Yap" + login: "Oturum Aç" createToken: "Erişim jetonu oluştur" test: "Bildirim testi" app: "Bağlı uygulamalardan gelen bildirimler" @@ -2709,7 +2739,7 @@ _deck: main: "Ana" widgets: "Widget'lar" notifications: "Bildirimler" - tl: "Ana Sayfa" + tl: "Pano" antenna: "Antenler" list: "Liste" channel: "Kanal" @@ -2920,7 +2950,7 @@ _reversi: freeMatch: "Ücretsiz Eşleştirme" lookingForPlayer: "Rakip aranıyor..." gameCanceled: "Oyun iptal edildi." - shareToTlTheGameWhenStart: "Oyun başlatıldığında zaman çizelgesinde paylaş" + shareToTlTheGameWhenStart: "Oyun başlatıldığında panoda paylaş" iStartedAGame: "Oyun başladı! #MisskeyReversi" opponentHasSettingsChanged: "Rakip ayarlarını değiştirmiş." allowIrregularRules: "Düzensiz kurallar (tamamen ücretsiz)" @@ -3148,7 +3178,7 @@ _clientPerformanceIssueTip: _clip: tip: "Klip, notları gruplandırmanıza olanak tanıyan bir özelliktir." _userLists: - tip: "Listeler, oluşturulurken belirttiğin herhangi bir kullanıcıyı içerebilir. Oluşturulan liste, yalnızca belirtilen kullanıcıları gösteren bir zaman çizelgesi olarak görüntülenebilir." + tip: "Listeler, oluşturulurken belirttiğin herhangi bir kullanıcıyı içerebilir. Oluşturulan liste, yalnızca belirtilen kullanıcıları gösteren bir pano olarak görüntülenebilir." watermark: "Filigran" defaultPreset: "Varsayılan Ön Ayar" _watermarkEditor: @@ -3181,7 +3211,6 @@ _imageEffector: title: "Effektler" addEffect: "Efektler Ekle" discardChangesConfirm: "Cidden çıkmak istiyor musun? Kaydedilmemiş değişikliklerin var." - nothingToConfigure: "Yapılandırılabilir seçenekler mevcut değildir." _fxs: chromaticAberration: "Renk Sapması" glitch: "Bozulma" @@ -3203,6 +3232,7 @@ _imageEffector: angle: "Açı" scale: "Boyut" size: "Boyut" + offset: "Pozisyon" color: "Renk" opacity: "Opaklık" normalize: "Normalize" @@ -3246,3 +3276,6 @@ _drafts: restoreFromDraft: "Taslaktan geri yükle" restore: "Geri yükle" listDrafts: "Taslaklar Listesi" +_qr: + showTabTitle: "Ekran" + raw: "Metin" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 46d9ab95ff..273ccfb078 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -921,6 +921,15 @@ postForm: "Створення нотатки" information: "Інформація" inMinutes: "х" inDays: "д" +widgets: "Віджети" +_imageEditing: + _vars: + filename: "Ім'я файлу" +_imageFrameEditor: + header: "Заголовок" + font: "Шрифт" + fontSerif: "Serif" + fontSansSerif: "Sans serif" _chat: invitations: "Запросити" noHistory: "Історія порожня" @@ -1421,6 +1430,14 @@ _widgets: userList: "Список користувачів" _userList: chooseList: "Виберіть список" +_widgetOptions: + height: "Висота" + _button: + colored: "Кольоровий" + _clock: + size: "Розмір" + _birthdayFollowings: + period: "Тривалість" _cw: hide: "Сховати" show: "Показати більше" @@ -1461,6 +1478,9 @@ _postForm: replyPlaceholder: "Відповідь на цю нотатку..." quotePlaceholder: "Прокоментуйте цю нотатку..." channelPlaceholder: "Опублікувати в каналі" + _howToUse: + visibility_title: "Видимість" + menu_title: "Меню" _placeholders: a: "Чим займаєтесь?" b: "Що відбувається навколо вас?" @@ -1655,3 +1675,6 @@ _imageEffector: color: "Колір" opacity: "Непрозорість" lightness: "Яскравість" +_qr: + showTabTitle: "Відображення" + raw: "Текст" diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index cfa2e26fd5..11db034823 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -837,6 +837,14 @@ replies: "Javob berish" renotes: "Qayta qayd etish" flip: "Teskari" information: "Haqida" +_imageEditing: + _vars: + filename: "Fayl nomi" +_imageFrameEditor: + header: "Sarlavha" + font: "Shrift" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" _chat: invitations: "Taklif qilish" noHistory: "Tarix yo'q" @@ -937,6 +945,12 @@ _widgets: jobQueue: "Vazifalar navbati" _userList: chooseList: "Ro'yxat tanlash" +_widgetOptions: + height: "balandligi" + _button: + colored: "rangli" + _birthdayFollowings: + period: "Davomiylik" _cw: show: "Ko‘proq ko‘rish" chars: "{count} ta belgi(lar)" @@ -964,6 +978,10 @@ _visibility: home: "Bosh sahifa" followers: "Obunachilar" specified: "Bevosita" +_postForm: + _howToUse: + visibility_title: "Ko'rinishi" + menu_title: "Menyu" _profile: name: "Ism" username: "Foydalanuvchi nomi" @@ -1106,3 +1124,6 @@ _imageEffector: _fxProps: color: "Rang" lightness: "Yoritish" +_qr: + showTabTitle: "Displey" + raw: "Matn" diff --git a/locales/verify.js b/locales/verify.js deleted file mode 100644 index a8e9875d6e..0000000000 --- a/locales/verify.js +++ /dev/null @@ -1,53 +0,0 @@ -import locales from './index.js'; - -let valid = true; - -function writeError(type, lang, tree, data) { - process.stderr.write(JSON.stringify({ type, lang, tree, data })); - process.stderr.write('\n'); - valid = false; -} - -function verify(expected, actual, lang, trace) { - for (let key in expected) { - if (!Object.prototype.hasOwnProperty.call(actual, key)) { - continue; - } - if (typeof expected[key] === 'object') { - if (typeof actual[key] !== 'object') { - writeError('mismatched_type', lang, trace ? `${trace}.${key}` : key, { expected: 'object', actual: typeof actual[key] }); - continue; - } - verify(expected[key], actual[key], lang, trace ? `${trace}.${key}` : key); - } else if (typeof expected[key] === 'string') { - switch (typeof actual[key]) { - case 'object': - writeError('mismatched_type', lang, trace ? `${trace}.${key}` : key, { expected: 'string', actual: 'object' }); - break; - case 'undefined': - continue; - case 'string': - const expectedParameters = new Set(expected[key].match(/\{[^}]+\}/g)?.map((s) => s.slice(1, -1))); - const actualParameters = new Set(actual[key].match(/\{[^}]+\}/g)?.map((s) => s.slice(1, -1))); - for (let parameter of expectedParameters) { - if (!actualParameters.has(parameter)) { - writeError('missing_parameter', lang, trace ? `${trace}.${key}` : key, { parameter }); - } - } - } - } - } -} - -const { ['ja-JP']: original, ...verifiees } = locales; - -for (let lang in verifiees) { - if (!Object.prototype.hasOwnProperty.call(locales, lang)) { - continue; - } - verify(original, verifiees[lang], lang); -} - -if (!valid) { - process.exit(1); -} diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index 8b4f1bfd5e..0bb1d54ae8 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -1196,7 +1196,6 @@ showAvatarDecorations: "Hiển thị trang trí ảnh đại diện" releaseToRefresh: "Thả để làm mới" refreshing: "Đang làm mới" pullDownToRefresh: "Kéo xuống để làm mới" -signupPendingError: "Đã xảy ra sự cố khi xác minh địa chỉ email của bạn. Liên kết có thể đã hết hạn." cwNotationRequired: "Nếu \"Ẩn nội dung\" được bật thì cần phải có chú thích." decorate: "Trang trí" lastNDays: "{n} ngày trước" @@ -1223,6 +1222,16 @@ migrateOldSettings: "Di chuyển cài đặt cũ" migrateOldSettings_description: "Thông thường, quá trình này diễn ra tự động, nhưng nếu vì lý do nào đó mà quá trình di chuyển không thành công, bạn có thể kích hoạt thủ công quy trình di chuyển, quá trình này sẽ ghi đè lên thông tin cấu hình hiện tại của bạn." inMinutes: "phút" inDays: "ngày" +widgets: "Tiện ích" +presets: "Mẫu thiết lập" +_imageEditing: + _vars: + filename: "Tên tập tin" +_imageFrameEditor: + header: "Ảnh bìa" + font: "Phông chữ" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" _chat: invitations: "Mời" noHistory: "Không có dữ liệu" @@ -1817,7 +1826,14 @@ _widgets: _userList: chooseList: "Chọn danh sách" clicker: "clicker" - chat: "Trò chuyện" +_widgetOptions: + height: "Chiều cao" + _button: + colored: "Với màu" + _clock: + size: "Kích thước" + _birthdayFollowings: + period: "Thời hạn" _cw: hide: "Ẩn" show: "Tải thêm" @@ -1860,6 +1876,9 @@ _postForm: replyPlaceholder: "Trả lời tút này" quotePlaceholder: "Trích dẫn tút này" channelPlaceholder: "Đăng lên một kênh" + _howToUse: + visibility_title: "Hiển thị" + menu_title: "Menu" _placeholders: a: "Bạn đang định làm gì?" b: "Hôm nay bạn có gì vui?" @@ -2043,7 +2062,6 @@ _deck: channel: "Kênh" mentions: "Lượt nhắc" direct: "Nhắn riêng" - chat: "Trò chuyện" _dialog: charactersExceeded: "Bạn nhắn quá giới hạn ký tự!! Hiện nay {current} / giới hạn {max}" charactersBelow: "Bạn nhắn quá ít tối thiểu ký tự!! Hiện nay {current} / Tối thiểu {min}" @@ -2096,6 +2114,10 @@ _imageEffector: angle: "Góc" scale: "Kích thước" size: "Kích thước" + offset: "Vị trí" color: "Màu sắc" opacity: "Độ trong suốt" lightness: "Độ sáng" +_qr: + showTabTitle: "Hiển thị" + raw: "Văn bản" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index b095374e4d..875ffe2b43 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -10,7 +10,7 @@ notifications: "通知" username: "用户名" password: "密码" initialPasswordForSetup: "初始化密码" -initialPasswordIsIncorrect: "初始化密码不正确" +initialPasswordIsIncorrect: "初始化密码不正确。" initialPasswordForSetupDescription: "如果是自己安装的 Misskey,请输入配置文件里设好的密码。\n如果使用的是 Misskey 的托管服务等,请输入服务商提供的密码。\n如果没有设置密码,请留空并继续。" forgotPassword: "忘记密码" fetchingAsApObject: "在联邦宇宙查询中..." @@ -53,10 +53,10 @@ copyRemoteLink: "复制远程链接" copyLinkRenote: "复制转帖链接" delete: "删除" deleteAndEdit: "删除并编辑" -deleteAndEditConfirm: "要删除此帖并再次编辑吗?对此帖的所有回应、转发和回复也将被删除。" +deleteAndEditConfirm: "要删除此帖并再次编辑吗?此帖下所有的回应、转发和回复也将被删除。" addToList: "添加至列表" addToAntenna: "添加到天线" -sendMessage: "发送" +sendMessage: "发送消息" copyRSS: "复制RSS" copyUsername: "复制用户名" copyUserId: "复制用户 ID" @@ -83,11 +83,13 @@ files: "文件" download: "下载" driveFileDeleteConfirm: "要删除「{name}」文件吗?附加此文件的帖子也会被删除。" unfollowConfirm: "要取消对 {name} 的关注吗?" +cancelFollowRequestConfirm: "要取消申请关注{name}吗?" +rejectFollowRequestConfirm: "要拒绝{name}的关注申请吗?" exportRequested: "导出请求已提交,这可能需要花一些时间,导出的文件将保存到网盘中。" importRequested: "导入请求已提交,这可能需要花一点时间。" lists: "列表" noLists: "列表为空" -note: "帖子" +note: "发帖" notes: "帖子" following: "关注中" followers: "关注者" @@ -106,8 +108,8 @@ privacy: "隐私" makeFollowManuallyApprove: "关注请求需要批准" defaultNoteVisibility: "默认可见性" follow: "关注" -followRequest: "关注申请" -followRequests: "关注申请" +followRequest: "申请关注" +followRequests: "关注请求" unfollow: "取消关注" followRequestPending: "关注请求待批准" enterEmoji: "输入表情符号" @@ -134,7 +136,7 @@ emojiPicker: "表情符号选择器" pinnedEmojisForReactionSettingDescription: "可以设置发表回应时置顶显示的表情符号" pinnedEmojisSettingDescription: "可以设置输入表情符号时置顶显示的表情符号" emojiPickerDisplay: "选择器显示设置" -overwriteFromPinnedEmojisForReaction: "从「置顶(回应)」设置覆盖" +overwriteFromPinnedEmojisForReaction: "使用「置顶(回应)」设置覆盖" overwriteFromPinnedEmojis: "从全局设置覆盖" reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。" rememberNoteVisibility: "保存上次设置的可见性" @@ -146,7 +148,7 @@ enterFileName: "输入文件名" mute: "屏蔽" unmute: "取消隐藏" renoteMute: "隐藏转帖" -renoteUnmute: "解除隐藏转帖" +renoteUnmute: "取消隐藏转帖" block: "屏蔽" unblock: "取消屏蔽" suspend: "冻结" @@ -172,7 +174,7 @@ emojiUrl: "emoji 地址" addEmoji: "添加表情符号" settingGuide: "推荐配置" cacheRemoteFiles: "缓存远程文件" -cacheRemoteFilesDescription: "启用此设定时,将在此服务器上缓存远程文件。虽然可以加快图片显示的速度,但是相对的会消耗大量的服务器存储空间。用户角色内的网盘容量决定了这个远程用户能在服务器上保留多少缓存。当超出了这个限制时,旧的文件将从缓存中被删除,成为链接。当禁用此设定时,则是从一开始就将远程文件保留为链接。此时推荐将 default.yml 的 proxyRemoteFiles 设置为 true 以优化缩略图生成及保护用户隐私。" +cacheRemoteFilesDescription: "启用此设定时,将在此服务器上缓存远程文件。虽然可以加快图片显示的速度,但是相对的会消耗大量的服务器存储空间。用户角色内的网盘容量决定了这个远程用户能在服务器上保留多少缓存。当超出了这个限制时,旧的文件将从缓存中被删除,成为链接。当禁用此设定时,则是从一开始就将远程文件保留为链接。此时推荐将 的 proxyRemoteFiles 设置为 true 以优化缩略图生成及保护用户隐私。" youCanCleanRemoteFilesCache: "可以使用文件管理的🗑️按钮来删除所有的缓存。" cacheRemoteSensitiveFiles: "缓存远程敏感媒体文件" cacheRemoteSensitiveFilesDescription: "如果禁用这项设定,远程服务器的敏感媒体将不会被缓存,而是直接链接。" @@ -182,7 +184,7 @@ flagAsCat: "喵!!!!!!!!!!!!" flagAsCatDescription: "喵喵喵??" flagShowTimelineReplies: "在时间线上显示帖子的回复" flagShowTimelineRepliesDescription: "启用时,时间线除了显示用户的帖子外,还会显示其他用户对帖子的回复。" -autoAcceptFollowed: "自动允许来自我关注的用户对我的关注请求" +autoAcceptFollowed: "自动允许回关请求" addAccount: "添加账户" reloadAccountsList: "更新账户列表" loginFailed: "登录失败" @@ -239,13 +241,13 @@ clearCachedFilesConfirm: "确定要清除所有缓存的远程文件吗?" blockedInstances: "被屏蔽的服务器" blockedInstancesDescription: "设定要屏蔽的服务器,以换行分隔。被屏蔽的服务器将无法与本服务器进行交换通讯。子域名也同样会被屏蔽。" silencedInstances: "被静音的服务器" -silencedInstancesDescription: "设置要静音的服务器,以换行分隔。被静音的服务器内所有的账户都被视为「静音」状态,且关注操作均需要被批准。被阻止的实例不受影响。" +silencedInstancesDescription: "设置要静音的服务器,以换行分隔。被静音的服务器内所有的账户都被视为「静音」状态,且关注操作均需要被批准。已被屏蔽的实例不受影响。" mediaSilencedInstances: "已隐藏媒体文件的服务器" -mediaSilencedInstancesDescription: "设置要隐藏媒体文件的服务器,以换行分隔。被设置的服务器内所有账号的文件均按照「敏感内容」处理,且将无法使用自定义表情符号。被阻止的实例不受影响。" +mediaSilencedInstancesDescription: "设置要隐藏媒体文件的服务器,以换行分隔。被设置的服务器内所有账号的文件均按照「敏感内容」处理,且将无法使用自定义表情符号。已被屏蔽的实例不受影响。" federationAllowedHosts: "允许联合的服务器" federationAllowedHostsDescription: "设定允许联合的服务器,以换行分隔。" -muteAndBlock: "隐藏和屏蔽" -mutedUsers: "已隐藏用户" +muteAndBlock: "隐藏/屏蔽" +mutedUsers: "已隐藏的用户" blockedUsers: "已屏蔽的用户" noUsers: "无用户" editProfile: "编辑资料" @@ -253,6 +255,7 @@ noteDeleteConfirm: "确定要删除该帖子吗?" pinLimitExceeded: "无法置顶更多了" done: "完成" processing: "正在处理" +preprocessing: "准备中" preview: "预览" default: "默认" defaultValueIs: "默认值: {value}" @@ -301,9 +304,10 @@ uploadFromUrlMayTakeTime: "上传可能需要一些时间完成。" uploadNFiles: "上传 {n} 个文件" explore: "发现" messageRead: "已读" +readAllChatMessages: "将所有消息标记为已读" noMoreHistory: "没有更多的历史记录" startChat: "开始聊天" -nUsersRead: "{n} 人已读" +nUsersRead: "{n}人已读" agreeTo: "勾选则表示已阅读并同意 {0}" agree: "同意" agreeBelow: "同意以下内容" @@ -333,6 +337,7 @@ fileName: "文件名称" selectFile: "选择文件" selectFiles: "选择文件" selectFolder: "选择文件夹" +unselectFolder: "取消全选文件夹" selectFolders: "选择多个文件夹" fileNotSelected: "未选择文件" renameFile: "重命名文件" @@ -345,6 +350,7 @@ addFile: "添加文件" showFile: "显示文件" emptyDrive: "网盘中无文件" emptyFolder: "此文件夹中无文件" +dropHereToUpload: "将文件拖动到这里来上传" unableToDelete: "无法删除" inputNewFileName: "请输入新文件名" inputNewDescription: "请输入新标题" @@ -395,7 +401,7 @@ basicInfo: "基本信息" pinnedUsers: "置顶用户" pinnedUsersDescription: "输入您想要固定到“发现”页面的用户,一行一个。" pinnedPages: "固定页面" -pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,一行一个。" +pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,以换行符分隔。" pinnedClipId: "置顶的便签 ID" pinnedNotes: "已置顶的帖子" hcaptcha: "hCaptcha" @@ -428,7 +434,7 @@ notifyAntenna: "开启通知" withFileAntenna: "仅带有附件的帖子" excludeNotesInSensitiveChannel: "排除敏感频道内的帖子" enableServiceworker: "启用 ServiceWorker" -antennaUsersDescription: "指定用户名,一行一个" +antennaUsersDescription: "指定用户名,用换行符进行分隔" caseSensitive: "区分大小写" withReplies: "包括回复" connectedTo: "您的账号已连到接以下第三方账号" @@ -460,7 +466,7 @@ moderationNote: "管理笔记" moderationNoteDescription: "可以用来记录仅在管理员之间共享的笔记。" addModerationNote: "添加管理笔记" moderationLogs: "管理日志" -nUsersMentioned: "{n} 被提到" +nUsersMentioned: "{n}人投稿" securityKeyAndPasskey: "安全密钥或 Passkey" securityKey: "安全密钥" lastUsed: "最后使用:" @@ -470,14 +476,14 @@ passwordLessLogin: "无密码登录" passwordLessLoginDescription: "不使用密码,仅使用安全密钥或 Passkey 登录" resetPassword: "重置密码" newPasswordIs: "新的密码是「{password}」" -reduceUiAnimation: "减少UI动画" +reduceUiAnimation: "减少 UI 动画" share: "分享" notFound: "未找到" notFoundDescription: "没有与指定 URL 对应的页面。" uploadFolder: "默认上传文件夹" markAsReadAllNotifications: "将所有通知标为已读" markAsReadAllUnreadNotes: "将所有帖子标记为已读" -markAsReadAllTalkMessages: "将所有聊天标记为已读" +markAsReadAllTalkMessages: "将所有私信标记为已读" help: "帮助" inputMessageHere: "在此键入信息" close: "关闭" @@ -537,7 +543,7 @@ regenerate: "重新生成" fontSize: "字体大小" mediaListWithOneImageAppearance: "仅一张图片的媒体列表高度" limitTo: "上限为 {x}" -noFollowRequests: "没有关注申请" +noFollowRequests: "没有关注请求" openImageInNewTab: "在新标签页中打开图片" dashboard: "管理面板" local: "本地" @@ -597,7 +603,7 @@ recentUsed: "最近使用" install: "安装" uninstall: "卸载" installedApps: "已授权的应用" -nothing: "没有" +nothing: "无" installedDate: "授权日期" lastUsedDate: "最近使用" state: "状态" @@ -687,13 +693,13 @@ emptyToDisableSmtpAuth: "用户名和密码留空可以禁用 SMTP 验证" smtpSecure: "在 SMTP 连接中使用隐式 SSL / TLS" smtpSecureInfo: "使用 STARTTLS 时关闭。" testEmail: "邮件发送测试" -wordMute: "隐藏关键词" +wordMute: "折叠关键词" wordMuteDescription: "折叠包含指定关键词的帖子。被折叠的帖子可单击展开。" -hardWordMute: "隐藏硬关键词" -showMutedWord: "显示已隐藏的关键词" -hardWordMuteDescription: "隐藏包含指定关键词的帖子。与隐藏关键词不同,帖子将完全不会显示。" +hardWordMute: "屏蔽关键词" +showMutedWord: "显示已折叠的关键词" +hardWordMuteDescription: "屏蔽包含指定关键词的帖子。与折叠关键词不同,帖子将完全不会显示。" regexpError: "正则表达式错误" -regexpErrorDescription: "{tab} 隐藏文字的第 {line} 行的正则表达式有错误:" +regexpErrorDescription: "{tab} 折叠关键词的第 {line} 行的正则表达式有错误:" instanceMute: "已隐藏的服务器" userSaysSomething: "{name} 说了什么,但是被屏蔽词过滤了" userSaysSomethingAbout: "{name} 说了关于「{word}」的什么" @@ -772,6 +778,7 @@ lockedAccountInfo: "即使启用该功能,只要帖子可见范围不是「仅 alwaysMarkSensitive: "默认将媒体文件标记为敏感内容" loadRawImages: "添加附件图像的缩略图时使用原始图像质量" disableShowingAnimatedImages: "不播放动画" +disableShowingAnimatedImages_caption: "如果即使关闭了此设置但动画仍无法播放,则可能是浏览器或操作系统的辅助功能设置,又或者是省电设置等产生了干扰。" highlightSensitiveMedia: "高亮显示敏感媒体" verificationEmailSent: "已发送确认电子邮件。请访问电子邮件中的链接以完成设置。" notSet: "未设置" @@ -779,7 +786,7 @@ emailVerified: "电子邮件地址已验证" noteFavoritesCount: "收藏的帖子数" pageLikesCount: "页面点赞次数" pageLikedCount: "页面被点赞次数" -contact: "联系人" +contact: "联系方式" useSystemFont: "使用系统默认字体" clips: "便签" experimentalFeatures: "实验性功能" @@ -790,7 +797,7 @@ makeExplorable: "使账号可见。" makeExplorableDescription: "关闭时,账号不会显示在\"发现\"中。" duplicate: "复制" left: "左" -center: "中央" +center: "居中" wide: "宽" narrow: "窄" reloadToApplySetting: "页面刷新后设置才会生效。是否现在刷新页面?" @@ -800,7 +807,7 @@ showTitlebar: "显示标题栏" clearCache: "清除缓存" onlineUsersCount: "{n} 人在线" nUsers: "{n} 用户" -nNotes: "{n} 帖子" +nNotes: "{n}帖子" sendErrorReports: "发送错误报告" sendErrorReportsDescription: "启用后,如果出现问题,可以与 Misskey 共享详细的错误信息,从而帮助提高软件的质量。错误信息包括操作系统版本、浏览器类型、行为历史记录等。" myTheme: "我的主题" @@ -812,7 +819,7 @@ advanced: "高级" advancedSettings: "高级设置" value: "值" createdAt: "创建日期" -updatedAt: "更新时间" +updatedAt: "更新日期" saveConfirm: "确定保存?" deleteConfirm: "确定删除?" invalidValue: "无效值。" @@ -824,7 +831,7 @@ youAreRunningUpToDateClient: "您所使用的客户端已经是最新的。" newVersionOfClientAvailable: "新版本的客户端可用。" usageAmount: "使用量" capacity: "容量" -inUse: "已使用" +inUse: "使用中" editCode: "编辑代码" apply: "应用" receiveAnnouncementFromInstance: "从服务器接收通知" @@ -869,12 +876,12 @@ noMaintainerInformationWarning: "尚未设置管理员信息。" noInquiryUrlWarning: "尚未设置联络地址。" noBotProtectionWarning: "尚未设置 Bot 防御。" configure: "设置" -postToGallery: "发送到图库" -postToHashtag: "投稿到这个标签" -gallery: "图库" +postToGallery: "创建新图集" +postToHashtag: "发布至该话题" +gallery: "图集" recentPosts: "最新发布" popularPosts: "热门投稿" -shareWithNote: "在帖子中分享" +shareWithNote: "分享到帖文" ads: "广告" expiration: "截止时间" startingperiod: "开始时间" @@ -885,7 +892,7 @@ middle: "中" low: "低" emailNotConfiguredWarning: "尚未设置电子邮件地址。" ratio: "比率" -previewNoteText: "预览文本" +previewNoteText: "预览正文" customCss: "自定义 CSS" customCssWarn: "这些设置必须有相关的基础知识,不当的配置可能导致客户端无法正常使用。" global: "全局" @@ -905,7 +912,7 @@ accountDeletionInProgress: "正在删除账户" usernameInfo: "在服务器上唯一标识您的帐户的名称。您可以使用字母 (a ~ z, A ~ Z)、数字 (0 ~ 9) 和下划线 (_)。用户名以后不能更改。" aiChanMode: "小蓝模式" devMode: "开发者模式" -keepCw: "回复时维持隐藏内容" +keepCw: "始终开启内容警告" pubSub: "Pub/Sub 账户" lastCommunication: "最近通信" resolved: "已解决" @@ -924,8 +931,8 @@ manageAccounts: "管理账户" makeReactionsPublic: "将回应设置为公开" makeReactionsPublicDescription: "将您发表过的回应设置成公开可见。" classic: "经典" -muteThread: "隐藏帖子列表" -unmuteThread: "取消隐藏帖子列表" +muteThread: "静音帖文串" +unmuteThread: "取消帖文串静音" followingVisibility: "关注的人的公开范围" followersVisibility: "关注者的公开范围" continueThread: "查看更多帖子" @@ -948,17 +955,17 @@ searchByGoogle: "Google" instanceDefaultLightTheme: "服务器默认浅色主题" instanceDefaultDarkTheme: "服务器默认深色主题" instanceDefaultThemeDescription: "以对象格式输入主题代码" -mutePeriod: "隐藏期限" +mutePeriod: "隐藏时长" period: "截止时间" indefinitely: "永久" -tenMinutes: "10 分钟" +tenMinutes: "10分钟" oneHour: "1 小时" -oneDay: "1 天" +oneDay: "1天" oneWeek: "1 周" -oneMonth: "1 个月" -threeMonths: "3 个月" +oneMonth: "1个月" +threeMonths: "3个月" oneYear: "1 年" -threeDays: "3 天" +threeDays: "3天" reflectMayTakeTime: "可能需要一些时间才能体现出效果。" failedToFetchAccountInformation: "获取账户信息失败" rateLimitExceeded: "已超过速率限制" @@ -967,8 +974,8 @@ cropImageAsk: "是否要裁剪图像?" cropYes: "去裁剪" cropNo: "就这样吧!" file: "文件" -recentNHours: "最近 {n} 小时" -recentNDays: "最近 {n} 天" +recentNHours: "最近{n}小时" +recentNDays: "最近{n}天" noEmailServerWarning: "电子邮件服务器未设置。" thereIsUnresolvedAbuseReportWarning: "有未解决的报告" recommended: "推荐" @@ -1018,6 +1025,9 @@ pushNotificationAlreadySubscribed: "推送通知消息已启用" pushNotificationNotSupported: "浏览器或服务器不支持推送通知消息" sendPushNotificationReadMessage: "删除已读推送通知消息" sendPushNotificationReadMessageCaption: "您终端设备的电池消耗可能会增加。" +pleaseAllowPushNotification: "请在浏览器中启用推送通知" +browserPushNotificationDisabled: "未能获取发送通知的权限" +browserPushNotificationDisabledDescription: "{serverName}无权限发送通知。请在浏览器设置中允许通知后重新尝试。" windowMaximize: "最大化" windowMinimize: "最小化" windowRestore: "还原" @@ -1027,7 +1037,7 @@ tools: "工具" cannotLoad: "无法加载" numberOfProfileView: "个人资料展示次数" like: "点赞!" -unlike: "取消赞" +unlike: "取消喜欢" numberOfLikes: "点赞数" show: "显示" neverShow: "不再显示" @@ -1054,6 +1064,7 @@ permissionDeniedError: "操作被拒绝" permissionDeniedErrorDescription: "本账户没有执行该操作的权限。" preset: "预设值" selectFromPresets: "从预设值中选择" +custom: "自定义" achievements: "成就" gotInvalidResponseError: "服务器无应答" gotInvalidResponseErrorDescription: "您的网络连接可能出现了问题, 或是远程服务器暂时不可用. 请稍后重试。" @@ -1062,7 +1073,7 @@ thisPostMayBeAnnoyingHome: "发到首页" thisPostMayBeAnnoyingCancel: "取消" thisPostMayBeAnnoyingIgnore: "就这样发布" collapseRenotes: "省略显示已经看过的转发内容" -collapseRenotesDescription: "将回应过或转贴过的贴子折叠表示。" +collapseRenotesDescription: "折叠显示回应或转发过的帖文。" internalServerError: "内部服务器错误" internalServerErrorDescription: "内部服务器发生了预期外的错误" copyErrorInfo: "复制错误信息" @@ -1078,7 +1089,7 @@ postToTheChannel: "发布到频道" cannotBeChangedLater: "之后不能再更改。" reactionAcceptance: "接受表情回应" likeOnly: "仅点赞" -likeOnlyForRemote: "远程仅点赞" +likeOnlyForRemote: "全部(远程仅点赞)" nonSensitiveOnly: "仅限非敏感内容" nonSensitiveOnlyForLocalLikeOnlyForRemote: "仅限非敏感内容(远程仅点赞)" rolesAssignedToMe: "指派给自己的角色" @@ -1103,7 +1114,7 @@ retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加" enableChartsForRemoteUser: "生成远程用户的图表" enableChartsForFederatedInstances: "生成远程服务器的图表" enableStatsForFederatedInstances: "获取远程服务器的信息" -showClipButtonInNoteFooter: "在贴文下方显示便签按钮" +showClipButtonInNoteFooter: "在帖文下方显示便签按钮" reactionsDisplaySize: "回应显示大小" limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示" noteIdOrUrl: "帖子 ID 或 URL" @@ -1141,7 +1152,7 @@ archive: "归档" archived: "已归档" unarchive: "取消归档" channelArchiveConfirmTitle: "要将 {name} 归档吗?" -channelArchiveConfirmDescription: "归档后,在频道列表与搜索结果中不会显示,也无法发布新的贴文。" +channelArchiveConfirmDescription: "归档后,不会在频道列表与搜索结果中显示,也无法发布新的帖文。" thisChannelArchived: "该频道已被归档。" displayOfNote: "显示帖子" initialAccountSetting: "初始设定" @@ -1149,7 +1160,7 @@ youFollowing: "正在关注" preventAiLearning: "拒绝接受生成式 AI 的学习" preventAiLearningDescription: "要求文章生成 AI 或图像生成 AI 不能够以发布的帖子和图像等内容作为学习对象。这是通过在 HTML 响应中包含 noai 标志来实现的,这不能完全阻止 AI 学习你的发布内容,并不是所有 AI 都会遵守这类请求。" options: "选项" -specifyUser: "用户指定" +specifyUser: "指定用户" lookupConfirm: "确定查询?" openTagPageConfirm: "确定打开话题标签页面?" specifyHost: "指定主机名" @@ -1167,6 +1178,7 @@ installed: "已安装" branding: "品牌" enableServerMachineStats: "公开服务器硬件统计信息" enableIdenticonGeneration: "启用生成用户 Identicon" +showRoleBadgesOfRemoteUsers: "显示远程用户的角色徽章" turnOffToImprovePerformance: "关闭该选项可以提高性能。" createInviteCode: "生成邀请码" createWithOptions: "使用选项来创建" @@ -1200,7 +1212,7 @@ renotes: "转发" loadReplies: "查看回复" loadConversation: "查看对话" pinnedList: "已置顶的列表" -keepScreenOn: "保持设备屏幕开启" +keepScreenOn: "保持屏幕常亮" verifiedLink: "已验证的链接" notifyNotes: "打开发帖通知" unnotifyNotes: "关闭发帖通知" @@ -1244,7 +1256,7 @@ releaseToRefresh: "松开以刷新" refreshing: "刷新中" pullDownToRefresh: "下拉以刷新" useGroupedNotifications: "分组显示通知" -signupPendingError: "确认电子邮件时出现错误。链接可能已过期。" +emailVerificationFailedError: "确认电子邮件时出现错误。链接可能已过期。" cwNotationRequired: "在启用「隐藏内容」时必须输入注释" doReaction: "回应" code: "代码" @@ -1264,7 +1276,7 @@ replaying: "重播中" endReplay: "结束回放" copyReplayData: "复制回放数据" ranking: "排行榜" -lastNDays: "最近 {n} 天" +lastNDays: "最近{n}天" backToTitle: "返回标题" hemisphere: "居住地区" withSensitive: "显示包含敏感媒体的帖子" @@ -1281,7 +1293,7 @@ useNativeUIForVideoAudioPlayer: "使用浏览器的 UI 播放动画及音频" keepOriginalFilename: "保持原文件名" keepOriginalFilenameDescription: "若关闭此设置,上传文件时文件名将被替换为随机字符。" noDescription: "没有描述" -alwaysConfirmFollow: "总是确认关注" +alwaysConfirmFollow: "在关注时始终确认" inquiry: "联系我们" tryAgain: "请再试一次" confirmWhenRevealingSensitiveMedia: "显示敏感内容前需要确认" @@ -1315,33 +1327,36 @@ acknowledgeNotesAndEnable: "理解注意事项后再开启。" federationSpecified: "此服务器已开启联合白名单。只能与管理员指定的服务器通信。" federationDisabled: "此服务器已禁用联合。无法与其它服务器上的用户通信。" draft: "草稿" +draftsAndScheduledNotes: "草稿和定时发送" confirmOnReact: "发送回应前需要确认" reactAreYouSure: "要用「{emoji}」进行回应吗?" markAsSensitiveConfirm: "要将此媒体标记为敏感吗?" unmarkAsSensitiveConfirm: "要将此媒体解除敏感标记吗?" preferences: "偏好设置" accessibility: "辅助功能" -preferencesProfile: "设置的配置" +preferencesProfile: "设置的配置文件" copyPreferenceId: "复制设置 ID" resetToDefaultValue: "重置为默认值" -overrideByAccount: "用账户覆盖" +overrideByAccount: "覆盖账号" untitled: "未命名" noName: "没有名字" skip: "跳过" restore: "恢复" syncBetweenDevices: "设备间同步" preferenceSyncConflictTitle: "服务器上已存在设定值" -preferenceSyncConflictText: "服务器上已有此设置的设定值。要覆盖哪个设定值?" +preferenceSyncConflictText: "即将保存设定值到服务器,但检测到服务器上已有此设置的设定值。要使用哪个设定值?" preferenceSyncConflictChoiceMerge: "合并" preferenceSyncConflictChoiceServer: "服务器上的设定值" preferenceSyncConflictChoiceDevice: "设备上的设定值" preferenceSyncConflictChoiceCancel: "取消同步" paste: "粘贴" emojiPalette: "表情符号调色板" -postForm: "投稿窗口" +postForm: "发帖窗口" textCount: "字数" information: "关于" chat: "聊天" +directMessage: "私信" +directMessage_short: "消息" migrateOldSettings: "迁移旧设置信息" migrateOldSettings_description: "通常设置信息将自动迁移。但如果由于某种原因迁移不成功,则可以手动触发迁移过程。当前的配置信息将被覆盖。" compress: "压缩" @@ -1359,61 +1374,128 @@ advice: "建议" realtimeMode: "实时模式" turnItOn: "开启" turnItOff: "关闭" -emojiMute: "隐藏表情符号" -emojiUnmute: "解除隐藏表情符号" +emojiMute: "打码表情符号" +emojiUnmute: "取消表情符号打码" muteX: "隐藏{x}" -unmuteX: "解除隐藏{x}" +unmuteX: "取消对{x}的隐藏" abort: "中止" tip: "提示和技巧" redisplayAllTips: "重新显示所有的提示和技巧" hideAllTips: "隐藏所有的提示和技巧" defaultImageCompressionLevel: "默认图像压缩等级" defaultImageCompressionLevel_description: "较低的等级可以保持画质,但会增加文件大小。
较高的等级可以减少文件大小,但相对应的画质将会降低。" -inMinutes: "分" -inDays: "日" +defaultCompressionLevel: "默认压缩等级" +defaultCompressionLevel_description: "较低的等级可以保持质量,但会增加文件大小。
较高的等级可以减少文件大小,但相对应的质量将会降低。" +inMinutes: "分钟" +inDays: "天" safeModeEnabled: "已启用安全模式" pluginsAreDisabledBecauseSafeMode: "因启用了安全模式,所有插件均已被禁用。" customCssIsDisabledBecauseSafeMode: "因启用了安全模式,无法应用自定义 CSS。" themeIsDefaultBecauseSafeMode: "启用安全模式时将使用默认主题。关闭安全模式后将还原。" +thankYouForTestingBeta: "感谢您协助测试 beta 版!" +createUserSpecifiedNote: "创建指定用户的帖子" +schedulePost: "定时发布" +scheduleToPostOnX: "预定在 {x} 发出" +scheduledToPostOnX: "已预定在 {x} 发出" +schedule: "定时" +scheduled: "定时" +widgets: "小工具" +deviceInfo: "设备信息" +deviceInfoDescription: "咨询技术问题时,将以下信息一并发送有助于解决问题。" +youAreAdmin: "你是管理员" +frame: "边框" +presets: "预设值" +zeroPadding: "填充 0" +nothingToConfigure: "没有项目" +_imageEditing: + _vars: + caption: "文件标题" + filename: "文件名称" + filename_without_ext: "不带扩展名的文件名" + year: "拍摄年" + month: "拍摄月" + day: "拍摄日" + hour: "拍摄时间(时)" + minute: "拍摄时间(分)" + second: "拍摄时间(秒)" + camera_model: "相机名称" + camera_lens_model: "镜头型号" + camera_mm: "焦距" + camera_mm_35: "焦距(35mm等效)" + camera_f: "光圈" + camera_s: "快门速度" + camera_iso: "ISO" + gps_lat: "纬度" + gps_long: "经度" +_imageFrameEditor: + title: "编辑边框" + tip: "您可以通过添加包含边框和元数据的标签来装饰图片。" + header: "顶栏" + footer: "页脚" + borderThickness: "边框宽度" + labelThickness: "标签宽度" + labelScale: "标签比例" + centered: "居中" + captionMain: "标题(大)" + captionSub: "标题(小)" + availableVariables: "可修改的变量" + withQrCode: "二维码" + backgroundColor: "背景颜色" + textColor: "文本颜色" + font: "字体" + fontSerif: "衬线字体" + fontSansSerif: "无衬线字体" + quitWithoutSaveConfirm: "放弃未保存的更改?" + failedToLoadImage: "图片加载失败" +_compression: + _quality: + high: "高质量" + medium: "中质量" + low: "低质量" + _size: + large: "大" + medium: "中" + small: "小" _order: newest: "从新到旧" oldest: "从旧到新" _chat: + messages: "消息" noMessagesYet: "还没有消息" newMessage: "新消息" individualChat: "私聊" - individualChat_description: "可以与特定用户进行一对一聊天。" + individualChat_description: "与特定的用户单独聊天。" roomChat: "群聊" - roomChat_description: "可以进行多人聊天。\n就算用户未允许私聊,只要接受了邀请,仍可以聊天。" - createRoom: "创建房间" - inviteUserToChat: "邀请用户来开始聊天" - yourRooms: "已创建的房间" - joiningRooms: "已加入的房间" + roomChat_description: "支持多人同时聊天。\n即使对方不允许私聊,只要接受邀请也能加入。" + createRoom: "创建群聊" + inviteUserToChat: "邀请用户来聊天吧" + yourRooms: "已创建的群聊" + joiningRooms: "已加入的群聊" invitations: "邀请" noInvitations: "没有邀请" history: "历史" noHistory: "没有历史记录" - noRooms: "没有房间" + noRooms: "没有群聊" inviteUser: "邀请用户" sentInvitations: "已发送的邀请" join: "加入" ignore: "忽略" - leave: "退出房间" + leave: "退出群聊" members: "成员" searchMessages: "搜索消息" home: "首页" send: "发送" newline: "换行" - muteThisRoom: "静音此房间" - deleteRoom: "删除房间" + muteThisRoom: "消息免打扰" + deleteRoom: "删除群聊" chatNotAvailableForThisAccountOrServer: "此服务器或者账户还未开启聊天功能。" chatIsReadOnlyForThisAccountOrServer: "此服务器或者账户内的聊天为只读。无法发布新信息或创建及加入群聊。" - chatNotAvailableInOtherAccount: "对方账户目前处于无法使用聊天的状态。" - cannotChatWithTheUser: "无法与此用户聊天" + chatNotAvailableInOtherAccount: "对方的账户当前无法使用私信。" + cannotChatWithTheUser: "无法私信该用户" cannotChatWithTheUser_description: "可能现在无法使用聊天,或者对方未开启聊天。" - youAreNotAMemberOfThisRoomButInvited: "您还未加入此房间,但已收到邀请。如要加入,请接受邀请。" + youAreNotAMemberOfThisRoomButInvited: "您尚未加入此群组,但已收到加入邀请。请接受邀请加入。" doYouAcceptInvitation: "要接受邀请吗?" - chatWithThisUser: "聊天" + chatWithThisUser: "私信" thisUserAllowsChatOnlyFromFollowers: "此用户仅接受关注者发起的聊天。" thisUserAllowsChatOnlyFromFollowing: "此用户仅接受关注的人发起的聊天。" thisUserAllowsChatOnlyFromMutualFollowing: "此用户仅接受互相关注的人发起的聊天。" @@ -1467,14 +1549,21 @@ _settings: showUrlPreview: "显示 URL 预览" showAvailableReactionsFirstInNote: "在顶部显示可用的回应" showPageTabBarBottom: "在下方显示页面标签栏" + emojiPaletteBanner: "可以将固定显示在表情符号选择器中的预设注册为调色板,也可以自定义表情符号选择器的显示方式。" + enableAnimatedImages: "启用动画图像" + settingsPersistence_title: "设置持久化" + settingsPersistence_description1: "启用设置持久化可防止设置信息丢失。" + settingsPersistence_description2: "根据环境不同,有可能无法开启。" _chat: showSenderName: "显示发送者的名字" sendOnEnter: "回车键发送" _preferencesProfile: - profileName: "配置名" + profileName: "配置文件名" profileNameDescription: "请指定用于识别此设备的名称" profileNameDescription2: "如「PC」、「手机」等" manageProfiles: "管理配置文件" + shareSameProfileBetweenDevicesIsNotRecommended: "不建议在多个设备间共用同一个配置文件。" + useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "若想在多个设备间同步某些设置,请为每个设置打开「多设备间同步」选项。" _preferencesBackup: autoBackup: "自动备份" restoreFromBackup: "从备份恢复" @@ -1484,6 +1573,7 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "需指定配置名以开启自动备份。" autoPreferencesBackupIsNotEnabledForThisDevice: "此设备未开启自动备份" backupFound: "已找到备份" + forceBackup: "强制备份设置" _accountSettings: requireSigninToViewContents: "需要登录才能显示内容" requireSigninToViewContentsDescription1: "您发布的所有帖子将变成需要登入后才会显示。有望防止爬虫收集各种信息。" @@ -1660,10 +1750,13 @@ _serverSettings: allowExternalApRedirect: "允许通过 ActivityPub 重定向查询" allowExternalApRedirect_description: "启用时,将允许其它服务器通过此服务器查询第三方内容,但有可能导致内容欺骗。" userGeneratedContentsVisibilityForVisitor: "用户生成内容对非用户的可见性" - userGeneratedContentsVisibilityForVisitor_description: "对于防止难以审核的不适当的远程内容等,通过自己的服务器无意中在互联网上公开等问题很有用。" + userGeneratedContentsVisibilityForVisitor_description: "对于防止诸如难以管理的不适当的远程内容通过自己的服务器意外地在互联网上公开等问题很有用。" userGeneratedContentsVisibilityForVisitor_description2: "包含服务器接收到的远程内容在内,无条件将服务器上的所有内容公开在互联网上存在风险。特别是对去中心化的特性不是很了解的访问者有可能将远程服务器上的内容误认为是在此服务器内生成的,需要特别留意。" restartServerSetupWizardConfirm_title: "要重新开始服务器初始设定向导吗?" restartServerSetupWizardConfirm_text: "现有的部分设定将重置。" + entrancePageStyle: "入口页面样式" + showTimelineForVisitor: "显示时间线" + showActivitiesForVisitor: "显示活动" _userGeneratedContentsVisibilityForVisitor: all: "全部公开" localOnly: "仅公开本地内容,隐藏远程内容" @@ -1769,7 +1862,7 @@ _achievements: _login500: title: "老熟人Ⅰ" description: "累计登录 500 天" - flavor: "诸君,我喜欢贴文" + flavor: "诸君,我喜欢帖文" _login600: title: "老熟人Ⅱ" description: "累计登录 600 天" @@ -1788,7 +1881,7 @@ _achievements: flavor: "感谢您使用 Misskey!" _noteClipped1: title: "忍不住要收藏到便签" - description: "第一次将贴文贴进便签" + description: "第一次将帖子加入便签" _noteFavorited1: title: "观星者" description: "第一次将帖子加入收藏" @@ -1888,7 +1981,7 @@ _achievements: description: "试图对网盘中的文件夹进行循环嵌套" _reactWithoutRead: title: "有好好读过吗?" - description: "在含有 100 字以上的帖子被发出三秒内做出回应" + description: "在含有100字以上的帖子被发出三秒内做出回应" _clickedClickHere: title: "点这里" description: "点了这里" @@ -1986,14 +2079,15 @@ _role: canManageAvatarDecorations: "管理头像挂件" driveCapacity: "网盘容量" maxFileSize: "可上传的最大文件大小" + maxFileSize_caption: "可能在反向代理或 CDN 等前端存在其它设定值。" alwaysMarkNsfw: "总是将文件标记为 NSFW" canUpdateBioMedia: "可以更新头像和横幅" pinMax: "帖子置顶数量限制" antennaMax: "可创建的最大天线数量" - wordMuteMax: "隐藏词的字数限制" + wordMuteMax: "折叠词的字数限制" webhookMax: "Webhook 创建数量限制" clipMax: "便签创建数量限制" - noteEachClipsMax: "单个便签内的贴文数量限制" + noteEachClipsMax: "便签内贴文的最大数量" userListMax: "用户列表创建数量限制" userEachUserListsMax: "单个用户列表内用户数量限制" rateLimitFactor: "速率限制" @@ -2008,11 +2102,12 @@ _role: canImportFollowing: "允许导入关注列表" canImportMuting: "允许导入隐藏列表" canImportUserLists: "允许导入用户列表" - chatAvailability: "允许聊天" + chatAvailability: "允许私信" uploadableFileTypes: "可上传的文件类型" uploadableFileTypes_caption: "指定 MIME 类型。可用换行指定多个类型,也可以用星号(*)作为通配符。(如 image/*)" uploadableFileTypes_caption2: "文件根据文件的不同,可能无法判断其类型。若要允许此类文件,请在指定中添加 {x}。" noteDraftLimit: "可在服务器上创建多少草稿" + scheduledNoteLimit: "可同时创建的定时帖子数量" watermarkAvailable: "能否使用水印功能" _condition: roleAssignedTo: "已分配给手动角色" @@ -2078,9 +2173,9 @@ _forgotPassword: ifNoEmail: "如果您没有设置电子邮件地址,请联系管理员。" contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。" _gallery: - my: "我的图库" - liked: "喜欢的图片" - like: "喜欢" + my: "我的图集" + liked: "喜欢的图集" + like: "喜欢!" unlike: "取消喜欢" _email: _follow: @@ -2140,32 +2235,32 @@ _instanceTicker: _serverDisconnectedBehavior: reload: "自动重载" dialog: "对话框警告" - quiet: "安静警告" + quiet: "静默警告" _channel: create: "创建频道" edit: "编辑频道" setBanner: "设置横幅" removeBanner: "删除横幅" - featured: "热点" - owned: "管理中" + featured: "热门" + owned: "正在管理" following: "正在关注" - usersCount: "有 {n} 人参与" - notesCount: "有 {n} 个帖子" + usersCount: "有{n}人参与" + notesCount: "有{n}个帖子" nameAndDescription: "名称与描述" nameOnly: "仅名称" - allowRenoteToExternal: "允许在频道外转帖及引用" + allowRenoteToExternal: "允许转发到频道外和引用" _menuDisplay: sideFull: "横向" sideIcon: "横向(图标)" top: "顶部" hide: "隐藏" _wordMute: - muteWords: "要隐藏的词" + muteWords: "要屏蔽的词" muteWordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" muteWordsDescription2: "正则表达式用斜线包裹" _instanceMute: - instanceMuteDescription: "隐藏服务器中所有的帖子和转帖,包括这些服务器上用户的回复。" - instanceMuteDescription2: "一行一个" + instanceMuteDescription: "隐藏来自这些服务器的所有帖子和转贴,包括这些服务器上用户的回复。" + instanceMuteDescription2: "通过换行符分隔进行设置" title: "下面实例中的帖子将被隐藏。" heading: "已隐藏的服务器" _theme: @@ -2240,7 +2335,7 @@ _sfx: noteMy: "我的帖子" notification: "通知" reaction: "选择回应时" - chatMessage: "聊天信息" + chatMessage: "私信" _soundSettings: driveFile: "使用网盘内的音频" driveFileWarn: "选择网盘上的文件" @@ -2251,28 +2346,29 @@ _soundSettings: driveFileError: "无法读取声音。请更改设置。" _ago: future: "未来" - justNow: "最近" - secondsAgo: "{n} 秒前" - minutesAgo: "{n} 分前" - hoursAgo: "{n} 小时前" - daysAgo: "{n} 日前" - weeksAgo: "{n} 周前" - monthsAgo: "{n} 月前" - yearsAgo: "{n} 年前" + justNow: "刚刚" + secondsAgo: "{n}秒前" + minutesAgo: "{n}分钟前" + hoursAgo: "{n}小时前" + daysAgo: "{n}天前" + weeksAgo: "{n}周前" + monthsAgo: "{n}个月前" + yearsAgo: "{n}年前" invalid: "没有" _timeIn: seconds: "{n}秒后" - minutes: "{n} 分后" - hours: "{n} 小时后" + minutes: "{n}分钟后" + hours: "{n}小时后" days: "{n}天后" - weeks: "{n} 周后" - months: "{n} 月后" - years: "{n} 年后" + weeks: "{n}周后" + months: "{n}个月后" + years: "{n}年后" _time: second: "秒" - minute: "分" + minute: "分钟" hour: "小时" - day: "日" + day: "天" + month: "个月" _2fa: alreadyRegistered: "此设备已被注册" registerTOTP: "开始设置验证器" @@ -2313,28 +2409,28 @@ _permissions: "write:favorites": "编辑收藏夹" "read:following": "查看关注信息" "write:following": "关注/取消关注" - "read:messaging": "查看消息" + "read:messaging": "查看私信" "write:messaging": "撰写或删除消息" - "read:mutes": "查看隐藏列表" - "write:mutes": "编辑隐藏列表" + "read:mutes": "查看已隐藏用户列表" + "write:mutes": "编辑已隐藏用户列表" "write:notes": "撰写或删除帖子" "read:notifications": "查看通知" "write:notifications": "管理通知" "read:reactions": "查看回应" - "write:reactions": "回应操作" + "write:reactions": "编辑回应" "write:votes": "投票" "read:pages": "查看页面" - "write:pages": "操作页面" + "write:pages": "编辑页面" "read:page-likes": "查看喜欢的页面" - "write:page-likes": "操作喜欢的页面" + "write:page-likes": "管理喜欢的页面" "read:user-groups": "查看用户组" - "write:user-groups": "操作用户组" + "write:user-groups": "编辑用户组" "read:channels": "查看频道" "write:channels": "管理频道" - "read:gallery": "浏览图库" - "write:gallery": "操作图库" - "read:gallery-likes": "读取喜欢的图片" - "write:gallery-likes": "操作喜欢的图片" + "read:gallery": "浏览图集" + "write:gallery": "编辑图集" + "read:gallery-likes": "浏览喜欢的图集" + "write:gallery-likes": "管理喜欢的图集" "read:flash": "查看 Play" "write:flash": "编辑 Play" "read:flash-likes": "查看 Play 的点赞" @@ -2362,33 +2458,33 @@ _permissions: "read:admin:roles": "查看角色" "write:admin:relays": "编辑中继" "read:admin:relays": "查看中继" - "write:admin:invite-codes": "编辑邀请码" + "write:admin:invite-codes": "管理邀请码" "read:admin:invite-codes": "查看邀请码" - "write:admin:announcements": "编辑公告" + "write:admin:announcements": "管理公告" "read:admin:announcements": "查看公告" "write:admin:avatar-decorations": "编辑头像挂件" "read:admin:avatar-decorations": "查看头像挂件" "write:admin:federation": "编辑联合相关信息" "write:admin:account": "编辑用户账户" "read:admin:account": "查看用户相关情报" - "write:admin:emoji": "编辑表情文字" - "read:admin:emoji": "查看表情文字" + "write:admin:emoji": "编辑表情符号" + "read:admin:emoji": "查看表情符号" "write:admin:queue": "编辑作业队列" "read:admin:queue": "查看作业队列相关情报" "write:admin:promo": "运营推广说明" - "write:admin:drive": "编辑用户网盘" + "write:admin:drive": "管理用户网盘" "read:admin:drive": "查看用户网盘相关情报" "read:admin:stream": "使用管理员用的 Websocket API" - "write:admin:ad": "编辑广告" + "write:admin:ad": "管理广告" "read:admin:ad": "查看广告" "write:invite-codes": "生成邀请码" "read:invite-codes": "获取已发行的邀请码" - "write:clip-favorite": "编辑便签的点赞" + "write:clip-favorite": "管理喜欢的便签" "read:clip-favorite": "查看便签的点赞" "read:federation": "查看联合相关信息" "write:report-abuse": "举报用户" "write:chat": "撰写或删除消息" - "read:chat": "查看聊天" + "read:chat": "查看私信" _auth: shareAccessTitle: "应用程序授权许可" shareAccess: "您要授权允许 “{name}” 访问您的帐户吗?" @@ -2402,12 +2498,13 @@ _auth: scopeUser: "以下面的用户进行操作" pleaseLogin: "在对应用进行授权许可之前,请先登录" byClickingYouWillBeRedirectedToThisUrl: "允许访问后将会自动重定向到以下 URL" + alreadyAuthorized: "此应用已有访问许可。" _antennaSources: all: "所有帖子" homeTimeline: "已关注用户的帖子" users: "来自指定用户的帖子" userList: "来自指定列表中的帖子" - userBlacklist: "除掉已选择用户后所有的帖子" + userBlacklist: "过滤指定用户后的所有帖子" _weekday: sunday: "星期日" monday: "星期一" @@ -2419,7 +2516,7 @@ _weekday: _widgets: profile: "个人资料" instanceInfo: "服务器信息" - memo: "便签" + memo: "便利贴" notifications: "通知" timeline: "时间线" calendar: "日历" @@ -2432,11 +2529,11 @@ _widgets: digitalClock: "数字时钟" unixClock: "UNIX 时钟" federation: "联合" - instanceCloud: "服务器云" - postForm: "投稿窗口" + instanceCloud: "服务器球状列表" + postForm: "发帖窗口" slideshow: "幻灯片展示" button: "按钮" - onlineUsers: "在线用户" + onlineUsers: "在线用户数" jobQueue: "作业队列" serverMetric: "服务器指标" aiscript: "AiScript 控制台" @@ -2447,7 +2544,45 @@ _widgets: chooseList: "选择列表" clicker: "点击器" birthdayFollowings: "今天是他们的生日" - chat: "聊天" + chat: "私信" +_widgetOptions: + showHeader: "显示标题" + transparent: "使背景透明" + height: "高度" + _button: + colored: "彩色" + _clock: + size: "大小" + thickness: "指针宽度" + thicknessThin: "细" + thicknessMedium: "普通" + thicknessThick: "粗" + graduations: "表盘刻度" + graduationDots: "点" + graduationArabic: "阿拉伯数字" + fadeGraduations: "淡化表盘" + sAnimation: "秒针动画" + sAnimationElastic: "跳动" + sAnimationEaseOut: "平滑" + twentyFour: "24 小时制" + labelTime: "时间" + labelTz: "时区" + labelTimeAndTz: "时间和时区" + timezone: "时区" + showMs: "显示毫秒" + showLabel: "显示标签" + _jobQueue: + sound: "播放音效" + _rss: + url: "RSS feed 的 URL" + refreshIntervalSec: "更新间隔(秒)" + maxEntries: "最大显示个数" + _rssTicker: + shuffle: "随机顺序" + duration: "滚动速度(秒)" + reverse: "反方向滚动" + _birthdayFollowings: + period: "期限" _cw: hide: "隐藏" show: "查看更多" @@ -2455,26 +2590,26 @@ _cw: files: "{count} 个文件" _poll: noOnlyOneChoice: "需要至少两个选项" - choiceN: "选择 {n}" + choiceN: "选项{n}" noMore: "无法再添加更多了" - canMultipleVote: "允许多个投票" + canMultipleVote: "允许多选" expiration: "截止时间" infinite: "永久" at: "指定日期" after: "指定时间" deadlineDate: "截止日期" - deadlineTime: "小时" - duration: "时长" - votesCount: "{n} 票" - totalVotes: "总票数 {n}" + deadlineTime: "时间" + duration: "期限" + votesCount: "{n}票" + totalVotes: "总计{n}票" vote: "投票" - showResult: "显示结果" + showResult: "查看结果" voted: "已投票" closed: "已截止" - remainingDays: "{d} 天 {h} 小时后截止" - remainingHours: "{h} 小时 {m} 分后截止" - remainingMinutes: "{m} 分 {s} 秒后截止" - remainingSeconds: "{s} 秒后截止" + remainingDays: "{d}天{h}小时后截止" + remainingHours: "{h}小时{m}分后截止" + remainingMinutes: "{m}分{s}秒后截止" + remainingSeconds: "{s}秒后截止" _visibility: public: "公开" publicDescription: "您的帖子将出现在全局时间线上" @@ -2492,10 +2627,24 @@ _postForm: replyPlaceholder: "回复这个帖子..." quotePlaceholder: "引用这个帖子..." channelPlaceholder: "发布到频道…" + showHowToUse: "显示窗口说明" + _howToUse: + content_title: "正文" + content_description: "在此输入要发布的内容。" + toolbar_title: "工具栏" + toolbar_description: "可在此添加文件和投票、设置注释和话题标签、插入表情符号和提及等。" + account_title: "账号菜单" + account_description: "可在此切换发帖用的账号、查看账户下保存的草稿及定时发送帖。" + visibility_title: "可见性" + visibility_description: "可在此设置帖子的公开范围。" + menu_title: "菜单" + menu_description: "可在此进行保存草稿、设置定时发帖、设置回应等其它操作。" + submit_title: "发帖按钮" + submit_description: "发布帖子。也可用 Ctrl + Enter / Cmd + Enter 来发帖。" _placeholders: - a: "现在如何?" - b: "发生了什么?" - c: "你有什么想法?" + a: "现在怎么样?" + b: "想好发些什么了吗?" + c: "在想些什么呢?" d: "你想要发布些什么吗?" e: "请写下来吧" f: "等待您的发布..." @@ -2524,7 +2673,7 @@ _exportOrImport: muteList: "隐藏" blockingList: "屏蔽" userLists: "列表" - excludeMutingUsers: "排除屏蔽用户" + excludeMutingUsers: "排除已隐藏用户" excludeInactiveUsers: "排除不活跃用户" withReplies: "在时间线中包含导入用户的回复" _charts: @@ -2568,7 +2717,7 @@ _play: editThisPage: "编辑此 Play" viewSource: "查看源代码" my: "我的 Play" - liked: "点赞的 Play" + liked: "喜欢的 Play" featured: "热门" title: "标题" script: "脚本" @@ -2585,7 +2734,7 @@ _pages: editThisPage: "编辑此页面" viewSource: "查看源代码" viewPage: "查看页面" - like: "赞" + like: "喜欢" unlike: "取消喜欢" my: "我的页面" liked: "喜欢的页面" @@ -2633,14 +2782,16 @@ _notification: youGotReply: "来自{name}的回复" youGotQuote: "来自{name}的引用" youRenoted: "来自{name}的转发" - youWereFollowed: "关注了你。" + youWereFollowed: "关注了你" youReceivedFollowRequest: "您有新的关注请求" yourFollowRequestAccepted: "您的关注请求已通过" pollEnded: "问卷调查结果已生成。" + scheduledNotePosted: "定时帖子已发布" + scheduledNotePostFailed: "定时帖子发布失败" newNote: "新的帖子" unreadAntennaNote: "天线 {name}" roleAssigned: "授予的角色" - chatRoomInvitationReceived: "受邀加入聊天室" + chatRoomInvitationReceived: "您已被邀请加入群聊" emptyPushNotificationMessage: "推送通知已更新" achievementEarned: "获得成就" testNotification: "测试通知" @@ -2666,10 +2817,12 @@ _notification: quote: "引用" reaction: "回应" pollEnded: "问卷调查结束" + scheduledNotePosted: "定时发送成功" + scheduledNotePostFailed: "定时发送失败" receiveFollowRequest: "收到关注请求" followRequestAccepted: "关注请求已通过" roleAssigned: "授予的角色" - chatRoomInvitationReceived: "受邀加入聊天室" + chatRoomInvitationReceived: "您已被邀请加入群聊" achievementEarned: "取得的成就" exportCompleted: "已完成导出" login: "登录" @@ -2701,10 +2854,18 @@ _deck: introduction: "将各列进行组合以创建您自己的界面!" introduction2: "可以随时通过屏幕右侧的 + 来添加列" widgetsIntroduction: "从列菜单中,选择“小工具编辑”来添加小工具" - useSimpleUiForNonRootPages: "用简易UI表示非根页面" + useSimpleUiForNonRootPages: "使用简易UI显示导航页面" usedAsMinWidthWhenFlexible: "「自适应宽度」被启用的时候,这就是最小的宽度" flexible: "自适应宽度" - enableSyncBetweenDevicesForProfiles: "启用个人资料信息跨设备同步" + enableSyncBetweenDevicesForProfiles: "启用配置文件跨设备同步" + showHowToUse: "查看用户界面说明" + _howToUse: + addColumn_title: "添加列" + addColumn_description: "可以选择要添加的列的类型。" + settings_title: "用户界面设置" + settings_description: "可以配置 Deck UI 的详细设置," + switchProfile_title: "切换配置文件" + switchProfile_description: "将用户界面布局保存为配置文件,以便随时切换。" _columns: main: "主列" widgets: "小工具" @@ -2716,7 +2877,7 @@ _deck: mentions: "提及" direct: "指定用户" roleTimeline: "角色时间线" - chat: "聊天" + chat: "私信" _dialog: charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" @@ -2736,7 +2897,7 @@ _webhookSettings: _events: follow: "关注时" followed: "被关注时" - note: "发布贴文时" + note: "发布帖文时" reply: "收到回复时" renote: "被转发时" reaction: "被回应时" @@ -2765,6 +2926,8 @@ _abuseReport: notifiedWebhook: "使用的 webhook" deleteConfirm: "要删除通知吗?" _moderationLogTypes: + clearQueue: "清除队列" + promoteQueue: "重新执行队列中的任务" createRole: "创建角色" deleteRole: "删除角色" updateRole: "更新角色" @@ -2809,11 +2972,11 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "新建了举报通知" updateAbuseReportNotificationRecipient: "更新了举报通知" deleteAbuseReportNotificationRecipient: "删除了举报通知" - deleteAccount: "删除了账户" - deletePage: "删除了页面" - deleteFlash: "删除了 Play" - deleteGalleryPost: "删除了图库稿件" - deleteChatRoom: "删除聊天室" + deleteAccount: "删除帐户" + deletePage: "删除页面" + deleteFlash: "删除 Play" + deleteGalleryPost: "删除图集内容" + deleteChatRoom: "删除群聊" updateProxyAccountDescription: "更新代理账户的简介" _fileViewer: title: "文件信息" @@ -2951,10 +3114,10 @@ _mediaControls: playbackRate: "播放速度" loop: "循环播放" _contextMenu: - title: "上下文菜单" - app: "应用" - appWithShift: "Shift 键应用" - native: "浏览器的用户界面" + title: "右键菜单" + app: "使用" + appWithShift: "按住 Shift 键使用" + native: "浏览器的原生界面" _gridComponent: _error: requiredValue: "此值为必填项" @@ -3032,8 +3195,8 @@ _selfXssPrevention: description2: "如果不能完全理解将要粘贴的内容,%c 请立即停止操作并关闭这个窗口。" description3: "详情请看这里。{link}" _followRequest: - recieved: "已收到申请" - sent: "已发送申请" + recieved: "收到的请求" + sent: "发送的请求" _remoteLookupErrors: _federationNotAllowed: title: "无法与此服务器通信" @@ -3068,7 +3231,7 @@ _bootErrors: serverError: "请稍等片刻再重试。若问题仍无法解决,请将以下 Error ID 一起发送给管理员。" solution: "以下方法或许可以解决问题:" solution1: "将浏览器及操作系统更新到最新版本" - solution2: "禁用广告屏蔽插件" + solution2: "禁用广告拦截插件" solution3: "清除浏览器缓存" solution4: "(Tor Browser)将 dom.webaudio.enabled 设定为 true" otherOption: "其它选项" @@ -3086,7 +3249,7 @@ _search: serverHostPlaceholder: "如:misskey.example.com" _serverSetupWizard: installCompleted: "Misskey 安装完成!" - firstCreateAccount: "首先来创建管理员账号吧。" + firstCreateAccount: "首先,创建一个管理员帐户。" accountCreated: "管理员账号已创建!" serverSetting: "服务器设置" youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "用此向导来轻松地以最佳方式配置服务器。" @@ -3096,7 +3259,7 @@ _serverSetupWizard: single: "单用户服务器" single_description: "仅供自己使用的单人服务器" single_youCanCreateMultipleAccounts: "使用单用户服务器模式使用时,也可以根据需要创建多个账号。" - group: "小圈子服务器" + group: "群组服务器" group_description: "邀请其他可信用户一起使用的多人服务器" open: "开放服务器" open_description: "以容纳不限定数量的用户的模式运行" @@ -3133,7 +3296,7 @@ _uploader: compressedToX: "压缩 {x}" savedXPercent: "节省了 {x}% 的空间" abortConfirm: "还有未上传的文件,要中止吗?" - doneConfirm: "还有未上传的文件,要完成吗?" + doneConfirm: "部分文件尚未上传,是否继续?" maxFileSizeIsX: "可上传最大 {x} 的文件。" allowedTypes: "可上传的文件类型" tip: "文件还没有被上传。可在此对话框中进行上传前确认、重命名、压缩、裁剪等操作。准备完成后,点击「上传」即可开始上传。" @@ -3153,16 +3316,19 @@ watermark: "水印" defaultPreset: "默认预设" _watermarkEditor: tip: "可在图像内增加包含作者等信息的水印。" - quitWithoutSaveConfirm: "不保存就退出吗?" + quitWithoutSaveConfirm: "放弃未保存的更改?" driveFileTypeWarn: "不支持此文件" driveFileTypeWarnDescription: "请选择图像文件" title: "编辑水印" - cover: "覆盖全体" + cover: "覆盖所有" repeat: "平铺" + preserveBoundingRect: "调整为旋转时不超出范围" opacity: "不透明度" scale: "大小" text: "文本" + qr: "二维码" position: "位置" + margin: "边距" type: "类型" image: "图片" advanced: "高级" @@ -3177,17 +3343,21 @@ _watermarkEditor: polkadotSubDotOpacity: "副波点的不透明度" polkadotSubDotRadius: "副波点的大小" polkadotSubDotDivisions: "副波点的数量" + leaveBlankToAccountUrl: "留空则为账户 URL" + failedToLoadImage: "图片加载失败" _imageEffector: title: "效果" addEffect: "添加效果" discardChangesConfirm: "丢弃当前设置并退出?" - nothingToConfigure: "还没有设置" + failedToLoadImage: "图片加载失败" _fxs: chromaticAberration: "色差" glitch: "故障" mirror: "镜像" invert: "反转颜色" grayscale: "黑白" + blur: "模糊" + pixelate: "马赛克" colorAdjust: "色彩校正" colorClamp: "颜色限制" colorClampAdvanced: "颜色限制(高级)" @@ -3199,10 +3369,14 @@ _imageEffector: checker: "检查" blockNoise: "块状噪点" tearing: "撕裂" + fill: "填充" _fxProps: angle: "角度" scale: "大小" size: "大小" + radius: "半径" + samples: "采样数" + offset: "位置" color: "颜色" opacity: "不透明度" normalize: "标准化" @@ -3231,6 +3405,7 @@ _imageEffector: zoomLinesThreshold: "集中线宽度" zoomLinesMaskSize: "中心直径" zoomLinesBlack: "变成黑色" + circle: "圆形" drafts: "草稿" _drafts: select: "选择草稿" @@ -3246,3 +3421,22 @@ _drafts: restoreFromDraft: "从草稿恢复" restore: "恢复" listDrafts: "草稿一览" + schedule: "定时发布" + listScheduledNotes: "定时发布列表" + cancelSchedule: "取消定时" +qr: "二维码" +_qr: + showTabTitle: "显示" + readTabTitle: "读取" + shareTitle: "{name} {acct}" + shareText: "请在 Fediverse 上关注我!" + chooseCamera: "选择相机" + cannotToggleFlash: "无法开关闪光灯" + turnOnFlash: "打开闪光灯" + turnOffFlash: "关闭闪光灯" + startQr: "重新打开二维码扫描器" + stopQr: "关闭二维码扫描器" + noQrCodeFound: "未找到二维码" + scanFile: "扫描设备上的图像" + raw: "文本" + mfm: "MFM" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index b9f5f9a6c9..19e4678931 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -75,7 +75,7 @@ receiveFollowRequest: "您有新的追隨請求" followRequestAccepted: "追隨請求已被接受" mention: "提及" mentions: "提及" -directNotes: "私訊" +directNotes: "指定使用者" importAndExport: "匯入與匯出" import: "匯入" export: "匯出" @@ -83,6 +83,8 @@ files: "檔案" download: "下載" driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此檔案的貼文也會跟著被刪除。" unfollowConfirm: "確定要取消追隨{name}嗎?" +cancelFollowRequestConfirm: "要取消向 {name} 送出的追隨申請嗎?" +rejectFollowRequestConfirm: "要拒絕來自 {name} 的追隨申請嗎?" exportRequested: "已請求匯出。這可能會花一點時間。匯出的檔案將會被放到雲端硬碟裡。" importRequested: "已請求匯入。這可能會花一點時間。" lists: "清單" @@ -172,7 +174,7 @@ emojiUrl: "表情符號 URL" addEmoji: "新增表情符號" settingGuide: "推薦設定" cacheRemoteFiles: "快取遠端檔案" -cacheRemoteFilesDescription: "啟用此設定後,遠端檔案會被快取在本伺服器的儲存空間中。雖然顯示圖片會變快,但會消耗較多伺服器的儲存空間。至於要快取遠端使用者到什麼程度,是依照角色的雲端硬碟容量而定。當超過這個限制時,從較舊的檔案開始自快取中刪除並改為連結。關閉這個設定時,遠端檔案從一開始就維持連結的方式,但建議將 default.yml 的 proxyRemoteFiles 設為 true,以便產生圖片的縮圖並保護使用者的隱私。" +cacheRemoteFilesDescription: "啟用這個設定後,遠端檔案會被快取到這台伺服器的儲存空間中。這樣能加快圖片的顯示速度,但會多占用伺服器的儲存容量。遠端使用者能保留多少快取,取決於其角色所設定的硬碟容量上限。若超過這個上限,系統會從最舊的檔案開始刪除快取並改成連結。若停用這個設定,遠端檔案一開始就只會以連結的形式保留。" youCanCleanRemoteFilesCache: "按檔案管理的🗑️按鈕,可將快取全部刪除。" cacheRemoteSensitiveFiles: "快取遠端的敏感檔案" cacheRemoteSensitiveFilesDescription: "若停用這個設定,則不會快取遠端的敏感檔案,而是直接連結。" @@ -253,6 +255,7 @@ noteDeleteConfirm: "確定刪除此貼文嗎?" pinLimitExceeded: "不能置頂更多貼文了" done: "完成" processing: "處理中" +preprocessing: "準備中" preview: "預覽" default: "預設" defaultValueIs: "預設值:{value}" @@ -301,6 +304,7 @@ uploadFromUrlMayTakeTime: "還需要一些時間才能完成上傳。" uploadNFiles: "上傳了 {n} 個檔案" explore: "探索" messageRead: "已讀" +readAllChatMessages: "將所有訊息標記為已讀" noMoreHistory: "沒有更多歷史紀錄" startChat: "開始聊天" nUsersRead: "{n} 人已讀" @@ -333,6 +337,7 @@ fileName: "檔案名稱" selectFile: "選擇檔案" selectFiles: "選擇檔案" selectFolder: "選擇資料夾" +unselectFolder: "取消選擇資料夾" selectFolders: "選擇資料夾" fileNotSelected: "尚未選擇檔案" renameFile: "重新命名檔案" @@ -345,6 +350,7 @@ addFile: "加入附件" showFile: "瀏覽文件" emptyDrive: "雲端硬碟為空" emptyFolder: "資料夾為空" +dropHereToUpload: "將檔案拖放至此處即可上傳" unableToDelete: "無法刪除" inputNewFileName: "輸入檔案名稱" inputNewDescription: "請輸入新標題 " @@ -772,6 +778,7 @@ lockedAccountInfo: "即使追隨需要核准,除非你將貼文的可見性設 alwaysMarkSensitive: "預設標記檔案為敏感內容" loadRawImages: "以原始圖檔顯示附件圖檔的縮圖" disableShowingAnimatedImages: "不播放動態圖檔" +disableShowingAnimatedImages_caption: "無論這個設定如何,如果動畫圖片無法播放,可能是因為瀏覽器或作業系統的無障礙設定、省電設定等產生了干擾。" highlightSensitiveMedia: "強調敏感標記" verificationEmailSent: "已發送驗證電子郵件。請點擊進入電子郵件中的連結以完成驗證。" notSet: "未設定" @@ -1018,6 +1025,9 @@ pushNotificationAlreadySubscribed: "推播通知啟用中" pushNotificationNotSupported: "瀏覽器或伺服器不支援推播通知" sendPushNotificationReadMessage: "如果已閱讀通知與訊息,就刪除推播通知" sendPushNotificationReadMessageCaption: "可能會導致裝置的電池消耗量增加。" +pleaseAllowPushNotification: "請允許瀏覽器的通知設定" +browserPushNotificationDisabled: "取得通知發送權限失敗" +browserPushNotificationDisabledDescription: "您沒有權限從 {serverName} 發送通知。請在瀏覽器設定中允許通知,然後再試一次。" windowMaximize: "最大化" windowMinimize: "最小化" windowRestore: "復原" @@ -1054,6 +1064,7 @@ permissionDeniedError: "操作被拒絕" permissionDeniedErrorDescription: "此帳戶沒有執行這個操作的權限。" preset: "預設值" selectFromPresets: "從預設值中選擇" +custom: "自訂" achievements: "成就" gotInvalidResponseError: "伺服器的回應無效" gotInvalidResponseErrorDescription: "伺服器可能已關閉或者在維護中,請稍後再試。" @@ -1078,9 +1089,9 @@ postToTheChannel: "發佈到頻道" cannotBeChangedLater: "之後不能變更。" reactionAcceptance: "接受表情反應" likeOnly: "僅限讚" -likeOnlyForRemote: "遠端僅限讚" +likeOnlyForRemote: "全部(遠端僅限讚)" nonSensitiveOnly: "僅限非敏感" -nonSensitiveOnlyForLocalLikeOnlyForRemote: "僅限非敏感(遠端僅限按讚)" +nonSensitiveOnlyForLocalLikeOnlyForRemote: "僅限非敏感(遠端僅限讚)" rolesAssignedToMe: "指派給自己的角色" resetPasswordConfirm: "重設密碼?" sensitiveWords: "敏感詞" @@ -1167,6 +1178,7 @@ installed: "已安裝" branding: "品牌宣傳" enableServerMachineStats: "公佈伺服器的機器資訊" enableIdenticonGeneration: "啟用生成使用者的 Identicon " +showRoleBadgesOfRemoteUsers: "顯示授予遠端使用者的角色徽章" turnOffToImprovePerformance: "關閉時會提高性能。" createInviteCode: "建立邀請碼" createWithOptions: "使用選項建立" @@ -1244,7 +1256,7 @@ releaseToRefresh: "放開以更新內容" refreshing: "載入更新中" pullDownToRefresh: "往下拉來更新內容" useGroupedNotifications: "分組顯示通知訊息" -signupPendingError: "驗證您的電子郵件地址時出現問題。連結可能已過期。" +emailVerificationFailedError: "驗證您的電子郵件地址時出現問題。連結可能已過期。" cwNotationRequired: "如果開啟「隱藏內容」,則需要註解說明。" doReaction: "做出反應" code: "程式碼" @@ -1315,6 +1327,7 @@ acknowledgeNotesAndEnable: "了解注意事項後再開啟。" federationSpecified: "此伺服器以白名單聯邦的方式運作。除了管理員指定的伺服器外,它無法與其他伺服器互動。" federationDisabled: "此伺服器未開啟站台聯邦。無法與其他伺服器上的使用者互動。" draft: "草稿\n" +draftsAndScheduledNotes: "草稿與排定發布" confirmOnReact: "在做出反應前先確認" reactAreYouSure: "用「 {emoji} 」反應嗎?" markAsSensitiveConfirm: "要將這個媒體設定為敏感嗎?" @@ -1342,6 +1355,8 @@ postForm: "發文視窗" textCount: "字數" information: "關於" chat: "聊天" +directMessage: "直接訊息" +directMessage_short: "訊息" migrateOldSettings: "遷移舊設定資訊" migrateOldSettings_description: "通常情況下,這會自動進行,但若因某些原因未能順利遷移,您可以手動觸發遷移處理。請注意,當前的設定資訊將會被覆寫。" compress: "壓縮" @@ -1351,7 +1366,7 @@ top: "上" embed: "嵌入" settingsMigrating: "正在移轉設定。請稍候……(之後也可以到「設定 → 其他 → 舊設定資訊移轉」中手動進行移轉)" readonly: "唯讀" -goToDeck: "回去甲板" +goToDeck: "回到多欄模式" federationJobs: "聯邦通訊作業" driveAboutTip: "在「雲端硬碟」中,會顯示過去上傳的檔案列表。
\n可以在附加到貼文時重新利用,或者事先上傳之後再用於發布。
\n請注意,刪除檔案後,之前使用過該檔案的所有地方(貼文、頁面、大頭貼、橫幅等)也會一併無法顯示。
\n也可以建立資料夾來整理檔案。" scrollToClose: "用滾輪關閉" @@ -1369,16 +1384,83 @@ redisplayAllTips: "重新顯示所有「提示與技巧」" hideAllTips: "隱藏所有「提示與技巧」" defaultImageCompressionLevel: "預設的影像壓縮程度" defaultImageCompressionLevel_description: "低的話可以保留畫質,但是會增加檔案的大小。
高的話可以減少檔案大小,但是會降低畫質。" +defaultCompressionLevel: "預設的壓縮程度" +defaultCompressionLevel_description: "低的話可以保留品質,但是會增加檔案的大小。
高的話可以減少檔案大小,但是會降低品質。" inMinutes: "分鐘" inDays: "日" safeModeEnabled: "啟用安全模式" pluginsAreDisabledBecauseSafeMode: "由於啟用安全模式,所有的外掛都被停用。" customCssIsDisabledBecauseSafeMode: "由於啟用安全模式,所有的客製 CSS 都被停用。" themeIsDefaultBecauseSafeMode: "在安全模式啟用期間將使用預設主題。關閉安全模式後會恢復原本的設定。" +thankYouForTestingBeta: "感謝您協助驗證 beta 版!" +createUserSpecifiedNote: "建立指定使用者的貼文" +schedulePost: "排定發布" +scheduleToPostOnX: "排定在 {x} 發布" +scheduledToPostOnX: "已排定在 {x} 發布貼文" +schedule: "排定" +scheduled: "排定" +widgets: "小工具" +deviceInfo: "硬體資訊" +deviceInfoDescription: "在提出技術性諮詢時,若能同時提供以下資訊,將有助於解決問題。" +youAreAdmin: "您是管理員" +frame: "邊框" +presets: "預設值" +zeroPadding: "補零" +nothingToConfigure: "無可設定的項目" +_imageEditing: + _vars: + caption: "檔案標題" + filename: "檔案名稱" + filename_without_ext: "無副檔名的檔案名稱" + year: "拍攝年份" + month: "拍攝月份" + day: "拍攝日期" + hour: "拍攝時間(小時)" + minute: "拍攝時間(分鐘)" + second: "拍攝時間(秒)" + camera_model: "相機名稱" + camera_lens_model: "鏡頭型號" + camera_mm: "焦距" + camera_mm_35: "焦距(換算為 35mm 底片等效焦距)" + camera_f: "光圈" + camera_s: "快門速度" + camera_iso: "ISO 感光度" + gps_lat: "緯度" + gps_long: "經度" +_imageFrameEditor: + title: "編輯邊框" + tip: "可以在圖片上添加包含邊框或 EXIF 的標籤來裝飾圖片。" + header: "標題" + footer: "頁尾" + borderThickness: "邊框寬度" + labelThickness: "標籤寬度" + labelScale: "標籤縮放比例" + centered: "置中對齊" + captionMain: "標題文字(大)" + captionSub: "標題文字(小)" + availableVariables: "可使用的變數" + withQrCode: "二維條碼" + backgroundColor: "背景顏色" + textColor: "文字顏色" + font: "字型" + fontSerif: "襯線體" + fontSansSerif: "無襯線體" + quitWithoutSaveConfirm: "不儲存就退出嗎?" + failedToLoadImage: "圖片載入失敗" +_compression: + _quality: + high: "高品質" + medium: "中品質" + low: "低品質" + _size: + large: "大" + medium: "中" + small: "小" _order: newest: "最新的在前" oldest: "最舊的在前" _chat: + messages: "訊息" noMessagesYet: "尚無訊息" newMessage: "新訊息" individualChat: "ㄧ對一聊天室" @@ -1467,6 +1549,11 @@ _settings: showUrlPreview: "顯示網址預覽" showAvailableReactionsFirstInNote: "將可用的反應顯示在頂部" showPageTabBarBottom: "在底部顯示頁面的標籤列" + emojiPaletteBanner: "可以將固定顯示在表情符號選擇器的預設項目註冊為調色盤,或者自訂選擇器的顯示方式。" + enableAnimatedImages: "啟用動畫圖片" + settingsPersistence_title: "設定的持久化" + settingsPersistence_description1: "啟用「設定的持久化」後,可以防止設定資訊遺失。" + settingsPersistence_description2: "依環境不同,可能無法啟用。" _chat: showSenderName: "顯示發送者的名稱" sendOnEnter: "按下 Enter 發送訊息" @@ -1475,6 +1562,8 @@ _preferencesProfile: profileNameDescription: "設定一個名稱來識別此裝置。" profileNameDescription2: "例如:「主要個人電腦」、「智慧型手機」等" manageProfiles: "管理個人檔案" + shareSameProfileBetweenDevicesIsNotRecommended: "不建議在多個裝置上共用同一個設定檔。" + useSyncBetweenDevicesOptionIfYouWantToSyncSetting: "如果您希望在多個裝置之間同步某些設定項目,請分別啟用「跨裝置同步」選項。" _preferencesBackup: autoBackup: "自動備份" restoreFromBackup: "從備份還原" @@ -1484,6 +1573,7 @@ _preferencesBackup: youNeedToNameYourProfileToEnableAutoBackup: "要啟用自動備份,必須設定檔案名稱。" autoPreferencesBackupIsNotEnabledForThisDevice: "此裝置未啟用自動備份設定。" backupFound: "找到設定的備份" + forceBackup: "強制備份設定" _accountSettings: requireSigninToViewContents: "須登入以顯示內容" requireSigninToViewContentsDescription1: "必須登入才會顯示您建立的貼文等內容。可望有效防止資訊被爬蟲蒐集。" @@ -1664,6 +1754,9 @@ _serverSettings: userGeneratedContentsVisibilityForVisitor_description2: "包括伺服器接收到的遠端內容在內,無條件地將伺服器內所有內容公開到網際網路上是具有風險的。特別是對於不了解分散式架構特性的瀏覽者來說,他們可能會誤以為這些遠端內容是由該伺服器所創建的,因此需要特別留意。" restartServerSetupWizardConfirm_title: "要重新執行伺服器的初始設定精靈嗎?" restartServerSetupWizardConfirm_text: "當前的部分設定將會被重設。" + entrancePageStyle: "入口頁面的樣式" + showTimelineForVisitor: "顯示時間軸" + showActivitiesForVisitor: "顯示活動" _userGeneratedContentsVisibilityForVisitor: all: "全部公開\n" localOnly: "僅公開本地內容,遠端內容則不公開\n" @@ -1986,6 +2079,7 @@ _role: canManageAvatarDecorations: "管理頭像裝飾" driveCapacity: "雲端硬碟容量" maxFileSize: "可上傳的最大檔案大小" + maxFileSize_caption: "前端可能還有其他設定值,例如反向代理或 CDN。" alwaysMarkNsfw: "總是將檔案標記為NSFW" canUpdateBioMedia: "允許更新大頭貼和橫幅" pinMax: "置頂貼文的最大數量" @@ -2013,6 +2107,7 @@ _role: uploadableFileTypes_caption: "請指定 MIME 類型。可以用換行區隔多個類型,也可以使用星號(*)作為萬用字元進行指定。(例如:image/*)\n" uploadableFileTypes_caption2: "有些檔案可能無法判斷其類型。若要允許這類檔案,請在指定中加入 {x}。" noteDraftLimit: "伺服器端可建立的貼文草稿數量上限\n" + scheduledNoteLimit: "同時建立的排定發布數量" watermarkAvailable: "浮水印功能是否可用" _condition: roleAssignedTo: "手動指派角色完成" @@ -2233,7 +2328,7 @@ _theme: buttonHoverBg: "按鈕背景 (漂浮)" inputBorder: "輸入框邊框" badge: "徽章" - messageBg: "私訊背景" + messageBg: "聊天的背景" fgHighlighted: "突顯文字" _sfx: note: "貼文" @@ -2273,6 +2368,7 @@ _time: minute: "分鐘" hour: "小時" day: "日" + month: "個月" _2fa: alreadyRegistered: "此裝置已被註冊過了" registerTOTP: "開始設定驗證應用程式" @@ -2402,6 +2498,7 @@ _auth: scopeUser: "以下列使用者身分操作" pleaseLogin: "必須登入以提供應用程式的存取權限。" byClickingYouWillBeRedirectedToThisUrl: "如果授予存取權限,就會自動導向到以下的網址" + alreadyAuthorized: "此應用程式已被授予存取權限。" _antennaSources: all: "全部貼文" homeTimeline: "來自已追隨使用者的貼文" @@ -2448,6 +2545,44 @@ _widgets: clicker: "點擊器" birthdayFollowings: "今天生日的使用者" chat: "聊天" +_widgetOptions: + showHeader: "檢視標頭 " + transparent: "使背景透明" + height: "高度" + _button: + colored: "彩色" + _clock: + size: "尺寸" + thickness: "指針粗細" + thicknessThin: "細" + thicknessMedium: "普通" + thicknessThick: "粗" + graduations: "刻度盤" + graduationDots: "圓點" + graduationArabic: "阿拉伯數字" + fadeGraduations: "刻度淡出" + sAnimation: "秒針的動畫效果" + sAnimationElastic: "真實的" + sAnimationEaseOut: "滑順" + twentyFour: "24 小時制" + labelTime: "時間" + labelTz: "時區" + labelTimeAndTz: "時間與時區" + timezone: "時區" + showMs: "顯示毫秒" + showLabel: "顯示標記" + _jobQueue: + sound: "播放音效" + _rss: + url: "RSS 訂閱網址" + refreshIntervalSec: "更新間隔(秒)" + maxEntries: "最大顯示數量" + _rssTicker: + shuffle: "顯示順序隨機排列" + duration: "RSS 跑馬燈的捲動速度(秒)" + reverse: "反方向滾動" + _birthdayFollowings: + period: "時長" _cw: hide: "隱藏" show: "顯示內容" @@ -2492,6 +2627,20 @@ _postForm: replyPlaceholder: "回覆此貼文..." quotePlaceholder: "引用此貼文..." channelPlaceholder: "發佈到頻道" + showHowToUse: "顯示表單說明" + _howToUse: + content_title: "內文" + content_description: "請輸入要發布的內容。" + toolbar_title: "工具列" + toolbar_description: "可以附加檔案或票選活動、設定註解與標籤、插入表情符號或提及等。" + account_title: "帳號選單" + account_description: "可以切換要發布的帳號,並查看該帳號所儲存的草稿與預約發布列表。" + visibility_title: "可見性" + visibility_description: "可以設定貼文的公開範圍。" + menu_title: "選單" + menu_description: "可以進行其他操作,例如儲存為草稿、預約發佈貼文、或設定反應等。\n" + submit_title: "貼文按鈕" + submit_description: "發布貼文。也可以使用 Ctrl + Enter 或 Cmd + Enter 來發布。" _placeholders: a: "今天過得如何?" b: "有什麼新鮮事嗎?" @@ -2601,7 +2750,7 @@ _pages: hideTitleWhenPinned: "被置頂於個人資料時隱藏頁面標題" font: "字型" fontSerif: "襯線體" - fontSansSerif: "黑體" + fontSansSerif: "無襯線體" eyeCatchingImageSet: "設定封面影像" eyeCatchingImageRemove: "刪除封面影像" chooseBlock: "新增方塊" @@ -2637,6 +2786,8 @@ _notification: youReceivedFollowRequest: "您有新的追隨請求" yourFollowRequestAccepted: "您的追隨請求已被核准" pollEnded: "問卷調查已產生結果" + scheduledNotePosted: "已排定發布貼文" + scheduledNotePostFailed: "排定發布貼文失敗了" newNote: "新的貼文" unreadAntennaNote: "天線 {name}" roleAssigned: "已授予角色" @@ -2666,6 +2817,8 @@ _notification: quote: "引用" reaction: "反應" pollEnded: "問卷調查結束" + scheduledNotePosted: "預約發佈成功" + scheduledNotePostFailed: "預約發佈失敗" receiveFollowRequest: "已收到追隨請求" followRequestAccepted: "追隨請求已接受" roleAssigned: "已授予角色" @@ -2705,6 +2858,14 @@ _deck: usedAsMinWidthWhenFlexible: "如果啟用「自動調整寬度」,此為最小寬度" flexible: "自動調整寬度" enableSyncBetweenDevicesForProfiles: "啟用裝置與裝置之間的設定檔資料同步化" + showHowToUse: "檢視使用者界面說明" + _howToUse: + addColumn_title: "新增欄位" + addColumn_description: "您可以選擇要新增的欄位類型。" + settings_title: "使用者界面設定" + settings_description: "您可以對多欄模式使用者界面做詳細設定。" + switchProfile_title: "切換設定檔" + switchProfile_description: "將使用者界面佈局儲存為設定檔,就可以隨時切換使用。" _columns: main: "主列" widgets: "小工具" @@ -2765,6 +2926,8 @@ _abuseReport: notifiedWebhook: "使用的 Webhook" deleteConfirm: "確定要刪除通知對象嗎?" _moderationLogTypes: + clearQueue: "清除佇列" + promoteQueue: "重新嘗試排程中的工作" createRole: "新增角色" deleteRole: "刪除角色 " updateRole: "更新角色設定" @@ -3135,7 +3298,7 @@ _uploader: abortConfirm: "有些檔案尚未上傳,您要中止嗎?" doneConfirm: "有些檔案尚未上傳,是否要完成上傳?" maxFileSizeIsX: "可上傳的最大檔案大小為 {x}。" - allowedTypes: "可上傳的檔案類型" + allowedTypes: "可上傳的檔案類型。" tip: "檔案尚未上傳。您可以在此對話框中進行上傳前的確認、重新命名、壓縮、裁切等操作。準備完成後,請點選「上傳」按鈕開始上傳。\n" _clientPerformanceIssueTip: title: "如果覺得電池消耗過快的話" @@ -3159,10 +3322,13 @@ _watermarkEditor: title: "編輯浮水印" cover: "覆蓋整體" repeat: "佈局" + preserveBoundingRect: "調整使其在旋轉時不會突出" opacity: "透明度" scale: "大小" text: "文字" + qr: "二維條碼" position: "位置" + margin: "邊界" type: "類型" image: "圖片" advanced: "進階" @@ -3177,17 +3343,21 @@ _watermarkEditor: polkadotSubDotOpacity: "子圓點的不透明度" polkadotSubDotRadius: "子圓點的尺寸" polkadotSubDotDivisions: "子圓點的數量" + leaveBlankToAccountUrl: "若留空則使用帳戶的 URL" + failedToLoadImage: "圖片載入失敗" _imageEffector: title: "特效" addEffect: "新增特效" discardChangesConfirm: "捨棄更改並退出嗎?" - nothingToConfigure: "無可設定的項目" + failedToLoadImage: "圖片載入失敗" _fxs: chromaticAberration: "色差" glitch: "異常雜訊效果" mirror: "鏡像" invert: "反轉色彩" grayscale: "黑白" + blur: "模糊" + pixelate: "馬賽克" colorAdjust: "色彩校正" colorClamp: "壓縮色彩" colorClampAdvanced: "壓縮色彩(進階)" @@ -3199,10 +3369,14 @@ _imageEffector: checker: "棋盤格" blockNoise: "阻擋雜訊" tearing: "撕裂" + fill: "填充" _fxProps: angle: "角度" scale: "大小" size: "大小" + radius: "半徑" + samples: "取樣數" + offset: "位置" color: "顏色" opacity: "透明度" normalize: "正規化" @@ -3231,6 +3405,7 @@ _imageEffector: zoomLinesThreshold: "集中線的寬度" zoomLinesMaskSize: "中心直徑" zoomLinesBlack: "變成黑色" + circle: "圓形" drafts: "草稿\n" _drafts: select: "選擇草槁" @@ -3246,3 +3421,22 @@ _drafts: restoreFromDraft: "從草稿復原\n" restore: "還原" listDrafts: "草稿清單" + schedule: "排定發布" + listScheduledNotes: "排定發布列表" + cancelSchedule: "解除排定" +qr: "二維條碼" +_qr: + showTabTitle: "檢視" + readTabTitle: "讀取" + shareTitle: "{name} {acct}" + shareText: "請在聯邦宇宙追隨我吧!" + chooseCamera: "選擇相機" + cannotToggleFlash: "無法切換閃光燈" + turnOnFlash: "開啟閃光燈" + turnOffFlash: "關閉閃光燈" + startQr: "啟動條碼掃描器" + stopQr: "停止條碼掃描器" + noQrCodeFound: "找不到 QR code" + scanFile: "掃描在裝置上的影像" + raw: "文字" + mfm: "MFM" diff --git a/package.json b/package.json index dc92be543f..129f6841a3 100644 --- a/package.json +++ b/package.json @@ -1,32 +1,37 @@ { "name": "misskey", - "version": "2025.8.0-beta.1", + "version": "2026.1.0-beta.0", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@10.14.0", + "packageManager": "pnpm@10.27.0", "workspaces": [ - "packages/frontend-shared", - "packages/frontend", - "packages/frontend-embed", - "packages/icons-subsetter", - "packages/backend", - "packages/sw", "packages/misskey-js", + "packages/i18n", "packages/misskey-reversi", - "packages/misskey-bubble-game" + "packages/misskey-bubble-game", + "packages/icons-subsetter", + "packages/frontend-shared", + "packages/frontend-builder", + "packages/sw", + "packages/backend", + "packages/frontend", + "packages/frontend-embed" ], "private": true, "scripts": { - "build-pre": "node ./scripts/build-pre.js", + "compile-config": "cd packages/backend && pnpm compile-config", + "build-pre": "node scripts/build-pre.mjs", "build-assets": "node ./scripts/build-assets.mjs", "build": "pnpm build-pre && pnpm -r build && pnpm build-assets", "build-storybook": "pnpm --filter frontend build-storybook", "build-misskey-js-with-types": "pnpm build-pre && pnpm --filter backend... --filter=!misskey-js build && pnpm --filter backend generate-api-json --no-build && ncp packages/backend/built/api.json packages/misskey-js/generator/api.json && pnpm --filter misskey-js update-autogen-code && pnpm --filter misskey-js build && pnpm --filter misskey-js api", - "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", - "start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js", + "start": "cd packages/backend && pnpm compile-config && node ./built/boot/entry.js", + "start:inspect": "cd packages/backend && pnpm compile-config && node --inspect ./built/boot/entry.js", + "start:test": "ncp ./.github/misskey/test.yml ./.config/test.yml && cd packages/backend && cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js", + "cli": "cd packages/backend && pnpm cli", "init": "pnpm migrate", "migrate": "cd packages/backend && pnpm migrate", "revert": "cd packages/backend && pnpm revert", @@ -34,7 +39,7 @@ "migrateandstart": "pnpm migrate && pnpm start", "watch": "pnpm dev", "dev": "node scripts/dev.mjs", - "lint": "pnpm -r lint", + "lint": "pnpm --no-bail -r lint", "cy:open": "pnpm cypress open --config-file=cypress.config.ts", "cy:run": "pnpm cypress run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", @@ -43,39 +48,40 @@ "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", "test": "pnpm -r test", "test-and-coverage": "pnpm -r test-and-coverage", - "clean": "node ./scripts/clean.js", - "clean-all": "node ./scripts/clean-all.js", + "clean": "node scripts/clean.mjs", + "clean-all": "node scripts/clean-all.mjs", "cleanall": "pnpm clean-all" }, "resolutions": { - "chokidar": "4.0.3", + "chokidar": "5.0.0", "lodash": "4.17.21" }, "dependencies": { - "cssnano": "7.1.0", - "esbuild": "0.25.8", - "execa": "9.6.0", - "fast-glob": "3.3.3", - "glob": "11.0.3", - "ignore-walk": "7.0.0", - "js-yaml": "4.1.0", + "cssnano": "7.1.2", + "esbuild": "0.27.2", + "execa": "9.6.1", + "ignore-walk": "8.0.0", + "js-yaml": "4.1.1", "postcss": "8.5.6", - "tar": "7.4.3", - "terser": "5.43.1", - "typescript": "5.9.2" + "tar": "7.5.2", + "terser": "5.44.1" }, "devDependencies": { - "@misskey-dev/eslint-plugin": "2.1.0", - "@types/node": "22.17.1", - "@typescript-eslint/eslint-plugin": "8.39.0", - "@typescript-eslint/parser": "8.39.0", - "cross-env": "7.0.3", - "cypress": "14.5.4", - "eslint": "9.33.0", - "globals": "16.3.0", + "@eslint/js": "9.39.2", + "@misskey-dev/eslint-plugin": "2.2.0", + "@types/js-yaml": "4.0.9", + "@types/node": "24.10.4", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "@typescript/native-preview": "7.0.0-dev.20251226.1", + "cross-env": "10.1.0", + "cypress": "15.8.1", + "eslint": "9.39.2", + "globals": "16.5.0", "ncp": "2.0.0", - "pnpm": "10.14.0", - "start-server-and-test": "2.0.13" + "pnpm": "10.27.0", + "typescript": "5.9.3", + "start-server-and-test": "2.1.3" }, "optionalDependencies": { "@tensorflow/tfjs-core": "4.22.0" @@ -84,8 +90,9 @@ "overrides": { "@aiscript-dev/aiscript-languageserver": "-" }, - "patchedDependencies": { - "typeorm": "patches/typeorm.patch" - } + "ignoredBuiltDependencies": [ + "@sentry-internal/node-cpu-profiler", + "exifreader" + ] } } diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc index f4bf7a4d2a..7e1767a67a 100644 --- a/packages/backend/.swcrc +++ b/packages/backend/.swcrc @@ -3,12 +3,17 @@ "jsc": { "parser": { "syntax": "typescript", + "jsx": true, "dynamicImport": true, "decorators": true }, "transform": { "legacyDecorator": true, - "decoratorMetadata": true + "decoratorMetadata": true, + "react": { + "runtime": "automatic", + "importSource": "@kitajs/html" + } }, "experimental": { "keepImportAssertions": true diff --git a/packages/backend/src/server/web/bios.css b/packages/backend/assets/misc/bios.css similarity index 100% rename from packages/backend/src/server/web/bios.css rename to packages/backend/assets/misc/bios.css diff --git a/packages/backend/src/server/web/bios.js b/packages/backend/assets/misc/bios.js similarity index 98% rename from packages/backend/src/server/web/bios.js rename to packages/backend/assets/misc/bios.js index 9ff5dca72a..f9716d8f00 100644 --- a/packages/backend/src/server/web/bios.js +++ b/packages/backend/assets/misc/bios.js @@ -9,7 +9,7 @@ window.onload = async () => { const account = JSON.parse(localStorage.getItem('account')); const i = account.token; - const api = (endpoint, data = {}) => { + const _api = (endpoint, data = {}) => { const promise = new Promise((resolve, reject) => { // Append a credential if (i) data.i = i; diff --git a/packages/backend/src/server/web/cli.css b/packages/backend/assets/misc/cli.css similarity index 100% rename from packages/backend/src/server/web/cli.css rename to packages/backend/assets/misc/cli.css diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/assets/misc/cli.js similarity index 100% rename from packages/backend/src/server/web/cli.js rename to packages/backend/assets/misc/cli.js diff --git a/packages/backend/src/server/web/error.css b/packages/backend/assets/misc/error.css similarity index 100% rename from packages/backend/src/server/web/error.css rename to packages/backend/assets/misc/error.css diff --git a/packages/backend/src/server/web/error.js b/packages/backend/assets/misc/error.js similarity index 100% rename from packages/backend/src/server/web/error.js rename to packages/backend/assets/misc/error.js diff --git a/packages/backend/assets/misc/flush.js b/packages/backend/assets/misc/flush.js new file mode 100644 index 0000000000..991b8ea808 --- /dev/null +++ b/packages/backend/assets/misc/flush.js @@ -0,0 +1,46 @@ +(async () => { + const msg = document.getElementById('msg'); + const successText = `\nSuccess Flush! Back to Misskey\n成功しました。Misskeyを開き直してください。`; + + if (!document.cookie) { + message('Your site data is fully cleared by your browser.'); + message(successText); + } else { + message('Your browser does not support Clear-Site-Data header. Start opportunistic flushing.'); + try { + localStorage.clear(); + message('localStorage cleared.'); + + const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => { + const delidb = indexedDB.deleteDatabase(name); + delidb.onsuccess = () => res(message(`indexedDB "${name}" cleared. (${i + 1}/${arr.length})`)); + delidb.onerror = e => rej(e) + })); + + await Promise.all(idbPromises); + + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage('clear'); + await navigator.serviceWorker.getRegistrations() + .then(registrations => { + return Promise.all(registrations.map(registration => registration.unregister())); + }) + .catch(e => { throw new Error(e) }); + } + + message(successText); + } catch (e) { + message(`\n${e}\n\nFlush Failed. Please retry.\n失敗しました。もう一度試してみてください。`); + message(`\nIf you retry more than 3 times, try manually clearing the browser cache or contact to instance admin.\n3回以上試しても失敗する場合、ブラウザのキャッシュを手動で消去し、それでもだめならインスタンス管理者に連絡してみてください。\n`) + + console.error(e); + setTimeout(() => { + location = '/'; + }, 10000) + } + } + + function message(text) { + msg.insertAdjacentHTML('beforeend', `

[${(new Date()).toString()}] ${text.replace(/\n/g,'
')}

`) + } +})(); diff --git a/packages/backend/assets/misc/info-card.css b/packages/backend/assets/misc/info-card.css new file mode 100644 index 0000000000..3e27223cc5 --- /dev/null +++ b/packages/backend/assets/misc/info-card.css @@ -0,0 +1,35 @@ +html, +body { + margin: 0; + padding: 0; + min-height: 100vh; + background: #fff; +} + +#a { + display: block; +} + +#banner { + background-size: cover; + background-position: center center; +} + +#title { + display: inline-block; + margin: 24px; + padding: 0.5em 0.8em; + color: #fff; + background: rgba(0, 0, 0, 0.5); + font-weight: bold; + font-size: 1.3em; +} + +#content { + overflow: auto; + color: #353c3e; +} + +#description { + margin: 24px; +} diff --git a/packages/backend/build.js b/packages/backend/build.js new file mode 100644 index 0000000000..52ca09b7a8 --- /dev/null +++ b/packages/backend/build.js @@ -0,0 +1,121 @@ +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { build } from 'esbuild'; +import { swcPlugin } from 'esbuild-plugin-swc'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); +const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8')); + +const resolveTsPathsPlugin = { + name: 'resolve-ts-paths', + setup(build) { + build.onResolve({ filter: /^\.{1,2}\/.*\.js$/ }, (args) => { + if (args.importer) { + const absPath = join(args.resolveDir, args.path); + const tsPath = absPath.slice(0, -3) + '.ts'; + if (fs.existsSync(tsPath)) return { path: tsPath }; + const tsxPath = absPath.slice(0, -3) + '.tsx'; + if (fs.existsSync(tsxPath)) return { path: tsxPath }; + } + }); + }, +}; + +const externalIpaddrPlugin = { + name: 'external-ipaddr', + setup(build) { + build.onResolve({ filter: /^ipaddr\.js$/ }, (args) => { + return { path: args.path, external: true }; + }); + }, +}; + +/** @type {import('esbuild').BuildOptions} */ +const options = { + entryPoints: ['./src/boot/entry.ts'], + minify: true, + keepNames: true, + bundle: true, + outdir: './built/boot', + target: 'node22', + platform: 'node', + format: 'esm', + sourcemap: 'linked', + packages: 'external', + banner: { + js: 'import { createRequire as topLevelCreateRequire } from "module";' + + 'import ___url___ from "url";' + + 'const require = topLevelCreateRequire(import.meta.url);' + + 'const __filename = ___url___.fileURLToPath(import.meta.url);' + + 'const __dirname = ___url___.fileURLToPath(new URL(".", import.meta.url));', + }, + plugins: [ + externalIpaddrPlugin, + resolveTsPathsPlugin, + swcPlugin({ + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + dynamicImport: true, + }, + transform: { + legacyDecorator: true, + decoratorMetadata: true, + }, + experimental: { + keepImportAssertions: true, + }, + baseUrl: join(_dirname, 'src'), + paths: { + '@/*': ['*'], + }, + target: 'esnext', + keepClassNames: true, + }, + }), + externalIpaddrPlugin, + ], + // external: [ + // 'slacc-*', + // 'class-transformer', + // 'class-validator', + // '@sentry/*', + // '@nestjs/websockets/socket-module', + // '@nestjs/microservices/microservices-module', + // '@nestjs/microservices', + // '@napi-rs/canvas-win32-x64-msvc', + // 'mock-aws-s3', + // 'aws-sdk', + // 'nock', + // 'sharp', + // 'jsdom', + // 're2', + // '@napi-rs/canvas', + // ], +}; + +const args = process.argv.slice(2).map(arg => arg.toLowerCase()); + +if (!args.includes('--no-clean')) { + fs.rmSync('./built', { recursive: true, force: true }); +} + +await buildSrc(); + +async function buildSrc() { + console.log(`[${_package.name}] start building...`); + + await build(options) + .then(() => { + console.log(`[${_package.name}] build succeeded.`); + }) + .catch((err) => { + process.stderr.write(err.stderr || err.message || err); + process.exit(1); + }); + + console.log(`[${_package.name}] finish building.`); +} diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index ba7c705def..d15a703ba2 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -25,7 +25,6 @@ export default [ }, }, rules: { - '@typescript-eslint/no-unused-vars': 'off', 'import/order': ['warn', { groups: [ 'builtin', diff --git a/packages/backend/jest.config.cjs b/packages/backend/jest.config.cjs index 5a4aa4e15a..22ffbbee5c 100644 --- a/packages/backend/jest.config.cjs +++ b/packages/backend/jest.config.cjs @@ -205,7 +205,7 @@ module.exports = { // Whether to use watchman for file crawling // watchman: true, - extensionsToTreatAsEsm: ['.ts'], + extensionsToTreatAsEsm: ['.ts', '.tsx'], testTimeout: 60000, diff --git a/packages/backend/jest.config.unit.cjs b/packages/backend/jest.config.unit.cjs index aa5992936b..957d0635c1 100644 --- a/packages/backend/jest.config.unit.cjs +++ b/packages/backend/jest.config.unit.cjs @@ -7,6 +7,7 @@ const base = require('./jest.config.cjs') module.exports = { ...base, + globalSetup: "/test/jest.setup.unit.cjs", testMatch: [ "/test/unit/**/*.ts", "/src/**/*.test.ts", diff --git a/packages/backend/jest.js b/packages/backend/jest.js index 0e761d8c92..61f6b00e85 100644 --- a/packages/backend/jest.js +++ b/packages/backend/jest.js @@ -10,7 +10,7 @@ const __dirname = path.dirname(__filename); const args = []; args.push(...[ - ...semver.satisfies(process.version, '^20.17.0 || ^22.0.0') ? ['--no-experimental-require-module'] : [], + ...semver.satisfies(process.version, '^20.17.0 || ^22.0.0 || ^24.10.0') ? ['--no-experimental-require-module'] : [], '--experimental-vm-modules', '--experimental-import-meta-resolve', path.join(__dirname, 'node_modules/jest/bin/jest.js'), diff --git a/packages/backend/jsconfig.json b/packages/backend/jsconfig.json deleted file mode 100644 index 1230aadd12..0000000000 --- a/packages/backend/jsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "es6", - "module": "commonjs", - "allowSyntheticDefaultImports": true - }, - "exclude": [ - "node_modules", - "jspm_packages", - "tmp", - "temp" - ] -} diff --git a/packages/backend/migration/1745378064470-composite-note-index.js b/packages/backend/migration/1745378064470-composite-note-index.js index 12108a6b3c..576bf7d19a 100644 --- a/packages/backend/migration/1745378064470-composite-note-index.js +++ b/packages/backend/migration/1745378064470-composite-note-index.js @@ -3,14 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { isConcurrentIndexMigrationEnabled } from "./js/migration-config.js"; +const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1'; export class CompositeNoteIndex1745378064470 { name = 'CompositeNoteIndex1745378064470'; - transaction = isConcurrentIndexMigrationEnabled() ? false : undefined; + transaction = isConcurrentIndexMigrationEnabled ? false : undefined; async up(queryRunner) { - const concurrently = isConcurrentIndexMigrationEnabled(); + const concurrently = isConcurrentIndexMigrationEnabled; if (concurrently) { const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`); @@ -29,7 +29,7 @@ export class CompositeNoteIndex1745378064470 { } async down(queryRunner) { - const mayConcurrently = isConcurrentIndexMigrationEnabled() ? 'CONCURRENTLY' : ''; + const mayConcurrently = isConcurrentIndexMigrationEnabled ? 'CONCURRENTLY' : ''; await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`); await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`); } diff --git a/packages/backend/migration/1746949539915-migrateSomeConfigFileSettingsToMeta.js b/packages/backend/migration/1746949539915-migrateSomeConfigFileSettingsToMeta.js index 3243f43b91..cb8bb33459 100644 --- a/packages/backend/migration/1746949539915-migrateSomeConfigFileSettingsToMeta.js +++ b/packages/backend/migration/1746949539915-migrateSomeConfigFileSettingsToMeta.js @@ -3,17 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import {loadConfig} from "./js/migration-config.js"; export class MigrateSomeConfigFileSettingsToMeta1746949539915 { name = 'MigrateSomeConfigFileSettingsToMeta1746949539915' async up(queryRunner) { - const config = loadConfig(); // $1 cannot be used in ALTER TABLE queries - await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT ${config.proxyRemoteFiles}`); - await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT ${config.signToActivityPubGet}`); - await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT ${!config.disallowExternalApRedirect}`); + await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT TRUE`); + await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT TRUE`); + await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT TRUE`); } async down(queryRunner) { diff --git a/packages/backend/migration/1756062689648-NonCascadingPageEyeCatching.js b/packages/backend/migration/1756062689648-NonCascadingPageEyeCatching.js new file mode 100644 index 0000000000..8554cc4304 --- /dev/null +++ b/packages/backend/migration/1756062689648-NonCascadingPageEyeCatching.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NonCascadingPageEyeCatching1756062689648 { + name = 'NonCascadingPageEyeCatching1756062689648' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "page" DROP CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa"`); + await queryRunner.query(`ALTER TABLE "page" ADD CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa" FOREIGN KEY ("eyeCatchingImageId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "page" DROP CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa"`); + await queryRunner.query(`ALTER TABLE "page" ADD CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa" FOREIGN KEY ("eyeCatchingImageId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/migration/1757823175259-sensitive-ad.js b/packages/backend/migration/1757823175259-sensitive-ad.js new file mode 100644 index 0000000000..46f0f270ab --- /dev/null +++ b/packages/backend/migration/1757823175259-sensitive-ad.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SensitiveAd1757823175259 { + name = 'SensitiveAd1757823175259' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "ad" ADD "isSensitive" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "isSensitive"`); + } +} diff --git a/packages/backend/migration/1758677617888-scheduled-post.js b/packages/backend/migration/1758677617888-scheduled-post.js new file mode 100644 index 0000000000..b31313d9db --- /dev/null +++ b/packages/backend/migration/1758677617888-scheduled-post.js @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ScheduledPost1758677617888 { + name = 'ScheduledPost1758677617888' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_draft" ADD "scheduledAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`ALTER TABLE "note_draft" ADD "isActuallyScheduled" boolean NOT NULL DEFAULT false`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "isActuallyScheduled"`); + await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "scheduledAt"`); + } +} diff --git a/packages/backend/migration/1760607435831-RoleBadgesRemoteUsers.js b/packages/backend/migration/1760607435831-RoleBadgesRemoteUsers.js new file mode 100644 index 0000000000..483d35a91b --- /dev/null +++ b/packages/backend/migration/1760607435831-RoleBadgesRemoteUsers.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RoleBadgesRemoteUsers1760607435831 { + name = 'RoleBadgesRemoteUsers1760607435831' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "showRoleBadgesOfRemoteUsers" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "showRoleBadgesOfRemoteUsers"`); + } +} diff --git a/packages/backend/migration/1760790899857-unnecessary-null-default.js b/packages/backend/migration/1760790899857-unnecessary-null-default.js new file mode 100644 index 0000000000..d34758315f --- /dev/null +++ b/packages/backend/migration/1760790899857-unnecessary-null-default.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UnnecessaryNullDefault1760790899857 { + name = 'UnnecessaryNullDefault1760790899857' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "userId" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "systemWebhookId" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "urlPreviewUserAgent" DROP DEFAULT`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "urlPreviewUserAgent" SET DEFAULT NULL`); + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "systemWebhookId" SET DEFAULT NULL`); + await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "userId" SET DEFAULT NULL`); + } +} diff --git a/packages/backend/migration/1761569941833-add-channel-muting.js b/packages/backend/migration/1761569941833-add-channel-muting.js new file mode 100644 index 0000000000..e985df90ba --- /dev/null +++ b/packages/backend/migration/1761569941833-add-channel-muting.js @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddChannelMuting1761569941833 { + name = 'AddChannelMuting1761569941833' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "channel_muting" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "channelId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_aec842e98f332ebd8e12f85bad6" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_34415e3062ae7a94617496e81c" ON "channel_muting" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_4d534d7177fc59879d942e96d0" ON "channel_muting" ("channelId") `); + await queryRunner.query(`CREATE INDEX "IDX_6dd314e96806b7df65ddadff72" ON "channel_muting" ("expiresAt") `); + await queryRunner.query(`CREATE INDEX "IDX_b96870ed326ccc7fa243970965" ON "channel_muting" ("userId", "channelId") `); + await queryRunner.query(`ALTER TABLE "note" ADD "renoteChannelId" character varying(32)`); + await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_34415e3062ae7a94617496e81c5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_4d534d7177fc59879d942e96d03" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_4d534d7177fc59879d942e96d03"`); + await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_34415e3062ae7a94617496e81c5"`); + await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "renoteChannelId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b96870ed326ccc7fa243970965"`); + await queryRunner.query(`DROP INDEX "public"."IDX_6dd314e96806b7df65ddadff72"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4d534d7177fc59879d942e96d0"`); + await queryRunner.query(`DROP INDEX "public"."IDX_34415e3062ae7a94617496e81c"`); + await queryRunner.query(`DROP TABLE "channel_muting"`); + } +} diff --git a/packages/backend/migration/1767169026317-birthday-index.js b/packages/backend/migration/1767169026317-birthday-index.js new file mode 100644 index 0000000000..972fc08c9b --- /dev/null +++ b/packages/backend/migration/1767169026317-birthday-index.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class BirthdayIndex1767169026317 { + name = 'BirthdayIndex1767169026317' + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`); + await queryRunner.query(`CREATE OR REPLACE FUNCTION get_birthday_date(birthday TEXT) RETURNS SMALLINT AS $$ BEGIN RETURN CAST((SUBSTR(birthday, 6, 2) || SUBSTR(birthday, 9, 2)) AS SMALLINT); END; $$ LANGUAGE plpgsql IMMUTABLE;`); + await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`); + } + + async down(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`); + await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`); + await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`); + } +} diff --git a/packages/backend/migration/js/migration-config.js b/packages/backend/migration/js/migration-config.js deleted file mode 100644 index 853735661b..0000000000 --- a/packages/backend/migration/js/migration-config.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { path as configYamlPath } from '../../built/config.js'; -import * as yaml from 'js-yaml'; -import fs from "node:fs"; - -export function isConcurrentIndexMigrationEnabled() { - return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1'; -} - -let loadedConfigCache = undefined; - -function loadConfigInternal() { - const config = yaml.load(fs.readFileSync(configYamlPath, 'utf-8')); - - return { - disallowExternalApRedirect: Boolean(config.disallowExternalApRedirect ?? false), - proxyRemoteFiles: Boolean(config.proxyRemoteFiles ?? false), - signToActivityPubGet: Boolean(config.signToActivityPubGet ?? true), - } -} - -export function loadConfig() { - if (loadedConfigCache === undefined) { - loadedConfigCache = loadConfigInternal(); - } - return loadedConfigCache; -} diff --git a/packages/backend/ormconfig.js b/packages/backend/ormconfig.js index f979c36ad7..1a8c146451 100644 --- a/packages/backend/ormconfig.js +++ b/packages/backend/ormconfig.js @@ -1,7 +1,8 @@ import { DataSource } from 'typeorm'; -import { loadConfig } from './built/config.js'; -import { entities } from './built/postgres.js'; -import { isConcurrentIndexMigrationEnabled } from "./migration/js/migration-config.js"; +import { loadConfig } from './src-js/config.js'; +import { entities } from './src-js/postgres.js'; + +const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1'; const config = loadConfig(); @@ -15,5 +16,5 @@ export default new DataSource({ extra: config.db.extra, entities: entities, migrations: ['migration/*.js'], - migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all', + migrationsTransactionMode: isConcurrentIndexMigrationEnabled ? 'each' : 'all', }); diff --git a/packages/backend/package.json b/packages/backend/package.json index 0d1ae4ac3d..c1a9ac8829 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -4,54 +4,57 @@ "private": true, "type": "module", "engines": { - "node": "^22.15.0" + "node": "^22.15.0 || ^24.10.0" }, "scripts": { - "start": "node ./built/boot/entry.js", - "start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js", - "migrate": "pnpm typeorm migration:run -d ormconfig.js", - "revert": "pnpm typeorm migration:revert -d ormconfig.js", - "check:connect": "node ./scripts/check_connect.js", - "build": "swc src -d built -D --strip-leading-paths", + "start": "pnpm compile-config && node ./built/boot/entry.js", + "start:inspect": "pnpm compile-config && node --inspect ./built/boot/entry.js", + "start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js", + "migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js", + "revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js", + "cli": "pnpm compile-config && node ./src-js/boot/cli.js", + "check:connect": "pnpm compile-config && node ./scripts/check_connect.js", + "compile-config": "node ./scripts/compile_config.js", + "build": "swc src -d src-js -D --strip-leading-paths && node ./build.js", "build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths", "watch:swc": "swc src -d built -D -w --strip-leading-paths", - "build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", - "watch": "node ./scripts/watch.mjs", + "build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json", + "watch": "pnpm compile-config && node ./scripts/watch.mjs", "restart": "pnpm build && pnpm start", - "dev": "node ./scripts/dev.mjs", - "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", + "dev": "pnpm compile-config && node ./scripts/dev.mjs", + "typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit", "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", - "jest": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs", - "jest:e2e": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs", - "jest:fed": "node ./jest.js --forceExit --config jest.config.fed.cjs", - "jest-and-coverage": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs", - "jest-and-coverage:e2e": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs", - "jest-clear": "cross-env NODE_ENV=test node ./jest.js --clearCache", + "jest": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs", + "jest:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs", + "jest:fed": "pnpm compile-config && node ./jest.js --forceExit --config jest.config.fed.cjs", + "jest-and-coverage": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs", + "jest-and-coverage:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs", + "jest-clear": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --clearCache", "test": "pnpm jest", "test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e", "test:fed": "pnpm jest:fed", "test-and-coverage": "pnpm jest-and-coverage", "test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e", "check-migrations": "node scripts/check_migrations_clean.js", - "generate-api-json": "node ./scripts/generate_api_json.js" + "generate-api-json": "pnpm compile-config && node ./scripts/generate_api_json.js" }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.13.3", - "@swc/core-darwin-x64": "1.13.3", + "@swc/core-darwin-arm64": "1.15.7", + "@swc/core-darwin-x64": "1.15.7", "@swc/core-freebsd-x64": "1.3.11", - "@swc/core-linux-arm-gnueabihf": "1.13.3", - "@swc/core-linux-arm64-gnu": "1.13.3", - "@swc/core-linux-arm64-musl": "1.13.3", - "@swc/core-linux-x64-gnu": "1.13.3", - "@swc/core-linux-x64-musl": "1.13.3", - "@swc/core-win32-arm64-msvc": "1.13.3", - "@swc/core-win32-ia32-msvc": "1.13.3", - "@swc/core-win32-x64-msvc": "1.13.3", + "@swc/core-linux-arm-gnueabihf": "1.15.7", + "@swc/core-linux-arm64-gnu": "1.15.7", + "@swc/core-linux-arm64-musl": "1.15.7", + "@swc/core-linux-x64-gnu": "1.15.7", + "@swc/core-linux-x64-musl": "1.15.7", + "@swc/core-win32-arm64-msvc": "1.15.7", + "@swc/core-win32-ia32-msvc": "1.15.7", + "@swc/core-win32-x64-msvc": "1.15.7", "@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs-node": "4.22.0", - "bufferutil": "4.0.9", + "bufferutil": "4.1.0", "slacc-android-arm-eabi": "0.0.10", "slacc-android-arm64": "0.0.10", "slacc-darwin-arm64": "0.0.10", @@ -65,123 +68,108 @@ "slacc-linux-x64-musl": "0.0.10", "slacc-win32-arm64-msvc": "0.0.10", "slacc-win32-x64-msvc": "0.0.10", - "utf-8-validate": "6.0.5" + "utf-8-validate": "6.0.6" }, "dependencies": { - "@aws-sdk/client-s3": "3.864.0", - "@aws-sdk/lib-storage": "3.864.0", + "@aws-sdk/client-s3": "3.958.0", + "@aws-sdk/lib-storage": "3.958.0", "@discordapp/twemoji": "16.0.1", - "@fastify/accepts": "5.0.2", - "@fastify/cookie": "11.0.2", - "@fastify/cors": "10.1.0", + "@fastify/accepts": "5.0.4", + "@fastify/cors": "11.2.0", "@fastify/express": "4.0.2", - "@fastify/http-proxy": "10.0.2", - "@fastify/multipart": "9.0.3", - "@fastify/static": "8.2.0", - "@fastify/view": "10.0.2", + "@fastify/http-proxy": "11.4.1", + "@fastify/multipart": "9.3.0", + "@fastify/static": "8.3.0", + "@kitajs/html": "4.2.11", "@misskey-dev/sharp-read-bmp": "1.2.0", - "@misskey-dev/summaly": "5.2.3", - "@napi-rs/canvas": "0.1.77", - "@nestjs/common": "11.1.6", - "@nestjs/core": "11.1.6", - "@nestjs/testing": "11.1.6", + "@misskey-dev/summaly": "5.2.5", + "@napi-rs/canvas": "0.1.87", + "@nestjs/common": "11.1.10", + "@nestjs/core": "11.1.10", + "@nestjs/testing": "11.1.10", "@peertube/http-signature": "1.7.0", - "@sentry/node": "8.55.0", - "@sentry/profiling-node": "8.55.0", - "@simplewebauthn/server": "12.0.0", - "@sinonjs/fake-timers": "11.3.1", - "@smithy/node-http-handler": "2.5.0", - "@swc/cli": "0.7.8", - "@swc/core": "1.13.3", + "@sentry/node": "10.32.1", + "@sentry/profiling-node": "10.32.1", + "@simplewebauthn/server": "13.2.2", + "@sinonjs/fake-timers": "15.1.0", + "@smithy/node-http-handler": "4.4.7", + "@swc/cli": "0.7.9", + "@swc/core": "1.15.7", "@twemoji/parser": "16.0.0", - "@types/redis-info": "3.0.3", "accepts": "1.3.8", "ajv": "8.17.1", "archiver": "7.0.1", "async-mutex": "0.5.0", - "bcryptjs": "2.4.3", + "bcryptjs": "3.0.3", "blurhash": "2.0.5", - "body-parser": "1.20.3", - "bullmq": "5.56.9", + "body-parser": "2.2.1", + "bullmq": "5.66.3", "cacheable-lookup": "7.0.0", - "cbor": "9.0.2", - "chalk": "5.5.0", - "chalk-template": "1.1.0", - "chokidar": "4.0.3", - "cli-highlight": "2.1.11", - "color-convert": "2.0.1", - "content-disposition": "0.5.4", - "date-fns": "2.30.0", + "chalk": "5.6.2", + "chalk-template": "1.1.2", + "chokidar": "5.0.0", + "color-convert": "3.1.3", + "content-disposition": "1.0.1", + "date-fns": "4.1.0", "deep-email-validator": "0.1.21", - "fastify": "5.4.0", + "fastify": "5.6.2", "fastify-raw-body": "5.0.0", - "feed": "4.2.2", - "file-type": "19.6.0", + "feed": "5.1.0", + "file-type": "21.2.0", "fluent-ffmpeg": "2.1.3", - "form-data": "4.0.4", - "got": "14.4.7", - "happy-dom": "16.8.1", + "form-data": "4.0.5", + "got": "14.6.5", "hpagent": "1.2.0", - "htmlescape": "1.1.1", "http-link-header": "1.1.3", - "ioredis": "5.7.0", + "i18n": "workspace:*", + "ioredis": "5.8.2", "ip-cidr": "4.0.2", - "ipaddr.js": "2.2.0", - "is-svg": "5.1.0", - "js-yaml": "4.1.0", - "jsdom": "26.1.0", + "ipaddr.js": "2.3.0", + "is-svg": "6.1.0", "json5": "2.2.3", - "jsonld": "8.3.3", - "jsrsasign": "11.1.0", - "juice": "11.0.1", - "meilisearch": "0.51.0", + "jsonld": "9.0.0", + "juice": "11.0.3", + "meilisearch": "0.54.0", "mfm-js": "0.25.0", - "microformats-parser": "2.0.4", - "mime-types": "2.1.35", + "mime-types": "3.0.2", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", - "ms": "3.0.0-canary.1", - "nanoid": "5.1.5", + "ms": "3.0.0-canary.202508261828", + "nanoid": "5.1.6", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "nodemailer": "6.10.1", + "node-html-parser": "7.0.1", + "nodemailer": "7.0.12", "nsfwjs": "4.2.0", - "oauth": "0.10.2", "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.4.0", - "parse5": "7.3.0", + "otpauth": "9.4.1", "pg": "8.16.3", - "pkce-challenge": "4.1.0", + "pkce-challenge": "5.0.1", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", - "pug": "3.0.3", "qrcode": "1.5.4", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.22.1", - "redis-info": "3.1.0", - "redis-lock": "0.1.4", + "re2": "1.23.0", "reflect-metadata": "0.2.2", "rename": "1.0.4", "rss-parser": "3.13.0", "rxjs": "7.8.2", "sanitize-html": "2.17.0", - "secure-json-parse": "3.0.2", + "secure-json-parse": "4.1.0", + "semver": "7.7.3", "sharp": "0.33.5", - "semver": "7.7.2", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.27.7", + "systeminformation": "5.28.1", "tinycolor2": "1.6.0", - "tmp": "0.2.4", + "tmp": "0.2.5", "tsc-alias": "1.8.16", - "tsconfig-paths": "4.2.0", - "typeorm": "0.3.25", - "typescript": "5.9.2", - "ulid": "2.4.0", + "typeorm": "0.3.28", + "ulid": "3.0.2", "vary": "1.1.2", "web-push": "3.6.7", "ws": "8.18.3", @@ -189,59 +177,57 @@ }, "devDependencies": { "@jest/globals": "29.7.0", - "@nestjs/platform-express": "10.4.20", - "@sentry/vue": "9.45.0", + "@kitajs/ts-html-plugin": "4.1.3", + "@nestjs/platform-express": "11.1.10", + "@sentry/vue": "10.32.1", "@simplewebauthn/types": "12.0.0", "@swc/jest": "0.2.39", "@types/accepts": "1.3.7", - "@types/archiver": "6.0.3", - "@types/bcryptjs": "2.4.6", + "@types/archiver": "7.0.0", "@types/body-parser": "1.19.6", "@types/color-convert": "2.0.4", "@types/content-disposition": "0.5.9", - "@types/fluent-ffmpeg": "2.1.27", - "@types/htmlescape": "1.1.3", + "@types/fluent-ffmpeg": "2.1.28", "@types/http-link-header": "1.0.7", "@types/jest": "29.5.14", - "@types/js-yaml": "4.0.9", - "@types/jsdom": "21.1.7", "@types/jsonld": "1.5.15", - "@types/jsrsasign": "10.5.15", - "@types/mime-types": "2.1.4", - "@types/ms": "0.7.34", - "@types/node": "22.17.1", - "@types/nodemailer": "6.4.17", - "@types/oauth": "0.9.6", + "@types/mime-types": "3.0.1", + "@types/ms": "2.1.0", + "@types/node": "24.10.4", + "@types/nodemailer": "7.0.4", "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", - "@types/pg": "8.15.5", - "@types/pug": "2.0.10", - "@types/qrcode": "1.5.5", + "@types/pg": "8.16.0", + "@types/qrcode": "1.5.6", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", "@types/sanitize-html": "2.16.0", - "@types/semver": "7.7.0", - "@types/simple-oauth2": "5.0.7", - "@types/sinonjs__fake-timers": "8.1.5", + "@types/semver": "7.7.1", + "@types/simple-oauth2": "5.0.8", + "@types/sinonjs__fake-timers": "15.0.1", "@types/supertest": "6.0.3", "@types/tinycolor2": "1.4.6", "@types/tmp": "0.2.6", "@types/vary": "1.1.3", "@types/web-push": "3.6.4", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.39.0", - "@typescript-eslint/parser": "8.39.0", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", "aws-sdk-client-mock": "4.1.0", - "cross-env": "7.0.3", + "cbor": "10.0.11", + "cross-env": "10.1.0", + "esbuild-plugin-swc": "1.0.1", "eslint-plugin-import": "2.32.0", - "execa": "8.0.1", - "fkill": "9.0.0", + "execa": "9.6.1", + "fkill": "10.0.1", "jest": "29.7.0", "jest-mock": "29.7.0", - "nodemon": "3.1.10", - "pid-port": "1.0.2", + "js-yaml": "4.1.1", + "nodemon": "3.1.11", + "pid-port": "2.0.0", "simple-oauth2": "5.1.0", - "supertest": "7.1.4" + "supertest": "7.1.4", + "vite": "7.3.0" } } diff --git a/packages/backend/scripts/check_connect.js b/packages/backend/scripts/check_connect.js index 96c4549ccb..a1cb839303 100644 --- a/packages/backend/scripts/check_connect.js +++ b/packages/backend/scripts/check_connect.js @@ -4,8 +4,8 @@ */ import Redis from 'ioredis'; -import { loadConfig } from '../built/config.js'; -import { createPostgresDataSource } from '../built/postgres.js'; +import { loadConfig } from '../src-js/config.js'; +import { createPostgresDataSource } from '../src-js/postgres.js'; const config = loadConfig(); @@ -16,26 +16,22 @@ async function connectToPostgres() { } async function connectToRedis(redisOptions) { - return await new Promise(async (resolve, reject) => { - const redis = new Redis({ + let redis; + try { + redis = new Redis({ ...redisOptions, lazyConnect: true, reconnectOnError: false, showFriendlyErrorStack: true, }); - redis.on('error', e => reject(e)); - try { - await redis.connect(); - resolve(); - - } catch (e) { - reject(e); - - } finally { - redis.disconnect(false); - } - }); + await Promise.race([ + new Promise((_, reject) => redis.on('error', e => reject(e))), + redis.connect(), + ]); + } finally { + redis.disconnect(false); + } } // If not all of these are defined, the default one gets reused. @@ -50,7 +46,7 @@ const promises = Array ])) .map(connectToRedis) .concat([ - connectToPostgres() + connectToPostgres(), ]); await Promise.all(promises); diff --git a/packages/backend/scripts/compile_config.js b/packages/backend/scripts/compile_config.js new file mode 100644 index 0000000000..e78fa3dc9f --- /dev/null +++ b/packages/backend/scripts/compile_config.js @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * YAMLファイルをJSONファイルに変換するスクリプト + * ビルド前に実行し、ランタイムにjs-yamlを含まないようにする + */ + +import fs from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import yaml from 'js-yaml'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const configDir = resolve(_dirname, '../../../.config'); +const OUTPUT_PATH = resolve(_dirname, '../../../built/.config.json'); + +// TODO: yamlのパースに失敗したときのエラーハンドリング + +/** + * YAMLファイルをJSONファイルに変換 + * @param {string} ymlPath - YAMLファイルのパス + */ +function yamlToJson(ymlPath) { + if (!fs.existsSync(ymlPath)) { + console.warn(`YAML file not found: ${ymlPath}`); + return; + } + + console.log(`${ymlPath} → ${OUTPUT_PATH}`); + + const yamlContent = fs.readFileSync(ymlPath, 'utf-8'); + const jsonContent = yaml.load(yamlContent); + if (!fs.existsSync(dirname(OUTPUT_PATH))) { + fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true }); + } + fs.writeFileSync(OUTPUT_PATH, JSON.stringify({ + '_NOTE_': 'This file is auto-generated from YAML file. DO NOT EDIT.', + ...jsonContent, + }), 'utf-8'); +} + +if (process.env.MISSKEY_CONFIG_YML) { + const customYmlPath = resolve(configDir, process.env.MISSKEY_CONFIG_YML); + yamlToJson(customYmlPath); +} else { + yamlToJson(resolve(configDir, process.env.NODE_ENV === 'test' ? 'test.yml' : 'default.yml')); +} + +console.log('Configuration compiled ✓'); diff --git a/packages/backend/scripts/dev.mjs b/packages/backend/scripts/dev.mjs index 023eb7eae6..db96eaf976 100644 --- a/packages/backend/scripts/dev.mjs +++ b/packages/backend/scripts/dev.mjs @@ -42,7 +42,7 @@ async function killProc() { './node_modules/nodemon/bin/nodemon.js', [ '-w', 'src', - '-e', 'ts,js,mjs,cjs,json,pug', + '-e', 'ts,js,mjs,cjs,tsx,json,pug', '--exec', 'pnpm', 'run', 'build', ], { diff --git a/packages/backend/scripts/generate_api_json.js b/packages/backend/scripts/generate_api_json.js index 798e243004..237f63a4d3 100644 --- a/packages/backend/scripts/generate_api_json.js +++ b/packages/backend/scripts/generate_api_json.js @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { writeFileSync, existsSync } from 'node:fs'; import { execa } from 'execa'; -import { writeFileSync, existsSync } from "node:fs"; async function main() { if (!process.argv.includes('--no-build')) { @@ -19,10 +19,10 @@ async function main() { } /** @type {import('../src/config.js')} */ - const { loadConfig } = await import('../built/config.js'); + const { loadConfig } = await import('../src-js/config.js'); /** @type {import('../src/server/api/openapi/gen-spec.js')} */ - const { genOpenapiSpec } = await import('../built/server/api/openapi/gen-spec.js'); + const { genOpenapiSpec } = await import('../src-js/server/api/openapi/gen-spec.js'); const config = loadConfig(); const spec = genOpenapiSpec(config, true); diff --git a/packages/backend/scripts/measure-memory.mjs b/packages/backend/scripts/measure-memory.mjs new file mode 100644 index 0000000000..3f30e24fb4 --- /dev/null +++ b/packages/backend/scripts/measure-memory.mjs @@ -0,0 +1,231 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * This script starts the Misskey backend server, waits for it to be ready, + * measures memory usage, and outputs the result as JSON. + * + * Usage: node scripts/measure-memory.mjs + */ + +import { fork } from 'node:child_process'; +import { setTimeout } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import * as http from 'node:http'; +import * as fs from 'node:fs/promises'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const SAMPLE_COUNT = 3; // Number of samples to measure +const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup +const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle + +const keys = { + VmPeak: 0, + VmSize: 0, + VmHWM: 0, + VmRSS: 0, + VmData: 0, + VmStk: 0, + VmExe: 0, + VmLib: 0, + VmPTE: 0, + VmSwap: 0, +}; + +async function getMemoryUsage(pid) { + const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8'); + + const result = {}; + for (const key of Object.keys(keys)) { + const match = status.match(new RegExp(`${key}:\\s+(\\d+)\\s+kB`)); + if (match) { + result[key] = parseInt(match[1], 10); + } else { + throw new Error(`Failed to parse ${key} from /proc/${pid}/status`); + } + } + + return result; +} + +async function measureMemory() { + // Start the Misskey backend server using fork to enable IPC + const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), ['expose-gc'], { + cwd: join(__dirname, '..'), + env: { + ...process.env, + NODE_ENV: 'production', + MK_DISABLE_CLUSTERING: '1', + }, + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + execArgv: [...process.execArgv, '--expose-gc'], + }); + + let serverReady = false; + + // Listen for the 'ok' message from the server indicating it's ready + serverProcess.on('message', (message) => { + if (message === 'ok') { + serverReady = true; + } + }); + + // Handle server output + serverProcess.stdout?.on('data', (data) => { + process.stderr.write(`[server stdout] ${data}`); + }); + + serverProcess.stderr?.on('data', (data) => { + process.stderr.write(`[server stderr] ${data}`); + }); + + // Handle server error + serverProcess.on('error', (err) => { + process.stderr.write(`[server error] ${err}\n`); + }); + + async function triggerGc() { + const ok = new Promise((resolve) => { + serverProcess.once('message', (message) => { + if (message === 'gc ok') resolve(); + }); + }); + + serverProcess.send('gc'); + + await ok; + + await setTimeout(1000); + } + + function createRequest() { + return new Promise((resolve, reject) => { + const req = http.request({ + host: 'localhost', + port: 61812, + path: '/api/meta', + method: 'POST', + }, (res) => { + res.on('data', () => { }); + res.on('end', () => { + resolve(); + }); + }); + req.on('error', (err) => { + reject(err); + }); + req.end(); + }); + } + + // Wait for server to be ready or timeout + const startupStartTime = Date.now(); + while (!serverReady) { + if (Date.now() - startupStartTime > STARTUP_TIMEOUT) { + serverProcess.kill('SIGTERM'); + throw new Error('Server startup timeout'); + } + await setTimeout(100); + } + + const startupTime = Date.now() - startupStartTime; + process.stderr.write(`Server started in ${startupTime}ms\n`); + + // Wait for memory to settle + await setTimeout(MEMORY_SETTLE_TIME); + + const pid = serverProcess.pid; + + const beforeGc = await getMemoryUsage(pid); + + await triggerGc(); + + const afterGc = await getMemoryUsage(pid); + + // create some http requests to simulate load + const REQUEST_COUNT = 10; + await Promise.all( + Array.from({ length: REQUEST_COUNT }).map(() => createRequest()), + ); + + await triggerGc(); + + const afterRequest = await getMemoryUsage(pid); + + // Stop the server + serverProcess.kill('SIGTERM'); + + // Wait for process to exit + let exited = false; + await new Promise((resolve) => { + serverProcess.on('exit', () => { + exited = true; + resolve(undefined); + }); + // Force kill after 10 seconds if not exited + setTimeout(10000).then(() => { + if (!exited) { + serverProcess.kill('SIGKILL'); + } + resolve(undefined); + }); + }); + + const result = { + timestamp: new Date().toISOString(), + beforeGc, + afterGc, + afterRequest, + }; + + return result; +} + +async function main() { + // 直列の方が時間的に分散されて正確そうだから直列でやる + const results = []; + for (let i = 0; i < SAMPLE_COUNT; i++) { + const res = await measureMemory(); + results.push(res); + } + + // Calculate averages + const beforeGc = structuredClone(keys); + const afterGc = structuredClone(keys); + const afterRequest = structuredClone(keys); + for (const res of results) { + for (const key of Object.keys(keys)) { + beforeGc[key] += res.beforeGc[key]; + afterGc[key] += res.afterGc[key]; + afterRequest[key] += res.afterRequest[key]; + } + } + for (const key of Object.keys(keys)) { + beforeGc[key] = Math.round(beforeGc[key] / SAMPLE_COUNT); + afterGc[key] = Math.round(afterGc[key] / SAMPLE_COUNT); + afterRequest[key] = Math.round(afterRequest[key] / SAMPLE_COUNT); + } + + const result = { + timestamp: new Date().toISOString(), + beforeGc, + afterGc, + afterRequest, + }; + + // Output as JSON to stdout + console.log(JSON.stringify(result, null, 2)); +} + +main().catch((err) => { + console.error(JSON.stringify({ + error: err.message, + timestamp: new Date().toISOString(), + })); + process.exit(1); +}); diff --git a/packages/backend/scripts/watch.mjs b/packages/backend/scripts/watch.mjs index a0ccea3b16..9d608b233c 100644 --- a/packages/backend/scripts/watch.mjs +++ b/packages/backend/scripts/watch.mjs @@ -21,7 +21,7 @@ import { execa } from 'execa'; }); }, 3000); - execa('tsc', ['-w', '-p', 'tsconfig.json'], { + execa('tsgo', ['-w', '-p', 'tsconfig.json'], { stdout: process.stdout, stderr: process.stderr, }); diff --git a/packages/backend/src/@types/redis-lock.d.ts b/packages/backend/src/@types/redis-lock.d.ts deleted file mode 100644 index b037cde5ee..0000000000 --- a/packages/backend/src/@types/redis-lock.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -declare module 'redis-lock' { - import type Redis from 'ioredis'; - - type Lock = (lockName: string, timeout?: number, taskToPerform?: () => Promise) => void; - function redisLock(client: Redis.Redis, retryDelay: number): Lock; - - export = redisLock; -} diff --git a/packages/backend/src/boot/cli.ts b/packages/backend/src/boot/cli.ts new file mode 100644 index 0000000000..a5618f8152 --- /dev/null +++ b/packages/backend/src/boot/cli.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import 'reflect-metadata'; +import { EventEmitter } from 'node:events'; +import { NestFactory } from '@nestjs/core'; +import { CommandModule } from '@/cli/CommandModule.js'; +import { NestLogger } from '@/NestLogger.js'; +import { CommandService } from '@/cli/CommandService.js'; + +process.title = 'Misskey Cli'; + +Error.stackTraceLimit = Infinity; +EventEmitter.defaultMaxListeners = 128; + +const app = await NestFactory.createApplicationContext(CommandModule, { + logger: new NestLogger(), +}); + +const commandService = app.get(CommandService); + +const command = process.argv[2] ?? 'help'; + +switch (command) { + case 'help': { + console.log('Available commands:'); + console.log(' help - Displays this help message'); + console.log(' reset-captcha - Resets the captcha'); + break; + } + case 'ping': { + await commandService.ping(); + break; + } + case 'reset-captcha': { + await commandService.resetCaptcha(); + console.log('Captcha has been reset.'); + break; + } + default: { + console.error(`Unrecognized command: ${command}`); + console.error('Use "help" to see available commands.'); + process.exit(1); + } +} + +process.exit(0); diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index da585ad68d..3a33d198a5 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -86,6 +86,18 @@ if (!envOption.disableClustering) { ev.mount(); } +process.on('message', msg => { + if (msg === 'gc') { + if (global.gc != null) { + logger.info('Manual GC triggered'); + global.gc(); + if (process.send != null) process.send('gc ok'); + } else { + logger.warn('Manual GC requested but gc is not available. Start the process with --expose-gc to enable this feature.'); + } + } +}); + readyRef.value = true; // ユニットテスト時にMisskeyが子プロセスで起動された時のため diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index d1fb3858db..041f58e509 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -4,14 +4,10 @@ */ import * as fs from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname } from 'node:path'; 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'; @@ -19,20 +15,15 @@ import { showMachineInfo } from '@/misc/show-machine-info.js'; import { envOption } from '@/env.js'; import { jobQueue, server } from './common.js'; -const _filename = fileURLToPath(import.meta.url); -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'); const themeColor = chalk.hex('#86b300'); -function greet() { +function greet(props: { version: string }) { if (!envOption.quiet) { //#region Misskey logo - const v = `v${meta.version}`; + const v = `v${props.version}`; console.log(themeColor(' _____ _ _ ')); console.log(themeColor(' | |_|___ ___| |_ ___ _ _ ')); console.log(themeColor(' | | | | |_ -|_ -| \'_| -_| | |')); @@ -41,14 +32,14 @@ function greet() { //#endregion console.log(' Misskey is an open-source decentralized microblogging platform.'); - console.log(chalk.rgb(255, 136, 0)(' If you like Misskey, please donate to support development. https://www.patreon.com/syuilo')); + console.log(chalk.rgb(255, 136, 0)(' If you like Misskey, please consider donating to support dev. https://misskey-hub.net/docs/donate/')); console.log(''); console.log(chalkTemplate`--- ${os.hostname()} {gray (PID: ${process.pid.toString()})} ---`); } bootLogger.info('Welcome to Misskey!'); - bootLogger.info(`Misskey v${meta.version}`, null, true); + bootLogger.info(`Misskey v${props.version}`, null, true); } /** @@ -59,21 +50,24 @@ export async function masterMain() { // initialize app try { - greet(); + config = loadConfigBoot(); + greet({ version: config.version }); showEnvironment(); await showMachineInfo(bootLogger); showNodejsVersion(); - config = loadConfigBoot(); //await connectDb(); if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString()); } catch (e) { - bootLogger.error('Fatal error occurred during initialization', null, true); + bootLogger.error('Fatal error occurred during initialization: ' + e, null, true); process.exit(1); } bootLogger.succ('Misskey initialized'); if (config.sentryForBackend) { + const Sentry = await import('@sentry/node'); + const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); + Sentry.init({ integrations: [ ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts index 5d4a15b29f..3feb6fd199 100644 --- a/packages/backend/src/boot/worker.ts +++ b/packages/backend/src/boot/worker.ts @@ -4,8 +4,6 @@ */ 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'; @@ -17,6 +15,9 @@ export async function workerMain() { const config = loadConfig(); if (config.sentryForBackend) { + const Sentry = await import('@sentry/node'); + const { nodeProfilingIntegration } = await import('@sentry/profiling-node'); + Sentry.init({ integrations: [ ...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []), diff --git a/packages/backend/src/cli/CommandModule.ts b/packages/backend/src/cli/CommandModule.ts new file mode 100644 index 0000000000..f4b1d25c18 --- /dev/null +++ b/packages/backend/src/cli/CommandModule.ts @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Module } from '@nestjs/common'; +import { CoreModule } from '@/core/CoreModule.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CommandService } from './CommandService.js'; + +@Module({ + imports: [ + GlobalModule, + CoreModule, + ], + providers: [ + CommandService, + ], + exports: [ + CommandService, + ], +}) +export class CommandModule {} diff --git a/packages/backend/src/cli/CommandService.ts b/packages/backend/src/cli/CommandService.ts new file mode 100644 index 0000000000..cdb2a9f382 --- /dev/null +++ b/packages/backend/src/cli/CommandService.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import type { Config } from '@/config.js'; +import { DI } from '@/di-symbols.js'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; + +@Injectable() +export class CommandService { + private logger: Logger; + + constructor( + @Inject(DI.config) + private config: Config, + + private metaService: MetaService, + ) { + } + + @bindThis + public async ping() { + console.log('pong'); + } + + @bindThis + public async resetCaptcha() { + await this.metaService.update({ + enableHcaptcha: false, + hcaptchaSiteKey: null, + hcaptchaSecretKey: null, + enableMcaptcha: false, + mcaptchaSitekey: null, + mcaptchaSecretKey: null, + mcaptchaInstanceUrl: null, + enableRecaptcha: false, + recaptchaSiteKey: null, + recaptchaSecretKey: null, + enableTurnstile: false, + turnstileSiteKey: null, + turnstileSecretKey: null, + enableTestcaptcha: false, + }); + } +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index f71f1d7e34..4cd82bed87 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -6,10 +6,11 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; -import * as yaml from 'js-yaml'; +import { type FastifyServerOptions } from 'fastify'; import type * as Sentry from '@sentry/node'; import type * as SentryVue from '@sentry/vue'; import type { RedisOptions } from 'ioredis'; +import type { ManifestChunk } from 'vite'; type RedisOptionsSource = Partial & { host: string; @@ -27,7 +28,9 @@ type Source = { url?: string; port?: number; socket?: string; + trustProxy?: FastifyServerOptions['trustProxy']; chmodSocket?: string; + enableIpRateLimit?: boolean; disableHsts?: boolean; db: { host: string; @@ -118,7 +121,9 @@ export type Config = { url: string; port: number; socket: string | undefined; + trustProxy: NonNullable; chmodSocket: string | undefined; + enableIpRateLimit: boolean; disableHsts: boolean | undefined; db: { host: string; @@ -184,9 +189,9 @@ export type Config = { authUrl: string; driveUrl: string; userAgent: string; - frontendEntry: { file: string | null }; + frontendEntry: ManifestChunk; frontendManifestExists: boolean; - frontendEmbedEntry: { file: string | null }; + frontendEmbedEntry: ManifestChunk; frontendEmbedManifestExists: boolean; mediaProxy: string; externalMediaProxyEnabled: boolean; @@ -214,33 +219,45 @@ export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -/** - * Path of configuration directory - */ -const dir = `${_dirname}/../../../.config`; +/** Path of repository root directory */ +let rootDir = _dirname; +// 見つかるまで上に遡る +while (!fs.existsSync(resolve(rootDir, 'packages'))) { + const parentDir = dirname(rootDir); + if (parentDir === rootDir) { + throw new Error('Cannot find root directory'); + } + rootDir = parentDir; +} -/** - * Path of configuration file - */ -export const path = process.env.MISSKEY_CONFIG_YML - ? resolve(dir, process.env.MISSKEY_CONFIG_YML) - : process.env.NODE_ENV === 'test' - ? resolve(dir, 'test.yml') - : resolve(dir, 'default.yml'); +/** Path of configuration directory */ +const configDir = resolve(rootDir, '.config'); +/** Path of built directory */ +const projectBuiltDir = resolve(rootDir, 'built'); + +const compiledConfigFilePathForTest = resolve(projectBuiltDir, '._config_.json'); + +export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest) + ? compiledConfigFilePathForTest + : resolve(projectBuiltDir, '.config.json'); export function loadConfig(): Config { - const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8')); + if (!fs.existsSync(compiledConfigFilePath)) { + throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.'); + } - const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json'); - const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json'); + const meta = JSON.parse(fs.readFileSync(resolve(projectBuiltDir, 'meta.json'), 'utf-8')); + + const frontendManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json')); + const frontendEmbedManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json')); const frontendManifest = frontendManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8')) + JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json'), 'utf-8')) : { 'src/_boot_.ts': { file: null } }; const frontendEmbedManifest = frontendEmbedManifestExists ? - JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8')) + JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json'), 'utf-8')) : { 'src/boot.ts': { file: null } }; - const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source; + const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source; const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? ''); const version = meta.version; @@ -266,8 +283,17 @@ export function loadConfig(): Config { url: url.origin, port: config.port ?? parseInt(process.env.PORT ?? '', 10), socket: config.socket, + trustProxy: config.trustProxy ?? [ + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16', + '127.0.0.1/32', + '::1/128', + 'fc00::/7', + ], chmodSocket: config.chmodSocket, disableHsts: config.disableHsts, + enableIpRateLimit: config.enableIpRateLimit ?? true, host, hostname, scheme, @@ -326,7 +352,7 @@ export function loadConfig(): Config { function tryCreateUrl(url: string) { try { return new URL(url); - } catch (e) { + } catch (_) { throw new Error(`url="${url}" is not a valid URL.`); } } diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index f8e3eaf01f..5d668bc582 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -75,7 +75,7 @@ export class AccountMoveService { */ @bindThis public async moveFromLocal(src: MiLocalUser, dst: MiLocalUser | MiRemoteUser): Promise { - const srcUri = this.userEntityService.getUserUri(src); + const _srcUri = this.userEntityService.getUserUri(src); const dstUri = this.userEntityService.getUserUri(dst); // add movedToUri to indicate that the user has moved diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index 248a9b8979..cbae280030 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -7,11 +7,10 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Injectable } from '@nestjs/common'; -import * as nsfw from 'nsfwjs'; -import si from 'systeminformation'; import { Mutex } from 'async-mutex'; import fetch from 'node-fetch'; import { bindThis } from '@/decorators.js'; +import type { NSFWJS, PredictionType } from 'nsfwjs'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -21,7 +20,7 @@ let isSupportedCpu: undefined | boolean = undefined; @Injectable() export class AiService { - private model: nsfw.NSFWJS; + private model: NSFWJS; private modelLoadMutex: Mutex = new Mutex(); constructor( @@ -29,7 +28,7 @@ export class AiService { } @bindThis - public async detectSensitive(path: string): Promise { + public async detectSensitive(source: string | Buffer): Promise { try { if (isSupportedCpu === undefined) { isSupportedCpu = await this.computeIsSupportedCpu(); @@ -44,6 +43,7 @@ export class AiService { tf.env().global.fetch = fetch; if (this.model == null) { + const nsfw = await import('nsfwjs'); await this.modelLoadMutex.runExclusive(async () => { if (this.model == null) { this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 }); @@ -51,7 +51,7 @@ export class AiService { }); } - const buffer = await fs.promises.readFile(path); + const buffer = source instanceof Buffer ? source : await fs.promises.readFile(source); const image = await tf.node.decodeImage(buffer, 3) as any; try { const predictions = await this.model.classify(image); @@ -83,6 +83,7 @@ export class AiService { @bindThis private async getCpuFlags(): Promise { + const si = await import('systeminformation'); const str = await si.cpuFlags(); return str.split(/\s+/); } diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index a9f6731977..f750ca212a 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -205,7 +205,7 @@ export class AnnouncementService { announcementId: announcementId, userId: user.id, }); - } catch (e) { + } catch (_) { return; } diff --git a/packages/backend/src/core/AppLockService.ts b/packages/backend/src/core/AppLockService.ts deleted file mode 100644 index bd2749cb87..0000000000 --- a/packages/backend/src/core/AppLockService.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { promisify } from 'node:util'; -import { Inject, Injectable } from '@nestjs/common'; -import redisLock from 'redis-lock'; -import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; - -/** - * Retry delay (ms) for lock acquisition - */ -const retryDelay = 100; - -@Injectable() -export class AppLockService { - private lock: (key: string, timeout?: number, _?: (() => Promise) | undefined) => Promise<() => void>; - - constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - ) { - this.lock = promisify(redisLock(this.redisClient, retryDelay)); - } - - /** - * Get AP Object lock - * @param uri AP object ID - * @param timeout Lock timeout (ms), The timeout releases previous lock. - * @returns Unlock function - */ - @bindThis - public getApLock(uri: string, timeout = 30 * 1000): Promise<() => void> { - return this.lock(`ap-object:${uri}`, timeout); - } - - @bindThis - public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> { - return this.lock(`chart-insert:${lockKey}`, timeout); - } -} diff --git a/packages/backend/src/core/AvatarDecorationService.ts b/packages/backend/src/core/AvatarDecorationService.ts index 4efd6122b1..70a50a0175 100644 --- a/packages/backend/src/core/AvatarDecorationService.ts +++ b/packages/backend/src/core/AvatarDecorationService.ts @@ -39,7 +39,7 @@ export class AvatarDecorationService implements OnApplicationShutdown { const obj = JSON.parse(data); if (obj.channel === 'internal') { - const { type, body } = obj.message as GlobalEvents['internal']['payload']; + const { type, body: _ } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'avatarDecorationCreated': case 'avatarDecorationUpdated': diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 12251595e2..d320a5ea36 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; -import type { ChannelFollowingsRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, MiUser } from '@/models/_.js'; import { MiChannel } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; @@ -23,6 +23,8 @@ export class ChannelFollowingService implements OnModuleInit { private redisClient: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, private idService: IdService, @@ -45,6 +47,50 @@ export class ChannelFollowingService implements OnModuleInit { onModuleInit() { } + /** + * フォローしているチャンネルの一覧を取得する. + * @param params + * @param [opts] + * @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意. + * @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない). + * @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない). + */ + @bindThis + public async list( + params: { + requestUserId: MiUser['id'], + }, + opts?: { + idOnly?: boolean; + joinUser?: boolean; + joinBannerFile?: boolean; + }, + ): Promise { + if (opts?.idOnly) { + const q = this.channelFollowingsRepository.createQueryBuilder('channel_following') + .select('channel_following.followeeId') + .where('channel_following.followerId = :userId', { userId: params.requestUserId }); + + return q + .getRawMany<{ channel_following_followeeId: string }>() + .then(xs => xs.map(x => ({ id: x.channel_following_followeeId } as MiChannel))); + } else { + const q = this.channelsRepository.createQueryBuilder('channel') + .innerJoin('channel_following', 'channel_following', 'channel_following.followeeId = channel.id') + .where('channel_following.followerId = :userId', { userId: params.requestUserId }); + + if (opts?.joinUser) { + q.innerJoinAndSelect('channel.user', 'user'); + } + + if (opts?.joinBannerFile) { + q.leftJoinAndSelect('channel.banner', 'drive_file'); + } + + return q.getMany(); + } + } + @bindThis public async follow( requestUser: MiLocalUser, diff --git a/packages/backend/src/core/ChannelMutingService.ts b/packages/backend/src/core/ChannelMutingService.ts new file mode 100644 index 0000000000..bf5b848d44 --- /dev/null +++ b/packages/backend/src/core/ChannelMutingService.ts @@ -0,0 +1,224 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { Brackets, In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiChannelMuting, MiUser } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; +import { RedisKVCache } from '@/misc/cache.js'; + +@Injectable() +export class ChannelMutingService { + public mutingChannelsCache: RedisKVCache>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + @Inject(DI.channelMutingRepository) + private channelMutingRepository: ChannelMutingRepository, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + this.mutingChannelsCache = new RedisKVCache>(this.redisClient, 'channelMutingChannels', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (userId) => this.channelMutingRepository.find({ + where: { userId: userId }, + select: ['channelId'], + }).then(xs => new Set(xs.map(x => x.channelId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.redisForSub.on('message', this.onMessage); + } + + /** + * ミュートしているチャンネルの一覧を取得する. + * @param params + * @param [opts] + * @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意. + * @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない). + * @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない). + */ + @bindThis + public async list( + params: { + requestUserId: MiUser['id'], + }, + opts?: { + idOnly?: boolean; + joinUser?: boolean; + joinBannerFile?: boolean; + }, + ): Promise { + if (opts?.idOnly) { + const q = this.channelMutingRepository.createQueryBuilder('channel_muting') + .select('channel_muting.channelId') + .where('channel_muting.userId = :userId', { userId: params.requestUserId }) + .andWhere(new Brackets(qb => { + qb.where('channel_muting.expiresAt IS NULL') + .orWhere('channel_muting.expiresAt > :now', { now: new Date() }); + })); + + return q + .getRawMany<{ channel_muting_channelId: string }>() + .then(xs => xs.map(x => ({ id: x.channel_muting_channelId } as MiChannel))); + } else { + const q = this.channelsRepository.createQueryBuilder('channel') + .innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id') + .where('channel_muting.userId = :userId', { userId: params.requestUserId }) + .andWhere(new Brackets(qb => { + qb.where('channel_muting.expiresAt IS NULL') + .orWhere('channel_muting.expiresAt > :now', { now: new Date() }); + })); + + if (opts?.joinUser) { + q.innerJoinAndSelect('channel.user', 'user'); + } + + if (opts?.joinBannerFile) { + q.leftJoinAndSelect('channel.banner', 'drive_file'); + } + + return q.getMany(); + } + } + + /** + * 期限切れのチャンネルミュート情報を取得する. + * + * @param [opts] + * @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルミュートを設定したユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない). + * @param {(boolean|undefined)} [opts.joinChannel=undefined] ミュート先のチャンネル情報をJOINするかどうか(falseまたは省略時はJOINしない). + */ + public async findExpiredMutings(opts?: { + joinUser?: boolean; + joinChannel?: boolean; + }): Promise { + const now = new Date(); + const q = this.channelMutingRepository.createQueryBuilder('channel_muting') + .where('channel_muting.expiresAt < :now', { now }); + + if (opts?.joinUser) { + q.innerJoinAndSelect('channel_muting.user', 'user'); + } + + if (opts?.joinChannel) { + q.leftJoinAndSelect('channel_muting.channel', 'channel'); + } + + return q.getMany(); + } + + /** + * 既にミュートされているかどうかをキャッシュから取得する. + * @param params + * @param params.requestUserId + */ + @bindThis + public async isMuted(params: { + requestUserId: MiUser['id'], + targetChannelId: MiChannel['id'], + }): Promise { + const mutedChannels = await this.mutingChannelsCache.get(params.requestUserId); + return (mutedChannels?.has(params.targetChannelId) ?? false); + } + + /** + * チャンネルをミュートする. + * @param params + * @param {(Date|null|undefined)} [params.expiresAt] ミュートの有効期限. nullまたは省略時は無期限. + */ + @bindThis + public async mute(params: { + requestUserId: MiUser['id'], + targetChannelId: MiChannel['id'], + expiresAt?: Date | null, + }): Promise { + await this.channelMutingRepository.insert({ + id: this.idService.gen(), + userId: params.requestUserId, + channelId: params.targetChannelId, + expiresAt: params.expiresAt, + }); + + this.globalEventService.publishInternalEvent('muteChannel', { + userId: params.requestUserId, + channelId: params.targetChannelId, + }); + } + + /** + * チャンネルのミュートを解除する. + * @param params + */ + @bindThis + public async unmute(params: { + requestUserId: MiUser['id'], + targetChannelId: MiChannel['id'], + }): Promise { + await this.channelMutingRepository.delete({ + userId: params.requestUserId, + channelId: params.targetChannelId, + }); + + this.globalEventService.publishInternalEvent('unmuteChannel', { + userId: params.requestUserId, + channelId: params.targetChannelId, + }); + } + + /** + * 期限切れのチャンネルミュート情報を削除する. + */ + @bindThis + public async eraseExpiredMutings(): Promise { + const expiredMutings = await this.findExpiredMutings(); + await this.channelMutingRepository.delete({ id: In(expiredMutings.map(x => x.id)) }); + + const userIds = [...new Set(expiredMutings.map(x => x.userId))]; + for (const userId of userIds) { + this.mutingChannelsCache.refresh(userId).then(); + } + } + + @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 'muteChannel': { + this.mutingChannelsCache.refresh(body.userId).then(); + break; + } + case 'unmuteChannel': { + this.mutingChannelsCache.delete(body.userId).then(); + break; + } + } + } + } + + @bindThis + public dispose(): void { + this.mutingChannelsCache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index a30bff0fe4..f075671d93 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -15,12 +15,12 @@ import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { UserSearchService } from '@/core/UserSearchService.js'; import { WebhookTestService } from '@/core/WebhookTestService.js'; import { FlashService } from '@/core/FlashService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; import { AnnouncementService } from './AnnouncementService.js'; import { AntennaService } from './AntennaService.js'; -import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; @@ -141,7 +141,7 @@ import { ApLoggerService } from './activitypub/ApLoggerService.js'; import { ApMfmService } from './activitypub/ApMfmService.js'; import { ApRendererService } from './activitypub/ApRendererService.js'; import { ApRequestService } from './activitypub/ApRequestService.js'; -import { ApResolverService } from './activitypub/ApResolverService.js'; +import { ApResolverService, Resolver } from './activitypub/ApResolverService.js'; import { JsonLdService } from './activitypub/JsonLdService.js'; import { RemoteLoggerService } from './RemoteLoggerService.js'; import { RemoteUserResolveService } from './RemoteUserResolveService.js'; @@ -165,7 +165,6 @@ const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useEx const $AiService: Provider = { provide: 'AiService', useExisting: AiService }; const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService }; const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService }; -const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService }; const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; @@ -225,6 +224,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService }; const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; +const $ChannelMutingService: Provider = { provide: 'ChannelMutingService', useExisting: ChannelMutingService }; const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; @@ -318,7 +318,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AiService, AnnouncementService, AntennaService, - AppLockService, AchievementService, AvatarDecorationService, CaptchaService, @@ -378,6 +377,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChannelMutingService, ChatService, RegistryApiService, ReversiService, @@ -447,6 +447,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, + Resolver, JsonLdService, RemoteLoggerService, RemoteUserResolveService, @@ -467,7 +468,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AiService, $AnnouncementService, $AntennaService, - $AppLockService, $AchievementService, $AvatarDecorationService, $CaptchaService, @@ -527,6 +527,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChannelMutingService, $ChatService, $RegistryApiService, $ReversiService, @@ -617,7 +618,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AiService, AnnouncementService, AntennaService, - AppLockService, AchievementService, AvatarDecorationService, CaptchaService, @@ -677,6 +677,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChannelMutingService, ChatService, RegistryApiService, ReversiService, @@ -745,6 +746,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ApRendererService, ApRequestService, ApResolverService, + Resolver, JsonLdService, RemoteLoggerService, RemoteUserResolveService, @@ -765,7 +767,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AiService, $AnnouncementService, $AntennaService, - $AppLockService, $AchievementService, $AvatarDecorationService, $CaptchaService, @@ -824,6 +825,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChannelMutingService, $ChatService, $RegistryApiService, $ReversiService, diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 567bad2a2d..816f83ec93 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -517,40 +517,43 @@ export class DriveService { this.registerLogger.debug(`ADD DRIVE FILE: user ${user?.id ?? 'not set'}, name ${detectedName}, tmp ${path}`); //#region Check drive usage and mime type - if (user && !isLink) { + if (user != null && !isLink) { const isLocalUser = this.userEntityService.isLocalUser(user); - const policies = await this.roleService.getUserPolicies(user.id); + const isModerator = isLocalUser ? await this.roleService.isModerator(user) : false; + if (!isModerator) { + const policies = await this.roleService.getUserPolicies(user.id); - const allowedMimeTypes = policies.uploadableFileTypes; - const isAllowed = allowedMimeTypes.some((mimeType) => { - if (mimeType === '*' || mimeType === '*/*') return true; - if (mimeType.endsWith('/*')) return info.type.mime.startsWith(mimeType.slice(0, -1)); - return info.type.mime === mimeType; - }); - if (!isAllowed) { - throw new IdentifiableError('bd71c601-f9b0-4808-9137-a330647ced9b', `Unallowed file type: ${info.type.mime}`); - } - - const driveCapacity = 1024 * 1024 * policies.driveCapacityMb; - const maxFileSize = 1024 * 1024 * policies.maxFileSizeMb; - - if (maxFileSize < info.size) { - if (isLocalUser) { - throw new IdentifiableError('f9e4e5f3-4df4-40b5-b400-f236945f7073', 'Max file size exceeded.'); + const allowedMimeTypes = policies.uploadableFileTypes; + const isAllowed = allowedMimeTypes.some((mimeType) => { + if (mimeType === '*' || mimeType === '*/*') return true; + if (mimeType.endsWith('/*')) return info.type.mime.startsWith(mimeType.slice(0, -1)); + return info.type.mime === mimeType; + }); + if (!isAllowed) { + throw new IdentifiableError('bd71c601-f9b0-4808-9137-a330647ced9b', `Unallowed file type: ${info.type.mime}`); } - } - const usage = await this.driveFileEntityService.calcDriveUsageOf(user); + const driveCapacity = 1024 * 1024 * policies.driveCapacityMb; + const maxFileSize = 1024 * 1024 * policies.maxFileSizeMb; - this.registerLogger.debug('drive capacity override applied'); - this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); - - // If usage limit exceeded - if (driveCapacity < usage + info.size) { - if (isLocalUser) { - throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); + if (maxFileSize < info.size) { + if (isLocalUser) { + throw new IdentifiableError('f9e4e5f3-4df4-40b5-b400-f236945f7073', 'Max file size exceeded.'); + } + } + + const usage = await this.driveFileEntityService.calcDriveUsageOf(user); + + this.registerLogger.debug('drive capacity override applied'); + this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); + + // If usage limit exceeded + if (driveCapacity < usage + info.size) { + if (isLocalUser) { + throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.'); + } + await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as MiRemoteUser, driveCapacity - info.size); } - await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as MiRemoteUser, driveCapacity - info.size); } } //#endregion diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index c7be0f7843..384704b252 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -366,7 +366,7 @@ export class EmailService { valid: true, reason: null, }; - } catch (error) { + } catch (_) { return { valid: false, reason: 'network', diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index 94c5691bf4..e39d70d683 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -19,6 +19,8 @@ import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { isChannelRelated } from '@/misc/is-channel-related.js'; type NoteFilter = (note: MiNote) => boolean; @@ -35,6 +37,7 @@ type TimelineOptions = { ignoreAuthorFromBlock?: boolean; ignoreAuthorFromMute?: boolean; ignoreAuthorFromInstanceBlock?: boolean; + ignoreAuthorChannelFromMute?: boolean; excludeNoFiles?: boolean; excludeReplies?: boolean; excludePureRenotes: boolean; @@ -55,6 +58,7 @@ export class FanoutTimelineEndpointService { private cacheService: CacheService, private fanoutTimelineService: FanoutTimelineService, private utilityService: UtilityService, + private channelMutingService: ChannelMutingService, ) { } @@ -111,11 +115,13 @@ export class FanoutTimelineEndpointService { userIdsWhoMeMutingRenotes, userIdsWhoBlockingMe, userMutedInstances, + userMutedChannels, ] = await Promise.all([ this.cacheService.userMutingsCache.fetch(ps.me.id), this.cacheService.renoteMutingsCache.fetch(ps.me.id), this.cacheService.userBlockedCache.fetch(ps.me.id), this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)), + this.channelMutingService.mutingChannelsCache.fetch(me.id), ]); const parentFilter = filter; @@ -126,6 +132,7 @@ export class FanoutTimelineEndpointService { if (isUserRelated(note.renote, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false; if (isInstanceMuted(note, userMutedInstances)) return false; + if (isChannelRelated(note, userMutedChannels, ps.ignoreAuthorChannelFromMute)) return false; return parentFilter(note); }; diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index ce3af7c774..955f7035d7 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -5,9 +5,9 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { JSDOM } from 'jsdom'; import tinycolor from 'tinycolor2'; import * as Redis from 'ioredis'; +import * as htmlParser from 'node-html-parser'; import type { MiInstance } from '@/models/Instance.js'; import type Logger from '@/logger.js'; import { DI } from '@/di-symbols.js'; @@ -15,7 +15,6 @@ import { LoggerService } from '@/core/LoggerService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import type { DOMWindow } from 'jsdom'; type NodeInfo = { openRegistrations?: unknown; @@ -59,7 +58,7 @@ export class FetchInstanceMetadataService { return await this.redisClient.set( `fetchInstanceMetadata:mutex:v2:${host}`, '1', 'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395 - 'GET' // 古い値を返す(なかったらnull) + 'GET', // 古い値を返す(なかったらnull) ); } @@ -181,15 +180,14 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchDom(instance: MiInstance): Promise { + private async fetchDom(instance: MiInstance): Promise { this.logger.info(`Fetching HTML of ${instance.host} ...`); const url = 'https://' + instance.host; const html = await this.httpRequestService.getHtml(url); - const { window } = new JSDOM(html); - const doc = window.document; + const doc = htmlParser.parse(html); return doc; } @@ -206,12 +204,12 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise { + private async fetchFaviconUrl(instance: MiInstance, doc: htmlParser.HTMLElement | null): Promise { const url = 'https://' + instance.host; if (doc) { // https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043 - const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href; + const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.attributes.rel === 'icon')?.attributes.href; if (href) { return (new URL(href, url)).href; @@ -232,7 +230,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record | null): Promise { + private async fetchIconUrl(instance: MiInstance, doc: htmlParser.HTMLElement | null, manifest: Record | null): Promise { if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) { const url = 'https://' + instance.host; return (new URL(manifest.icons[0].src, url)).href; @@ -246,9 +244,9 @@ export class FetchInstanceMetadataService { // https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559 const href = [ - links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href, - links.find(link => link.relList.contains('apple-touch-icon'))?.href, - links.find(link => link.relList.contains('icon'))?.href, + links.find(link => link.attributes.rel?.split(/\s+/).includes('apple-touch-icon-precomposed'))?.attributes.href, + links.find(link => link.attributes.rel?.split(/\s+/).includes('apple-touch-icon'))?.attributes.href, + links.find(link => link.attributes.rel?.split(/\s+/).includes('icon'))?.attributes.href, ] .find(href => href); @@ -261,7 +259,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getThemeColor(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record | null): Promise { const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color; if (themeColor) { @@ -273,7 +271,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getSiteName(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeName === 'string') { return info.metadata.nodeName; @@ -298,7 +296,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { + private async getDescription(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeDescription === 'string') { return info.metadata.nodeDescription; diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 6250d4d3a1..c7c9f8037d 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -20,6 +20,7 @@ import { AiService } from '@/core/AiService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; import type { PredictionType } from 'nsfwjs'; export type FileInfo = { @@ -204,16 +205,7 @@ export class FileInfoService { return [sensitive, porn]; } - if ([ - 'image/jpeg', - 'image/png', - 'image/webp', - ].includes(mime)) { - const result = await this.aiService.detectSensitive(source); - if (result) { - [sensitive, porn] = judgePrediction(result); - } - } else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) { + if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) { const [outDir, disposeOutDir] = await createTempDir(); try { const command = FFmpeg() @@ -281,6 +273,23 @@ export class FileInfoService { } finally { disposeOutDir(); } + } else if (isMimeImage(mime, 'sharp-convertible-image-with-bmp')) { + /* + * tfjs-node は限られた画像形式しか受け付けないため、sharp で PNG に変換する + * せっかくなので内部処理で使われる最大サイズの299x299に事前にリサイズする + */ + const png = await (await sharpBmp(source, mime)) + .resize(299, 299, { + withoutEnlargement: false, + }) + .rotate() + .flatten({ background: { r: 119, g: 119, b: 119 } }) // 透過部分を18%グレーで塗りつぶす + .png() + .toBuffer(); + const result = await this.aiService.detectSensitive(png); + if (result) { + [sensitive, porn] = judgePrediction(result); + } } return [sensitive, porn]; @@ -330,7 +339,7 @@ export class FileInfoService { } @bindThis - public fixMime(mime: string | fileType.MimeType): string { + public fixMime(mime: string): string { // see https://github.com/misskey-dev/misskey/pull/10686 if (mime === 'audio/x-flac') { return 'audio/flac'; @@ -475,25 +484,13 @@ export class FileInfoService { * Calculate blurhash string of image */ @bindThis - private getBlurhash(path: string, type: string): Promise { - return new Promise(async (resolve, reject) => { - (await sharpBmp(path, type)) - .raw() - .ensureAlpha() - .resize(64, 64, { fit: 'inside' }) - .toBuffer((err, buffer, info) => { - if (err) return reject(err); - - let hash; - - try { - hash = blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5); - } catch (e) { - return reject(e); - } - - resolve(hash); - }); - }); + private async getBlurhash(path: string, type: string): Promise { + const sharp = await sharpBmp(path, type); + const { data: buffer, info } = await sharp + .raw() + .ensureAlpha() + .resize(64, 64, { fit: 'inside' }) + .toBuffer({ resolveWithObject: true }); + return blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5); } } diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 3215b41c8d..da5982abf6 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -38,11 +38,7 @@ export interface BroadcastTypes { emojis: Packed<'EmojiDetailed'>[]; }; emojiDeleted: { - emojis: { - id?: string; - name: string; - [other: string]: any; - }[]; + emojis: Packed<'EmojiDetailed'>[]; }; announcementCreated: { announcement: Packed<'Announcement'>; @@ -255,6 +251,8 @@ export interface InternalEventTypes { metaUpdated: { before?: MiMeta; after: MiMeta; }; followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; + muteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; + unmuteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; updateUserProfile: MiUserProfile; mute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index f7973cbb66..5714bde8bf 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -37,17 +37,23 @@ class HttpRequestServiceAgent extends http.Agent { @bindThis public createConnection(options: http.ClientRequestArgs, callback?: (err: Error | null, stream: stream.Duplex) => void): stream.Duplex { - const socket = super.createConnection(options, callback) - .on('connect', () => { - if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') { - const address = socket.remoteAddress; - if (address && ipaddr.isValid(address)) { - if (this.isPrivateIp(address)) { - socket.destroy(new Error(`Blocked address: ${address}`)); - } + const socket = super.createConnection(options, callback); + + if (socket == null) { + throw new Error('Failed to create socket'); + } + + socket.on('connect', () => { + if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') { + const address = socket.remoteAddress; + if (address && ipaddr.isValid(address)) { + if (this.isPrivateIp(address)) { + socket.destroy(new Error(`Blocked address: ${address}`)); } } - }); + } + }); + return socket; } @@ -76,17 +82,23 @@ class HttpsRequestServiceAgent extends https.Agent { @bindThis public createConnection(options: http.ClientRequestArgs, callback?: (err: Error | null, stream: stream.Duplex) => void): stream.Duplex { - const socket = super.createConnection(options, callback) - .on('connect', () => { - if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') { - const address = socket.remoteAddress; - if (address && ipaddr.isValid(address)) { - if (this.isPrivateIp(address)) { - socket.destroy(new Error(`Blocked address: ${address}`)); - } + const socket = super.createConnection(options, callback); + + if (socket == null) { + throw new Error('Failed to create socket'); + } + + socket.on('connect', () => { + if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') { + const address = socket.remoteAddress; + if (address && ipaddr.isValid(address)) { + if (this.isPrivateIp(address)) { + socket.destroy(new Error(`Blocked address: ${address}`)); } } - }); + } + }); + return socket; } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 28d980f718..274966d921 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -5,26 +5,19 @@ import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import * as parse5 from 'parse5'; -import { type Document, type HTMLParagraphElement, Window, XMLSerializer } from 'happy-dom'; +import * as htmlParser from 'node-html-parser'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { intersperse } from '@/misc/prelude/array.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; -import type { DefaultTreeAdapterMap } from 'parse5'; +import { escapeHtml } from '@/misc/escape-html.js'; import type * as mfm from 'mfm-js'; -const treeAdapter = parse5.defaultTreeAdapter; -type Node = DefaultTreeAdapterMap['node']; -type ChildNode = DefaultTreeAdapterMap['childNode']; - const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; -export type Appender = (document: Document, body: HTMLParagraphElement) => void; - @Injectable() export class MfmService { constructor( @@ -40,68 +33,68 @@ export class MfmService { const normalizedHashtagNames = hashtagNames == null ? undefined : new Set(hashtagNames.map(x => normalizeForSearch(x))); - const dom = parse5.parseFragment(html); + const doc = htmlParser.parse(`
${html}
`); let text = ''; - for (const n of dom.childNodes) { + for (const n of doc.childNodes) { analyze(n); } return text.trim(); - function getText(node: Node): string { - if (treeAdapter.isTextNode(node)) return node.value; - if (!treeAdapter.isElementNode(node)) return ''; - if (node.nodeName === 'br') return '\n'; + function getText(node: htmlParser.Node): string { + if (node instanceof htmlParser.TextNode) return node.textContent; + if (!(node instanceof htmlParser.HTMLElement)) return ''; + if (node.tagName === 'BR') return '\n'; - if (node.childNodes) { + if (node.childNodes != null) { return node.childNodes.map(n => getText(n)).join(''); } return ''; } - function appendChildren(childNodes: ChildNode[]): void { - if (childNodes) { + function analyzeChildren(childNodes: htmlParser.Node[] | null): void { + if (childNodes != null) { for (const n of childNodes) { analyze(n); } } } - function analyze(node: Node) { - if (treeAdapter.isTextNode(node)) { - text += node.value; + function analyze(node: htmlParser.Node) { + if (node instanceof htmlParser.TextNode) { + text += node.textContent; return; } // Skip comment or document type node - if (!treeAdapter.isElementNode(node)) { + if (!(node instanceof htmlParser.HTMLElement)) { return; } - switch (node.nodeName) { - case 'br': { + switch (node.tagName) { + case 'BR': { text += '\n'; 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'); + const rel = node.attributes.rel; + const href = node.attributes.href; // ハッシュタグ - if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) { + if (normalizedHashtagNames && href != null && normalizedHashtagNames.has(normalizeForSearch(txt))) { text += txt; // メンション - } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) { + } else if (txt.startsWith('@') && !(rel != null && rel.startsWith('me '))) { const part = txt.split('@'); if (part.length === 2 && href) { //#region ホスト名部分が省略されているので復元する - const acct = `${txt}@${(new URL(href.value)).hostname}`; + const acct = `${txt}@${(new URL(href)).hostname}`; text += acct; //#endregion } else if (part.length === 3) { @@ -116,17 +109,17 @@ export class MfmService { if (!href) { return txt; } - if (!txt || txt === href.value) { // #6383: Missing text node - if (href.value.match(urlRegexFull)) { - return href.value; + if (!txt || txt === href) { // #6383: Missing text node + if (href.match(urlRegexFull)) { + return href; } else { - return `<${href.value}>`; + return `<${href}>`; } } - if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { - return `[${txt}](<${href.value}>)`; // #6846 + if (href.match(urlRegex) && !href.match(urlRegexFull)) { + return `[${txt}](<${href}>)`; // #6846 } else { - return `[${txt}](${href.value})`; + return `[${txt}](${href})`; } }; @@ -135,60 +128,64 @@ export class MfmService { break; } - case 'h1': { + case 'H1': { text += '【'; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += '】\n'; break; } - case 'b': - case 'strong': { + case 'B': + case 'STRONG': { text += '**'; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += '**'; break; } - case 'small': { + case 'SMALL': { text += ''; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += ''; break; } - case 's': - case 'del': { + case 'S': + case 'DEL': { text += '~~'; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += '~~'; break; } - case 'i': - case 'em': { + case 'I': + case 'EM': { text += ''; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); text += ''; break; } - case 'ruby': { + case 'RUBY': { let ruby: [string, string][] = []; for (const child of node.childNodes) { - if (child.nodeName === 'rp') { + if ((child instanceof htmlParser.TextNode) && !/\s|\[|\]/.test(child.textContent)) { + ruby.push([child.textContent, '']); continue; } - if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) { - ruby.push([child.value, '']); + + if (!(child instanceof htmlParser.HTMLElement)) continue; + + if (child.tagName === 'RP') { continue; } - if (child.nodeName === 'rt' && ruby.length > 0) { + + if (child.tagName === 'RT' && ruby.length > 0) { const rt = getText(child); if (/\s|\[|\]/.test(rt)) { // If any space is included in rt, it is treated as a normal text ruby = []; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); break; } else { ruby.at(-1)![1] = rt; @@ -197,7 +194,7 @@ export class MfmService { } // If any other element is included in ruby, it is treated as a normal text ruby = []; - appendChildren(node.childNodes); + analyzeChildren(node.childNodes); break; } for (const [base, rt] of ruby) { @@ -207,26 +204,30 @@ export class MfmService { } // block code (
)
-				case 'pre': {
-					if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
+				case 'PRE': {
+					if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.HTMLElement) && node.childNodes[0].tagName === 'CODE') {
 						text += '\n```\n';
 						text += getText(node.childNodes[0]);
 						text += '\n```\n';
+					} else if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.TextNode) && node.childNodes[0].textContent.startsWith('') && node.childNodes[0].textContent.endsWith('')) {
+						text += '\n```\n';
+						text += node.childNodes[0].textContent.slice(6, -7);
+						text += '\n```\n';
 					} else {
-						appendChildren(node.childNodes);
+						analyzeChildren(node.childNodes);
 					}
 					break;
 				}
 
 				// inline code ()
-				case 'code': {
+				case 'CODE': {
 					text += '`';
-					appendChildren(node.childNodes);
+					analyzeChildren(node.childNodes);
 					text += '`';
 					break;
 				}
 
-				case 'blockquote': {
+				case 'BLOCKQUOTE': {
 					const t = getText(node);
 					if (t) {
 						text += '\n> ';
@@ -235,33 +236,33 @@ export class MfmService {
 					break;
 				}
 
-				case 'p':
-				case 'h2':
-				case 'h3':
-				case 'h4':
-				case 'h5':
-				case 'h6': {
+				case 'P':
+				case 'H2':
+				case 'H3':
+				case 'H4':
+				case 'H5':
+				case 'H6': {
 					text += '\n\n';
-					appendChildren(node.childNodes);
+					analyzeChildren(node.childNodes);
 					break;
 				}
 
 				// other block elements
-				case 'div':
-				case 'header':
-				case 'footer':
-				case 'article':
-				case 'li':
-				case 'dt':
-				case 'dd': {
+				case 'DIV':
+				case 'HEADER':
+				case 'FOOTER':
+				case 'ARTICLE':
+				case 'LI':
+				case 'DT':
+				case 'DD': {
 					text += '\n';
-					appendChildren(node.childNodes);
+					analyzeChildren(node.childNodes);
 					break;
 				}
 
 				default:	// includes inline elements
 				{
-					appendChildren(node.childNodes);
+					analyzeChildren(node.childNodes);
 					break;
 				}
 			}
@@ -269,52 +270,35 @@ export class MfmService {
 	}
 
 	@bindThis
-	public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
+	public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], extraHtml: string | null = null) {
 		if (nodes == null) {
 			return null;
 		}
 
-		const { happyDOM, window } = new Window();
-
-		const doc = window.document;
-
-		const body = doc.createElement('p');
-
-		function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
-			if (children) {
-				for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
-			}
+		function toHtml(children?: mfm.MfmNode[]): string {
+			if (children == null) return '';
+			return children.map(x => handlers[x.type](x)).join('');
 		}
 
 		function fnDefault(node: mfm.MfmFn) {
-			const el = doc.createElement('i');
-			appendChildren(node.children, el);
-			return el;
+			return `${toHtml(node.children)}`;
 		}
 
-		const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
+		const handlers = {
 			bold: (node) => {
-				const el = doc.createElement('b');
-				appendChildren(node.children, el);
-				return el;
+				return `${toHtml(node.children)}`;
 			},
 
 			small: (node) => {
-				const el = doc.createElement('small');
-				appendChildren(node.children, el);
-				return el;
+				return `${toHtml(node.children)}`;
 			},
 
 			strike: (node) => {
-				const el = doc.createElement('del');
-				appendChildren(node.children, el);
-				return el;
+				return `${toHtml(node.children)}`;
 			},
 
 			italic: (node) => {
-				const el = doc.createElement('i');
-				appendChildren(node.children, el);
-				return el;
+				return `${toHtml(node.children)}`;
 			},
 
 			fn: (node) => {
@@ -323,11 +307,8 @@ export class MfmService {
 						const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
 						try {
 							const date = new Date(parseInt(text, 10) * 1000);
-							const el = doc.createElement('time');
-							el.setAttribute('datetime', date.toISOString());
-							el.textContent = date.toISOString();
-							return el;
-						} catch (err) {
+							return ``;
+						} catch (_) {
 							return fnDefault(node);
 						}
 					}
@@ -336,21 +317,9 @@ export class MfmService {
 						if (node.children.length === 1) {
 							const child = node.children[0];
 							const text = child.type === 'text' ? child.props.text : '';
-							const rubyEl = doc.createElement('ruby');
-							const rtEl = doc.createElement('rt');
 
-							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
-
-							rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
-							rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
-							rubyEl.appendChild(rpStartEl);
-							rubyEl.appendChild(rtEl);
-							rubyEl.appendChild(rpEndEl);
-							return rubyEl;
+							// ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキスト(ルビテキスト)」にフォールバックするようにする
+							return `${escapeHtml(text.split(' ')[0])}(${escapeHtml(text.split(' ')[1])})`;
 						} else {
 							const rt = node.children.at(-1);
 
@@ -359,21 +328,9 @@ export class MfmService {
 							}
 
 							const text = rt.type === 'text' ? rt.props.text : '';
-							const rubyEl = doc.createElement('ruby');
-							const rtEl = doc.createElement('rt');
 
-							// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
-							const rpStartEl = doc.createElement('rp');
-							rpStartEl.appendChild(doc.createTextNode('('));
-							const rpEndEl = doc.createElement('rp');
-							rpEndEl.appendChild(doc.createTextNode(')'));
-
-							appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
-							rtEl.appendChild(doc.createTextNode(text.trim()));
-							rubyEl.appendChild(rpStartEl);
-							rubyEl.appendChild(rtEl);
-							rubyEl.appendChild(rpEndEl);
-							return rubyEl;
+							// ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキスト(ルビテキスト)」にフォールバックするようにする
+							return `${toHtml(node.children.slice(0, node.children.length - 1))}(${escapeHtml(text.trim())})`;
 						}
 					}
 
@@ -384,125 +341,98 @@ export class MfmService {
 			},
 
 			blockCode: (node) => {
-				const pre = doc.createElement('pre');
-				const inner = doc.createElement('code');
-				inner.textContent = node.props.code;
-				pre.appendChild(inner);
-				return pre;
+				return `
${escapeHtml(node.props.code)}
`; }, center: (node) => { - const el = doc.createElement('div'); - appendChildren(node.children, el); - return el; + return `
${toHtml(node.children)}
`; }, emojiCode: (node) => { - return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); + return `\u200B:${escapeHtml(node.props.name)}:\u200B`; }, unicodeEmoji: (node) => { - return doc.createTextNode(node.props.emoji); + return node.props.emoji; }, hashtag: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`); - a.textContent = `#${node.props.hashtag}`; - a.setAttribute('rel', 'tag'); - return a; + return ``; }, inlineCode: (node) => { - const el = doc.createElement('code'); - el.textContent = node.props.code; - return el; + return `${escapeHtml(node.props.code)}`; }, mathInline: (node) => { - const el = doc.createElement('code'); - el.textContent = node.props.formula; - return el; + return `${escapeHtml(node.props.formula)}`; }, mathBlock: (node) => { - const el = doc.createElement('code'); - el.textContent = node.props.formula; - return el; + return `
${escapeHtml(node.props.formula)}
`; }, link: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', node.props.url); - appendChildren(node.children, a); - return a; + try { + const url = new URL(node.props.url); + return `${toHtml(node.children)}`; + } catch (_) { + return `[${toHtml(node.children)}](${escapeHtml(node.props.url)})`; + } }, mention: (node) => { - const a = doc.createElement('a'); const { username, host, acct } = node.props; const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase()); - a.setAttribute('href', remoteUserInfo + const href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) - : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`); - a.className = 'u-url mention'; - a.textContent = acct; - return a; + : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`; + try { + const url = new URL(href); + return `${escapeHtml(acct)}`; + } catch (_) { + return escapeHtml(acct); + } }, quote: (node) => { - const el = doc.createElement('blockquote'); - appendChildren(node.children, el); - return el; + return `
${toHtml(node.children)}
`; }, text: (node) => { if (!node.props.text.match(/[\r\n]/)) { - return doc.createTextNode(node.props.text); + return escapeHtml(node.props.text); } - const el = doc.createElement('span'); - const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x)); + let html = ''; - for (const x of intersperse('br', nodes)) { - el.appendChild(x === 'br' ? doc.createElement('br') : x); + const lines = node.props.text.split(/\r\n|\r|\n/).map(x => escapeHtml(x)); + + for (const x of intersperse('br', lines)) { + html += x === 'br' ? '
' : x; } - return el; + return html; }, url: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', node.props.url); - a.textContent = node.props.url; - return a; + try { + const url = new URL(node.props.url); + return `${escapeHtml(node.props.url)}`; + } catch (_) { + return escapeHtml(node.props.url); + } }, search: (node) => { - const a = doc.createElement('a'); - a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`); - a.textContent = node.props.content; - return a; + return `${escapeHtml(node.props.content)}`; }, plain: (node) => { - const el = doc.createElement('span'); - appendChildren(node.children, el); - return el; + return `${toHtml(node.children)}`; }, - }; + } satisfies { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => string } as { [K in mfm.MfmNode['type']]: (node: mfm.MfmNode) => string }; - appendChildren(nodes, body); - - for (const additionalAppender of additionalAppenders) { - additionalAppender(doc, body); - } - - // Remove the unnecessary namespace - const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*

/, '

'); - - happyDOM.close().catch(err => {}); - - return serialized; + return `${toHtml(nodes)}${extraHtml ?? ''}`; } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index cd5b148ed7..6115542655 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -13,7 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf import { extractHashtags } from '@/misc/extract-hashtags.js'; import type { IMentionedRemoteUsers } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js'; -import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiApp } from '@/models/App.js'; import { concat } from '@/misc/prelude/array.js'; @@ -56,6 +56,7 @@ import { trackPromise } from '@/misc/promise-tracker.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { CollapsedQueue } from '@/misc/collapsed-queue.js'; import { CacheService } from '@/core/CacheService.js'; +import { isQuote, isRenote } from '@/misc/is-renote.js'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -192,6 +193,12 @@ export class NoteCreateService implements OnApplicationShutdown { @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private idService: IdService, @@ -221,6 +228,167 @@ export class NoteCreateService implements OnApplicationShutdown { this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount); } + @bindThis + public async fetchAndCreate(user: { + id: MiUser['id']; + username: MiUser['username']; + host: MiUser['host']; + isBot: MiUser['isBot']; + isCat: MiUser['isCat']; + }, data: { + createdAt: Date; + replyId: MiNote['id'] | null; + renoteId: MiNote['id'] | null; + fileIds: MiDriveFile['id'][]; + text: string | null; + cw: string | null; + visibility: string; + visibleUserIds: MiUser['id'][]; + channelId: MiChannel['id'] | null; + localOnly: boolean; + reactionAcceptance: MiNote['reactionAcceptance']; + poll: IPoll | null; + apMentions?: MinimumUser[] | null; + apHashtags?: string[] | null; + apEmojis?: string[] | null; + }): Promise { + const visibleUsers = data.visibleUserIds.length > 0 ? await this.usersRepository.findBy({ + id: In(data.visibleUserIds), + }) : []; + + let files: MiDriveFile[] = []; + if (data.fileIds.length > 0) { + files = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.userId = :userId AND file.id IN (:...fileIds)', { + userId: user.id, + fileIds: data.fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds: data.fileIds }) + .getMany(); + + if (files.length !== data.fileIds.length) { + throw new IdentifiableError('801c046c-5bf5-4234-ad2b-e78fc20a2ac7', 'No such file'); + } + } + + let renote: MiNote | null = null; + if (data.renoteId != null) { + // Fetch renote to note + renote = await this.notesRepository.findOne({ + where: { id: data.renoteId }, + relations: ['user', 'renote', 'reply'], + }); + + if (renote == null) { + throw new IdentifiableError('53983c56-e163-45a6-942f-4ddc485d4290', 'No such renote target'); + } else if (isRenote(renote) && !isQuote(renote)) { + throw new IdentifiableError('bde24c37-121f-4e7d-980d-cec52f599f02', 'Cannot renote pure renote'); + } + + // Check blocking + if (renote.userId !== user.id) { + const blockExist = await this.blockingsRepository.exists({ + where: { + blockerId: renote.userId, + blockeeId: user.id, + }, + }); + if (blockExist) { + throw new IdentifiableError('2b4fe776-4414-4a2d-ae39-f3418b8fd4d3', 'You have been blocked by the user'); + } + } + + if (renote.visibility === 'followers' && renote.userId !== user.id) { + // 他人のfollowers noteはreject + throw new IdentifiableError('90b9d6f0-893a-4fef-b0f1-e9a33989f71a', 'Renote target visibility'); + } else if (renote.visibility === 'specified') { + // specified / direct noteはreject + throw new IdentifiableError('48d7a997-da5c-4716-b3c3-92db3f37bf7d', 'Renote target visibility'); + } + + if (renote.channelId && renote.channelId !== data.channelId) { + // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック + // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する + const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId }); + if (renoteChannel == null) { + // リノートしたいノートが書き込まれているチャンネルが無い + throw new IdentifiableError('b060f9a6-8909-4080-9e0b-94d9fa6f6a77', 'No such channel'); + } else if (!renoteChannel.allowRenoteToExternal) { + // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 + throw new IdentifiableError('7e435f4a-780d-4cfc-a15a-42519bd6fb67', 'Channel does not allow renote to external'); + } + } + } + + let reply: MiNote | null = null; + if (data.replyId != null) { + // Fetch reply + reply = await this.notesRepository.findOne({ + where: { id: data.replyId }, + relations: ['user'], + }); + + if (reply == null) { + throw new IdentifiableError('60142edb-1519-408e-926d-4f108d27bee0', 'No such reply target'); + } else if (isRenote(reply) && !isQuote(reply)) { + throw new IdentifiableError('f089e4e2-c0e7-4f60-8a23-e5a6bf786b36', 'Cannot reply to pure renote'); + } else if (!await this.noteEntityService.isVisibleForMe(reply, user.id)) { + throw new IdentifiableError('11cd37b3-a411-4f77-8633-c580ce6a8dce', 'No such reply target'); + } else if (reply.visibility === 'specified' && data.visibility !== 'specified') { + throw new IdentifiableError('ced780a1-2012-4caf-bc7e-a95a291294cb', 'Cannot reply to specified note with different visibility'); + } + + // Check blocking + if (reply.userId !== user.id) { + const blockExist = await this.blockingsRepository.exists({ + where: { + blockerId: reply.userId, + blockeeId: user.id, + }, + }); + if (blockExist) { + throw new IdentifiableError('b0df6025-f2e8-44b4-a26a-17ad99104612', 'You have been blocked by the user'); + } + } + } + + if (data.poll) { + if (data.poll.expiresAt != null) { + if (data.poll.expiresAt.getTime() < Date.now()) { + throw new IdentifiableError('0c11c11e-0c8d-48e7-822c-76ccef660068', 'Poll expiration must be future time'); + } + } + } + + let channel: MiChannel | null = null; + if (data.channelId != null) { + channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false }); + + if (channel == null) { + throw new IdentifiableError('bfa3905b-25f5-4894-b430-da331a490e4b', 'No such channel'); + } + } + + return this.create(user, { + createdAt: data.createdAt, + files: files, + poll: data.poll, + text: data.text, + reply, + renote, + cw: data.cw, + localOnly: data.localOnly, + reactionAcceptance: data.reactionAcceptance, + visibility: data.visibility, + visibleUsers, + channel, + apMentions: data.apMentions, + apHashtags: data.apHashtags, + apEmojis: data.apEmojis, + }); + } + @bindThis public async create(user: { id: MiUser['id']; @@ -464,6 +632,7 @@ export class NoteCreateService implements OnApplicationShutdown { replyUserHost: data.reply ? data.reply.userHost : null, renoteUserId: data.renote ? data.renote.userId : null, renoteUserHost: data.renote ? data.renote.userHost : null, + renoteChannelId: data.renote ? data.renote.channelId : null, userHost: user.host, }); diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts index c43be96efa..e144138c2c 100644 --- a/packages/backend/src/core/NoteDraftService.ts +++ b/packages/backend/src/core/NoteDraftService.ts @@ -5,32 +5,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; -import type { noteVisibilities, noteReactionAcceptances } from '@/types.js'; import { DI } from '@/di-symbols.js'; import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; -import { IPoll } from '@/models/Poll.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { isRenote, isQuote } from '@/misc/is-renote.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { QueueService } from '@/core/QueueService.js'; -export type NoteDraftOptions = { - replyId?: MiNote['id'] | null; - renoteId?: MiNote['id'] | null; - text?: string | null; - cw?: string | null; - localOnly?: boolean | null; - reactionAcceptance?: typeof noteReactionAcceptances[number]; - visibility?: typeof noteVisibilities[number]; - fileIds?: MiDriveFile['id'][]; - visibleUserIds?: MiUser['id'][]; - hashtag?: string; - channelId?: MiChannel['id'] | null; - poll?: (IPoll & { expiredAfter?: number | null }) | null; -}; +export type NoteDraftOptions = Omit; @Injectable() export class NoteDraftService { @@ -56,6 +42,7 @@ export class NoteDraftService { private roleService: RoleService, private idService: IdService, private noteEntityService: NoteEntityService, + private queueService: QueueService, ) { } @@ -72,36 +59,43 @@ export class NoteDraftService { @bindThis public async create(me: MiLocalUser, data: NoteDraftOptions): Promise { //#region check draft limit + const policies = await this.roleService.getUserPolicies(me.id); const currentCount = await this.noteDraftsRepository.countBy({ userId: me.id, }); - if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteDraftLimit) { + if (currentCount >= policies.noteDraftLimit) { throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts'); } + + if (data.isActuallyScheduled) { + const currentScheduledCount = await this.noteDraftsRepository.countBy({ + userId: me.id, + isActuallyScheduled: true, + }); + if (currentScheduledCount >= policies.scheduledNoteLimit) { + throw new IdentifiableError('c3275f19-4558-4c59-83e1-4f684b5fab66', 'Too many scheduled notes'); + } + } //#endregion - if (data.poll) { - if (typeof data.poll.expiresAt === 'number') { - if (data.poll.expiresAt < Date.now()) { - throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); - } - } else if (typeof data.poll.expiredAfter === 'number') { - data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); - } + await this.validate(me, data); + + const draft = await this.noteDraftsRepository.insertOne({ + ...data, + id: this.idService.gen(), + userId: me.id, + }); + + if (draft.scheduledAt && draft.isActuallyScheduled) { + this.schedule(draft); } - const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data); - - appliedDraft.id = this.idService.gen(); - appliedDraft.userId = me.id; - const draft = this.noteDraftsRepository.save(appliedDraft); - return draft; } @bindThis - public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise { + public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: Partial): Promise { const draft = await this.noteDraftsRepository.findOneBy({ id: draftId, userId: me.id, @@ -111,19 +105,36 @@ export class NoteDraftService { throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft'); } - if (data.poll) { - if (typeof data.poll.expiresAt === 'number') { - if (data.poll.expiresAt < Date.now()) { - throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); - } - } else if (typeof data.poll.expiredAfter === 'number') { - data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); + //#region check draft limit + const policies = await this.roleService.getUserPolicies(me.id); + + if (!draft.isActuallyScheduled && data.isActuallyScheduled) { + const currentScheduledCount = await this.noteDraftsRepository.countBy({ + userId: me.id, + isActuallyScheduled: true, + }); + if (currentScheduledCount >= policies.scheduledNoteLimit) { + throw new IdentifiableError('bacdf856-5c51-4159-b88a-804fa5103be5', 'Too many scheduled notes'); } } + //#endregion - const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data); + await this.validate(me, data); - return await this.noteDraftsRepository.save(appliedDraft); + const updatedDraft = await this.noteDraftsRepository.createQueryBuilder().update() + .set(data) + .where('id = :id', { id: draftId }) + .returning('*') + .execute() + .then((response) => response.raw[0]); + + this.clearSchedule(draftId).then(() => { + if (updatedDraft.scheduledAt != null && updatedDraft.isActuallyScheduled) { + this.schedule(updatedDraft); + } + }); + + return updatedDraft; } @bindThis @@ -138,6 +149,8 @@ export class NoteDraftService { } await this.noteDraftsRepository.delete(draft.id); + + this.clearSchedule(draftId); } @bindThis @@ -154,28 +167,29 @@ export class NoteDraftService { return draft; } - // 関連エンティティを取得し紐づける部分を共通化する @bindThis - public async checkAndSetDraftNoteOptions( + public async validate( me: MiLocalUser, - draft: MiNoteDraft, - data: NoteDraftOptions, - ): Promise { - data.visibility ??= 'public'; - data.localOnly ??= false; - if (data.reactionAcceptance === undefined) data.reactionAcceptance = null; - if (data.channelId != null) { - data.visibility = 'public'; - data.visibleUserIds = []; - data.localOnly = true; + data: Partial, + ): Promise { + if (data.isActuallyScheduled) { + if (data.scheduledAt == null) { + throw new IdentifiableError('94a89a43-3591-400a-9c17-dd166e71fdfa', 'scheduledAt is required when isActuallyScheduled is true'); + } else if (data.scheduledAt.getTime() < Date.now()) { + throw new IdentifiableError('b34d0c1b-996f-4e34-a428-c636d98df457', 'scheduledAt must be in the future'); + } } - let appliedDraft = draft; + if (data.pollExpiresAt != null) { + if (data.pollExpiresAt.getTime() < Date.now()) { + throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); + } + } //#region visibleUsers - let visibleUsers: MiUser[] = []; - if (data.visibleUserIds != null) { - visibleUsers = await this.usersRepository.findBy({ + let _visibleUsers: MiUser[] = []; + if (data.visibleUserIds != null && data.visibleUserIds.length > 0) { + _visibleUsers = await this.usersRepository.findBy({ id: In(data.visibleUserIds), }); } @@ -184,7 +198,7 @@ export class NoteDraftService { //#region files let files: MiDriveFile[] = []; const fileIds = data.fileIds ?? null; - if (fileIds != null) { + if (fileIds != null && fileIds.length > 0) { files = await this.driveFilesRepository.createQueryBuilder('file') .where('file.userId = :userId AND file.id IN (:...fileIds)', { userId: me.id, @@ -288,27 +302,38 @@ export class NoteDraftService { } } //#endregion + } - appliedDraft = { - ...appliedDraft, - visibility: data.visibility, - cw: data.cw ?? null, - fileIds: fileIds ?? [], - replyId: data.replyId ?? null, - renoteId: data.renoteId ?? null, - channelId: data.channelId ?? null, - text: data.text ?? null, - hashtag: data.hashtag ?? null, - hasPoll: data.poll != null, - pollChoices: data.poll ? data.poll.choices : [], - pollMultiple: data.poll ? data.poll.multiple : false, - pollExpiresAt: data.poll ? data.poll.expiresAt : null, - pollExpiredAfter: data.poll ? data.poll.expiredAfter ?? null : null, - visibleUserIds: data.visibleUserIds ?? [], - localOnly: data.localOnly, - reactionAcceptance: data.reactionAcceptance, - } satisfies MiNoteDraft; + @bindThis + public async schedule(draft: MiNoteDraft): Promise { + if (!draft.isActuallyScheduled) return; + if (draft.scheduledAt == null) return; + if (draft.scheduledAt.getTime() <= Date.now()) return; - return appliedDraft; + const delay = draft.scheduledAt.getTime() - Date.now(); + this.queueService.postScheduledNoteQueue.add(draft.id, { + noteDraftId: draft.id, + }, { + delay, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, + }); + } + + @bindThis + public async clearSchedule(draftId: MiNoteDraft['id']): Promise { + // TODO: 線形探索なのをどうにかする + const jobs = await this.queueService.postScheduledNoteQueue.getJobs(['delayed', 'waiting', 'active']); + for (const job of jobs) { + if (job.data.noteDraftId === draftId) { + await job.remove(); + } + } } } diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index eeade4569b..310ffec7ce 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -202,7 +202,7 @@ export class NotificationService implements OnApplicationShutdown { } // TODO - //const locales = await import('../../../../locales/index.js'); + //const locales = await import('i18n'); // TODO: locale ファイルをクライアント用とサーバー用で分けたい @@ -271,7 +271,7 @@ export class NotificationService implements OnApplicationShutdown { let untilTime = untilId ? this.toXListId(untilId) : null; let notifications: MiNotification[]; - for (;;) { + for (; ;) { let notificationsRes: [id: string, fields: string[]][]; // sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照 diff --git a/packages/backend/src/core/PageService.ts b/packages/backend/src/core/PageService.ts index 7f0e5c7ccc..4abeb30fce 100644 --- a/packages/backend/src/core/PageService.ts +++ b/packages/backend/src/core/PageService.ts @@ -29,7 +29,7 @@ export interface PageBody { variables: Array>; script: string; eyeCatchingImage?: MiDriveFile | null; - font: string; + font: 'serif' | 'sans-serif'; alignCenter: boolean; hideTitleWhenPinned: boolean; } @@ -141,7 +141,7 @@ export class PageService { eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null), }); - console.log("page.content", page.content); + console.log('page.content', page.content); if (body.content != null) { const beforeReferencedNotes = this.collectReferencedNotes(page.content); diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts index b10b8e5899..ecd96261e0 100644 --- a/packages/backend/src/core/QueueModule.ts +++ b/packages/backend/src/core/QueueModule.ts @@ -16,11 +16,13 @@ import { RelationshipJobData, UserWebhookDeliverJobData, SystemWebhookDeliverJobData, + PostScheduledNoteJobData, } from '../queue/types.js'; import type { Provider } from '@nestjs/common'; export type SystemQueue = Bull.Queue>; export type EndedPollNotificationQueue = Bull.Queue; +export type PostScheduledNoteQueue = Bull.Queue; export type DeliverQueue = Bull.Queue; export type InboxQueue = Bull.Queue; export type DbQueue = Bull.Queue; @@ -41,6 +43,12 @@ const $endedPollNotification: Provider = { inject: [DI.config], }; +const $postScheduledNote: Provider = { + provide: 'queue:postScheduledNote', + useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)), + inject: [DI.config], +}; + const $deliver: Provider = { provide: 'queue:deliver', useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)), @@ -89,6 +97,7 @@ const $systemWebhookDeliver: Provider = { providers: [ $system, $endedPollNotification, + $postScheduledNote, $deliver, $inbox, $db, @@ -100,6 +109,7 @@ const $systemWebhookDeliver: Provider = { exports: [ $system, $endedPollNotification, + $postScheduledNote, $deliver, $inbox, $db, @@ -113,6 +123,7 @@ export class QueueModule implements OnApplicationShutdown { constructor( @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @@ -129,6 +140,7 @@ export class QueueModule implements OnApplicationShutdown { await Promise.all([ this.systemQueue.close(), this.endedPollNotificationQueue.close(), + this.postScheduledNoteQueue.close(), this.deliverQueue.close(), this.inboxQueue.close(), this.dbQueue.close(), diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 0f225a8242..f90ae80731 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -6,7 +6,6 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import { MetricsTime, type JobType } from 'bullmq'; -import { parse as parseRedisInfo } from 'redis-info'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; @@ -31,6 +30,7 @@ import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, + PostScheduledNoteQueue, InboxQueue, ObjectStorageQueue, RelationshipQueue, @@ -44,6 +44,7 @@ import type * as Bull from 'bullmq'; export const QUEUE_TYPES = [ 'system', 'endedPollNotification', + 'postScheduledNote', 'deliver', 'inbox', 'db', @@ -84,6 +85,19 @@ const REPEATABLE_SYSTEM_JOB_DEF = [{ pattern: '0 4 * * *', }]; +function parseRedisInfo(infoText: string): Record { + const fields = infoText + .split('\n') + .filter(line => line.length > 0 && !line.startsWith('#')) + .map(line => line.trim().split(':')); + + const result: Record = {}; + for (const [key, value] of fields) { + result[key] = value; + } + return result; +} + @Injectable() export class QueueService { constructor( @@ -92,6 +106,7 @@ export class QueueService { @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, @@ -717,6 +732,7 @@ export class QueueService { switch (type) { case 'system': return this.systemQueue; case 'endedPollNotification': return this.endedPollNotificationQueue; + case 'postScheduledNote': return this.postScheduledNoteQueue; case 'deliver': return this.deliverQueue; case 'inbox': return this.inboxQueue; case 'db': return this.dbQueue; @@ -756,8 +772,8 @@ export class QueueService { @bindThis public async queueRetryJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { const queue = this.getQueue(queueType); - const job: Bull.Job | null = await queue.getJob(jobId); - if (job) { + const job = await queue.getJob(jobId); + if (job != null) { if (job.finishedOn != null) { await job.retry(); } else { @@ -769,8 +785,8 @@ export class QueueService { @bindThis public async queueRemoveJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { const queue = this.getQueue(queueType); - const job: Bull.Job | null = await queue.getJob(jobId); - if (job) { + const job = await queue.getJob(jobId); + if (job != null) { await job.remove(); } } @@ -803,8 +819,8 @@ export class QueueService { @bindThis public async queueGetJob(queueType: typeof QUEUE_TYPES[number], jobId: string) { const queue = this.getQueue(queueType); - const job: Bull.Job | null = await queue.getJob(jobId); - if (job) { + const job = await queue.getJob(jobId); + if (job != null) { return this.packJobData(job); } else { throw new Error(`Job not found: ${jobId}`); @@ -886,7 +902,7 @@ export class QueueService { }, db: { version: db.redis_version, - mode: db.redis_mode, + mode: db.redis_mode as 'cluster' | 'standalone' | 'sentinel', runId: db.run_id, processId: db.process_id, port: parseInt(db.tcp_port), diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index b47972008c..203cd54498 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -32,6 +32,7 @@ import { NotificationService } from '@/core/NotificationService.js'; import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; import { MAX_NOTE_ATTACHMENTS } from '@/const.js'; +// misskey-js の rolePolicies と同期すべし export type RolePolicies = { gtlAvailable: boolean; ltlAvailable: boolean; @@ -69,6 +70,7 @@ export type RolePolicies = { chatAvailability: 'available' | 'readonly' | 'unavailable'; uploadableFileTypes: string[]; noteDraftLimit: number; + scheduledNoteLimit: number; watermarkAvailable: boolean; canNote: boolean; renotePolicy: 'allow' | 'renoteOnly' | 'disallow'; @@ -106,20 +108,21 @@ export const DEFAULT_POLICIES: RolePolicies = { userEachUserListsLimit: 50, rateLimitFactor: 1, avatarDecorationLimit: 1, - canImportAntennas: true, - canImportBlocking: true, - canImportFollowing: true, - canImportMuting: true, - canImportUserLists: true, + canImportAntennas: false, + canImportBlocking: false, + canImportFollowing: false, + canImportMuting: false, + canImportUserLists: false, chatAvailability: 'available', uploadableFileTypes: [ - 'text/plain', + 'text/*', 'application/json', 'image/*', 'video/*', 'audio/*', ], noteDraftLimit: 10, + scheduledNoteLimit: 1, watermarkAvailable: true, canNote: true, renotePolicy: 'allow', @@ -322,7 +325,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { default: return false; } - } catch (err) { + } catch (_) { // TODO: log error return false; } @@ -455,6 +458,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { return [...set]; }), noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)), + scheduledNoteLimit: calc('scheduledNoteLimit', vs => Math.max(...vs)), watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)), canNote: calc('canNote', vs => vs.some(v => v === true)), renotePolicy: calc('renotePolicy', aggregateRenotePolicy), diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index 71dc718916..87097ada93 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -190,8 +190,7 @@ export class SearchService { return this.searchNoteByMeiliSearch(q, me, opts, pagination); } default: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const typeCheck: never = this.provider; + const _: never = this.provider; return []; } } diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index 7920e58e36..3ecb912a64 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -49,8 +49,8 @@ export class UserSuspendService { }); (async () => { - await this.postSuspend(user).catch(e => {}); - await this.unFollowAll(user).catch(e => {}); + await this.postSuspend(user).catch(_ => {}); + await this.unFollowAll(user).catch(_ => {}); })(); } @@ -67,7 +67,7 @@ export class UserSuspendService { }); (async () => { - await this.postUnsuspend(user).catch(e => {}); + await this.postUnsuspend(user).catch(_ => {}); })(); } diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 67ec6cc7b0..e3ceebccae 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -98,7 +98,7 @@ export class UtilityService { try { // TODO: RE2インスタンスをキャッシュ return new RE2(regexp[1], regexp[2]).test(text); - } catch (err) { + } catch (_) { // This should never happen due to input sanitisation. return false; } @@ -133,6 +133,7 @@ export class UtilityService { @bindThis public isFederationAllowedHost(host: string): boolean { + if (this.isSelfHost(host)) return true; if (this.meta.federation === 'none') return false; if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false; if (this.isBlockedHost(this.meta.blockedHosts, host)) return false; diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index 372e1e2ab7..31c8d67c60 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -66,7 +66,6 @@ export class WebAuthnService { userID: isoUint8Array.fromUTF8String(userId), userName: userName, userDisplayName: userDisplayName, - attestationType: 'indirect', excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{ id: key.id, transports: key.transports ?? undefined, diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 907b5ea6be..b112912b1b 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -106,6 +106,7 @@ function generateDummyNote(override?: Partial): MiNote { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + renoteChannelId: null, ...override, }; } @@ -244,7 +245,6 @@ export class WebhookTestService { case 'reaction': return; default: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const _exhaustiveAssertion: never = params.type; return; } @@ -327,7 +327,6 @@ export class WebhookTestService { break; } default: { - // eslint-disable-next-line @typescript-eslint/no-unused-vars const _exhaustiveAssertion: never = params.type; return; } @@ -412,7 +411,7 @@ export class WebhookTestService { name: user.name, username: user.username, host: user.host, - avatarUrl: user.avatarId == null ? null : user.avatarUrl, + avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? '', avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash, avatarDecorations: user.avatarDecorations.map(it => ({ id: it.id, diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e88f60b806..ff47ca930d 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; @@ -14,8 +15,8 @@ import { NotePiningService } from '@/core/NotePiningService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { acquireApObjectLock } from '@/misc/distributed-lock.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; -import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; import { IdService } from '@/core/IdService.js'; import { StatusError } from '@/misc/status-error.js'; @@ -48,8 +49,8 @@ export class ApInboxService { @Inject(DI.config) private config: Config, - @Inject(DI.meta) - private meta: MiMeta, + @Inject(DI.redis) + private redisClient: Redis.Redis, @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -76,7 +77,6 @@ export class ApInboxService { private userBlockingService: UserBlockingService, private noteCreateService: NoteCreateService, private noteDeleteService: NoteDeleteService, - private appLockService: AppLockService, private apResolverService: ApResolverService, private apDbResolverService: ApDbResolverService, private apLoggerService: ApLoggerService, @@ -95,7 +95,7 @@ export class ApInboxService { if (isCollectionOrOrderedCollection(activity)) { const results = [] as [string, string | void][]; // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems); if (items.length >= resolver.getRecursionLimit()) { @@ -221,7 +221,7 @@ export class ApInboxService { this.logger.info(`Accept: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(err => { this.logger.error(`Resolution failed: ${err}`); @@ -284,7 +284,7 @@ export class ApInboxService { this.logger.info(`Announce: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); if (!activity.object) return 'skip: activity has no object property'; const targetUri = getApId(activity.object); @@ -311,7 +311,7 @@ export class ApInboxService { // アナウンス先が許可されているかチェック if (!this.utilityService.isFederationAllowedUri(uri)) return; - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisClient, uri); try { // 既に同じURIを持つものが登録されていないかチェック @@ -406,7 +406,7 @@ export class ApInboxService { } // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -438,7 +438,7 @@ export class ApInboxService { } } - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisClient, uri); try { const exist = await this.apNoteService.fetchNote(note); @@ -522,7 +522,7 @@ export class ApInboxService { private async deleteNote(actor: MiRemoteUser, uri: string): Promise { this.logger.info(`Deleting the Note: ${uri}`); - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisClient, uri); try { const note = await this.apDbResolverService.getNoteFromApId(uri); @@ -575,7 +575,7 @@ export class ApInboxService { this.logger.info(`Reject: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -642,7 +642,7 @@ export class ApInboxService { this.logger.info(`Undo: ${uri}`); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -774,7 +774,7 @@ export class ApInboxService { this.logger.debug('Update'); // eslint-disable-next-line no-param-reassign - resolver ??= this.apResolverService.createResolver(); + resolver ??= await this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts index f4c07e472c..a928ed5ccf 100644 --- a/packages/backend/src/core/activitypub/ApMfmService.ts +++ b/packages/backend/src/core/activitypub/ApMfmService.ts @@ -5,7 +5,7 @@ import { Injectable } from '@nestjs/common'; import * as mfm from 'mfm-js'; -import { MfmService, Appender } from '@/core/MfmService.js'; +import { MfmService } from '@/core/MfmService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { extractApHashtagObjects } from './models/tag.js'; @@ -25,17 +25,17 @@ export class ApMfmService { } @bindThis - public getNoteHtml(note: Pick, additionalAppender: Appender[] = []) { + public getNoteHtml(note: Pick, extraHtml: string | null = null) { let noMisskeyContent = false; const srcMfm = (note.text ?? ''); const parsed = mfm.parse(srcMfm); - if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { + if (extraHtml == null && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { noMisskeyContent = true; } - const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), additionalAppender); + const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), extraHtml); return { content, diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 55521d6e3a..8c461b6031 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js'; import type { MiPoll } from '@/models/Poll.js'; import type { MiPollVote } from '@/models/PollVote.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; -import { MfmService, type Appender } from '@/core/MfmService.js'; +import { MfmService } from '@/core/MfmService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { MiUserKeypair } from '@/models/UserKeypair.js'; @@ -28,6 +28,7 @@ import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { IdService } from '@/core/IdService.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { escapeHtml } from '@/misc/escape-html.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; @@ -384,7 +385,7 @@ export class ApRendererService { inReplyTo = null; } - let quote; + let quote: string | undefined; if (note.renoteId) { const renote = await this.notesRepository.findOneBy({ id: note.renoteId }); @@ -430,29 +431,18 @@ export class ApRendererService { poll = await this.pollsRepository.findOneBy({ noteId: note.id }); } - const apAppend: Appender[] = []; + let extraHtml: string | null = null; - if (quote) { + if (quote != null) { // Append quote link as `

RE: ...` - // the claas name `quote-inline` is used in non-misskey clients for styling quote notes. + // the class name `quote-inline` is used in non-misskey clients for styling quote notes. // For compatibility, the span part should be kept as possible. - apAppend.push((doc, body) => { - body.appendChild(doc.createElement('br')); - body.appendChild(doc.createElement('br')); - const span = doc.createElement('span'); - span.className = 'quote-inline'; - span.appendChild(doc.createTextNode('RE: ')); - const link = doc.createElement('a'); - link.setAttribute('href', quote); - link.textContent = quote; - span.appendChild(link); - body.appendChild(span); - }); + extraHtml = `

RE: ${escapeHtml(quote)}`; } const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; - const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend); + const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, extraHtml); const emojis = await this.getEmojis(note.emojis); const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji)); @@ -525,7 +515,7 @@ export class ApRendererService { const restPart = maybeUrl.slice(match[0].length); return `${urlPart}${restPart}`; - } catch (e) { + } catch (_) { return maybeUrl; } }; diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 61d328ccac..d14b82dc92 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -6,7 +6,7 @@ import * as crypto from 'node:crypto'; import { URL } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { Window } from 'happy-dom'; +import * as htmlParser from 'node-html-parser'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; @@ -215,29 +215,9 @@ export class ApRequestService { _followAlternate === true ) { const html = await res.text(); - const { window, happyDOM } = new Window({ - settings: { - disableJavaScriptEvaluation: true, - disableJavaScriptFileLoading: true, - disableCSSFileLoading: true, - disableComputedStyleRendering: true, - handleDisabledFileLoadingAsSuccess: true, - navigation: { - disableMainFrameNavigation: true, - disableChildFrameNavigation: true, - disableChildPageNavigation: true, - disableFallbackToSetURL: true, - }, - timer: { - maxTimeout: 0, - maxIntervalTime: 0, - maxIntervalIterations: 0, - }, - }, - }); - const document = window.document; + try { - document.documentElement.innerHTML = html; + const document = htmlParser.parse(html); const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); if (alternate) { @@ -246,10 +226,8 @@ export class ApRequestService { return await this.signedGet(href, user, allowSoftfail, false); } } - } catch (e) { + } catch (_) { // something went wrong parsing the HTML, ignore the whole thing - } finally { - happyDOM.close().catch(err => {}); } } //#endregion diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index 646150455b..0f51b1ce8d 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -3,10 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; +import type { + FollowRequestsRepository, + MiMeta, + NoteReactionsRepository, + NotesRepository, + PollsRepository, + UsersRepository +} from '@/models/_.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { DI } from '@/di-symbols.js'; @@ -16,26 +23,43 @@ import { LoggerService } from '@/core/LoggerService.js'; import type Logger from '@/logger.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { ICollection, IObject, IOrderedCollection } from './type.js'; import { isCollectionOrOrderedCollection } from './type.js'; import { ApDbResolverService } from './ApDbResolverService.js'; import { ApRendererService } from './ApRendererService.js'; import { ApRequestService } from './ApRequestService.js'; import { FetchAllowSoftFailMask } from './misc/check-against-url.js'; -import type { IObject, ICollection, IOrderedCollection } from './type.js'; +import { ModuleRef } from '@nestjs/core'; +@Injectable({ scope: Scope.TRANSIENT }) export class Resolver { private history: Set; private user?: MiLocalUser; private logger: Logger; + private recursionLimit = 256; constructor( + @Inject(DI.config) private config: Config, + + @Inject(DI.meta) private meta: MiMeta, + + @Inject(DI.usersRepository) private usersRepository: UsersRepository, + + @Inject(DI.notesRepository) private notesRepository: NotesRepository, + + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, + + @Inject(DI.noteReactionsRepository) private noteReactionsRepository: NoteReactionsRepository, + + @Inject(DI.followRequestsRepository) private followRequestsRepository: FollowRequestsRepository, + private utilityService: UtilityService, private systemAccountService: SystemAccountService, private apRequestService: ApRequestService, @@ -43,7 +67,6 @@ export class Resolver { private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, private loggerService: LoggerService, - private recursionLimit = 256, ) { this.history = new Set(); this.logger = this.loggerService.getLogger('ap-resolve'); @@ -180,54 +203,12 @@ export class Resolver { @Injectable() export class ApResolverService { constructor( - @Inject(DI.config) - private config: Config, - - @Inject(DI.meta) - private meta: MiMeta, - - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.pollsRepository) - private pollsRepository: PollsRepository, - - @Inject(DI.noteReactionsRepository) - private noteReactionsRepository: NoteReactionsRepository, - - @Inject(DI.followRequestsRepository) - private followRequestsRepository: FollowRequestsRepository, - - private utilityService: UtilityService, - private systemAccountService: SystemAccountService, - private apRequestService: ApRequestService, - private httpRequestService: HttpRequestService, - private apRendererService: ApRendererService, - private apDbResolverService: ApDbResolverService, - private loggerService: LoggerService, + private moduleRef: ModuleRef, ) { } @bindThis - public createResolver(): Resolver { - return new Resolver( - this.config, - this.meta, - this.usersRepository, - this.notesRepository, - this.pollsRepository, - this.noteReactionsRepository, - this.followRequestsRepository, - this.utilityService, - this.systemAccountService, - this.apRequestService, - this.httpRequestService, - this.apRendererService, - this.apDbResolverService, - this.loggerService, - ); + public async createResolver(): Promise { + return await this.moduleRef.create(Resolver); } } diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index e7ece87b01..0496774c19 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -46,7 +46,7 @@ export class ApImageService { throw new Error('actor has been suspended'); } - const image = await this.apResolverService.createResolver().resolve(value); + const image = await (await this.apResolverService.createResolver()).resolve(value); if (!isDocument(image)) return null; diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 8abacd293f..1fc5728c98 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -5,14 +5,15 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import type { PollsRepository, EmojisRepository, MiMeta } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; +import { acquireApObjectLock } from '@/misc/distributed-lock.js'; import { toArray, toSingle, unique } from '@/misc/prelude/array.js'; import type { MiEmoji } from '@/models/Emoji.js'; -import { AppLockService } from '@/core/AppLockService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; import type Logger from '@/logger.js'; @@ -48,6 +49,9 @@ export class ApNoteService { @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, @@ -67,7 +71,6 @@ export class ApNoteService { private apMentionService: ApMentionService, private apImageService: ApImageService, private apQuestionService: ApQuestionService, - private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, private apDbResolverService: ApDbResolverService, @@ -125,7 +128,7 @@ export class ApNoteService { @bindThis public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise { // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const object = await resolver.resolve(value); @@ -354,7 +357,7 @@ export class ApNoteService { throw new StatusError('blocked host', 451); } - const unlock = await this.appLockService.getApLock(uri); + const unlock = await acquireApObjectLock(this.redisClient, uri); try { //#region このサーバーに既に登録されていたらそれを返す diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index e52078ed0f..ebe8e9c964 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -310,7 +310,7 @@ export class ApPersonService implements OnModuleInit { } // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const object = await resolver.resolve(uri); if (object.id == null) throw new Error('invalid object.id: ' + object.id); @@ -500,7 +500,7 @@ export class ApPersonService implements OnModuleInit { //#endregion // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const object = hint ?? await resolver.resolve(uri); @@ -678,7 +678,7 @@ export class ApPersonService implements OnModuleInit { // リモートサーバーからフェッチしてきて登録 // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); return await this.createPerson(uri, resolver); } @@ -707,7 +707,7 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Updating the featured: ${user.uri}`); - const _resolver = resolver ?? this.apResolverService.createResolver(); + const _resolver = resolver ?? await this.apResolverService.createResolver(); // Resolve to (Ordered)Collection Object const collection = await _resolver.resolveCollection(user.featured); diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index a2cdaf02ca..8ac2f21e26 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -45,7 +45,7 @@ export class ApQuestionService { @bindThis public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise { // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const question = await resolver.resolve(source); if (!isQuestion(question)) throw new Error('invalid type'); @@ -91,7 +91,7 @@ export class ApQuestionService { // resolve new Question object // eslint-disable-next-line no-param-reassign - if (resolver == null) resolver = this.apResolverService.createResolver(); + if (resolver == null) resolver = await this.apResolverService.createResolver(); const question = await resolver.resolve(value); this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); diff --git a/packages/backend/src/core/chart/charts/active-users.ts b/packages/backend/src/core/chart/charts/active-users.ts index 05905f3782..7b9840af87 100644 --- a/packages/backend/src/core/chart/charts/active-users.ts +++ b/packages/backend/src/core/chart/charts/active-users.ts @@ -5,11 +5,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/active-users.js'; @@ -28,11 +29,13 @@ export default class ActiveUsersChart extends Chart { // eslint-d @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private chartLoggerService: ChartLoggerService, private idService: IdService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/ap-request.ts b/packages/backend/src/core/chart/charts/ap-request.ts index 04e771a95b..ed790de7b5 100644 --- a/packages/backend/src/core/chart/charts/ap-request.ts +++ b/packages/backend/src/core/chart/charts/ap-request.ts @@ -5,9 +5,10 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/ap-request.js'; @@ -22,10 +23,12 @@ export default class ApRequestChart extends Chart { // eslint-dis @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/drive.ts b/packages/backend/src/core/chart/charts/drive.ts index 613e074a9f..782873809a 100644 --- a/packages/backend/src/core/chart/charts/drive.ts +++ b/packages/backend/src/core/chart/charts/drive.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/drive.js'; @@ -23,10 +24,12 @@ export default class DriveChart extends Chart { // eslint-disable @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index c9b43cc66d..b7a7f640b8 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { FollowingsRepository, InstancesRepository, MiMeta } from '@/models/_.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/federation.js'; @@ -26,16 +27,18 @@ export default class FederationChart extends Chart { // eslint-di @Inject(DI.meta) private meta: MiMeta, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/instance.ts b/packages/backend/src/core/chart/charts/instance.ts index 97f3bc6f2b..b1657e0a0b 100644 --- a/packages/backend/src/core/chart/charts/instance.ts +++ b/packages/backend/src/core/chart/charts/instance.ts @@ -5,13 +5,14 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/instance.js'; @@ -26,6 +27,9 @@ export default class InstanceChart extends Chart { // eslint-disa @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -39,10 +43,9 @@ export default class InstanceChart extends Chart { // eslint-disa private followingsRepository: FollowingsRepository, private utilityService: UtilityService, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/notes.ts b/packages/backend/src/core/chart/charts/notes.ts index f763b5fffa..aa64e2329a 100644 --- a/packages/backend/src/core/chart/charts/notes.ts +++ b/packages/backend/src/core/chart/charts/notes.ts @@ -5,11 +5,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { Not, IsNull, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { NotesRepository } from '@/models/_.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/notes.js'; @@ -24,13 +25,15 @@ export default class NotesChart extends Chart { // eslint-disable @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-drive.ts b/packages/backend/src/core/chart/charts/per-user-drive.ts index 404964d8b7..f7e92aecea 100644 --- a/packages/backend/src/core/chart/charts/per-user-drive.ts +++ b/packages/backend/src/core/chart/charts/per-user-drive.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { DriveFilesRepository } from '@/models/_.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-drive.js'; @@ -25,14 +26,16 @@ export default class PerUserDriveChart extends Chart { // eslint- @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private appLockService: AppLockService, private driveFileEntityService: DriveFileEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-following.ts b/packages/backend/src/core/chart/charts/per-user-following.ts index 588ac638de..ea431a5131 100644 --- a/packages/backend/src/core/chart/charts/per-user-following.ts +++ b/packages/backend/src/core/chart/charts/per-user-following.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { Not, IsNull, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { FollowingsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-following.js'; @@ -25,14 +26,16 @@ export default class PerUserFollowingChart extends Chart { // esl @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - private appLockService: AppLockService, private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-notes.ts b/packages/backend/src/core/chart/charts/per-user-notes.ts index e4900772bb..824d60042d 100644 --- a/packages/backend/src/core/chart/charts/per-user-notes.ts +++ b/packages/backend/src/core/chart/charts/per-user-notes.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import type { NotesRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-notes.js'; @@ -25,13 +26,15 @@ export default class PerUserNotesChart extends Chart { // eslint- @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-pv.ts b/packages/backend/src/core/chart/charts/per-user-pv.ts index 31708fefa8..b3e1b2cea1 100644 --- a/packages/backend/src/core/chart/charts/per-user-pv.ts +++ b/packages/backend/src/core/chart/charts/per-user-pv.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-pv.js'; @@ -23,10 +24,12 @@ export default class PerUserPvChart extends Chart { // eslint-dis @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/per-user-reactions.ts b/packages/backend/src/core/chart/charts/per-user-reactions.ts index c29c4d2870..7bc1d9e7fa 100644 --- a/packages/backend/src/core/chart/charts/per-user-reactions.ts +++ b/packages/backend/src/core/chart/charts/per-user-reactions.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/per-user-reactions.js'; @@ -25,11 +26,13 @@ export default class PerUserReactionsChart extends Chart { // esl @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-grouped.ts b/packages/backend/src/core/chart/charts/test-grouped.ts index 7a2844f4ed..8dd1a5d996 100644 --- a/packages/backend/src/core/chart/charts/test-grouped.ts +++ b/packages/backend/src/core/chart/charts/test-grouped.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test-grouped.js'; import type { KVs } from '../core.js'; @@ -24,10 +25,12 @@ export default class TestGroupedChart extends Chart { // eslint-d @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema, true); + super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema, true); } protected async tickMajor(group: string): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-intersection.ts b/packages/backend/src/core/chart/charts/test-intersection.ts index b8d0556c9f..23b8649cce 100644 --- a/packages/backend/src/core/chart/charts/test-intersection.ts +++ b/packages/backend/src/core/chart/charts/test-intersection.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test-intersection.js'; import type { KVs } from '../core.js'; @@ -22,10 +23,12 @@ export default class TestIntersectionChart extends Chart { // esl @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test-unique.ts b/packages/backend/src/core/chart/charts/test-unique.ts index f94e008059..b84dd419ba 100644 --- a/packages/backend/src/core/chart/charts/test-unique.ts +++ b/packages/backend/src/core/chart/charts/test-unique.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test-unique.js'; import type { KVs } from '../core.js'; @@ -22,10 +23,12 @@ export default class TestUniqueChart extends Chart { // eslint-di @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/test.ts b/packages/backend/src/core/chart/charts/test.ts index a90dc8f99b..0e95ce9239 100644 --- a/packages/backend/src/core/chart/charts/test.ts +++ b/packages/backend/src/core/chart/charts/test.ts @@ -5,10 +5,11 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import { AppLockService } from '@/core/AppLockService.js'; +import * as Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; import Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { name, schema } from './entities/test.js'; import type { KVs } from '../core.js'; @@ -24,10 +25,12 @@ export default class TestChart extends Chart { // eslint-disable- @Inject(DI.db) private db: DataSource, - private appLockService: AppLockService, + @Inject(DI.redis) + private redisClient: Redis.Redis, + logger: Logger, ) { - super(db, (k) => appLockService.getChartInsertLock(k), logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/chart/charts/users.ts b/packages/backend/src/core/chart/charts/users.ts index d148fc629b..4471c1df23 100644 --- a/packages/backend/src/core/chart/charts/users.ts +++ b/packages/backend/src/core/chart/charts/users.ts @@ -5,12 +5,13 @@ import { Injectable, Inject } from '@nestjs/common'; import { Not, IsNull, DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import type { MiUser } from '@/models/User.js'; -import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import type { UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; +import { acquireChartInsertLock } from '@/misc/distributed-lock.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; import { name, schema } from './entities/users.js'; @@ -25,14 +26,16 @@ export default class UsersChart extends Chart { // eslint-disable @Inject(DI.db) private db: DataSource, + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private appLockService: AppLockService, private userEntityService: UserEntityService, private chartLoggerService: ChartLoggerService, ) { - super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema); + super(db, (k) => acquireChartInsertLock(redisClient, k), chartLoggerService.logger, name, schema); } protected async tickMajor(): Promise>> { diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 1ba7ca8e57..e26cddd281 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -4,36 +4,40 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js'; +import type { + ChannelFavoritesRepository, + ChannelFollowingsRepository, ChannelMutingRepository, + ChannelsRepository, + DriveFilesRepository, + MiDriveFile, + MiNote, + NotesRepository, +} from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; import type { MiUser } from '@/models/User.js'; import type { MiChannel } from '@/models/Channel.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; import { NoteEntityService } from './NoteEntityService.js'; -import { In } from 'typeorm'; @Injectable() export class ChannelEntityService { constructor( @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, - @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.channelFavoritesRepository) private channelFavoritesRepository: ChannelFavoritesRepository, - + @Inject(DI.channelMutingRepository) + private channelMutingRepository: ChannelMutingRepository, @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private noteEntityService: NoteEntityService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, @@ -45,31 +49,59 @@ export class ChannelEntityService { src: MiChannel['id'] | MiChannel, me?: { id: MiUser['id'] } | null | undefined, detailed?: boolean, + opts?: { + bannerFiles?: Map; + followings?: Set; + favorites?: Set; + muting?: Set; + pinnedNotes?: Map; + }, ): Promise> { const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); - const meId = me ? me.id : null; - const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; + let bannerFile: MiDriveFile | null = null; + if (channel.bannerId) { + bannerFile = opts?.bannerFiles?.get(channel.bannerId) + ?? await this.driveFilesRepository.findOneByOrFail({ id: channel.bannerId }); + } - const isFollowing = meId ? await this.channelFollowingsRepository.exists({ - where: { - followerId: meId, - followeeId: channel.id, - }, - }) : false; + let isFollowing = false; + let isFavorited = false; + let isMuting = false; + if (me) { + isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({ + where: { + followerId: me.id, + followeeId: channel.id, + }, + }); - const isFavorited = meId ? await this.channelFavoritesRepository.exists({ - where: { - userId: meId, - channelId: channel.id, - }, - }) : false; + isFavorited = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({ + where: { + userId: me.id, + channelId: channel.id, + }, + }); - const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ - where: { - id: In(channel.pinnedNoteIds), - }, - }) : []; + isMuting = opts?.muting?.has(channel.id) ?? await this.channelMutingRepository.exists({ + where: { + userId: me.id, + channelId: channel.id, + }, + }); + } + + const pinnedNotes = Array.of(); + if (channel.pinnedNoteIds.length > 0) { + pinnedNotes.push( + ...( + opts?.pinnedNotes + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(it => it != null) + : await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) }) + ), + ); + } return { id: channel.id, @@ -78,7 +110,8 @@ export class ChannelEntityService { name: channel.name, description: channel.description, userId: channel.userId, - bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, + bannerUrl: bannerFile ? this.driveFileEntityService.getPublicUrl(bannerFile) : null, + bannerId: channel.bannerId, pinnedNoteIds: channel.pinnedNoteIds, color: channel.color, isArchived: channel.isArchived, @@ -90,6 +123,7 @@ export class ChannelEntityService { ...(me ? { isFollowing, isFavorited, + isMuting, hasUnreadNote: false, // 後方互換性のため } : {}), @@ -98,5 +132,72 @@ export class ChannelEntityService { } : {}), }; } + + @bindThis + public async packMany( + src: MiChannel['id'][] | MiChannel[], + me?: { id: MiUser['id'] } | null | undefined, + detailed?: boolean, + ): Promise[]> { + // IDのみの要素がある場合、DBからオブジェクトを取得して補う + const channels = src.filter(it => typeof it === 'object') as MiChannel[]; + channels.push( + ...(await this.channelsRepository.find({ + where: { + id: In(src.filter(it => typeof it !== 'object') as MiChannel['id'][]), + }, + })), + ); + channels.sort((a, b) => a.id.localeCompare(b.id)); + + const bannerFiles = await this.driveFilesRepository + .findBy({ + id: In(channels.map(it => it.bannerId).filter(it => it != null)), + }) + .then(it => new Map(it.map(it => [it.id, it]))); + + const followings = me + ? await this.channelFollowingsRepository + .findBy({ + followerId: me.id, + followeeId: In(channels.map(it => it.id)), + }) + .then(it => new Set(it.map(it => it.followeeId))) + : new Set(); + + const favorites = me + ? await this.channelFavoritesRepository + .findBy({ + userId: me.id, + channelId: In(channels.map(it => it.id)), + }) + .then(it => new Set(it.map(it => it.channelId))) + : new Set(); + + const muting = me + ? await this.channelMutingRepository + .findBy({ + userId: me.id, + channelId: In(channels.map(it => it.id)), + }) + .then(it => new Set(it.map(it => it.channelId))) + : new Set(); + + const pinnedNotes = await this.notesRepository + .find({ + where: { + id: In(channels.flatMap(it => it.pinnedNoteIds)), + }, + }) + .then(it => new Map(it.map(it => [it.id, it]))); + + return Promise.all(channels.map(it => this.pack(it, me, detailed, { + bannerFiles, + followings, + favorites, + muting, + pinnedNotes, + }))); + } } diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts index 6bce2413fd..f69a484398 100644 --- a/packages/backend/src/core/entities/ChatEntityService.ts +++ b/packages/backend/src/core/entities/ChatEntityService.ts @@ -54,12 +54,13 @@ export class ChatEntityService { const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); - const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + // userは削除されている可能性があるのでnull許容 + const reactions: { user: Packed<'UserLite'> | null; reaction: string; }[] = []; for (const record of message.reactions) { const [userId, reaction] = record.split('/'); reactions.push({ - user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId).catch(() => null), reaction, }); } @@ -76,7 +77,7 @@ export class ChatEntityService { toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined, fileId: message.fileId, file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, - reactions, + reactions: reactions.filter((r): r is { user: Packed<'UserLite'>; reaction: string; } => r.user != null), }; } @@ -108,6 +109,7 @@ export class ChatEntityService { } } + // TODO: packedUsersに削除されたユーザーもnullとして含める const [packedUsers, packedFiles, packedRooms] = await Promise.all([ this.userEntityService.packMany(users, me) .then(users => new Map(users.map(u => [u.id, u]))), @@ -136,7 +138,7 @@ export class ChatEntityService { const reactions: { reaction: string; }[] = []; for (const record of message.reactions) { - const [userId, reaction] = record.split('/'); + const [, reaction] = record.split('/'); reactions.push({ reaction, }); @@ -183,12 +185,13 @@ export class ChatEntityService { const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); - const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + // userは削除されている可能性があるのでnull許容 + const reactions: { user: Packed<'UserLite'> | null; reaction: string; }[] = []; for (const record of message.reactions) { const [userId, reaction] = record.split('/'); reactions.push({ - user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId).catch(() => null), reaction, }); } @@ -202,7 +205,7 @@ export class ChatEntityService { toRoomId: message.toRoomId!, fileId: message.fileId, file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, - reactions, + reactions: reactions.filter((r): r is { user: Packed<'UserLite'>; reaction: string; } => r.user != null), }; } diff --git a/packages/backend/src/core/entities/DriveFileEntityService.ts b/packages/backend/src/core/entities/DriveFileEntityService.ts index a6f7f369a6..1865d494c4 100644 --- a/packages/backend/src/core/entities/DriveFileEntityService.ts +++ b/packages/backend/src/core/entities/DriveFileEntityService.ts @@ -17,6 +17,7 @@ import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { isMimeImage } from '@/misc/is-mime-image.js'; import { IdService } from '@/core/IdService.js'; +import { uniqueByKey } from '@/misc/unique-by-key.js'; import { UtilityService } from '../UtilityService.js'; import { VideoProcessingService } from '../VideoProcessingService.js'; import { UserEntityService } from './UserEntityService.js'; @@ -226,6 +227,7 @@ export class DriveFileEntityService { options?: PackOptions, hint?: { packedUser?: Packed<'UserLite'> + packedFolder?: Packed<'DriveFolder'> }, ): Promise | null> { const opts = Object.assign({ @@ -250,9 +252,9 @@ export class DriveFileEntityService { thumbnailUrl: this.getThumbnailUrl(file), comment: file.comment, folderId: file.folderId, - folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { + folder: opts.detail && file.folderId ? (hint?.packedFolder ?? this.driveFolderEntityService.pack(file.folderId, { detail: true, - }) : null, + })) : null, userId: file.userId, user: (opts.withUser && file.userId) ? hint?.packedUser ?? this.userEntityService.pack(file.userId) : null, }); @@ -263,10 +265,41 @@ export class DriveFileEntityService { files: MiDriveFile[], options?: PackOptions, ): Promise[]> { - 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) } : {}))); + // -- ユーザ情報の事前取得 -- + + let userMap: Map> | null = null; + if (options?.withUser) { + const users = files + .map(({ user, userId }) => user ?? userId) + .filter(x => x != null); + + const uniqueUsers = uniqueByKey(users, (user) => typeof user === 'string' ? user : user.id); + const packedUsers = await this.userEntityService.packMany(uniqueUsers); + userMap = new Map(packedUsers.map(user => [user.id, user])); + } + + // -- フォルダ情報の事前取得 -- + + let folderMap: Map> | null = null; + if (options?.detail) { + const folders = files + .map(({ folder, folderId }) => folder ?? folderId) + .filter(x => x != null); + + const uniqueFolders = uniqueByKey(folders, (folder) => typeof folder === 'string' ? folder : folder.id); + const packedFolders = await this.driveFolderEntityService.packMany(uniqueFolders, { detail: true }); + folderMap = new Map(packedFolders.map(folder => [folder.id, folder])); + } + + const items = await Promise.all(files.map(f => this.packNullable( + f, + options, + { + packedUser: f.userId ? userMap?.get(f.userId) : undefined, + packedFolder: f.folderId ? folderMap?.get(f.folderId) : undefined, + }, + ))); + return items.filter(x => x != null); } diff --git a/packages/backend/src/core/entities/DriveFolderEntityService.ts b/packages/backend/src/core/entities/DriveFolderEntityService.ts index 299f23ad38..326421e149 100644 --- a/packages/backend/src/core/entities/DriveFolderEntityService.ts +++ b/packages/backend/src/core/entities/DriveFolderEntityService.ts @@ -12,6 +12,9 @@ import type { } from '@/models/Blocking.js'; import type { MiDriveFolder } from '@/models/DriveFolder.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; +import { In } from 'typeorm'; +import { uniqueByKey } from '@/misc/unique-by-key.js'; +import { splitIdAndObjects } from '@/misc/split-id-and-objects.js'; @Injectable() export class DriveFolderEntityService { @@ -32,12 +35,20 @@ export class DriveFolderEntityService { options?: { detail: boolean }, + hint?: { + folderMap?: Map; + foldersCountMap?: Map | null; + filesCountMap?: Map | null; + parentPacker?: (id: string) => Promise>; + }, ): Promise> { const opts = Object.assign({ detail: false, }, options); - const folder = typeof src === 'object' ? src : await this.driveFoldersRepository.findOneByOrFail({ id: src }); + const folder = typeof src === 'object' + ? src + : hint?.folderMap?.get(src) ?? await this.driveFoldersRepository.findOneByOrFail({ id: src }); return await awaitAll({ id: folder.id, @@ -46,20 +57,141 @@ export class DriveFolderEntityService { parentId: folder.parentId, ...(opts.detail ? { - foldersCount: this.driveFoldersRepository.countBy({ - parentId: folder.id, - }), - filesCount: this.driveFilesRepository.countBy({ - folderId: folder.id, - }), + foldersCount: hint?.foldersCountMap?.get(folder.id) + ?? this.driveFoldersRepository.countBy({ + parentId: folder.id, + }), + filesCount: hint?.filesCountMap?.get(folder.id) + ?? this.driveFilesRepository.countBy({ + folderId: folder.id, + }), ...(folder.parentId ? { - parent: this.pack(folder.parentId, { - detail: true, - }), + parent: hint?.parentPacker + ? hint.parentPacker(folder.parentId) + : this.pack(folder.parentId, { detail: true }, hint), } : {}), } : {}), }); } -} + public async packMany( + src: Array, + options?: { + detail: boolean + }, + ): Promise>> { + /** + * 重複を除去しつつ、必要なDriveFolderオブジェクトをすべて取得する + */ + const collectUniqueObjects = async (src: Array) => { + const uniqueSrc = uniqueByKey( + src, + (s) => typeof s === 'string' ? s : s.id, + ); + const { ids, objects } = splitIdAndObjects(uniqueSrc); + + const uniqueObjects = new Map(objects.map(s => [s.id, s])); + const needsFetchIds = ids.filter(id => !uniqueObjects.has(id)); + + if (needsFetchIds.length > 0) { + const fetchedObjects = await this.driveFoldersRepository.find({ + where: { + id: In(needsFetchIds), + }, + }); + for (const obj of fetchedObjects) { + uniqueObjects.set(obj.id, obj); + } + } + + return uniqueObjects; + }; + + /** + * 親フォルダーを再帰的に収集する + */ + const collectAncestors = async (folderMap: Map) => { + for (;;) { + const parentIds = new Set(); + for (const folder of folderMap.values()) { + if (folder.parentId != null && !folderMap.has(folder.parentId)) { + parentIds.add(folder.parentId); + } + } + + if (parentIds.size === 0) break; + + const fetchedParents = await this.driveFoldersRepository.find({ + where: { + id: In([...parentIds]), + }, + }); + + if (fetchedParents.length === 0) break; + + for (const parent of fetchedParents) { + folderMap.set(parent.id, parent); + } + } + }; + + const opts = Object.assign({ + detail: false, + }, options); + + const folderMap = await collectUniqueObjects(src); + + let foldersCountMap: Map | null = null; + let filesCountMap: Map | null = null; + if (opts.detail) { + await collectAncestors(folderMap); + + const ids = [...folderMap.keys()]; + if (ids.length > 0) { + const folderCounts = await this.driveFoldersRepository.createQueryBuilder('folder') + .select('folder.parentId', 'parentId') + .addSelect('COUNT(*)', 'count') + .where('folder.parentId IN (:...ids)', { ids }) + .groupBy('folder.parentId') + .getRawMany<{ parentId: string; count: string }>(); + + const fileCounts = await this.driveFilesRepository.createQueryBuilder('file') + .select('file.folderId', 'folderId') + .addSelect('COUNT(*)', 'count') + .where('file.folderId IN (:...ids)', { ids }) + .groupBy('file.folderId') + .getRawMany<{ folderId: string; count: string }>(); + + foldersCountMap = new Map(folderCounts.map(row => [row.parentId, Number(row.count)])); + filesCountMap = new Map(fileCounts.map(row => [row.folderId, Number(row.count)])); + } else { + foldersCountMap = new Map(); + filesCountMap = new Map(); + } + } + + const packedMap = new Map>>(); + const packFromId = (id: string): Promise> => { + const cached = packedMap.get(id); + if (cached) return cached; + + const folder = folderMap.get(id); + if (!folder) { + throw new Error(`DriveFolder not found: ${id}`); + } + + const packedPromise = this.pack(folder, options, { + folderMap, + foldersCountMap, + filesCountMap, + parentPacker: packFromId, + }); + packedMap.set(id, packedPromise); + + return packedPromise; + }; + + return Promise.all(src.map(s => packFromId(typeof s === 'string' ? s : s.id))); + } +} diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 490d3f2511..309de3b08f 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -41,7 +41,7 @@ export class EmojiEntityService { @bindThis public packSimpleMany( - emojis: any[], + emojis: (MiEmoji['id'] | MiEmoji)[], ) { return Promise.all(emojis.map(x => this.packSimple(x))); } @@ -69,7 +69,7 @@ export class EmojiEntityService { @bindThis public packDetailedMany( - emojis: any[], + emojis: (MiEmoji['id'] | MiEmoji)[], ): Promise[]> { return Promise.all(emojis.map(x => this.packDetailed(x))); } diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index f8abfb2f98..8e56ddbc02 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -55,13 +55,13 @@ export class MetaEntityService { if (instance.defaultLightTheme) { try { defaultLightTheme = JSON.stringify(JSON5.parse(instance.defaultLightTheme)); - } catch (e) { + } catch (_) { } } if (instance.defaultDarkTheme) { try { defaultDarkTheme = JSON.stringify(JSON5.parse(instance.defaultDarkTheme)); - } catch (e) { + } catch (_) { } } @@ -117,6 +117,7 @@ export class MetaEntityService { ratio: ad.ratio, imageUrl: ad.imageUrl, dayOfWeek: ad.dayOfWeek, + isSensitive: ad.isSensitive ? true : undefined, })), notesPerOneAd: instance.notesPerOneAd, enableEmail: instance.enableEmail, diff --git a/packages/backend/src/core/entities/NoteDraftEntityService.ts b/packages/backend/src/core/entities/NoteDraftEntityService.ts index 3ef8cdaa12..71e41a588d 100644 --- a/packages/backend/src/core/entities/NoteDraftEntityService.ts +++ b/packages/backend/src/core/entities/NoteDraftEntityService.ts @@ -105,6 +105,8 @@ export class NoteDraftEntityService implements OnModuleInit { const packed: Packed<'NoteDraft'> = await awaitAll({ id: noteDraft.id, createdAt: this.idService.parse(noteDraft.id).date.toISOString(), + scheduledAt: noteDraft.scheduledAt?.getTime() ?? null, + isActuallyScheduled: noteDraft.isActuallyScheduled, userId: noteDraft.userId, user: packedUsers?.get(noteDraft.userId) ?? this.userEntityService.pack(noteDraft.user ?? noteDraft.userId, me), text: text, @@ -112,13 +114,13 @@ export class NoteDraftEntityService implements OnModuleInit { visibility: noteDraft.visibility, localOnly: noteDraft.localOnly, reactionAcceptance: noteDraft.reactionAcceptance, - visibleUserIds: noteDraft.visibility === 'specified' ? noteDraft.visibleUserIds : undefined, - hashtag: noteDraft.hashtag ?? undefined, + visibleUserIds: noteDraft.visibleUserIds, + hashtag: noteDraft.hashtag, fileIds: noteDraft.fileIds, files: packedFiles != null ? this.packAttachedFiles(noteDraft.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(noteDraft.fileIds), replyId: noteDraft.replyId, renoteId: noteDraft.renoteId, - channelId: noteDraft.channelId ?? undefined, + channelId: noteDraft.channelId, channel: channel ? { id: channel.id, name: channel.name, @@ -127,6 +129,12 @@ export class NoteDraftEntityService implements OnModuleInit { allowRenoteToExternal: channel.allowRenoteToExternal, userId: channel.userId, } : undefined, + poll: noteDraft.hasPoll ? { + choices: noteDraft.pollChoices, + multiple: noteDraft.pollMultiple, + expiresAt: noteDraft.pollExpiresAt?.toISOString(), + expiredAfter: noteDraft.pollExpiredAfter, + } : null, ...(opts.detail ? { reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, { @@ -138,13 +146,6 @@ export class NoteDraftEntityService implements OnModuleInit { detail: true, skipHide: opts.skipHide, })) : undefined, - - poll: noteDraft.hasPoll ? { - choices: noteDraft.pollChoices, - multiple: noteDraft.pollMultiple, - expiresAt: noteDraft.pollExpiresAt?.toISOString(), - expiredAfter: noteDraft.pollExpiredAfter, - } : undefined, } : {} ), }); diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 6871ba2c72..e7847ba74e 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -15,6 +15,7 @@ import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepos import { bindThis } from '@/decorators.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; +import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; @@ -116,12 +117,7 @@ export class NoteEntityService implements OnModuleInit { private treatVisibility(packedNote: Packed<'Note'>): Packed<'Note'>['visibility'] { if (packedNote.visibility === 'public' || packedNote.visibility === 'home') { const followersOnlyBefore = packedNote.user.makeNotesFollowersOnlyBefore; - if ((followersOnlyBefore != null) - && ( - (followersOnlyBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (followersOnlyBefore * 1000))) - || (followersOnlyBefore > 0 && (new Date(packedNote.createdAt).getTime() < followersOnlyBefore * 1000)) - ) - ) { + if (shouldHideNoteByTime(followersOnlyBefore, packedNote.createdAt)) { packedNote.visibility = 'followers'; } } @@ -141,12 +137,7 @@ export class NoteEntityService implements OnModuleInit { if (!hide) { const hiddenBefore = packedNote.user.makeNotesHiddenBefore; - if ((hiddenBefore != null) - && ( - (hiddenBefore <= 0 && (Date.now() - new Date(packedNote.createdAt).getTime() > 0 - (hiddenBefore * 1000))) - || (hiddenBefore > 0 && (new Date(packedNote.createdAt).getTime() < hiddenBefore * 1000)) - ) - ) { + if (shouldHideNoteByTime(hiddenBefore, packedNote.createdAt)) { hide = true; } } diff --git a/packages/backend/src/core/entities/NoteReactionEntityService.ts b/packages/backend/src/core/entities/NoteReactionEntityService.ts index 46ec13704c..fe4926bfe3 100644 --- a/packages/backend/src/core/entities/NoteReactionEntityService.ts +++ b/packages/backend/src/core/entities/NoteReactionEntityService.ts @@ -49,15 +49,12 @@ export class NoteReactionEntityService implements OnModuleInit { public async pack( src: MiNoteReaction['id'] | MiNoteReaction, me?: { id: MiUser['id'] } | null | undefined, - options?: { - withNote: boolean; - }, + options?: object, hints?: { packedUser?: Packed<'UserLite'> }, ): Promise> { - const opts = Object.assign({ - withNote: false, + const _opts = Object.assign({ }, options); const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); @@ -67,9 +64,6 @@ export class NoteReactionEntityService implements OnModuleInit { createdAt: this.idService.parse(reaction.id).date.toISOString(), 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), - } : {}), }; } @@ -77,16 +71,50 @@ export class NoteReactionEntityService implements OnModuleInit { public async packMany( reactions: MiNoteReaction[], me?: { id: MiUser['id'] } | null | undefined, - options?: { - withNote: boolean; - }, + options?: object, ): Promise[]> { const opts = Object.assign({ - withNote: false, }, options); 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) }))); } + + @bindThis + public async packWithNote( + src: MiNoteReaction['id'] | MiNoteReaction, + me?: { id: MiUser['id'] } | null | undefined, + options?: object, + hints?: { + packedUser?: Packed<'UserLite'> + }, + ): Promise> { + const _opts = Object.assign({ + }, options); + + const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src }); + + return { + id: reaction.id, + createdAt: this.idService.parse(reaction.id).date.toISOString(), + user: hints?.packedUser ?? await this.userEntityService.pack(reaction.user ?? reaction.userId, me), + type: this.reactionService.convertLegacyReaction(reaction.reaction), + note: await this.noteEntityService.pack(reaction.note ?? reaction.noteId, me), + }; + } + + @bindThis + public async packManyWithNote( + reactions: MiNoteReaction[], + me?: { id: MiUser['id'] } | null | undefined, + options?: object, + ): Promise[]> { + const opts = Object.assign({ + }, options); + 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.packWithNote(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 e91fb9eb51..0e96237d32 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -21,7 +21,18 @@ import type { OnModuleInit } from '@nestjs/common'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; -const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]); +const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set([ + 'note', + 'mention', + 'reply', + 'renote', + 'renote:grouped', + 'quote', + 'reaction', + 'reaction:grouped', + 'pollEnded', + 'scheduledNotePosted', +] as (typeof groupedNotificationTypes[number])[]); @Injectable() export class NotificationEntityService implements OnModuleInit { diff --git a/packages/backend/src/core/entities/ReversiGameEntityService.ts b/packages/backend/src/core/entities/ReversiGameEntityService.ts index df042e75c1..21099bad3e 100644 --- a/packages/backend/src/core/entities/ReversiGameEntityService.ts +++ b/packages/backend/src/core/entities/ReversiGameEntityService.ts @@ -14,6 +14,10 @@ import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { UserEntityService } from './UserEntityService.js'; +function assertBw(bw: string): bw is Packed<'ReversiGameDetailed'>['bw'] { + return ['random', '1', '2'].includes(bw); +} + @Injectable() export class ReversiGameEntityService { constructor( @@ -58,7 +62,7 @@ export class ReversiGameEntityService { surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, - bw: game.bw, + bw: assertBw(game.bw) ? game.bw : 'random', isLlotheo: game.isLlotheo, canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, @@ -116,7 +120,7 @@ export class ReversiGameEntityService { surrenderedUserId: game.surrenderedUserId, timeoutUserId: game.timeoutUserId, black: game.black, - bw: game.bw, + bw: assertBw(game.bw) ? game.bw : 'random', isLlotheo: game.isLlotheo, canPutEverywhere: game.canPutEverywhere, loopedBoard: game.loopedBoard, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index d4769d24d4..0f4051e7b8 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -471,8 +471,8 @@ export class UserEntityService implements OnModuleInit { (profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount : null; - const isModerator = isMe && isDetailed ? this.roleService.isModerator(user) : null; - const isAdmin = isMe && isDetailed ? this.roleService.isAdministrator(user) : null; + const isModerator = isMe && isDetailed ? this.roleService.isModerator(user) : undefined; + const isAdmin = isMe && isDetailed ? this.roleService.isAdministrator(user) : undefined; const unreadAnnouncements = isMe && isDetailed ? (await this.announcementService.getUnreadAnnouncements(user)).map((announcement) => ({ createdAt: this.idService.parse(announcement.id).date.toISOString(), @@ -481,6 +481,7 @@ export class UserEntityService implements OnModuleInit { const notificationsInfo = isMe && isDetailed ? await this.getNotificationsInfo(user.id) : null; + // TODO: 例えば avatarUrl: true など間違った型を設定しても型エラーにならないのをどうにかする(ジェネリクス使わない方法で実装するしかなさそう?) const packed = { id: user.id, name: user.name, @@ -511,8 +512,8 @@ export class UserEntityService implements OnModuleInit { } : undefined) : undefined, emojis: this.customEmojiService.populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), - // パフォーマンス上の理由でローカルユーザーのみ - badgeRoles: user.host == null ? this.roleService.getUserBadgeRoles(user.id).then((rs) => rs + // パフォーマンス上の理由で、明示的に設定しない場合はローカルユーザーのみ取得 + badgeRoles: (this.meta.showRoleBadgesOfRemoteUsers || 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) => ({ @@ -719,7 +720,7 @@ export class UserEntityService implements OnModuleInit { me, { ...options, - userProfile: profilesMap.get(u.id), + userProfile: profilesMap?.get(u.id), userRelations: userRelations, userMemos: userMemos, pinNotes: pinNotes, diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index d229efb123..a972e5861c 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -4,13 +4,12 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import si from 'systeminformation'; import Xev from 'xev'; import * as osUtils from 'os-utils'; import { bindThis } from '@/decorators.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; import { MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; const ev = new Xev(); @@ -97,12 +96,14 @@ function cpuUsage(): Promise { // MEMORY STAT async function mem() { + const si = await import('systeminformation'); const data = await si.mem(); return data; } // NETWORK STAT async function net() { + const si = await import('systeminformation'); const iface = await si.networkInterfaceDefault(); const data = await si.networkStats(iface); return data[0]; @@ -110,5 +111,6 @@ async function net() { // FS STAT async function fs() { + const si = await import('systeminformation'); return await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 })); } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index c915133453..b9ca76233c 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -70,6 +70,7 @@ export const DI = { channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), channelFavoritesRepository: Symbol('channelFavoritesRepository'), + channelMutingRepository: Symbol('channelMutingRepository'), registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), systemWebhooksRepository: Symbol('systemWebhooksRepository'), diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index c50f2b723c..0d1c7ee46e 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -56,7 +56,7 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi try { return new RE2(regexp[1], regexp[2]).test(text); - } catch (err) { + } catch (_) { // This should never happen due to input sanitisation. return false; } diff --git a/packages/backend/src/misc/distributed-lock.ts b/packages/backend/src/misc/distributed-lock.ts new file mode 100644 index 0000000000..93bd741f62 --- /dev/null +++ b/packages/backend/src/misc/distributed-lock.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Redis from 'ioredis'; + +export async function acquireDistributedLock( + redis: Redis.Redis, + name: string, + timeout: number, + maxRetries: number, + retryInterval: number, +): Promise<() => Promise> { + const lockKey = `lock:${name}`; + const identifier = Math.random().toString(36).slice(2); + + let retries = 0; + while (retries < maxRetries) { + const result = await redis.set(lockKey, identifier, 'PX', timeout, 'NX'); + if (result === 'OK') { + return async () => { + const currentIdentifier = await redis.get(lockKey); + if (currentIdentifier === identifier) { + await redis.del(lockKey); + } + }; + } + + await new Promise(resolve => setTimeout(resolve, retryInterval)); + retries++; + } + + throw new Error(`Failed to acquire lock ${name}`); +} + +export function acquireApObjectLock( + redis: Redis.Redis, + uri: string, +): Promise<() => Promise> { + return acquireDistributedLock(redis, `ap-object:${uri}`, 30 * 1000, 50, 100); +} + +export function acquireChartInsertLock( + redis: Redis.Redis, + name: string, +): Promise<() => Promise> { + return acquireDistributedLock(redis, `chart-insert:${name}`, 30 * 1000, 50, 500); +} diff --git a/packages/backend/src/misc/escape-html.ts b/packages/backend/src/misc/escape-html.ts new file mode 100644 index 0000000000..819aeeed52 --- /dev/null +++ b/packages/backend/src/misc/escape-html.ts @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/backend/src/misc/get-ip-hash.ts b/packages/backend/src/misc/get-ip-hash.ts index e132fa8f31..571996973b 100644 --- a/packages/backend/src/misc/get-ip-hash.ts +++ b/packages/backend/src/misc/get-ip-hash.ts @@ -12,7 +12,7 @@ export function getIpHash(ip: string): string { // (this means for IPv4 the entire address is used) const prefix = IPCIDR.createAddress(ip).mask(64); return 'ip-' + BigInt('0b' + prefix).toString(36); - } catch (e) { + } catch (_) { const prefix = IPCIDR.createAddress(ip.replace(/:[0-9]+$/, '')).mask(64); return 'ip-' + BigInt('0b' + prefix).toString(36); } diff --git a/packages/backend/src/misc/i18n.ts b/packages/backend/src/misc/i18n.ts index 6cbbdef74c..40067cacf5 100644 --- a/packages/backend/src/misc/i18n.ts +++ b/packages/backend/src/misc/i18n.ts @@ -26,7 +26,7 @@ export class I18n> { } } return str; - } catch (e) { + } catch (_) { console.warn(`missing localization '${key}'`); return key; } diff --git a/packages/backend/src/misc/is-channel-related.ts b/packages/backend/src/misc/is-channel-related.ts new file mode 100644 index 0000000000..fef736dad6 --- /dev/null +++ b/packages/backend/src/misc/is-channel-related.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { MiNote } from '@/models/Note.js'; +import { Packed } from '@/misc/json-schema.js'; + +/** + * {@link note}が{@link channelIds}のチャンネルに関連するかどうかを判定し、関連する場合はtrueを返します。 + * 関連するというのは、{@link channelIds}のチャンネルに向けての投稿であるか、またはそのチャンネルの投稿をリノート・引用リノートした投稿であるかを指します。 + * + * @param note 確認対象のノート + * @param channelIds 確認対象のチャンネルID一覧 + * @param ignoreAuthor trueの場合、ノートの所属チャンネルが{@link channelIds}に含まれていても無視します(デフォルトはfalse) + */ +export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set, ignoreAuthor = false): boolean { + // ノートの所属チャンネルが確認対象のチャンネルID一覧に含まれている場合 + if (!ignoreAuthor && note.channelId && channelIds.has(note.channelId)) { + return true; + } + + const renoteChannelId = note.renote?.channelId; + if (renoteChannelId != null && renoteChannelId !== note.channelId && channelIds.has(renoteChannelId)) { + return true; + } + + // NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない(チャンネルのノートにチャンネル外からのリプライまたはその逆はないはずなので) + + return false; +} diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index ed47edff9b..cf233defd9 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -22,7 +22,7 @@ import { packedFollowingSchema } from '@/models/json-schema/following.js'; import { packedMutingSchema } from '@/models/json-schema/muting.js'; import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'; import { packedBlockingSchema } from '@/models/json-schema/blocking.js'; -import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js'; +import { packedNoteReactionSchema, packedNoteReactionWithNoteSchema } 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 { packedPageBlockSchema, packedPageSchema } from '@/models/json-schema/page.js'; @@ -64,7 +64,9 @@ import { packedMetaDetailedOnlySchema, packedMetaDetailedSchema, packedMetaLiteSchema, + packedMetaClientOptionsSchema, } from '@/models/json-schema/meta.js'; +import { packedUserWebhookSchema } from '@/models/json-schema/user-webhook.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessageLiteForRoomSchema, packedChatMessageLiteFor1on1Schema } from '@/models/json-schema/chat-message.js'; @@ -92,6 +94,7 @@ export const refs = { Note: packedNoteSchema, NoteDraft: packedNoteDraftSchema, NoteReaction: packedNoteReactionSchema, + NoteReactionWithNote: packedNoteReactionWithNoteSchema, NoteFavorite: packedNoteFavoriteSchema, Notification: packedNotificationSchema, DriveFile: packedDriveFileSchema, @@ -133,6 +136,8 @@ export const refs = { MetaLite: packedMetaLiteSchema, MetaDetailedOnly: packedMetaDetailedOnlySchema, MetaDetailed: packedMetaDetailedSchema, + MetaClientOptions: packedMetaClientOptionsSchema, + UserWebhook: packedUserWebhookSchema, SystemWebhook: packedSystemWebhookSchema, AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, ChatMessage: packedChatMessageSchema, @@ -259,8 +264,6 @@ type ObjectSchemaTypeDef

= never : any; -type ObjectSchemaType

= NullOrUndefined>; - export type SchemaTypeDef

= p['type'] extends 'null' ? null : p['type'] extends 'integer' ? number : diff --git a/packages/backend/src/misc/json-stringify-html-safe.ts b/packages/backend/src/misc/json-stringify-html-safe.ts new file mode 100644 index 0000000000..aac12d57db --- /dev/null +++ b/packages/backend/src/misc/json-stringify-html-safe.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const ESCAPE_LOOKUP = { + '&': '\\u0026', + '>': '\\u003e', + '<': '\\u003c', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +} as Record; + +const ESCAPE_REGEX = /[&><\u2028\u2029]/g; + +export function htmlSafeJsonStringify(obj: any): string { + return JSON.stringify(obj).replace(ESCAPE_REGEX, x => ESCAPE_LOOKUP[x]); +} diff --git a/packages/backend/src/misc/should-hide-note-by-time.ts b/packages/backend/src/misc/should-hide-note-by-time.ts new file mode 100644 index 0000000000..ea1951e66c --- /dev/null +++ b/packages/backend/src/misc/should-hide-note-by-time.ts @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * ノートが指定された時間条件に基づいて非表示対象かどうかを判定する + * @param hiddenBefore 非表示条件(負の値: 作成からの経過秒数、正の値: UNIXタイムスタンプ秒、null: 判定しない) + * @param createdAt ノートの作成日時(ISO 8601形式の文字列 または Date オブジェクト) + * @returns 非表示にすべき場合は true + */ +export function shouldHideNoteByTime(hiddenBefore: number | null | undefined, createdAt: string | Date): boolean { + if (hiddenBefore == null) { + return false; + } + + const createdAtTime = typeof createdAt === 'string' ? new Date(createdAt).getTime() : createdAt.getTime(); + + if (hiddenBefore <= 0) { + // 負の値: 作成からの経過時間(秒)で判定 + const elapsedSeconds = (Date.now() - createdAtTime) / 1000; + const hideAfterSeconds = Math.abs(hiddenBefore); + return elapsedSeconds >= hideAfterSeconds; + } else { + // 正の値: 絶対的なタイムスタンプ(秒)で判定 + const createdAtSeconds = createdAtTime / 1000; + return createdAtSeconds <= hiddenBefore; + } +} diff --git a/packages/backend/src/misc/show-machine-info.ts b/packages/backend/src/misc/show-machine-info.ts index 8ddec35f23..b279eb9546 100644 --- a/packages/backend/src/misc/show-machine-info.ts +++ b/packages/backend/src/misc/show-machine-info.ts @@ -4,15 +4,11 @@ */ import * as os from 'node:os'; -import sysUtils from 'systeminformation'; import type Logger from '@/logger.js'; export async function showMachineInfo(parentLogger: Logger) { const logger = parentLogger.createSubLogger('machine'); logger.debug(`Hostname: ${os.hostname()}`); logger.debug(`Platform: ${process.platform} Arch: ${process.arch}`); - const mem = await sysUtils.mem(); - const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1); - const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1); - logger.debug(`CPU: ${os.cpus().length} core MEM: ${totalmem}GB (available: ${availmem}GB)`); + logger.debug(`CPU: ${os.cpus().length} core MEM: ${(os.totalmem() / 1024 / 1024 / 1024).toFixed(1)}GB (available: ${(os.freemem() / 1024 / 1024 / 1024).toFixed(1)}GB)`); } diff --git a/packages/backend/src/misc/split-id-and-objects.ts b/packages/backend/src/misc/split-id-and-objects.ts new file mode 100644 index 0000000000..d23bb93695 --- /dev/null +++ b/packages/backend/src/misc/split-id-and-objects.ts @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * idとオブジェクトを分離する + * @param input idまたはオブジェクトの配列 + * @returns idの配列とオブジェクトの配列 + */ +export function splitIdAndObjects(input: (T | string)[]): { ids: string[]; objects: T[] } { + const ids: string[] = []; + const objects : T[] = []; + + for (const item of input) { + if (typeof item === 'string') { + ids.push(item); + } else { + objects.push(item); + } + } + + return { + ids, + objects, + }; +} diff --git a/packages/backend/src/misc/unique-by-key.ts b/packages/backend/src/misc/unique-by-key.ts new file mode 100644 index 0000000000..4308e29d21 --- /dev/null +++ b/packages/backend/src/misc/unique-by-key.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * itemsの中でkey関数が返す値が重複しないようにした配列を返す + * @param items 重複を除去したい配列 + * @param key 重複判定に使うキーを返す関数 + * @returns 重複を除去した配列 + */ +export function uniqueByKey(items: Iterable, key: (item: TItem) => TKey): TItem[] { + const map = new Map(); + for (const item of items) { + const k = key(item); + if (!map.has(k)) { + map.set(k, item); + } + } + return [...map.values()]; +} diff --git a/packages/backend/src/models/AbuseReportNotificationRecipient.ts b/packages/backend/src/models/AbuseReportNotificationRecipient.ts index 17ec6abed5..daed81c174 100644 --- a/packages/backend/src/models/AbuseReportNotificationRecipient.ts +++ b/packages/backend/src/models/AbuseReportNotificationRecipient.ts @@ -67,7 +67,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザ. */ - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'userId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId1' }) @@ -76,7 +76,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のユーザプロフィール. */ - @ManyToOne(type => MiUserProfile, { + @ManyToOne(() => MiUserProfile, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'userId', referencedColumnName: 'userId', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_userId2' }) @@ -96,7 +96,7 @@ export class MiAbuseReportNotificationRecipient { /** * 通知先のシステムWebhook. */ - @ManyToOne(type => MiSystemWebhook, { + @ManyToOne(() => MiSystemWebhook, { onDelete: 'CASCADE', }) @JoinColumn({ name: 'systemWebhookId', referencedColumnName: 'id', foreignKeyConstraintName: 'FK_abuse_report_notification_recipient_systemWebhookId' }) diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts index d43ebf9342..cd49fcddfe 100644 --- a/packages/backend/src/models/AbuseUserReport.ts +++ b/packages/backend/src/models/AbuseUserReport.ts @@ -18,7 +18,7 @@ export class MiAbuseUserReport { @Column(id()) public targetUserId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiAbuseUserReport { @Column(id()) public reporterId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -40,7 +40,7 @@ export class MiAbuseUserReport { }) public assigneeId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/AccessToken.ts b/packages/backend/src/models/AccessToken.ts index 6f98c14ec1..a853dcc6cb 100644 --- a/packages/backend/src/models/AccessToken.ts +++ b/packages/backend/src/models/AccessToken.ts @@ -41,7 +41,7 @@ export class MiAccessToken { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -53,7 +53,7 @@ export class MiAccessToken { }) public appId: MiApp['id'] | null; - @ManyToOne(type => MiApp, { + @ManyToOne(() => MiApp, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Ad.ts b/packages/backend/src/models/Ad.ts index 108e991c70..0d402fcbe8 100644 --- a/packages/backend/src/models/Ad.ts +++ b/packages/backend/src/models/Ad.ts @@ -54,10 +54,17 @@ export class MiAd { length: 8192, nullable: false, }) public memo: string; + @Column('integer', { default: 0, nullable: false, }) public dayOfWeek: number; + + @Column('boolean', { + default: false, + }) + public isSensitive: boolean; + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts index d0c59fff50..f664c75262 100644 --- a/packages/backend/src/models/Announcement.ts +++ b/packages/backend/src/models/Announcement.ts @@ -79,7 +79,7 @@ export class MiAnnouncement { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/AnnouncementRead.ts b/packages/backend/src/models/AnnouncementRead.ts index 47de8dd180..2133cff140 100644 --- a/packages/backend/src/models/AnnouncementRead.ts +++ b/packages/backend/src/models/AnnouncementRead.ts @@ -18,7 +18,7 @@ export class MiAnnouncementRead { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiAnnouncementRead { @Column(id()) public announcementId: MiAnnouncement['id']; - @ManyToOne(type => MiAnnouncement, { + @ManyToOne(() => MiAnnouncement, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Antenna.ts b/packages/backend/src/models/Antenna.ts index ccc8823703..3433cf20af 100644 --- a/packages/backend/src/models/Antenna.ts +++ b/packages/backend/src/models/Antenna.ts @@ -24,7 +24,7 @@ export class MiAntenna { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -45,7 +45,7 @@ export class MiAntenna { }) public userListId: MiUserList['id'] | null; - @ManyToOne(type => MiUserList, { + @ManyToOne(() => MiUserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/App.ts b/packages/backend/src/models/App.ts index 0185e2995c..bbb80b99ef 100644 --- a/packages/backend/src/models/App.ts +++ b/packages/backend/src/models/App.ts @@ -20,7 +20,7 @@ export class MiApp { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', nullable: true, }) diff --git a/packages/backend/src/models/AuthSession.ts b/packages/backend/src/models/AuthSession.ts index 03050ba955..a7273e63bf 100644 --- a/packages/backend/src/models/AuthSession.ts +++ b/packages/backend/src/models/AuthSession.ts @@ -25,7 +25,7 @@ export class MiAuthSession { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', nullable: true, }) @@ -35,7 +35,7 @@ export class MiAuthSession { @Column(id()) public appId: MiApp['id']; - @ManyToOne(type => MiApp, { + @ManyToOne(() => MiApp, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Blocking.ts b/packages/backend/src/models/Blocking.ts index 34a6efe5a6..49b584f509 100644 --- a/packages/backend/src/models/Blocking.ts +++ b/packages/backend/src/models/Blocking.ts @@ -20,7 +20,7 @@ export class MiBlocking { }) public blockeeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiBlocking { }) public blockerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/BubbleGameRecord.ts b/packages/backend/src/models/BubbleGameRecord.ts index 686e39c118..5dd7009fc6 100644 --- a/packages/backend/src/models/BubbleGameRecord.ts +++ b/packages/backend/src/models/BubbleGameRecord.ts @@ -18,7 +18,7 @@ export class MiBubbleGameRecord { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Channel.ts b/packages/backend/src/models/Channel.ts index f5e9b17e3e..5a5b914eb1 100644 --- a/packages/backend/src/models/Channel.ts +++ b/packages/backend/src/models/Channel.ts @@ -27,7 +27,7 @@ export class MiChannel { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', }) @JoinColumn() @@ -52,7 +52,7 @@ export class MiChannel { }) public bannerId: MiDriveFile['id'] | null; - @ManyToOne(type => MiDriveFile, { + @ManyToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/ChannelFavorite.ts b/packages/backend/src/models/ChannelFavorite.ts index 167f41cf16..4f49468598 100644 --- a/packages/backend/src/models/ChannelFavorite.ts +++ b/packages/backend/src/models/ChannelFavorite.ts @@ -20,7 +20,7 @@ export class MiChannelFavorite { }) public channelId: MiChannel['id']; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChannelFavorite { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChannelFollowing.ts b/packages/backend/src/models/ChannelFollowing.ts index c7afdd05b0..7597e704a8 100644 --- a/packages/backend/src/models/ChannelFollowing.ts +++ b/packages/backend/src/models/ChannelFollowing.ts @@ -21,7 +21,7 @@ export class MiChannelFollowing { }) public followeeId: MiChannel['id']; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiChannelFollowing { }) public followerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChannelMuting.ts b/packages/backend/src/models/ChannelMuting.ts new file mode 100644 index 0000000000..b7054c9c5f --- /dev/null +++ b/packages/backend/src/models/ChannelMuting.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChannel } from './Channel.js'; + +@Entity('channel_muting') +@Index(['userId', 'channelId'], {}) +export class MiChannelMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public userId: MiUser['id']; + + @ManyToOne(() => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Index() + @Column({ + ...id(), + }) + public channelId: MiChannel['id']; + + @ManyToOne(() => MiChannel, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public channel: MiChannel | null; + + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public expiresAt: Date | null; +} diff --git a/packages/backend/src/models/ChatApproval.ts b/packages/backend/src/models/ChatApproval.ts index 55c9f07e9a..bd2509b67f 100644 --- a/packages/backend/src/models/ChatApproval.ts +++ b/packages/backend/src/models/ChatApproval.ts @@ -19,7 +19,7 @@ export class MiChatApproval { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -31,7 +31,7 @@ export class MiChatApproval { }) public otherId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts index 3d2b64268e..530ef9b842 100644 --- a/packages/backend/src/models/ChatMessage.ts +++ b/packages/backend/src/models/ChatMessage.ts @@ -20,7 +20,7 @@ export class MiChatMessage { }) public fromUserId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChatMessage { }) public toUserId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -44,7 +44,7 @@ export class MiChatMessage { }) public toRoomId: MiChatRoom['id'] | null; - @ManyToOne(type => MiChatRoom, { + @ManyToOne(() => MiChatRoom, { onDelete: 'CASCADE', }) @JoinColumn() @@ -72,7 +72,7 @@ export class MiChatMessage { }) public fileId: MiDriveFile['id'] | null; - @ManyToOne(type => MiDriveFile, { + @ManyToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatRoom.ts b/packages/backend/src/models/ChatRoom.ts index ad2a910b78..c148b16af8 100644 --- a/packages/backend/src/models/ChatRoom.ts +++ b/packages/backend/src/models/ChatRoom.ts @@ -23,7 +23,7 @@ export class MiChatRoom { }) public ownerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatRoomInvitation.ts b/packages/backend/src/models/ChatRoomInvitation.ts index 36ce12bc92..5827d0401d 100644 --- a/packages/backend/src/models/ChatRoomInvitation.ts +++ b/packages/backend/src/models/ChatRoomInvitation.ts @@ -20,7 +20,7 @@ export class MiChatRoomInvitation { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChatRoomInvitation { }) public roomId: MiChatRoom['id']; - @ManyToOne(type => MiChatRoom, { + @ManyToOne(() => MiChatRoom, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ChatRoomMembership.ts b/packages/backend/src/models/ChatRoomMembership.ts index 3cb5524859..d59b4426df 100644 --- a/packages/backend/src/models/ChatRoomMembership.ts +++ b/packages/backend/src/models/ChatRoomMembership.ts @@ -20,7 +20,7 @@ export class MiChatRoomMembership { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -32,7 +32,7 @@ export class MiChatRoomMembership { }) public roomId: MiChatRoom['id']; - @ManyToOne(type => MiChatRoom, { + @ManyToOne(() => MiChatRoom, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Clip.ts b/packages/backend/src/models/Clip.ts index 6295a329fb..ddd0298f44 100644 --- a/packages/backend/src/models/Clip.ts +++ b/packages/backend/src/models/Clip.ts @@ -25,7 +25,7 @@ export class MiClip { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ClipFavorite.ts b/packages/backend/src/models/ClipFavorite.ts index 40bdb9f4aa..2d46fd0f0e 100644 --- a/packages/backend/src/models/ClipFavorite.ts +++ b/packages/backend/src/models/ClipFavorite.ts @@ -18,7 +18,7 @@ export class MiClipFavorite { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiClipFavorite { @Column(id()) public clipId: MiClip['id']; - @ManyToOne(type => MiClip, { + @ManyToOne(() => MiClip, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/ClipNote.ts b/packages/backend/src/models/ClipNote.ts index 6e1d2bec4c..23df66c4e0 100644 --- a/packages/backend/src/models/ClipNote.ts +++ b/packages/backend/src/models/ClipNote.ts @@ -21,7 +21,7 @@ export class MiClipNote { }) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiClipNote { }) public clipId: MiClip['id']; - @ManyToOne(type => MiClip, { + @ManyToOne(() => MiClip, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/DriveFile.ts b/packages/backend/src/models/DriveFile.ts index 7b03e3e494..79189b10eb 100644 --- a/packages/backend/src/models/DriveFile.ts +++ b/packages/backend/src/models/DriveFile.ts @@ -22,7 +22,7 @@ export class MiDriveFile { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', }) @JoinColumn() @@ -142,7 +142,7 @@ export class MiDriveFile { }) public folderId: MiDriveFolder['id'] | null; - @ManyToOne(type => MiDriveFolder, { + @ManyToOne(() => MiDriveFolder, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/DriveFolder.ts b/packages/backend/src/models/DriveFolder.ts index 07046d6e11..7e34c07f46 100644 --- a/packages/backend/src/models/DriveFolder.ts +++ b/packages/backend/src/models/DriveFolder.ts @@ -26,7 +26,7 @@ export class MiDriveFolder { }) public userId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -40,7 +40,7 @@ export class MiDriveFolder { }) public parentId: MiDriveFolder['id'] | null; - @ManyToOne(type => MiDriveFolder, { + @ManyToOne(() => MiDriveFolder, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/Flash.ts b/packages/backend/src/models/Flash.ts index 5db7dca992..ed677a9de3 100644 --- a/packages/backend/src/models/Flash.ts +++ b/packages/backend/src/models/Flash.ts @@ -38,7 +38,7 @@ export class MiFlash { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/FlashLike.ts b/packages/backend/src/models/FlashLike.ts index a9fb48123e..0d99c2a9ae 100644 --- a/packages/backend/src/models/FlashLike.ts +++ b/packages/backend/src/models/FlashLike.ts @@ -18,7 +18,7 @@ export class MiFlashLike { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiFlashLike { @Column(id()) public flashId: MiFlash['id']; - @ManyToOne(type => MiFlash, { + @ManyToOne(() => MiFlash, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/FollowRequest.ts b/packages/backend/src/models/FollowRequest.ts index 3ff5e7a478..468829b7e8 100644 --- a/packages/backend/src/models/FollowRequest.ts +++ b/packages/backend/src/models/FollowRequest.ts @@ -20,7 +20,7 @@ export class MiFollowRequest { }) public followeeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiFollowRequest { }) public followerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Following.ts b/packages/backend/src/models/Following.ts index 62cbc29f26..fe62166287 100644 --- a/packages/backend/src/models/Following.ts +++ b/packages/backend/src/models/Following.ts @@ -21,7 +21,7 @@ export class MiFollowing { }) public followeeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiFollowing { }) public followerId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/GalleryLike.ts b/packages/backend/src/models/GalleryLike.ts index ed0963122d..787b38e46d 100644 --- a/packages/backend/src/models/GalleryLike.ts +++ b/packages/backend/src/models/GalleryLike.ts @@ -18,7 +18,7 @@ export class MiGalleryLike { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiGalleryLike { @Column(id()) public postId: MiGalleryPost['id']; - @ManyToOne(type => MiGalleryPost, { + @ManyToOne(() => MiGalleryPost, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/GalleryPost.ts b/packages/backend/src/models/GalleryPost.ts index 04d8823e37..f66956628b 100644 --- a/packages/backend/src/models/GalleryPost.ts +++ b/packages/backend/src/models/GalleryPost.ts @@ -36,7 +36,7 @@ export class MiGalleryPost { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index f8021a7a84..620853450c 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -21,7 +21,7 @@ export class MiMeta { }) public rootUserId: MiUser['id'] | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'SET NULL', nullable: true, }) @@ -717,10 +717,19 @@ export class MiMeta { }) public remoteNotesCleaningExpiryDaysForEachNotes: number; + @Column('boolean', { + default: false, + }) + public showRoleBadgesOfRemoteUsers: boolean; + @Column('jsonb', { default: { }, }) - public clientOptions: Record; + public clientOptions: { + entrancePageStyle: 'classic' | 'simple'; + showTimelineForVisitor: boolean; + showActivitiesForVisitor: boolean; + }; } export type SoftwareSuspension = { diff --git a/packages/backend/src/models/ModerationLog.ts b/packages/backend/src/models/ModerationLog.ts index edde315fdf..c22114a36d 100644 --- a/packages/backend/src/models/ModerationLog.ts +++ b/packages/backend/src/models/ModerationLog.ts @@ -16,7 +16,7 @@ export class MiModerationLog { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Muting.ts b/packages/backend/src/models/Muting.ts index e1240b9c4e..9406b97a62 100644 --- a/packages/backend/src/models/Muting.ts +++ b/packages/backend/src/models/Muting.ts @@ -26,7 +26,7 @@ export class MiMuting { }) public muteeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -39,7 +39,7 @@ export class MiMuting { }) public muterId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 26d5c1d535..089fe8f188 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -35,7 +35,7 @@ export class MiNote { }) public replyId: MiNote['id'] | null; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -49,7 +49,7 @@ export class MiNote { }) public renoteId: MiNote['id'] | null; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -83,7 +83,7 @@ export class MiNote { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -208,7 +208,7 @@ export class MiNote { }) public channelId: MiChannel['id'] | null; - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { onDelete: 'CASCADE', }) @JoinColumn() @@ -248,6 +248,14 @@ export class MiNote { }) public renoteUserHost: string | null; + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]', + }) + public renoteChannelId: MiChannel['id'] | null; + //#endregion + constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/NoteDraft.ts b/packages/backend/src/models/NoteDraft.ts index 6483748bc2..5bfd9699fe 100644 --- a/packages/backend/src/models/NoteDraft.ts +++ b/packages/backend/src/models/NoteDraft.ts @@ -27,7 +27,7 @@ export class MiNoteDraft { public replyId: MiNote['id'] | null; // There is a possibility that replyId is not null but reply is null when the reply note is deleted. - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -42,7 +42,7 @@ export class MiNoteDraft { public renoteId: MiNote['id'] | null; // There is a possibility that renoteId is not null but renote is null when the renote note is deleted. - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { createForeignKeyConstraints: false, }) @JoinColumn() @@ -66,7 +66,7 @@ export class MiNoteDraft { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -120,13 +120,13 @@ export class MiNoteDraft { // There is a possibility that channelId is not null but channel is null when the channel is deleted. // (deleting channel is not implemented so it's not happening now but may happen in the future) - @ManyToOne(type => MiChannel, { + @ManyToOne(() => MiChannel, { createForeignKeyConstraints: false, }) @JoinColumn() public channel: MiChannel | null; - // 以下、Pollについて追加 + //#region 以下、Pollについて追加 @Column('boolean', { default: false, @@ -151,13 +151,18 @@ export class MiNoteDraft { }) public pollExpiredAfter: number | null; - // ここまで追加 + //#endregion - constructor(data: Partial) { - if (data == null) return; + // 予約日時 + // これがあるだけでは実際に予約されているかどうかはわからない + @Column('timestamp with time zone', { + nullable: true, + }) + public scheduledAt: Date | null; - for (const [k, v] of Object.entries(data)) { - (this as any)[k] = v; - } - } + // scheduledAtに基づいて実際にスケジュールされているか + @Column('boolean', { + default: false, + }) + public isActuallyScheduled: boolean; } diff --git a/packages/backend/src/models/NoteFavorite.ts b/packages/backend/src/models/NoteFavorite.ts index cf76c767b0..0e498eb70d 100644 --- a/packages/backend/src/models/NoteFavorite.ts +++ b/packages/backend/src/models/NoteFavorite.ts @@ -18,7 +18,7 @@ export class MiNoteFavorite { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiNoteFavorite { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/NoteReaction.ts b/packages/backend/src/models/NoteReaction.ts index 42dfcaa9ad..98263081ab 100644 --- a/packages/backend/src/models/NoteReaction.ts +++ b/packages/backend/src/models/NoteReaction.ts @@ -18,7 +18,7 @@ export class MiNoteReaction { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiNoteReaction { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/NoteThreadMuting.ts b/packages/backend/src/models/NoteThreadMuting.ts index e7bd39f348..32bb829c0b 100644 --- a/packages/backend/src/models/NoteThreadMuting.ts +++ b/packages/backend/src/models/NoteThreadMuting.ts @@ -19,7 +19,7 @@ export class MiNoteThreadMuting { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index 5764a307b0..7fa17e20fa 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -9,7 +9,9 @@ import { MiNote } from './Note.js'; import { MiAccessToken } from './AccessToken.js'; import { MiRole } from './Role.js'; import { MiDriveFile } from './DriveFile.js'; +import { MiNoteDraft } from './NoteDraft.js'; +// misskey-js の notificationTypes と同期すべし export type MiNotification = { type: 'note'; id: string; @@ -59,6 +61,16 @@ export type MiNotification = { createdAt: string; notifierId: MiUser['id']; noteId: MiNote['id']; +} | { + type: 'scheduledNotePosted'; + id: string; + createdAt: string; + noteId: MiNote['id']; +} | { + type: 'scheduledNotePostFailed'; + id: string; + createdAt: string; + noteDraftId: MiNoteDraft['id']; } | { type: 'receiveFollowRequest'; id: string; diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index 0b59e7a92c..8811200801 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -47,7 +47,7 @@ export class MiPage { @Column('varchar', { length: 32, }) - public font: string; + public font: 'serif' | 'sans-serif'; @Index() @Column({ @@ -56,7 +56,7 @@ export class MiPage { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -68,8 +68,8 @@ export class MiPage { }) public eyeCatchingImageId: MiDriveFile['id'] | null; - @ManyToOne(type => MiDriveFile, { - onDelete: 'CASCADE', + @ManyToOne(() => MiDriveFile, { + onDelete: 'SET NULL', }) @JoinColumn() public eyeCatchingImage: MiDriveFile | null; diff --git a/packages/backend/src/models/PageLike.ts b/packages/backend/src/models/PageLike.ts index 05ca22cf2c..cf3025ae1c 100644 --- a/packages/backend/src/models/PageLike.ts +++ b/packages/backend/src/models/PageLike.ts @@ -18,7 +18,7 @@ export class MiPageLike { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiPageLike { @Column(id()) public pageId: MiPage['id']; - @ManyToOne(type => MiPage, { + @ManyToOne(() => MiPage, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PasswordResetRequest.ts b/packages/backend/src/models/PasswordResetRequest.ts index fdaf21056b..3379b540ee 100644 --- a/packages/backend/src/models/PasswordResetRequest.ts +++ b/packages/backend/src/models/PasswordResetRequest.ts @@ -24,7 +24,7 @@ export class MiPasswordResetRequest { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Poll.ts b/packages/backend/src/models/Poll.ts index ca985c8b24..d82e29fb85 100644 --- a/packages/backend/src/models/Poll.ts +++ b/packages/backend/src/models/Poll.ts @@ -15,7 +15,7 @@ export class MiPoll { @PrimaryColumn(id()) public noteId: MiNote['id']; - @OneToOne(type => MiNote, { + @OneToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PollVote.ts b/packages/backend/src/models/PollVote.ts index b5c780293c..600ca8ea41 100644 --- a/packages/backend/src/models/PollVote.ts +++ b/packages/backend/src/models/PollVote.ts @@ -18,7 +18,7 @@ export class MiPollVote { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -28,7 +28,7 @@ export class MiPollVote { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PromoNote.ts b/packages/backend/src/models/PromoNote.ts index ae27adec9e..871f7471fc 100644 --- a/packages/backend/src/models/PromoNote.ts +++ b/packages/backend/src/models/PromoNote.ts @@ -13,7 +13,7 @@ export class MiPromoNote { @PrimaryColumn(id()) public noteId: MiNote['id']; - @OneToOne(type => MiNote, { + @OneToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/PromoRead.ts b/packages/backend/src/models/PromoRead.ts index b2a698cc7b..15a3573ef3 100644 --- a/packages/backend/src/models/PromoRead.ts +++ b/packages/backend/src/models/PromoRead.ts @@ -18,7 +18,7 @@ export class MiPromoRead { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiPromoRead { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RegistrationTicket.ts b/packages/backend/src/models/RegistrationTicket.ts index 0a4e4b9189..07216599d3 100644 --- a/packages/backend/src/models/RegistrationTicket.ts +++ b/packages/backend/src/models/RegistrationTicket.ts @@ -23,7 +23,7 @@ export class MiRegistrationTicket { }) public expiresAt: Date | null; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -36,7 +36,7 @@ export class MiRegistrationTicket { }) public createdById: MiUser['id'] | null; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RegistryItem.ts b/packages/backend/src/models/RegistryItem.ts index 335e8b9eab..869980bbff 100644 --- a/packages/backend/src/models/RegistryItem.ts +++ b/packages/backend/src/models/RegistryItem.ts @@ -25,7 +25,7 @@ export class MiRegistryItem { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RenoteMuting.ts b/packages/backend/src/models/RenoteMuting.ts index 448a0b7663..b760a09c53 100644 --- a/packages/backend/src/models/RenoteMuting.ts +++ b/packages/backend/src/models/RenoteMuting.ts @@ -20,7 +20,7 @@ export class MiRenoteMuting { }) public muteeId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiRenoteMuting { }) public muterId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 146dbbc3b8..e3db6f8838 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -21,6 +21,7 @@ import { MiChannel, MiChannelFavorite, MiChannelFollowing, + MiChannelMuting, MiClip, MiClipFavorite, MiClipNote, @@ -429,6 +430,12 @@ const $channelFavoritesRepository: Provider = { inject: [DI.db], }; +const $channelMutingRepository: Provider = { + provide: DI.channelMutingRepository, + useFactory: (db: DataSource) => db.getRepository(MiChannelMuting).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $registryItemsRepository: Provider = { provide: DI.registryItemsRepository, useFactory: (db: DataSource) => db.getRepository(MiRegistryItem).extend(miRepository as MiRepository), @@ -597,6 +604,7 @@ const $reversiGamesRepository: Provider = { $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, + $channelMutingRepository, $registryItemsRepository, $webhooksRepository, $systemWebhooksRepository, @@ -674,6 +682,7 @@ const $reversiGamesRepository: Provider = { $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, + $channelMutingRepository, $registryItemsRepository, $webhooksRepository, $systemWebhooksRepository, diff --git a/packages/backend/src/models/ReversiGame.ts b/packages/backend/src/models/ReversiGame.ts index 6b29a0ce8c..fbbf24792f 100644 --- a/packages/backend/src/models/ReversiGame.ts +++ b/packages/backend/src/models/ReversiGame.ts @@ -27,7 +27,7 @@ export class MiReversiGame { @Column(id()) public user1Id: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -36,7 +36,7 @@ export class MiReversiGame { @Column(id()) public user2Id: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/RoleAssignment.ts b/packages/backend/src/models/RoleAssignment.ts index 37755d631b..cb96377f66 100644 --- a/packages/backend/src/models/RoleAssignment.ts +++ b/packages/backend/src/models/RoleAssignment.ts @@ -21,7 +21,7 @@ export class MiRoleAssignment { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiRoleAssignment { }) public roleId: MiRole['id']; - @ManyToOne(type => MiRole, { + @ManyToOne(() => MiRole, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Signin.ts b/packages/backend/src/models/Signin.ts index f8ff9c57d7..59cbad735d 100644 --- a/packages/backend/src/models/Signin.ts +++ b/packages/backend/src/models/Signin.ts @@ -16,7 +16,7 @@ export class MiSignin { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/SwSubscription.ts b/packages/backend/src/models/SwSubscription.ts index 0c531132b3..a95aede44f 100644 --- a/packages/backend/src/models/SwSubscription.ts +++ b/packages/backend/src/models/SwSubscription.ts @@ -16,7 +16,7 @@ export class MiSwSubscription { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/SystemAccount.ts b/packages/backend/src/models/SystemAccount.ts index f32880b81d..2a48e62ed1 100644 --- a/packages/backend/src/models/SystemAccount.ts +++ b/packages/backend/src/models/SystemAccount.ts @@ -18,7 +18,7 @@ export class MiSystemAccount { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index a6e9edcf5f..084dd35485 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -99,7 +99,7 @@ export class MiUser { }) public avatarId: MiDriveFile['id'] | null; - @OneToOne(type => MiDriveFile, { + @OneToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() @@ -112,7 +112,7 @@ export class MiUser { }) public bannerId: MiDriveFile['id'] | null; - @OneToOne(type => MiDriveFile, { + @OneToOne(() => MiDriveFile, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/UserKeypair.ts b/packages/backend/src/models/UserKeypair.ts index f5252d126c..894739c84c 100644 --- a/packages/backend/src/models/UserKeypair.ts +++ b/packages/backend/src/models/UserKeypair.ts @@ -12,7 +12,7 @@ export class MiUserKeypair { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserList.ts b/packages/backend/src/models/UserList.ts index 5fb991a87d..05fd833b6f 100644 --- a/packages/backend/src/models/UserList.ts +++ b/packages/backend/src/models/UserList.ts @@ -25,7 +25,7 @@ export class MiUserList { }) public isPublic: boolean; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserListFavorite.ts b/packages/backend/src/models/UserListFavorite.ts index 80b2d61eb7..67ab92d98c 100644 --- a/packages/backend/src/models/UserListFavorite.ts +++ b/packages/backend/src/models/UserListFavorite.ts @@ -18,7 +18,7 @@ export class MiUserListFavorite { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiUserListFavorite { @Column(id()) public userListId: MiUserList['id']; - @ManyToOne(type => MiUserList, { + @ManyToOne(() => MiUserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserListMembership.ts b/packages/backend/src/models/UserListMembership.ts index af659d071d..1a2b3fffc1 100644 --- a/packages/backend/src/models/UserListMembership.ts +++ b/packages/backend/src/models/UserListMembership.ts @@ -21,7 +21,7 @@ export class MiUserListMembership { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -34,7 +34,7 @@ export class MiUserListMembership { }) public userListId: MiUserList['id']; - @ManyToOne(type => MiUserList, { + @ManyToOne(() => MiUserList, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserMemo.ts b/packages/backend/src/models/UserMemo.ts index 29e28d290a..facc8c6b1c 100644 --- a/packages/backend/src/models/UserMemo.ts +++ b/packages/backend/src/models/UserMemo.ts @@ -20,7 +20,7 @@ export class MiUserMemo { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -33,7 +33,7 @@ export class MiUserMemo { }) public targetUserId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserNotePining.ts b/packages/backend/src/models/UserNotePining.ts index 92c5cd55d0..950da2ad22 100644 --- a/packages/backend/src/models/UserNotePining.ts +++ b/packages/backend/src/models/UserNotePining.ts @@ -18,7 +18,7 @@ export class MiUserNotePining { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -27,7 +27,7 @@ export class MiUserNotePining { @Column(id()) public noteId: MiNote['id']; - @ManyToOne(type => MiNote, { + @ManyToOne(() => MiNote, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 501b539210..b05bf14ef9 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -17,7 +17,7 @@ export class MiUserProfile { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() @@ -215,7 +215,7 @@ export class MiUserProfile { }) public pinnedPageId: MiPage['id'] | null; - @OneToOne(type => MiPage, { + @OneToOne(() => MiPage, { onDelete: 'SET NULL', }) @JoinColumn() diff --git a/packages/backend/src/models/UserPublickey.ts b/packages/backend/src/models/UserPublickey.ts index 6bcd785304..8c23d368e9 100644 --- a/packages/backend/src/models/UserPublickey.ts +++ b/packages/backend/src/models/UserPublickey.ts @@ -12,7 +12,7 @@ export class MiUserPublickey { @PrimaryColumn(id()) public userId: MiUser['id']; - @OneToOne(type => MiUser, { + @OneToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/UserSecurityKey.ts b/packages/backend/src/models/UserSecurityKey.ts index 0babbe1abe..577ec359e4 100644 --- a/packages/backend/src/models/UserSecurityKey.ts +++ b/packages/backend/src/models/UserSecurityKey.ts @@ -18,7 +18,7 @@ export class MiUserSecurityKey { @Column(id()) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/Webhook.ts b/packages/backend/src/models/Webhook.ts index b4cab4edc8..5f833115cc 100644 --- a/packages/backend/src/models/Webhook.ts +++ b/packages/backend/src/models/Webhook.ts @@ -22,7 +22,7 @@ export class MiWebhook { }) public userId: MiUser['id']; - @ManyToOne(type => MiUser, { + @ManyToOne(() => MiUser, { onDelete: 'CASCADE', }) @JoinColumn() diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 84b5cbed0a..c4528e3a77 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -5,18 +5,9 @@ import { FindOneOptions, - InsertQueryBuilder, ObjectLiteral, - QueryRunner, Repository, - SelectQueryBuilder, } from 'typeorm'; -import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.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 { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAccessToken } from '@/models/AccessToken.js'; @@ -32,6 +23,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiChannel } from '@/models/Channel.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; +import { MiChannelMuting } from "@/models/ChannelMuting.js"; import { MiChatApproval } from '@/models/ChatApproval.js'; import { MiChatMessage } from '@/models/ChatMessage.js'; import { MiChatRoom } from '@/models/ChatRoom.js'; @@ -95,66 +87,12 @@ import { MiWebhook } from '@/models/Webhook.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; export interface MiRepository { - createTableColumnNames(this: Repository & MiRepository): string[]; - insertOne(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>): Promise; - - insertOneImpl(this: Repository & MiRepository, entity: QueryDeepPartialEntity, findOptions?: Pick, 'relations'>, queryRunner?: QueryRunner): 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 opt = this.manager.connection.options as PostgresConnectionOptions; - if (opt.replication) { - const queryRunner = this.manager.connection.createQueryRunner('master'); - try { - return this.insertOneImpl(entity, findOptions, queryRunner); - } finally { - await queryRunner.release(); - } - } else { - return this.insertOneImpl(entity, findOptions); - } - }, - async insertOneImpl(entity, findOptions?, queryRunner?) { - // ---- insert + returningの結果を共通テーブル式(CTE)に保持するクエリを生成 ---- - - 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)); - - // ---- 共通テーブル式(CTE)から結果を取得 ---- - const builder = this.createQueryBuilder(undefined, queryRunner).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}`); - } + return await this.insert(entity).then(x => this.findOneOrFail({ where: x.identifiers[0], ...findOptions })); }, } satisfies MiRepository; @@ -172,6 +110,7 @@ export { MiBlocking, MiChannelFollowing, MiChannelFavorite, + MiChannelMuting, MiClip, MiClipNote, MiClipFavorite, @@ -251,6 +190,7 @@ export type AuthSessionsRepository = Repository & MiRepository & MiRepository; export type ChannelFollowingsRepository = Repository & MiRepository; export type ChannelFavoritesRepository = Repository & MiRepository; +export type ChannelMutingRepository = Repository & MiRepository; export type ClipsRepository = Repository & MiRepository; export type ClipNotesRepository = Repository & MiRepository; export type ClipFavoritesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/ad.ts b/packages/backend/src/models/json-schema/ad.ts index b01b39a38b..d88ac23894 100644 --- a/packages/backend/src/models/json-schema/ad.ts +++ b/packages/backend/src/models/json-schema/ad.ts @@ -60,5 +60,10 @@ export const packedAdSchema = { optional: false, nullable: false, }, + isSensitive: { + type: 'boolean', + optional: false, + nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index d233f7858d..e7613290d1 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -40,6 +40,11 @@ export const packedChannelSchema = { format: 'url', nullable: true, optional: false, }, + bannerId: { + type: 'string', + nullable: true, optional: false, + format: 'id', + }, pinnedNoteIds: { type: 'array', nullable: false, optional: false, @@ -80,6 +85,10 @@ export const packedChannelSchema = { type: 'boolean', optional: true, nullable: false, }, + isMuting: { + type: 'boolean', + optional: true, nullable: false, + }, pinnedNotes: { type: 'array', optional: true, nullable: false, diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 357ff26041..0c3ec141bc 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -72,8 +72,7 @@ export const packedMetaLiteSchema = { optional: false, nullable: true, }, clientOptions: { - type: 'object', - optional: false, nullable: false, + ref: 'MetaClientOptions', }, disableRegistration: { type: 'boolean', @@ -195,6 +194,10 @@ export const packedMetaLiteSchema = { type: 'integer', optional: false, nullable: false, }, + isSensitive: { + type: 'boolean', + optional: true, nullable: false, + }, }, }, }, @@ -393,3 +396,23 @@ export const packedMetaDetailedSchema = { }, ], } as const; + +export const packedMetaClientOptionsSchema = { + type: 'object', + optional: false, nullable: false, + properties: { + entrancePageStyle: { + type: 'string', + enum: ['classic', 'simple'], + optional: false, nullable: false, + }, + showTimelineForVisitor: { + type: 'boolean', + optional: false, nullable: false, + }, + showActivitiesForVisitor: { + type: 'boolean', + optional: false, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/note-draft.ts b/packages/backend/src/models/json-schema/note-draft.ts index 504b263a6d..8144ac7b3b 100644 --- a/packages/backend/src/models/json-schema/note-draft.ts +++ b/packages/backend/src/models/json-schema/note-draft.ts @@ -23,7 +23,7 @@ export const packedNoteDraftSchema = { }, cw: { type: 'string', - optional: true, nullable: true, + optional: false, nullable: true, }, userId: { type: 'string', @@ -37,27 +37,23 @@ export const packedNoteDraftSchema = { }, replyId: { type: 'string', - optional: true, nullable: true, + optional: false, nullable: true, format: 'id', - example: 'xxxxxxxxxx', }, renoteId: { type: 'string', - optional: true, nullable: true, + optional: false, nullable: true, format: 'id', - example: 'xxxxxxxxxx', }, reply: { type: 'object', optional: true, nullable: true, ref: 'Note', - description: 'The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null.', }, renote: { type: 'object', optional: true, nullable: true, ref: 'Note', - description: 'The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null.', }, visibility: { type: 'string', @@ -66,7 +62,7 @@ export const packedNoteDraftSchema = { }, visibleUserIds: { type: 'array', - optional: true, nullable: false, + optional: false, nullable: false, items: { type: 'string', optional: false, nullable: false, @@ -75,7 +71,7 @@ export const packedNoteDraftSchema = { }, fileIds: { type: 'array', - optional: true, nullable: false, + optional: false, nullable: false, items: { type: 'string', optional: false, nullable: false, @@ -93,11 +89,11 @@ export const packedNoteDraftSchema = { }, hashtag: { type: 'string', - optional: true, nullable: false, + optional: false, nullable: true, }, poll: { type: 'object', - optional: true, nullable: true, + optional: false, nullable: true, properties: { expiresAt: { type: 'string', @@ -124,9 +120,8 @@ export const packedNoteDraftSchema = { }, channelId: { type: 'string', - optional: true, nullable: true, + optional: false, nullable: true, format: 'id', - example: 'xxxxxxxxxx', }, channel: { type: 'object', @@ -160,12 +155,20 @@ export const packedNoteDraftSchema = { }, localOnly: { type: 'boolean', - optional: true, nullable: false, + optional: false, nullable: false, }, reactionAcceptance: { type: 'string', optional: false, nullable: true, enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null], }, + scheduledAt: { + type: 'number', + optional: false, nullable: true, + }, + isActuallyScheduled: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/note-reaction.ts b/packages/backend/src/models/json-schema/note-reaction.ts index 95658ace1f..04c9f34232 100644 --- a/packages/backend/src/models/json-schema/note-reaction.ts +++ b/packages/backend/src/models/json-schema/note-reaction.ts @@ -10,7 +10,6 @@ export const packedNoteReactionSchema = { type: 'string', optional: false, nullable: false, format: 'id', - example: 'xxxxxxxxxx', }, createdAt: { type: 'string', @@ -28,3 +27,33 @@ export const packedNoteReactionSchema = { }, }, } as const; + +export const packedNoteReactionWithNoteSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + type: { + type: 'string', + optional: false, nullable: false, + }, + note: { + type: 'object', + optional: false, nullable: false, + ref: 'Note', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 6de120c8d7..30e9c9327a 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -207,6 +207,36 @@ export const packedNotificationSchema = { optional: false, nullable: false, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNotePosted'], + }, + note: { + type: 'object', + ref: 'Note', + optional: false, nullable: false, + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['scheduledNotePostFailed'], + }, + noteDraft: { + type: 'object', + ref: 'NoteDraft', + optional: false, nullable: false, + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/page.ts b/packages/backend/src/models/json-schema/page.ts index 748d6f1245..8f6d5c675d 100644 --- a/packages/backend/src/models/json-schema/page.ts +++ b/packages/backend/src/models/json-schema/page.ts @@ -174,6 +174,7 @@ export const packedPageSchema = { font: { type: 'string', optional: false, nullable: false, + enum: ['serif', 'sans-serif'], }, script: { type: 'string', diff --git a/packages/backend/src/models/json-schema/reversi-game.ts b/packages/backend/src/models/json-schema/reversi-game.ts index cb37200384..378ae41cb5 100644 --- a/packages/backend/src/models/json-schema/reversi-game.ts +++ b/packages/backend/src/models/json-schema/reversi-game.ts @@ -81,6 +81,7 @@ export const packedReversiGameLiteSchema = { bw: { type: 'string', optional: false, nullable: false, + enum: ['random', '1', '2'], }, noIrregularRules: { type: 'boolean', @@ -199,6 +200,7 @@ export const packedReversiGameDetailedSchema = { bw: { type: 'string', optional: false, nullable: false, + enum: ['random', '1', '2'], }, noIrregularRules: { type: 'boolean', diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 48b4d6372a..373ff38fbb 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -317,6 +317,10 @@ export const packedRolePoliciesSchema = { type: 'integer', optional: false, nullable: false, }, + scheduledNoteLimit: { + type: 'integer', + optional: false, nullable: false, + }, watermarkAvailable: { type: 'boolean', optional: false, nullable: false, diff --git a/packages/backend/src/models/json-schema/user-webhook.ts b/packages/backend/src/models/json-schema/user-webhook.ts new file mode 100644 index 0000000000..8ea0991716 --- /dev/null +++ b/packages/backend/src/models/json-schema/user-webhook.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { webhookEventTypes } from '@/models/Webhook.js'; + +export const packedUserWebhookSchema = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + userId: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + on: { + type: 'array', + items: { + type: 'string', + optional: false, nullable: false, + enum: webhookEventTypes, + }, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + secret: { + type: 'string', + optional: false, nullable: false, + }, + active: { + type: 'boolean', + optional: false, nullable: false, + }, + latestSentAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: true, + }, + latestStatus: { + type: 'integer', + optional: false, nullable: true, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 2b5f706ff9..f71ec1d023 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -65,7 +65,7 @@ export const packedUserLiteSchema = { avatarUrl: { type: 'string', format: 'url', - nullable: true, optional: false, + nullable: false, optional: false, }, avatarBlurhash: { type: 'string', @@ -465,11 +465,11 @@ export const packedMeDetailedOnlySchema = { }, isModerator: { type: 'boolean', - nullable: true, optional: false, + nullable: false, optional: false, }, isAdmin: { type: 'boolean', - nullable: true, optional: false, + nullable: false, optional: false, }, injectFeaturedNote: { type: 'boolean', @@ -591,7 +591,7 @@ export const packedMeDetailedOnlySchema = { }, mutedInstances: { type: 'array', - nullable: true, optional: false, + nullable: false, optional: false, items: { type: 'string', nullable: false, optional: false, @@ -609,6 +609,8 @@ export const packedMeDetailedOnlySchema = { quote: { optional: true, ...notificationRecieveConfig }, reaction: { optional: true, ...notificationRecieveConfig }, pollEnded: { optional: true, ...notificationRecieveConfig }, + scheduledNotePosted: { optional: true, ...notificationRecieveConfig }, + scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig }, receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig }, @@ -616,6 +618,9 @@ export const packedMeDetailedOnlySchema = { achievementEarned: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, + login: { optional: true, ...notificationRecieveConfig }, + createToken: { optional: true, ...notificationRecieveConfig }, + exportCompleted: { optional: true, ...notificationRecieveConfig }, }, }, emailNotificationTypes: { diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index f6cbbbe64c..3dcd3f0965 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -6,7 +6,6 @@ // https://github.com/typeorm/typeorm/issues/2400 import pg from 'pg'; import { DataSource, Logger, type QueryRunner } from 'typeorm'; -import * as highlight from 'cli-highlight'; import { entities as charts } from '@/core/chart/entities.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -25,6 +24,7 @@ import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; +import { MiChannelMuting } from '@/models/ChannelMuting.js'; import { MiClip } from '@/models/Clip.js'; import { MiClipNote } from '@/models/ClipNote.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; @@ -100,12 +100,6 @@ export type LoggerProps = { printReplicationMode?: boolean, }; -function highlightSql(sql: string) { - return highlight.highlight(sql, { - language: 'sql', ignoreIllegals: true, - }); -} - function truncateSql(sql: string) { return sql.length > 100 ? `${sql.substring(0, 100)}...` : sql; } @@ -131,7 +125,7 @@ class MyCustomLogger implements Logger { modded = truncateSql(modded); } - return highlightSql(modded); + return modded; } @bindThis @@ -239,6 +233,7 @@ export const entities = [ MiChannel, MiChannelFollowing, MiChannelFavorite, + MiChannelMuting, MiRegistryItem, MiAd, MiPasswordResetRequest, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index e01414cd53..e64882c4df 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -10,6 +10,7 @@ import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; +import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; @@ -79,6 +80,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor UserWebhookDeliverProcessorService, SystemWebhookDeliverProcessorService, EndedPollNotificationProcessorService, + PostScheduledNoteProcessorService, DeliverProcessorService, InboxProcessorService, AggregateRetentionProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 7b64182754..2b3b3fc0ad 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -5,7 +5,6 @@ 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'; @@ -14,6 +13,7 @@ import { CheckModeratorsActivityProcessorService } from '@/queue/processors/Chec import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; +import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js'; @@ -85,6 +85,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private relationshipQueueWorker: Bull.Worker; private objectStorageQueueWorker: Bull.Worker; private endedPollNotificationQueueWorker: Bull.Worker; + private postScheduledNoteQueueWorker: Bull.Worker; constructor( @Inject(DI.config) @@ -94,6 +95,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService, private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService, private endedPollNotificationProcessorService: EndedPollNotificationProcessorService, + private postScheduledNoteProcessorService: PostScheduledNoteProcessorService, private deliverProcessorService: DeliverProcessorService, private inboxProcessorService: InboxProcessorService, private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService, @@ -154,6 +156,13 @@ export class QueueProcessorService implements OnApplicationShutdown { }; } + let Sentry: typeof import('@sentry/node') | undefined; + if (this.config.sentryForBackend) { + import('@sentry/node').then((mod) => { + Sentry = mod; + }); + } + //#region system { const processer = (job: Bull.Job) => { @@ -172,7 +181,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.systemQueueWorker = new Bull.Worker(QUEUE.SYSTEM, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: System: ' + job.name }, () => processer(job)); } else { return processer(job); @@ -189,7 +198,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err: Error) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -229,7 +238,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.dbQueueWorker = new Bull.Worker(QUEUE.DB, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: DB: ' + job.name }, () => processer(job)); } else { return processer(job); @@ -246,7 +255,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -261,7 +270,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region deliver { this.deliverQueueWorker = new Bull.Worker(QUEUE.DELIVER, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: Deliver' }, () => this.deliverProcessorService.process(job)); } else { return this.deliverProcessorService.process(job); @@ -286,7 +295,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -301,7 +310,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region inbox { this.inboxQueueWorker = new Bull.Worker(QUEUE.INBOX, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: Inbox' }, () => this.inboxProcessorService.process(job)); } else { return this.inboxProcessorService.process(job); @@ -326,7 +335,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -341,7 +350,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region user-webhook deliver { this.userWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.USER_WEBHOOK_DELIVER, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: UserWebhookDeliver' }, () => this.userWebhookDeliverProcessorService.process(job)); } else { return this.userWebhookDeliverProcessorService.process(job); @@ -366,7 +375,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -381,7 +390,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region system-webhook deliver { this.systemWebhookDeliverQueueWorker = new Bull.Worker(QUEUE.SYSTEM_WEBHOOK_DELIVER, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: SystemWebhookDeliver' }, () => this.systemWebhookDeliverProcessorService.process(job)); } else { return this.systemWebhookDeliverProcessorService.process(job); @@ -406,7 +415,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -431,7 +440,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.relationshipQueueWorker = new Bull.Worker(QUEUE.RELATIONSHIP, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: Relationship: ' + job.name }, () => processer(job)); } else { return processer(job); @@ -453,7 +462,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -476,7 +485,7 @@ export class QueueProcessorService implements OnApplicationShutdown { }; this.objectStorageQueueWorker = new Bull.Worker(QUEUE.OBJECT_STORAGE, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: ObjectStorage: ' + job.name }, () => processer(job)); } else { return processer(job); @@ -494,7 +503,7 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); - if (config.sentryForBackend) { + if (Sentry != null) { Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, @@ -509,7 +518,7 @@ export class QueueProcessorService implements OnApplicationShutdown { //#region ended poll notification { this.endedPollNotificationQueueWorker = new Bull.Worker(QUEUE.ENDED_POLL_NOTIFICATION, (job) => { - if (this.config.sentryForBackend) { + if (Sentry != null) { return Sentry.startSpan({ name: 'Queue: EndedPollNotification' }, () => this.endedPollNotificationProcessorService.process(job)); } else { return this.endedPollNotificationProcessorService.process(job); @@ -520,6 +529,21 @@ export class QueueProcessorService implements OnApplicationShutdown { }); } //#endregion + + //#region post scheduled note + { + this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => { + if (Sentry != null) { + return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job)); + } else { + return this.postScheduledNoteProcessorService.process(job); + } + }, { + ...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE), + autorun: false, + }); + } + //#endregion } @bindThis @@ -534,6 +558,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.run(), this.objectStorageQueueWorker.run(), this.endedPollNotificationQueueWorker.run(), + this.postScheduledNoteQueueWorker.run(), ]); } @@ -549,6 +574,7 @@ export class QueueProcessorService implements OnApplicationShutdown { this.relationshipQueueWorker.close(), this.objectStorageQueueWorker.close(), this.endedPollNotificationQueueWorker.close(), + this.postScheduledNoteQueueWorker.close(), ]); } diff --git a/packages/backend/src/queue/const.ts b/packages/backend/src/queue/const.ts index 7e146a7e03..625204b7ad 100644 --- a/packages/backend/src/queue/const.ts +++ b/packages/backend/src/queue/const.ts @@ -12,6 +12,7 @@ export const QUEUE = { INBOX: 'inbox', SYSTEM: 'system', ENDED_POLL_NOTIFICATION: 'endedPollNotification', + POST_SCHEDULED_NOTE: 'postScheduledNote', DB: 'db', RELATIONSHIP: 'relationship', OBJECT_STORAGE: 'objectStorage', diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts index 448fc9c763..e898e6dd48 100644 --- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts @@ -4,14 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MutingsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { UserMutingService } from '@/core/UserMutingService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; @Injectable() export class CheckExpiredMutingsProcessorService { @@ -22,6 +21,7 @@ export class CheckExpiredMutingsProcessorService { private mutingsRepository: MutingsRepository, private userMutingService: UserMutingService, + private channelMutingService: ChannelMutingService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings'); @@ -41,6 +41,8 @@ export class CheckExpiredMutingsProcessorService { await this.userMutingService.unmute(expired); } + await this.channelMutingService.eraseExpiredMutings(); + this.logger.succ('All expired mutings checked.'); } } diff --git a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts index 36c34c753c..bc99dea000 100644 --- a/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanRemoteNotesProcessorService.ts @@ -51,6 +51,17 @@ export class CleanRemoteNotesProcessorService { skipped: boolean; transientErrors: number; }> { + const getConfig = () => { + return { + enabled: this.meta.enableRemoteNotesCleaning, + maxDuration: this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000, // Convert minutes to milliseconds + // The date limit for the newest note to be considered for deletion. + // All notes newer than this limit will always be retained. + newestLimit: this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes)), + }; + }; + + const initialConfig = getConfig(); if (!this.meta.enableRemoteNotesCleaning) { this.logger.info('Remote notes cleaning is disabled, skipping...'); return { @@ -64,13 +75,9 @@ export class CleanRemoteNotesProcessorService { this.logger.info('cleaning remote notes...'); - const maxDuration = this.meta.remoteNotesCleaningMaxProcessingDurationInMinutes * 60 * 1000; // Convert minutes to milliseconds const startAt = Date.now(); //#region queries - // The date limit for the newest note to be considered for deletion. - // All notes newer than this limit will always be retained. - const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes)); // The condition for removing the notes. // The note must be: @@ -92,7 +99,7 @@ export class CleanRemoteNotesProcessorService { const minId = (await this.notesRepository.createQueryBuilder('note') .select('MIN(note.id)', 'minId') .where({ - id: LessThan(newestLimit), + id: LessThan(initialConfig.newestLimit), userHost: Not(IsNull()), replyId: IsNull(), renoteId: IsNull(), @@ -155,12 +162,12 @@ export class CleanRemoteNotesProcessorService { // | fff | fff | TRUE | // | ggg | ggg | FALSE | // - const candidateNotesQuery = this.db.createQueryBuilder() + const candidateNotesQuery = ({ limit }: { limit: number }) => this.db.createQueryBuilder() .select(`"${candidateNotesCteName}"."id"`, 'id') .addSelect('unremovable."id" IS NULL', 'isRemovable') .addSelect(`BOOL_OR("${candidateNotesCteName}"."isBase")`, 'isBase') .addCommonTableExpression( - `((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(currentLimit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`, + `((SELECT "base".* FROM (${candidateNotesQueryBase.orderBy('note.id', 'ASC').limit(limit).getQuery()}) AS "base") UNION ${candidateNotesQueryInductive.getQuery()})`, candidateNotesCteName, { recursive: true }, ) @@ -178,6 +185,11 @@ export class CleanRemoteNotesProcessorService { let lowThroughputWarned = false; let transientErrors = 0; for (;;) { + const { enabled, maxDuration, newestLimit } = getConfig(); + if (!enabled) { + this.logger.info('Remote notes cleaning is disabled, processing stopped...'); + break; + } //#region check time const batchBeginAt = Date.now(); @@ -205,13 +217,38 @@ export class CleanRemoteNotesProcessorService { let noteIds = null; try { - noteIds = await candidateNotesQuery.setParameters( + noteIds = await candidateNotesQuery({ limit: currentLimit }).setParameters( { newestLimit, cursorLeft }, ).getRawMany<{ id: MiNote['id'], isRemovable: boolean, isBase: boolean }>(); } catch (e) { - if (currentLimit > minimumLimit && e instanceof QueryFailedError && e.driverError?.code === '57014') { - // Statement timeout (maybe suddenly hit a large note tree), reduce the limit and try again - // continuous failures will eventually converge to currentLimit == minimumLimit and then throw + if (e instanceof QueryFailedError && e.driverError?.code === '57014') { + // Statement timeout (maybe suddenly hit a large note tree), if possible, reduce the limit and try again + // if not possible, skip the current batch of notes and find the next root note + if (currentLimit <= minimumLimit) { + job.log('Local note tree complexity is too high, finding next root note...'); + + const idWindow = await this.notesRepository.createQueryBuilder('note') + .select('id') + .where('note.id > :cursorLeft') + .andWhere(removalCriteria) + .andWhere({ replyId: IsNull(), renoteId: IsNull() }) + .orderBy('note.id', 'ASC') + .limit(minimumLimit + 1) + .setParameters({ cursorLeft, newestLimit }) + .getRawMany<{ id?: MiNote['id'] }>(); + + job.log(`Skipped note IDs: ${idWindow.slice(0, minimumLimit).map(id => id.id).join(', ')}`); + + const lastId = idWindow.at(minimumLimit)?.id; + + if (!lastId) { + job.log('No more notes to clean.'); + break; + } + + cursorLeft = lastId; + continue; + } currentLimit = Math.max(minimumLimit, Math.floor(currentLimit * 0.25)); continue; } diff --git a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts index 486dc4c01f..be7d4e9e21 100644 --- a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts @@ -5,21 +5,20 @@ import * as fs from 'node:fs'; import { Writable } from 'node:stream'; -import { Inject, Injectable, StreamableFile } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; import { format as dateFormat } from 'date-fns'; import { DI } from '@/di-symbols.js'; -import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, NotesRepository, PollsRepository, UsersRepository } from '@/models/_.js'; +import type { ClipNotesRepository, ClipsRepository, MiClip, MiClipNote, MiUser, PollsRepository, UsersRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import type { MiPoll } from '@/models/Poll.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; -import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { Packed } from '@/misc/json-schema.js'; import { IdService } from '@/core/IdService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; @@ -43,6 +42,7 @@ export class ExportClipsProcessorService { private driveService: DriveService, private queueLoggerService: QueueLoggerService, + private queryService: QueryService, private idService: IdService, private notificationService: NotificationService, ) { @@ -100,16 +100,16 @@ export class ExportClipsProcessorService { }); while (true) { - const clips = await this.clipsRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - }); + const query = this.clipsRepository.createQueryBuilder('clip') + .where('clip.userId = :userId', { userId: user.id }) + .orderBy('clip.id', 'ASC') + .take(100); + + if (cursor) { + query.andWhere('clip.id > :cursor', { cursor }); + } + + const clips = await query.getMany(); if (clips.length === 0) { job.updateProgress(100); @@ -124,7 +124,7 @@ export class ExportClipsProcessorService { const isFirst = exportedClipsCount === 0; await writer.write(isFirst ? content : ',\n' + content); - await this.processClipNotes(writer, clip.id); + await this.processClipNotes(writer, clip.id, user.id); await writer.write(']}'); exportedClipsCount++; @@ -134,22 +134,25 @@ export class ExportClipsProcessorService { } } - async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string): Promise { + async processClipNotes(writer: WritableStreamDefaultWriter, clipId: string, userId: string): Promise { let exportedClipNotesCount = 0; let cursor: MiClipNote['id'] | null = null; while (true) { - const clipNotes = await this.clipNotesRepository.find({ - where: { - clipId, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - relations: ['note', 'note.user'], - }) as (MiClipNote & { note: MiNote & { user: MiUser } })[]; + const query = this.clipNotesRepository.createQueryBuilder('clipNote') + .leftJoinAndSelect('clipNote.note', 'note') + .leftJoinAndSelect('note.user', 'user') + .where('clipNote.clipId = :clipId', { clipId }) + .orderBy('clipNote.id', 'ASC') + .take(100); + + if (cursor) { + query.andWhere('clipNote.id > :cursor', { cursor }); + } + + this.queryService.generateVisibilityQuery(query, { id: userId }); + + const clipNotes = await query.getMany() as (MiClipNote & { note: MiNote & { user: MiUser } })[]; if (clipNotes.length === 0) { break; @@ -158,6 +161,11 @@ export class ExportClipsProcessorService { cursor = clipNotes.at(-1)?.id ?? null; for (const clipNote of clipNotes) { + const noteCreatedAt = this.idService.parse(clipNote.note.id).date; + if (shouldHideNoteByTime(clipNote.note.user.makeNotesHiddenBefore, noteCreatedAt)) { + continue; + } + let poll: MiPoll | undefined; if (clipNote.note.hasPoll) { poll = await this.pollsRepository.findOneByOrFail({ noteId: clipNote.note.id }); diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts index e237cd4975..53ecd2d180 100644 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -123,8 +123,8 @@ export class ExportCustomEmojisProcessorService { metaStream.end(); // Create archive - await new Promise(async (resolve) => { - const [archivePath, archiveCleanup] = await createTemp(); + const [archivePath, archiveCleanup] = await createTemp(); + await new Promise((resolve) => { const archiveStream = fs.createWriteStream(archivePath); const archive = archiver('zip', { zlib: { level: 0 }, diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index 7918c8ccb5..87a8ded307 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -5,7 +5,6 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; -import { MoreThan } from 'typeorm'; import { format as dateFormat } from 'date-fns'; import { DI } from '@/di-symbols.js'; import type { MiNoteFavorite, NoteFavoritesRepository, PollsRepository, MiUser, UsersRepository } from '@/models/_.js'; @@ -17,6 +16,8 @@ import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; @@ -37,6 +38,7 @@ export class ExportFavoritesProcessorService { private driveService: DriveService, private queueLoggerService: QueueLoggerService, + private queryService: QueryService, private idService: IdService, private notificationService: NotificationService, ) { @@ -83,17 +85,20 @@ export class ExportFavoritesProcessorService { }); while (true) { - const favorites = await this.noteFavoritesRepository.find({ - where: { - userId: user.id, - ...(cursor ? { id: MoreThan(cursor) } : {}), - }, - take: 100, - order: { - id: 1, - }, - relations: ['note', 'note.user'], - }) as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[]; + const query = this.noteFavoritesRepository.createQueryBuilder('favorite') + .leftJoinAndSelect('favorite.note', 'note') + .leftJoinAndSelect('note.user', 'user') + .where('favorite.userId = :userId', { userId: user.id }) + .orderBy('favorite.id', 'ASC') + .take(100); + + if (cursor) { + query.andWhere('favorite.id > :cursor', { cursor }); + } + + this.queryService.generateVisibilityQuery(query, { id: user.id }); + + const favorites = await query.getMany() as (MiNoteFavorite & { note: MiNote & { user: MiUser } })[]; if (favorites.length === 0) { job.updateProgress(100); @@ -103,6 +108,11 @@ export class ExportFavoritesProcessorService { cursor = favorites.at(-1)?.id ?? null; for (const favorite of favorites) { + const noteCreatedAt = this.idService.parse(favorite.note.id).date; + if (shouldHideNoteByTime(favorite.note.user.makeNotesHiddenBefore, noteCreatedAt)) { + continue; + } + let poll: MiPoll | undefined; if (favorite.note.hasPoll) { poll = await this.pollsRepository.findOneByOrFail({ noteId: favorite.note.id }); diff --git a/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts new file mode 100644 index 0000000000..719a09980c --- /dev/null +++ b/packages/backend/src/queue/processors/PostScheduledNoteProcessorService.ts @@ -0,0 +1,72 @@ +/* + * 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 { NoteDraftsRepository } from '@/models/_.js'; +import type Logger from '@/logger.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { bindThis } from '@/decorators.js'; +import { NoteCreateService } from '@/core/NoteCreateService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; +import type * as Bull from 'bullmq'; +import type { PostScheduledNoteJobData } from '../types.js'; + +@Injectable() +export class PostScheduledNoteProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.noteDraftsRepository) + private noteDraftsRepository: NoteDraftsRepository, + + private noteCreateService: NoteCreateService, + private notificationService: NotificationService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('post-scheduled-note'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + const draft = await this.noteDraftsRepository.findOne({ where: { id: job.data.noteDraftId }, relations: ['user'] }); + if (draft == null || draft.user == null || draft.scheduledAt == null || !draft.isActuallyScheduled) { + return; + } + + try { + const note = await this.noteCreateService.fetchAndCreate(draft.user, { + createdAt: new Date(), + fileIds: draft.fileIds, + poll: draft.hasPoll ? { + choices: draft.pollChoices, + multiple: draft.pollMultiple, + expiresAt: draft.pollExpiredAfter ? new Date(Date.now() + draft.pollExpiredAfter) : draft.pollExpiresAt ? new Date(draft.pollExpiresAt) : null, + } : null, + text: draft.text ?? null, + replyId: draft.replyId, + renoteId: draft.renoteId, + cw: draft.cw, + localOnly: draft.localOnly, + reactionAcceptance: draft.reactionAcceptance, + visibility: draft.visibility, + visibleUserIds: draft.visibleUserIds, + channelId: draft.channelId, + }); + + // await不要 + this.noteDraftsRepository.remove(draft); + + // await不要 + this.notificationService.createNotification(draft.userId, 'scheduledNotePosted', { + noteId: note.id, + }); + } catch (_) { + this.notificationService.createNotification(draft.userId, 'scheduledNotePostFailed', { + noteDraftId: draft.id, + }); + } + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 757daea88b..1cb2b93918 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -109,6 +109,10 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; +export type PostScheduledNoteJobData = { + noteDraftId: string; +}; + export type SystemWebhookDeliverJobData = { type: T; content: SystemWebhookPayload; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index a5fb5b82e3..54ffeecc6b 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -116,7 +116,7 @@ export class ActivityPubServerService { try { signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' }); - } catch (e) { + } catch (_) { reply.code(401); return; } diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 772c37094c..f5034d0733 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -7,27 +7,22 @@ import * as fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import { Inject, Injectable } from '@nestjs/common'; -import rename from 'rename'; -import sharp from 'sharp'; -import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import type { Config } from '@/config.js'; -import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import { createTemp } from '@/misc/create-temp.js'; -import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; import { StatusError } from '@/misc/status-error.js'; import type Logger from '@/logger.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; -import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { InternalStorageService } from '@/core/InternalStorageService.js'; -import { contentDisposition } from '@/misc/content-disposition.js'; import { FileInfoService } from '@/core/FileInfoService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { isMimeImage } from '@/misc/is-mime-image.js'; -import { correctFilename } from '@/misc/correct-filename.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; +import { FileServerDriveHandler } from './file/FileServerDriveHandler.js'; +import { FileServerFileResolver } from './file/FileServerFileResolver.js'; +import { FileServerProxyHandler } from './file/FileServerProxyHandler.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify'; const _filename = fileURLToPath(import.meta.url); @@ -38,6 +33,9 @@ const assets = `${_dirname}/../../server/file/assets/`; @Injectable() export class FileServerService { private logger: Logger; + private driveHandler: FileServerDriveHandler; + private proxyHandler: FileServerProxyHandler; + private fileResolver: FileServerFileResolver; constructor( @Inject(DI.config) @@ -54,6 +52,24 @@ export class FileServerService { private loggerService: LoggerService, ) { this.logger = this.loggerService.getLogger('server', 'gray'); + this.fileResolver = new FileServerFileResolver( + this.driveFilesRepository, + this.fileInfoService, + this.downloadService, + this.internalStorageService, + ); + this.driveHandler = new FileServerDriveHandler( + this.config, + this.fileResolver, + assets, + this.videoProcessingService, + ); + this.proxyHandler = new FileServerProxyHandler( + this.config, + this.fileResolver, + assets, + this.imageProcessingService, + ); //this.createServer = this.createServer.bind(this); } @@ -78,7 +94,7 @@ export class FileServerService { }); fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => { - return await this.sendDriveFile(request, reply) + return await this.driveHandler.handle(request, reply) .catch(err => this.errorHandler(request, reply, err)); }); fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => { @@ -91,7 +107,7 @@ export class FileServerService { Params: { url: string; }; Querystring: { url?: string; }; }>('/proxy/:url*', async (request, reply) => { - return await this.proxyHandler(request, reply) + return await this.proxyHandler.handle(request, reply) .catch(err => this.errorHandler(request, reply, err)); }); @@ -116,462 +132,4 @@ export class FileServerService { reply.code(500); return; } - - @bindThis - private async sendDriveFile(request: FastifyRequest<{ Params: { key: string; } }>, reply: FastifyReply) { - const key = request.params.key; - const file = await this.getFileFromKey(key).then(); - - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - if (file.state === 'remote') { - let image: IImageStreamable | null = null; - - if (file.fileRole === 'thumbnail') { - if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/static.webp`); - url.searchParams.set('url', file.url); - url.searchParams.set('static', '1'); - - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } else if (file.mime.startsWith('video/')) { - const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); - if (externalThumbnail) { - file.cleanup(); - return await reply.redirect(externalThumbnail, 301); - } - - image = await this.videoProcessingService.generateVideoThumbnail(file.path); - } - } - - if (file.fileRole === 'webpublic') { - if (['image/svg+xml'].includes(file.mime)) { - reply.header('Cache-Control', 'max-age=31536000, immutable'); - - const url = new URL(`${this.config.mediaProxy}/svg.webp`); - url.searchParams.set('url', file.url); - - file.cleanup(); - return await reply.redirect(url.toString(), 301); - } - } - - if (!image) { - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - } - - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } - - if (file.fileRole !== 'original') { - const filename = rename(file.filename, { - suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', - extname: file.ext ? `.${file.ext}` : '.unknown', - }).toString(); - - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.mime) ? file.mime : 'application/octet-stream'); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; - } - - return fs.createReadStream(file.path); - } else { - reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(file.file.type) ? file.file.type : 'application/octet-stream'); - reply.header('Content-Length', file.file.size); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', contentDisposition('inline', file.filename)); - - if (request.headers.range && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - const fileStream = fs.createReadStream(file.path, { - start, - end, - }); - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - return fileStream; - } - - return fs.createReadStream(file.path); - } - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async proxyHandler(request: FastifyRequest<{ Params: { url: string; }; Querystring: { url?: string; }; }>, reply: FastifyReply) { - const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; - - if (typeof url !== 'string') { - reply.code(400); - return; - } - - // アバタークロップなど、どうしてもオリジンである必要がある場合 - const mustOrigin = 'origin' in request.query; - - if (this.config.externalMediaProxyEnabled && !mustOrigin) { - // 外部のメディアプロキシが有効なら、そちらにリダイレクト - - reply.header('Cache-Control', 'public, max-age=259200'); // 3 days - - const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); - - for (const [key, value] of Object.entries(request.query)) { - url.searchParams.append(key, value); - } - - return await reply.redirect( - url.toString(), - 301, - ); - } - - if (!request.headers['user-agent']) { - throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); - } else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { - throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); - } - - // Create temp file - const file = await this.getStreamAndTypeFromUrl(url); - if (file === '404') { - reply.code(404); - reply.header('Cache-Control', 'max-age=86400'); - return reply.sendFile('/dummy.png', assets); - } - - if (file === '204') { - reply.code(204); - reply.header('Cache-Control', 'max-age=86400'); - return; - } - - try { - const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); - const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); - - if ( - 'emoji' in request.query || - 'avatar' in request.query || - 'static' in request.query || - 'preview' in request.query || - 'badge' in request.query - ) { - if (!isConvertibleImage) { - // 画像でないなら404でお茶を濁す - throw new StatusError('Unexpected mime', 404); - } - } - - let image: IImageStreamable | null = null; - if ('emoji' in request.query || 'avatar' in request.query) { - if (!isAnimationConvertibleImage && !('static' in request.query)) { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } else { - const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in request.query) })) - .resize({ - height: 'emoji' in request.query ? 128 : 320, - withoutEnlargement: true, - }) - .webp(webpDefault); - - image = { - data, - ext: 'webp', - type: 'image/webp', - }; - } - } else if ('static' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); - } else if ('preview' in request.query) { - image = this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); - } else if ('badge' in request.query) { - const mask = (await sharpBmp(file.path, file.mime)) - .resize(96, 96, { - fit: 'contain', - position: 'centre', - withoutEnlargement: false, - }) - .greyscale() - .normalise() - .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast - .flatten({ background: '#000' }) - .toColorspace('b-w'); - - const stats = await mask.clone().stats(); - - if (stats.entropy < 0.1) { - // エントロピーがあまりない場合は404にする - throw new StatusError('Skip to provide badge', 404); - } - - const data = sharp({ - create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, - }) - .pipelineColorspace('b-w') - .boolean(await mask.png().toBuffer(), 'eor'); - - image = { - data: await data.png().toBuffer(), - ext: 'png', - type: 'image/png', - }; - } else if (file.mime === 'image/svg+xml') { - image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); - } else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { - throw new StatusError('Rejected type', 403, 'Rejected type'); - } - - if (!image) { - if (request.headers.range && file.file && file.file.size > 0) { - const range = request.headers.range as string; - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1; - if (end > file.file.size) { - end = file.file.size - 1; - } - const chunksize = end - start + 1; - - image = { - data: fs.createReadStream(file.path, { - start, - end, - }), - ext: file.ext, - type: file.mime, - }; - - reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); - reply.header('Accept-Ranges', 'bytes'); - reply.header('Content-Length', chunksize); - reply.code(206); - } else { - image = { - data: fs.createReadStream(file.path), - ext: file.ext, - type: file.mime, - }; - } - } - - if ('cleanup' in file) { - if ('pipe' in image.data && typeof image.data.pipe === 'function') { - // image.dataがstreamなら、stream終了後にcleanup - image.data.on('end', file.cleanup); - image.data.on('close', file.cleanup); - } else { - // image.dataがstreamでないなら直ちにcleanup - file.cleanup(); - } - } - - reply.header('Content-Type', image.type); - reply.header('Cache-Control', 'max-age=31536000, immutable'); - reply.header('Content-Disposition', - contentDisposition( - 'inline', - correctFilename(file.filename, image.ext), - ), - ); - return image.data; - } catch (e) { - if ('cleanup' in file) file.cleanup(); - throw e; - } - } - - @bindThis - private async getStreamAndTypeFromUrl(url: string): Promise< - { state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - if (url.startsWith(`${this.config.url}/files/`)) { - const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); - if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); - - return await this.getFileFromKey(key); - } - - return await this.downloadAndDetectTypeFromUrl(url); - } - - @bindThis - private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } - > { - const [path, cleanup] = await createTemp(); - try { - const { filename } = await this.downloadService.downloadUrl(url, path); - - const { mime, ext } = await this.fileInfoService.detectType(path); - - return { - state: 'remote', - mime, ext, - path, cleanup, - filename, - }; - } catch (e) { - cleanup(); - throw e; - } - } - - @bindThis - private async getFileFromKey(key: string): Promise< - { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; } - | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: MiDriveFile; filename: string; mime: string; ext: string | null; path: string; } - | '404' - | '204' - > { - // Fetch drive file - const file = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.accessKey = :accessKey', { accessKey: key }) - .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) - .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) - .getOne(); - - if (file == null) return '404'; - - const isThumbnail = file.thumbnailAccessKey === key; - const isWebpublic = file.webpublicAccessKey === key; - - if (!file.storedInternal) { - if (!(file.isLink && file.uri)) return '204'; - const result = await this.downloadAndDetectTypeFromUrl(file.uri); - file.size = (await fs.promises.stat(result.path)).size; // DB file.sizeは正確とは限らないので - return { - ...result, - url: file.uri, - fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', - file, - filename: file.name, - }; - } - - const path = this.internalStorageService.resolvePath(key); - - if (isThumbnail || isWebpublic) { - const { mime, ext } = await this.fileInfoService.detectType(path); - return { - state: 'stored_internal', - fileRole: isThumbnail ? 'thumbnail' : 'webpublic', - file, - filename: file.name, - mime, ext, - path, - }; - } - - return { - state: 'stored_internal', - fileRole: 'original', - file, - filename: file.name, - // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる - mime: this.fileInfoService.fixMime(file.type), - ext: null, - path, - }; - } } diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 239ef82dec..93c36f5365 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -48,8 +48,6 @@ export class NodeinfoServerService { @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { const nodeinfo2 = async (version: number) => { - const now = Date.now(); - const notesChart = await this.notesChart.getChart('hour', 1, null); const localPosts = notesChart.local.total[0]; diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 0223650329..8259a2a9e4 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -13,7 +13,6 @@ import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ServerService } from './ServerService.js'; import { WellKnownServerService } from './WellKnownServerService.js'; import { GetterService } from './api/GetterService.js'; -import { ChannelsService } from './api/stream/ChannelsService.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { ApiLoggerService } from './api/ApiLoggerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -25,29 +24,31 @@ import { SignupApiService } from './api/SignupApiService.js'; import { StreamingApiServerService } from './api/StreamingApiServerService.js'; import { OpenApiServerService } from './api/openapi/OpenApiServerService.js'; import { ClientServerService } from './web/ClientServerService.js'; +import { HtmlTemplateService } from './web/HtmlTemplateService.js'; import { FeedService } from './web/FeedService.js'; import { UrlPreviewService } from './web/UrlPreviewService.js'; import { ClientLoggerService } from './web/ClientLoggerService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; -import { MainChannelService } from './api/stream/channels/main.js'; -import { AdminChannelService } from './api/stream/channels/admin.js'; -import { AntennaChannelService } from './api/stream/channels/antenna.js'; -import { ChannelChannelService } from './api/stream/channels/channel.js'; -import { DriveChannelService } from './api/stream/channels/drive.js'; -import { GlobalTimelineChannelService } from './api/stream/channels/global-timeline.js'; -import { HashtagChannelService } from './api/stream/channels/hashtag.js'; -import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; -import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; -import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; -import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; -import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; -import { UserListChannelService } from './api/stream/channels/user-list.js'; -import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; -import { ChatUserChannelService } from './api/stream/channels/chat-user.js'; -import { ChatRoomChannelService } from './api/stream/channels/chat-room.js'; -import { ReversiChannelService } from './api/stream/channels/reversi.js'; -import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; +import MainStreamConnection from '@/server/api/stream/Connection.js'; +import { MainChannel } from './api/stream/channels/main.js'; +import { AdminChannel } from './api/stream/channels/admin.js'; +import { AntennaChannel } from './api/stream/channels/antenna.js'; +import { ChannelChannel } from './api/stream/channels/channel.js'; +import { DriveChannel } from './api/stream/channels/drive.js'; +import { GlobalTimelineChannel } from './api/stream/channels/global-timeline.js'; +import { HashtagChannel } from './api/stream/channels/hashtag.js'; +import { HomeTimelineChannel } from './api/stream/channels/home-timeline.js'; +import { HybridTimelineChannel } from './api/stream/channels/hybrid-timeline.js'; +import { LocalTimelineChannel } from './api/stream/channels/local-timeline.js'; +import { QueueStatsChannel } from './api/stream/channels/queue-stats.js'; +import { ServerStatsChannel } from './api/stream/channels/server-stats.js'; +import { UserListChannel } from './api/stream/channels/user-list.js'; +import { RoleTimelineChannel } from './api/stream/channels/role-timeline.js'; +import { ChatUserChannel } from './api/stream/channels/chat-user.js'; +import { ChatRoomChannel } from './api/stream/channels/chat-room.js'; +import { ReversiChannel } from './api/stream/channels/reversi.js'; +import { ReversiGameChannel } from './api/stream/channels/reversi-game.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @Module({ @@ -58,6 +59,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j providers: [ ClientServerService, ClientLoggerService, + HtmlTemplateService, FeedService, HealthServerService, UrlPreviewService, @@ -67,7 +69,7 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j ServerService, WellKnownServerService, GetterService, - ChannelsService, + MainStreamConnection, ApiCallService, ApiLoggerService, ApiServerService, @@ -78,24 +80,24 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j SigninService, SignupApiService, StreamingApiServerService, - MainChannelService, - AdminChannelService, - AntennaChannelService, - ChannelChannelService, - DriveChannelService, - GlobalTimelineChannelService, - HashtagChannelService, - RoleTimelineChannelService, - ChatUserChannelService, - ChatRoomChannelService, - ReversiChannelService, - ReversiGameChannelService, - HomeTimelineChannelService, - HybridTimelineChannelService, - LocalTimelineChannelService, - QueueStatsChannelService, - ServerStatsChannelService, - UserListChannelService, + MainChannel, + AdminChannel, + AntennaChannel, + ChannelChannel, + DriveChannel, + GlobalTimelineChannel, + HashtagChannel, + RoleTimelineChannel, + ChatUserChannel, + ChatRoomChannel, + ReversiChannel, + ReversiGameChannel, + HomeTimelineChannel, + HybridTimelineChannel, + LocalTimelineChannel, + QueueStatsChannel, + ServerStatsChannel, + UserListChannel, OpenApiServerService, OAuth2ProviderService, ], diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 23c085ee27..ef9ac81f95 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown { @bindThis public async launch(): Promise { const fastify = Fastify({ - trustProxy: true, + trustProxy: this.config.trustProxy, logger: false, }); this.#fastify = fastify; @@ -238,30 +238,6 @@ export class ServerService implements OnApplicationShutdown { } }); - fastify.get<{ Params: { code: string } }>('/verify-email/:code', async (request, reply) => { - const profile = await this.userProfilesRepository.findOneBy({ - emailVerifyCode: request.params.code, - }); - - if (profile != null) { - await this.userProfilesRepository.update({ userId: profile.userId }, { - emailVerified: true, - emailVerifyCode: null, - }); - - this.globalEventService.publishMainStream(profile.userId, 'meUpdated', await this.userEntityService.pack(profile.userId, { id: profile.userId }, { - schema: 'MeDetailed', - includeSecrets: true, - })); - - reply.code(200).send('Verification succeeded! メールアドレスの認証に成功しました。'); - return; - } else { - reply.code(404).send('Verification failed. Please try again. メールアドレスの認証に失敗しました。もう一度お試しください'); - return; - } - }); - fastify.register(this.clientServerService.createServer); this.streamingApiServerService.attach(fastify.server); diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 7a4af407a3..0ccb3df631 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -7,7 +7,6 @@ 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'; @@ -37,6 +36,7 @@ export class ApiCallService implements OnApplicationShutdown { private logger: Logger; private userIpHistories: Map>; private userIpHistoriesClearIntervalId: NodeJS.Timeout; + private Sentry: typeof import('@sentry/node') | null = null; constructor( @Inject(DI.meta) @@ -59,6 +59,12 @@ export class ApiCallService implements OnApplicationShutdown { this.userIpHistoriesClearIntervalId = setInterval(() => { this.userIpHistories.clear(); }, 1000 * 60 * 60); + + if (this.config.sentryForBackend) { + import('@sentry/node').then((Sentry) => { + this.Sentry = Sentry; + }); + } } #sendApiError(reply: FastifyReply, err: ApiError): void { @@ -120,8 +126,8 @@ export class ApiCallService implements OnApplicationShutdown { }, }); - if (this.config.sentryForBackend) { - Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { + if (this.Sentry != null) { + this.Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, { level: 'error', user: { id: userId, @@ -307,11 +313,14 @@ export class ApiCallService implements OnApplicationShutdown { } if (ep.meta.limit) { - // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. - let limitActor: string; + let limitActor: string | null = null; if (user) { limitActor = user.id; - } else { + } else if (this.config.enableIpRateLimit) { + if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) { + this.logger.warn('Recieved API request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.'); + } + limitActor = getIpHash(request.ip); } @@ -324,7 +333,7 @@ export class ApiCallService implements OnApplicationShutdown { // TODO: 毎リクエスト計算するのもあれだしキャッシュしたい const factor = user ? (await this.roleService.getUserPolicies(user.id)).rateLimitFactor : 1; - if (factor > 0) { + if (limitActor != null && factor > 0) { // Rate limit const rateLimit = await this.rateLimiterService.limit(limit as IEndpointMeta['limit'] & { key: NonNullable }, limitActor, factor); if (rateLimit != null) { @@ -417,7 +426,7 @@ export class ApiCallService implements OnApplicationShutdown { if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { try { data[k] = JSON.parse(data[k]); - } catch (e) { + } catch (_) { throw new ApiError({ message: 'Invalid param.', code: 'INVALID_PARAM', @@ -432,8 +441,8 @@ export class ApiCallService implements OnApplicationShutdown { } // API invoking - if (this.config.sentryForBackend) { - return await Sentry.startSpan({ + if (this.Sentry != null) { + return await this.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))); diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 32818003ad..57d74ef2b1 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -176,6 +176,17 @@ export class ApiServerService { } }); + fastify.all('/clear-browser-cache', (request, reply) => { + if (['GET', 'POST'].includes(request.method)) { + reply.header('Clear-Site-Data', '"cache", "prefetchCache", "prerenderCache", "executionContexts"'); + reply.code(204); + reply.send(); + } else { + reply.code(405); + reply.send(); + } + }); + // Make sure any unknown path under /api returns HTTP 404 Not Found, // because otherwise ClientServerService will return the base client HTML // page with HTTP 200. diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index 3e889372d8..5c9d16a95a 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -15,6 +15,7 @@ import type { UserSecurityKeysRepository, UsersRepository, } from '@/models/_.js'; +import type Logger from '@/logger.js'; import type { Config } from '@/config.js'; import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser } from '@/models/User.js'; @@ -23,6 +24,7 @@ import { bindThis } from '@/decorators.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { UserAuthService } from '@/core/UserAuthService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; +import { LoggerService } from '@/core/LoggerService.js'; import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { SigninService } from './SigninService.js'; @@ -31,6 +33,8 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; @Injectable() export class SigninApiService { + private logger: Logger; + constructor( @Inject(DI.config) private config: Config, @@ -50,6 +54,7 @@ export class SigninApiService { @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, + private loggerService: LoggerService, private idService: IdService, private rateLimiterService: RateLimiterService, private signinService: SigninService, @@ -57,6 +62,7 @@ export class SigninApiService { private webAuthnService: WebAuthnService, private captchaService: CaptchaService, ) { + this.logger = this.loggerService.getLogger('Signin'); } @bindThis @@ -90,16 +96,21 @@ export class SigninApiService { } // not more than 1 attempt per second and not more than 10 attempts per hour - const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); - if (rateLimit != null) { - reply.code(429); - return { - error: { - message: 'Too many failed attempts to sign in. Try again later.', - code: 'TOO_MANY_AUTHENTICATION_FAILURES', - id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', - }, - }; + if (this.config.enableIpRateLimit) { + if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) { + this.logger.warn('Recieved signin request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.'); + } + const rateLimit = await this.rateLimiterService.limit({ key: 'signin', duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, getIpHash(request.ip)); + if (rateLimit != null) { + reply.code(429); + return { + error: { + message: 'Too many failed attempts to sign in. Try again later.', + code: 'TOO_MANY_AUTHENTICATION_FAILURES', + id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', + }, + }; + } } if (typeof username !== 'string') { @@ -220,7 +231,7 @@ export class SigninApiService { try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { return await fail(403, { id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f', }); diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts index 9ba23c54e2..6feb4c3afa 100644 --- a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -84,19 +84,25 @@ export class SigninWithPasskeyApiService { return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); }; - try { + if (this.config.enableIpRateLimit) { + if (process.env.NODE_ENV === 'production' && (request.ip === '::1' || request.ip === '127.0.0.1')) { + this.logger.warn('Recieved signin with passkey request from localhost IP address for rate limiting in production environment. This is likely due to an improper trustProxy setting in the config file.'); + } + + try { // Not more than 1 API call per 250ms and not more than 100 attempts per 30min // NOTE: 1 Sign-in require 2 API calls - await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); - } catch (err) { - reply.code(429); - return { - error: { - message: 'Too many failed attempts to sign in. Try again later.', - code: 'TOO_MANY_AUTHENTICATION_FAILURES', - id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', - }, - }; + await this.rateLimiterService.limit({ key: 'signin-with-passkey', duration: 60 * 30 * 1000, max: 200, minInterval: 250 }, getIpHash(request.ip)); + } catch (_) { + reply.code(429); + return { + error: { + message: 'Too many failed attempts to sign in. Try again later.', + code: 'TOO_MANY_AUTHENTICATION_FAILURES', + id: '22d05606-fbcf-421a-a2db-b32610dcfd1b', + }, + }; + } } // Initiate Passkey Auth challenge with context diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 53336a087d..b419c51ef1 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -255,7 +255,7 @@ export class SignupApiService { throw new FastifyReplyError(400, 'EXPIRED'); } - const { account, secret } = await this.signupService.signup({ + const { account } = await this.signupService.signup({ username: pendingUser.username, passwordHash: pendingUser.password, }); diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 2a4e1fc574..8a317bdc4e 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -8,17 +8,14 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, MiAccessToken } from '@/models/_.js'; -import { NotificationService } from '@/core/NotificationService.js'; +import type { MiAccessToken } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; -import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; import { UserService } from '@/core/UserService.js'; -import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; -import MainStreamConnection from './stream/Connection.js'; -import { ChannelsService } from './stream/ChannelsService.js'; +import MainStreamConnection, { ConnectionRequest } from './stream/Connection.js'; import type * as http from 'node:http'; +import { ContextIdFactory, ModuleRef } from '@nestjs/core'; @Injectable() export class StreamingApiServerService { @@ -30,15 +27,9 @@ export class StreamingApiServerService { @Inject(DI.redisForSub) private redisForSub: Redis.Redis, - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private cacheService: CacheService, + private moduleRef: ModuleRef, private authenticateService: AuthenticateService, - private channelsService: ChannelsService, - private notificationService: NotificationService, private usersService: UserService, - private channelFollowingService: ChannelFollowingService, ) { } @@ -92,13 +83,12 @@ export class StreamingApiServerService { return; } - const stream = new MainStreamConnection( - this.channelsService, - this.notificationService, - this.cacheService, - this.channelFollowingService, - user, app, - ); + const contextId = ContextIdFactory.create(); + this.moduleRef.registerRequestByContextId({ + user, + token: app, + }, contextId); + const stream = await this.moduleRef.create(MainStreamConnection, contextId); await stream.init(); @@ -121,7 +111,7 @@ export class StreamingApiServerService { user: MiLocalUser | null; app: MiAccessToken | null }) => { - const { stream, user, app } = ctx; + const { stream, user } = ctx; const ev = new EventEmitter(); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index c0c43dd5c9..9311c80eaa 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -143,6 +143,9 @@ export * as 'channels/timeline' from './endpoints/channels/timeline.js'; export * as 'channels/unfavorite' from './endpoints/channels/unfavorite.js'; export * as 'channels/unfollow' from './endpoints/channels/unfollow.js'; export * as 'channels/update' from './endpoints/channels/update.js'; +export * as 'channels/mute/create' from './endpoints/channels/mute/create.js'; +export * as 'channels/mute/delete' from './endpoints/channels/mute/delete.js'; +export * as 'channels/mute/list' from './endpoints/channels/mute/list.js'; export * as 'charts/active-users' from './endpoints/charts/active-users.js'; export * as 'charts/ap-request' from './endpoints/charts/ap-request.js'; export * as 'charts/drive' from './endpoints/charts/drive.js'; @@ -388,6 +391,7 @@ export * as 'users/featured-notes' from './endpoints/users/featured-notes.js'; export * as 'users/flashs' from './endpoints/users/flashs.js'; export * as 'users/followers' from './endpoints/users/followers.js'; export * as 'users/following' from './endpoints/users/following.js'; +export * as 'users/get-following-birthday-users' from './endpoints/users/get-following-birthday-users.js'; export * as 'users/gallery/posts' from './endpoints/users/gallery/posts.js'; export * as 'users/get-frequently-replied-users' from './endpoints/users/get-frequently-replied-users.js'; export * as 'users/lists/create' from './endpoints/users/lists/create.js'; @@ -412,6 +416,7 @@ export * as 'users/search' from './endpoints/users/search.js'; export * as 'users/search-by-username-and-host' from './endpoints/users/search-by-username-and-host.js'; export * as 'users/show' from './endpoints/users/show.js'; export * as 'users/update-memo' from './endpoints/users/update-memo.js'; +export * as 'verify-email' from './endpoints/verify-email.js'; export * as 'chat/messages/create-to-user' from './endpoints/chat/messages/create-to-user.js'; export * as 'chat/messages/create-to-room' from './endpoints/chat/messages/create-to-room.js'; export * as 'chat/messages/delete' from './endpoints/chat/messages/delete.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index 06047b58a6..6606202118 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -34,13 +34,22 @@ export const meta = { res: { type: 'object', optional: false, nullable: false, - ref: 'MeDetailed', - properties: { - token: { - type: 'string', - optional: false, nullable: false, + allOf: [ + { + type: 'object', + ref: 'MeDetailed', }, - }, + { + type: 'object', + optional: false, nullable: false, + properties: { + token: { + type: 'string', + optional: false, nullable: false, + }, + }, + } + ], }, } as const; 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 955154f4fb..01697ae185 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/create.ts @@ -36,6 +36,7 @@ export const paramDef = { startsAt: { type: 'integer' }, imageUrl: { type: 'string', minLength: 1 }, dayOfWeek: { type: 'integer' }, + isSensitive: { type: 'boolean' }, }, required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl', 'dayOfWeek'], } as const; @@ -55,6 +56,7 @@ export default class extends Endpoint { // eslint- expiresAt: new Date(ps.expiresAt), startsAt: new Date(ps.startsAt), dayOfWeek: ps.dayOfWeek, + isSensitive: ps.isSensitive, url: ps.url, imageUrl: ps.imageUrl, priority: ps.priority, @@ -73,6 +75,7 @@ export default class extends Endpoint { // eslint- expiresAt: ad.expiresAt.toISOString(), startsAt: ad.startsAt.toISOString(), dayOfWeek: ad.dayOfWeek, + isSensitive: ad.isSensitive, url: ad.url, imageUrl: ad.imageUrl, priority: ad.priority, diff --git a/packages/backend/src/server/api/endpoints/admin/ad/list.ts b/packages/backend/src/server/api/endpoints/admin/ad/list.ts index 4f897d98e4..f67cad5bd2 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/list.ts @@ -63,6 +63,7 @@ export default class extends Endpoint { // eslint- expiresAt: ad.expiresAt.toISOString(), startsAt: ad.startsAt.toISOString(), dayOfWeek: ad.dayOfWeek, + isSensitive: ad.isSensitive, url: ad.url, imageUrl: ad.imageUrl, memo: ad.memo, 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 4e3d731aca..a3d9aaddc6 100644 --- a/packages/backend/src/server/api/endpoints/admin/ad/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/ad/update.ts @@ -39,6 +39,7 @@ export const paramDef = { expiresAt: { type: 'integer' }, startsAt: { type: 'integer' }, dayOfWeek: { type: 'integer' }, + isSensitive: { type: 'boolean' }, }, required: ['id'], } as const; @@ -66,6 +67,7 @@ export default class extends Endpoint { // eslint- expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : undefined, startsAt: ps.startsAt ? new Date(ps.startsAt) : undefined, dayOfWeek: ps.dayOfWeek, + isSensitive: ps.isSensitive, }); const updatedAd = await this.adsRepository.findOneByOrFail({ id: ad.id }); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index b8bfda73a4..74462b302a 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -72,7 +72,7 @@ export default class extends Endpoint { // eslint- private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { - const { raw, packed } = await this.announcementService.create({ + const { packed } = await this.announcementService.create({ updatedAt: null, title: ps.title, text: ps.text, diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index 81a788de2b..aeebceed5a 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -49,6 +49,36 @@ export const meta = { type: 'string', optional: false, nullable: false, }, + icon: { + type: 'string', + optional: false, nullable: false, + enum: ['info', 'warning', 'error', 'success'], + }, + display: { + type: 'string', + optional: false, nullable: false, + enum: ['normal', 'banner', 'dialog'], + }, + isActive: { + type: 'boolean', + optional: false, nullable: false, + }, + forExistingUsers: { + type: 'boolean', + optional: false, nullable: false, + }, + silence: { + type: 'boolean', + optional: false, nullable: false, + }, + needConfirmationToRead: { + type: 'boolean', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: true, + }, imageUrl: { type: 'string', optional: false, nullable: true, 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 b84a5c73f9..e7a70d0762 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 @@ -157,6 +157,22 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + maybeSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + maybePorn: { + type: 'boolean', + optional: false, nullable: false, + }, + requestIp: { + type: 'string', + optional: false, nullable: true, + }, + requestHeaders: { + type: 'object', + optional: false, nullable: true, + }, }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index cf03859ce5..d4305e7d7c 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -76,7 +76,7 @@ export default class extends Endpoint { // eslint- try { // Create file driveFile = await this.driveService.uploadFromUrl({ url: emoji.originalUrl, user: null, force: true }); - } catch (e) { + } catch (_) { // TODO: need to return Drive Error throw new ApiError(); } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts index 660aa55bf8..b9448b4bc2 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -24,39 +24,7 @@ export const meta = { optional: false, nullable: false, items: { type: 'object', - optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - host: { - type: 'string', - optional: false, nullable: true, - description: 'The local host is represented with `null`.', - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - }, + ref: 'EmojiDetailed', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts index 34d200455e..658367409c 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -24,39 +24,7 @@ export const meta = { optional: false, nullable: false, items: { type: 'object', - optional: false, nullable: false, - properties: { - id: { - type: 'string', - optional: false, nullable: false, - format: 'id', - }, - aliases: { - type: 'array', - optional: false, nullable: false, - items: { - type: 'string', - optional: false, nullable: false, - }, - }, - name: { - type: 'string', - optional: false, nullable: false, - }, - category: { - type: 'string', - optional: false, nullable: true, - }, - host: { - type: 'string', - optional: false, nullable: true, - description: 'The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files.', - }, - url: { - type: 'string', - optional: false, nullable: false, - }, - }, + ref: 'EmojiDetailed', }, }, } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts index 7bde10af46..e20bc21f6b 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -117,7 +117,7 @@ export default class extends Endpoint { // eslint- case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists); } // 網羅性チェック - const mustBeNever: never = error; + const _mustBeNever: never = error; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 6ec908d5bf..5beed3a7e8 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -223,10 +223,12 @@ export const meta = { sensitiveMediaDetection: { type: 'string', optional: false, nullable: false, + enum: ['none', 'all', 'local', 'remote'], }, sensitiveMediaDetectionSensitivity: { type: 'string', optional: false, nullable: false, + enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'], }, setSensitiveFlagAutomatically: { type: 'boolean', @@ -426,8 +428,7 @@ export const meta = { optional: false, nullable: true, }, clientOptions: { - type: 'object', - optional: false, nullable: false, + ref: 'MetaClientOptions', }, description: { type: 'string', @@ -473,6 +474,10 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + feedbackUrl: { + type: 'string', + optional: false, nullable: true, + }, summalyProxy: { type: 'string', optional: false, nullable: true, @@ -587,6 +592,10 @@ export const meta = { type: 'number', optional: false, nullable: false, }, + showRoleBadgesOfRemoteUsers: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, } as const; @@ -742,6 +751,7 @@ export default class extends Endpoint { // eslint- enableRemoteNotesCleaning: instance.enableRemoteNotesCleaning, remoteNotesCleaningExpiryDaysForEachNotes: instance.remoteNotesCleaningExpiryDaysForEachNotes, remoteNotesCleaningMaxProcessingDurationInMinutes: instance.remoteNotesCleaningMaxProcessingDurationInMinutes, + showRoleBadgesOfRemoteUsers: instance.showRoleBadgesOfRemoteUsers, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts index f3e440b4cb..86158d7e22 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -52,18 +52,14 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { const jobs = await this.deliverQueue.getJobs(['delayed']); - const res = [] as [string, number][]; + const counts = new Map(); for (const job of jobs) { const host = new URL(job.data.to).host; - if (res.find(x => x[0] === host)) { - res.find(x => x[0] === host)![1]++; - } else { - res.push([host, 1]); - } + counts.set(host, (counts.get(host) ?? 0) + 1); } - res.sort((a, b) => b[1] - a[1]); + const res = [...counts.entries()].sort((a, b) => b[1] - a[1]); return res; }); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts index e7589cba81..ad6a823b8f 100644 --- a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -52,18 +52,14 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { const jobs = await this.inboxQueue.getJobs(['delayed']); - const res = [] as [string, number][]; + const counts = new Map(); for (const job of jobs) { const host = new URL(job.data.signature.keyId).host; - if (res.find(x => x[0] === host)) { - res.find(x => x[0] === host)![1]++; - } else { - res.push([host, 1]); - } + counts.set(host, (counts.get(host) ?? 0) + 1); } - res.sort((a, b) => b[1] - a[1]); + const res = [...counts.entries()].sort((a, b) => b[1] - a[1]); return res; }); 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 d7f9e4eaa3..b69699c338 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, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; +import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, PostScheduledNoteQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js'; export const meta = { tags: ['admin'], @@ -49,6 +49,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, + @Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue, @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts index 80b6a4d32e..603be514c8 100644 --- a/packages/backend/src/server/api/endpoints/admin/server-info.ts +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -4,7 +4,6 @@ */ import * as os from 'node:os'; -import si from 'systeminformation'; import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import * as Redis from 'ioredis'; @@ -112,6 +111,8 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async () => { + const si = await import('systeminformation'); + const memStats = await si.mem(); const fsStats = await si.fsSize(); const netInterface = await si.networkInterfaceDefault(); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 1ba6853dbe..2fd7ab8ca2 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -103,6 +103,8 @@ export const meta = { quote: { optional: true, ...notificationRecieveConfig }, reaction: { optional: true, ...notificationRecieveConfig }, pollEnded: { optional: true, ...notificationRecieveConfig }, + scheduledNotePosted: { optional: true, ...notificationRecieveConfig }, + scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig }, receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig }, 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 a1a2a99d6e..7a8dfc4555 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -3,7 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Injectable, Inject } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; import type { MiMeta } from '@/models/Meta.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -67,7 +68,14 @@ export const paramDef = { description: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true }, - clientOptions: { type: 'object', nullable: false }, + clientOptions: { + type: 'object', nullable: false, + properties: { + entrancePageStyle: { type: 'string', nullable: false, enum: ['classic', 'simple'] }, + showTimelineForVisitor: { type: 'boolean', nullable: false }, + showActivitiesForVisitor: { type: 'boolean', nullable: false }, + }, + }, cacheRemoteFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' }, @@ -209,6 +217,7 @@ export const paramDef = { enableRemoteNotesCleaning: { type: 'boolean' }, remoteNotesCleaningExpiryDaysForEachNotes: { type: 'number' }, remoteNotesCleaningMaxProcessingDurationInMinutes: { type: 'number' }, + showRoleBadgesOfRemoteUsers: { type: 'boolean' }, }, required: [], } as const; @@ -216,6 +225,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private metaService: MetaService, private moderationLogService: ModerationLogService, ) { @@ -328,7 +340,10 @@ export default class extends Endpoint { // eslint- } if (ps.clientOptions !== undefined) { - set.clientOptions = ps.clientOptions; + set.clientOptions = { + ...serverSettings.clientOptions, + ...ps.clientOptions, + }; } if (ps.cacheRemoteFiles !== undefined) { @@ -743,6 +758,10 @@ export default class extends Endpoint { // eslint- set.remoteNotesCleaningMaxProcessingDurationInMinutes = ps.remoteNotesCleaningMaxProcessingDurationInMinutes; } + if (ps.showRoleBadgesOfRemoteUsers !== undefined) { + set.showRoleBadgesOfRemoteUsers = ps.showRoleBadgesOfRemoteUsers; + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index b2d9cea03c..c59479d370 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, AntennasRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; @@ -14,6 +15,7 @@ import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { trackPromise } from '@/misc/promise-tracker.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -69,6 +71,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private fanoutTimelineService: FanoutTimelineService, private globalEventService: GlobalEventService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -108,6 +111,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + // -- ミュートされたチャンネル対策 + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + // NOTE: センシティブ除外の設定はこのエンドポイントでは無視する。 // https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255 diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index 14286bc23e..ff03fce72b 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -43,7 +43,7 @@ export default class extends Endpoint { // eslint- private apResolverService: ApResolverService, ) { super(meta, paramDef, async (ps, me) => { - const resolver = this.apResolverService.createResolver(); + const resolver = await this.apResolverService.createResolver(); const object = await resolver.resolve(ps.uri); return object; }); diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 4afed7dc5c..47da6b4fbd 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -18,9 +18,9 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; -import { ApiError } from '../../error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['federation'], @@ -148,7 +148,7 @@ export default class extends Endpoint { // eslint- if (this.utilityService.isSelfHost(host)) return null; // リモートから一旦オブジェクトフェッチ - const resolver = this.apResolverService.createResolver(); + const resolver = await this.apResolverService.createResolver(); // allow ap/show exclusively to lookup URLs that are cross-origin or non-canonical (like https://alice.example.com/@bob@bob.example.com -> https://bob.example.com/@bob) const object = await resolver.resolve(uri, FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId).catch((err) => { if (err instanceof IdentifiableError) { @@ -215,7 +215,7 @@ export default class extends Endpoint { // eslint- type: 'Note', object, }; - } catch (e) { + } catch (_) { return null; } } diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts index e3a6d2d670..8d49b6fd0f 100644 --- a/packages/backend/src/server/api/endpoints/channels/create.ts +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -46,7 +46,7 @@ export const paramDef = { type: 'object', properties: { name: { type: 'string', minLength: 1, maxLength: 128 }, - description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, + description: { type: 'string', nullable: true, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, color: { type: 'string', minLength: 1, maxLength: 16 }, isSensitive: { type: 'boolean', nullable: true }, diff --git a/packages/backend/src/server/api/endpoints/channels/mute/create.ts b/packages/backend/src/server/api/endpoints/channels/mute/create.ts new file mode 100644 index 0000000000..26ce707c7a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/mute/create.ts @@ -0,0 +1,90 @@ +/* + * 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 type { ChannelsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; + +export const meta = { + tags: ['channels', 'mute'], + + requireCredential: true, + prohibitMoved: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such Channel.', + code: 'NO_SUCH_CHANNEL', + id: '7174361e-d58f-31d6-2e7c-6fb830786a3f', + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING_CHANNEL', + id: '5a251978-769a-da44-3e89-3931e43bb592', + }, + + expiresAtIsPast: { + message: 'Cannot set past date to "expiresAt".', + code: 'EXPIRES_AT_IS_PAST', + id: '42b32236-df2c-a45f-fdbf-def67268f749', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + expiresAt: { + type: 'integer', + nullable: true, + description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.', + }, + }, + required: ['channelId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + private channelMutingService: ChannelMutingService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check if exists the channel + const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (!targetChannel) { + throw new ApiError(meta.errors.noSuchChannel); + } + + // Check if already muting + const exist = await this.channelMutingService.isMuted({ + requestUserId: me.id, + targetChannelId: targetChannel.id, + }); + if (exist) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Check if expiresAt is past + if (ps.expiresAt && ps.expiresAt <= Date.now()) { + throw new ApiError(meta.errors.expiresAtIsPast); + } + + await this.channelMutingService.mute({ + requestUserId: me.id, + targetChannelId: targetChannel.id, + expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/mute/delete.ts b/packages/backend/src/server/api/endpoints/channels/mute/delete.ts new file mode 100644 index 0000000000..79abeebe99 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/mute/delete.ts @@ -0,0 +1,73 @@ +/* + * 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 type { ChannelsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['channels', 'mute'], + + requireCredential: true, + prohibitMoved: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such Channel.', + code: 'NO_SUCH_CHANNEL', + id: 'e7998769-6e94-d9c2-6b8f-94a527314aba', + }, + + notMuting: { + message: 'You are not muting that channel.', + code: 'NOT_MUTING_CHANNEL', + id: '14d55962-6ea8-d990-1333-d6bef78dc2ab', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + private channelMutingService: ChannelMutingService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check if exists the channel + const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (!targetChannel) { + throw new ApiError(meta.errors.noSuchChannel); + } + + // Check muting + const exist = await this.channelMutingService.isMuted({ + requestUserId: me.id, + targetChannelId: targetChannel.id, + }); + if (!exist) { + throw new ApiError(meta.errors.notMuting); + } + + await this.channelMutingService.unmute({ + requestUserId: me.id, + targetChannelId: targetChannel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/mute/list.ts b/packages/backend/src/server/api/endpoints/channels/mute/list.ts new file mode 100644 index 0000000000..74338eea38 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/mute/list.ts @@ -0,0 +1,49 @@ +/* + * 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 { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; + +export const meta = { + tags: ['channels', 'mute'], + + requireCredential: true, + prohibitMoved: true, + + kind: 'read:channels', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private channelMutingService: ChannelMutingService, + private channelEntityService: ChannelEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const mutings = await this.channelMutingService.list({ + requestUserId: me.id, + }); + return await this.channelEntityService.packMany(mutings, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 46b050d4b4..4f56bc2110 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { MiLocalUser } from '@/models/User.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -70,6 +71,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private activeUsersChart: ActiveUsersChart, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -98,6 +100,7 @@ export default class extends Endpoint { // eslint- useDbFallback: true, redisTimelines: [`channelTimeline:${channel.id}`], excludePureRenotes: false, + ignoreAuthorChannelFromMute: true, dbFallback: async (untilId, sinceId, limit) => { return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me); }, @@ -122,6 +125,16 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.channel', 'channel'); this.queryService.generateBaseNoteFilteringQuery(query, me); + + if (me) { + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id).filter(x => x !== ps.channelId)); + if (mutingChannelIds.length > 0) { + query.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + query.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + } + } //#endregion return await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts index dba2938b39..5ec55896e4 100644 --- a/packages/backend/src/server/api/endpoints/channels/update.ts +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -50,7 +50,7 @@ export const paramDef = { properties: { channelId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 128 }, - description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, + description: { type: 'string', nullable: true, maxLength: 2048 }, bannerId: { type: 'string', format: 'misskey:id', nullable: true }, isArchived: { type: 'boolean', nullable: true }, pinnedNoteIds: { diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts index 2e4a3ff820..af20ea9f8d 100644 --- a/packages/backend/src/server/api/endpoints/clips/list.ts +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; +import { QueryService } from '@/core/QueryService.js'; import type { ClipsRepository } from '@/models/_.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -29,7 +30,13 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + sinceDate: { type: 'integer' }, + untilDate: { type: 'integer' }, + }, required: [], } as const; @@ -39,12 +46,14 @@ export default class extends Endpoint { // eslint- @Inject(DI.clipsRepository) private clipsRepository: ClipsRepository, + private queryService: QueryService, private clipEntityService: ClipEntityService, ) { super(meta, paramDef, async (ps, me) => { - const clips = await this.clipsRepository.findBy({ - userId: me.id, - }); + const query = this.queryService.makePaginationQuery(this.clipsRepository.createQueryBuilder('clip'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) + .andWhere('clip.userId = :userId', { userId: me.id }); + + const clips = await query.limit(ps.limit).getMany(); return await this.clipEntityService.packMany(clips, me); }); diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts index e378669f0a..8696c6f6e8 100644 --- a/packages/backend/src/server/api/endpoints/flash/update.ts +++ b/packages/backend/src/server/api/endpoints/flash/update.ts @@ -73,8 +73,8 @@ export default class extends Endpoint { // eslint- updatedAt: new Date(), ...Object.fromEntries( Object.entries(ps).filter( - ([key, val]) => (key !== 'flashId') && Object.hasOwn(paramDef.properties, key) - ) + ([key, val]) => (key !== 'flashId') && Object.hasOwn(paramDef.properties, key), + ), ), }); }); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts index 65eece5b97..8dc5cafb56 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -81,7 +81,7 @@ export default class extends Endpoint { try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts index 9391aee5e0..050dbaf49e 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -212,7 +212,7 @@ export default class extends Endpoint { try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts index a54c598213..b6c837eda7 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/register.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -72,7 +72,7 @@ export default class extends Endpoint { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts index c350136eae..6e5d9943de 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -61,7 +61,7 @@ export default class extends Endpoint { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts index b5a53cc889..23b577dc18 100644 --- a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -57,7 +57,7 @@ export default class extends Endpoint { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index 055b5cc061..523d81ac73 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -46,6 +46,14 @@ export const meta = { type: 'string', }, }, + iconUrl: { + type: 'string', + optional: true, nullable: true, + }, + description: { + type: 'string', + optional: true, nullable: true, + }, }, }, }, @@ -88,6 +96,8 @@ export default class extends Endpoint { // eslint- createdAt: this.idService.parse(token.id).date.toISOString(), lastUsedAt: token.lastUsedAt?.toISOString(), permission: token.app ? token.app.permission : token.permission, + iconUrl: token.iconUrl, + description: token.description ?? token.app?.description ?? null, }))); }); } diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts index bb78d47149..19ea187ee8 100644 --- a/packages/backend/src/server/api/endpoints/i/change-password.ts +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -45,7 +45,7 @@ export default class extends Endpoint { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index bfa0b4605d..42324c7778 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -49,7 +49,7 @@ export default class extends Endpoint { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts index f933eaab00..4fe39bb8e8 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -71,7 +71,6 @@ export default class extends Endpoint { // eslint- private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { - const EXTRA_LIMIT = 100; const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : undefined); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : undefined); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts index da1faee30d..c2f4281f36 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -91,7 +91,7 @@ export default class extends Endpoint { // eslint- try { await this.userAuthService.twoFactorAuthenticate(profile, token); - } catch (e) { + } catch (_) { throw new Error('authentication failed'); } } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 082d97f5d4..5207d9f2b0 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -7,7 +7,7 @@ import RE2 from 're2'; import * as mfm from 'mfm-js'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; -import { JSDOM } from 'jsdom'; +import * as htmlParser from 'node-html-parser'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; @@ -209,6 +209,8 @@ export const paramDef = { quote: notificationRecieveConfig, reaction: notificationRecieveConfig, pollEnded: notificationRecieveConfig, + scheduledNotePosted: notificationRecieveConfig, + scheduledNotePostFailed: notificationRecieveConfig, receiveFollowRequest: notificationRecieveConfig, followRequestAccepted: notificationRecieveConfig, roleAssigned: notificationRecieveConfig, @@ -293,8 +295,20 @@ export default class extends Endpoint { // eslint- if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope; function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { - // TODO: ちゃんと数える - const length = JSON.stringify(mutedWords).length; + const count = (arr: (string[] | string)[]) => { + let length = 0; + for (const item of arr) { + if (typeof item === 'string') { + length += item.length; + } else if (Array.isArray(item)) { + for (const subItem of item) { + length += subItem.length; + } + } + } + return length; + }; + const length = count(mutedWords); if (length > limit) { throw new ApiError(meta.errors.tooManyMutedWords); } @@ -309,7 +323,7 @@ export default class extends Endpoint { // eslint- try { new RE2(regexp[1], regexp[2]); - } catch (err) { + } catch (_) { throw new ApiError(meta.errors.invalidRegexp); } } @@ -555,16 +569,15 @@ export default class extends Endpoint { // eslint- try { const html = await this.httpRequestService.getHtml(url); - const { window } = new JSDOM(html); - const doc: Document = window.document; + const doc = htmlParser.parse(html); const myLink = `${this.config.url}/@${user.username}`; 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.attributes.href === myLink); + const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.attributes.rel?.split(/\s+/).includes('me') && link.attributes.href === myLink); if (includesMyLink || includesRelMeLinks) { await this.userProfilesRepository.createQueryBuilder('profile').update() @@ -574,9 +587,7 @@ export default class extends Endpoint { // eslint- }) .execute(); } - - window.close(); - } catch (err) { + } catch (_) { // なにもしない } } diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts index 394c178f2a..8a3ba9e026 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts @@ -21,29 +21,7 @@ export const meta = { type: 'array', items: { type: 'object', - properties: { - id: { - type: 'string', - format: 'misskey:id', - }, - userId: { - type: 'string', - format: 'misskey:id', - }, - name: { type: 'string' }, - on: { - type: 'array', - items: { - type: 'string', - enum: webhookEventTypes, - }, - }, - url: { type: 'string' }, - secret: { type: 'string' }, - active: { type: 'boolean' }, - latestSentAt: { type: 'string', format: 'date-time', nullable: true }, - latestStatus: { type: 'integer', nullable: true }, - }, + ref: 'UserWebhook', }, }, } as const; @@ -65,19 +43,17 @@ export default class extends Endpoint { // eslint- userId: me.id, }); - return webhooks.map(webhook => ( - { - id: webhook.id, - userId: webhook.userId, - name: webhook.name, - on: webhook.on, - url: webhook.url, - secret: webhook.secret, - active: webhook.active, - latestSentAt: webhook.latestSentAt ? webhook.latestSentAt.toISOString() : null, - latestStatus: webhook.latestStatus, - } - )); + return webhooks.map(webhook => ({ + id: webhook.id, + userId: webhook.userId, + name: webhook.name, + on: webhook.on, + url: webhook.url, + secret: webhook.secret, + active: webhook.active, + latestSentAt: webhook.latestSentAt ? webhook.latestSentAt.toISOString() : null, + latestStatus: webhook.latestStatus, + })); }); } } diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts index 4a0c09ff0c..1c19081c98 100644 --- a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts +++ b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts @@ -28,29 +28,7 @@ export const meta = { res: { type: 'object', - properties: { - id: { - type: 'string', - format: 'misskey:id', - }, - userId: { - type: 'string', - format: 'misskey:id', - }, - name: { type: 'string' }, - on: { - type: 'array', - items: { - type: 'string', - enum: webhookEventTypes, - }, - }, - url: { type: 'string' }, - secret: { type: 'string' }, - active: { type: 'boolean' }, - latestSentAt: { type: 'string', format: 'date-time', nullable: true }, - latestStatus: { type: 'integer', nullable: true }, - }, + ref: 'UserWebhook', }, } as const; diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 43af4f2545..dc42b1e5f3 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -6,17 +6,10 @@ import ms from 'ms'; import { In } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { MiUser } from '@/models/User.js'; -import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiNote } from '@/models/Note.js'; -import type { MiChannel } from '@/models/Channel.js'; import { MAX_NOTE_ATTACHMENTS, MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; -import { DI } from '@/di-symbols.js'; -import { isQuote, isRenote } from '@/misc/is-renote.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../error.js'; @@ -223,168 +216,28 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - - @Inject(DI.blockingsRepository) - private blockingsRepository: BlockingsRepository, - - @Inject(DI.driveFilesRepository) - private driveFilesRepository: DriveFilesRepository, - - @Inject(DI.channelsRepository) - private channelsRepository: ChannelsRepository, - private noteEntityService: NoteEntityService, private noteCreateService: NoteCreateService, ) { super(meta, paramDef, async (ps, me) => { - let visibleUsers: MiUser[] = []; - if (ps.visibleUserIds) { - visibleUsers = await this.usersRepository.findBy({ - id: In(ps.visibleUserIds), - }); - } - - let files: MiDriveFile[] = []; - const fileIds = ps.fileIds ?? ps.mediaIds ?? null; - if (fileIds != null) { - files = await this.driveFilesRepository.createQueryBuilder('file') - .where('file.userId = :userId AND file.id IN (:...fileIds)', { - userId: me.id, - fileIds, - }) - .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') - .setParameters({ fileIds }) - .getMany(); - - if (files.length !== fileIds.length) { - throw new ApiError(meta.errors.noSuchFile); - } - } - - let renote: MiNote | null = null; - if (ps.renoteId != null) { - // Fetch renote to note - renote = await this.notesRepository.findOne({ - where: { id: ps.renoteId }, - relations: ['user', 'renote', 'reply'], - }); - - if (renote == null) { - throw new ApiError(meta.errors.noSuchRenoteTarget); - } else if (isRenote(renote) && !isQuote(renote)) { - throw new ApiError(meta.errors.cannotReRenote); - } - - // Check blocking - if (renote.userId !== me.id) { - const blockExist = await this.blockingsRepository.exists({ - where: { - blockerId: renote.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - - if (renote.visibility === 'followers' && renote.userId !== me.id) { - // 他人のfollowers noteはreject - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } else if (renote.visibility === 'specified') { - // specified / direct noteはreject - throw new ApiError(meta.errors.cannotRenoteDueToVisibility); - } - - if (renote.channelId && renote.channelId !== ps.channelId) { - // チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック - // リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する - const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId }); - if (renoteChannel == null) { - // リノートしたいノートが書き込まれているチャンネルが無い - throw new ApiError(meta.errors.noSuchChannel); - } else if (!renoteChannel.allowRenoteToExternal) { - // リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合 - throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); - } - } - } - - let reply: MiNote | null = null; - if (ps.replyId != null) { - // Fetch reply - reply = await this.notesRepository.findOne({ - where: { id: ps.replyId }, - relations: ['user'], - }); - - if (reply == null) { - throw new ApiError(meta.errors.noSuchReplyTarget); - } else if (isRenote(reply) && !isQuote(reply)) { - throw new ApiError(meta.errors.cannotReplyToPureRenote); - } else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) { - throw new ApiError(meta.errors.cannotReplyToInvisibleNote); - } else if (reply.visibility === 'specified' && ps.visibility !== 'specified') { - throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); - } - - // Check blocking - if (reply.userId !== me.id) { - const blockExist = await this.blockingsRepository.exists({ - where: { - blockerId: reply.userId, - blockeeId: me.id, - }, - }); - if (blockExist) { - throw new ApiError(meta.errors.youHaveBeenBlocked); - } - } - } - - if (ps.poll) { - if (typeof ps.poll.expiresAt === 'number') { - if (ps.poll.expiresAt < Date.now()) { - throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); - } - } else if (typeof ps.poll.expiredAfter === 'number') { - ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter; - } - } - - let channel: MiChannel | null = null; - if (ps.channelId != null) { - channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false }); - - if (channel == null) { - throw new ApiError(meta.errors.noSuchChannel); - } - } - - // 投稿を作成 try { - const note = await this.noteCreateService.create(me, { + const note = await this.noteCreateService.fetchAndCreate(me, { createdAt: new Date(), - files: files, + fileIds: ps.fileIds ?? ps.mediaIds ?? [], poll: ps.poll ? { choices: ps.poll.choices, multiple: ps.poll.multiple ?? false, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, - } : undefined, - text: ps.text ?? undefined, - reply, - renote, - cw: ps.cw, + expiresAt: ps.poll.expiredAfter ? new Date(Date.now() + ps.poll.expiredAfter) : ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } : null, + text: ps.text ?? null, + replyId: ps.replyId ?? null, + renoteId: ps.renoteId ?? null, + cw: ps.cw ?? null, localOnly: ps.localOnly, reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, - visibleUsers, - channel, + visibleUserIds: ps.visibleUserIds ?? [], + channelId: ps.channelId ?? null, apMentions: ps.noExtractMentions ? [] : undefined, apHashtags: ps.noExtractHashtags ? [] : undefined, apEmojis: ps.noExtractEmojis ? [] : undefined, @@ -393,17 +246,47 @@ export default class extends Endpoint { // eslint- return { createdNote: await this.noteEntityService.pack(note, me), }; - } catch (e) { + } catch (err) { // TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい - if (e instanceof IdentifiableError) { - switch (e.id) { + if (err instanceof IdentifiableError) { + switch (err.id) { case '689ee33f-f97c-479a-ac49-1b9f8140af99': throw new ApiError(meta.errors.containsProhibitedWords); case '9f466dab-c856-48cd-9e65-ff90ff750580': throw new ApiError(meta.errors.containsTooManyMentions); + case '801c046c-5bf5-4234-ad2b-e78fc20a2ac7': + throw new ApiError(meta.errors.noSuchFile); + case '53983c56-e163-45a6-942f-4ddc485d4290': + throw new ApiError(meta.errors.noSuchRenoteTarget); + case 'bde24c37-121f-4e7d-980d-cec52f599f02': + throw new ApiError(meta.errors.cannotReRenote); + case '2b4fe776-4414-4a2d-ae39-f3418b8fd4d3': + throw new ApiError(meta.errors.youHaveBeenBlocked); + case '90b9d6f0-893a-4fef-b0f1-e9a33989f71a': + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + case '48d7a997-da5c-4716-b3c3-92db3f37bf7d': + throw new ApiError(meta.errors.cannotRenoteDueToVisibility); + case 'b060f9a6-8909-4080-9e0b-94d9fa6f6a77': + throw new ApiError(meta.errors.noSuchChannel); + case '7e435f4a-780d-4cfc-a15a-42519bd6fb67': + throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel); + case '60142edb-1519-408e-926d-4f108d27bee0': + throw new ApiError(meta.errors.noSuchReplyTarget); + case 'f089e4e2-c0e7-4f60-8a23-e5a6bf786b36': + throw new ApiError(meta.errors.cannotReplyToPureRenote); + case '11cd37b3-a411-4f77-8633-c580ce6a8dce': + throw new ApiError(meta.errors.cannotReplyToInvisibleNote); + case 'ced780a1-2012-4caf-bc7e-a95a291294cb': + throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); + case 'b0df6025-f2e8-44b4-a26a-17ad99104612': + throw new ApiError(meta.errors.youHaveBeenBlocked); + case '0c11c11e-0c8d-48e7-822c-76ccef660068': + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + case 'bfa3905b-25f5-4894-b430-da331a490e4b': + throw new ApiError(meta.errors.noSuchChannel); } } - throw e; + throw err; } }); } diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts index 1c28ec22d0..efb5ee01d1 100644 --- a/packages/backend/src/server/api/endpoints/notes/drafts/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/drafts/create.ts @@ -124,11 +124,29 @@ export const meta = { id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', }, + tooManyScheduledNotes: { + message: 'You cannot create scheduled notes any more.', + code: 'TOO_MANY_SCHEDULED_NOTES', + id: '22ae69eb-09e3-4541-a850-773cfa45e693', + }, + cannotRenoteToExternal: { message: 'Cannot Renote to External.', code: 'CANNOT_RENOTE_TO_EXTERNAL', id: 'ed1952ac-2d26-4957-8b30-2deda76bedf7', }, + + scheduledAtRequired: { + message: 'scheduledAt is required when isActuallyScheduled is true.', + code: 'SCHEDULED_AT_REQUIRED', + id: '15e28a55-e74c-4d65-89b7-8880cdaaa87d', + }, + + scheduledAtMustBeInFuture: { + message: 'scheduledAt must be in the future.', + code: 'SCHEDULED_AT_MUST_BE_IN_FUTURE', + id: 'e4bed6c9-017e-4934-aed0-01c22cc60ec1', + }, }, limit: { @@ -162,7 +180,7 @@ export const paramDef = { fileIds: { type: 'array', uniqueItems: true, - minItems: 1, + minItems: 0, maxItems: 16, items: { type: 'string', format: 'misskey:id' }, }, @@ -183,6 +201,8 @@ export const paramDef = { }, required: ['choices'], }, + scheduledAt: { type: 'integer', nullable: true }, + isActuallyScheduled: { type: 'boolean', default: false }, }, required: [], } as const; @@ -195,23 +215,24 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { const draft = await this.noteDraftService.create(me, { - fileIds: ps.fileIds, - poll: ps.poll ? { - choices: ps.poll.choices, - multiple: ps.poll.multiple ?? false, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, - expiredAfter: ps.poll.expiredAfter ?? null, - } : undefined, + fileIds: ps.fileIds ?? [], + pollChoices: ps.poll?.choices ?? [], + pollMultiple: ps.poll?.multiple ?? false, + pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null, + pollExpiredAfter: ps.poll?.expiredAfter ?? null, + hasPoll: ps.poll != null, text: ps.text ?? null, - replyId: ps.replyId ?? undefined, - renoteId: ps.renoteId ?? undefined, + replyId: ps.replyId ?? null, + renoteId: ps.renoteId ?? null, cw: ps.cw ?? null, - ...(ps.hashtag ? { hashtag: ps.hashtag } : {}), + hashtag: ps.hashtag ?? null, localOnly: ps.localOnly, reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, visibleUserIds: ps.visibleUserIds ?? [], - channelId: ps.channelId ?? undefined, + channelId: ps.channelId ?? null, + scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null, + isActuallyScheduled: ps.isActuallyScheduled, }).catch((err) => { if (err instanceof IdentifiableError) { switch (err.id) { @@ -241,6 +262,12 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.cannotReplyToInvisibleNote); case '215dbc76-336c-4d2a-9605-95766ba7dab0': throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility); + case 'c3275f19-4558-4c59-83e1-4f684b5fab66': + throw new ApiError(meta.errors.tooManyScheduledNotes); + case '94a89a43-3591-400a-9c17-dd166e71fdfa': + throw new ApiError(meta.errors.scheduledAtRequired); + case 'b34d0c1b-996f-4e34-a428-c636d98df457': + throw new ApiError(meta.errors.scheduledAtMustBeInFuture); default: throw err; } diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts index f24f9b8fb2..0774f09228 100644 --- a/packages/backend/src/server/api/endpoints/notes/drafts/list.ts +++ b/packages/backend/src/server/api/endpoints/notes/drafts/list.ts @@ -41,6 +41,7 @@ export const paramDef = { untilId: { type: 'string', format: 'misskey:id' }, sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, + scheduled: { type: 'boolean', nullable: true }, }, required: [], } as const; @@ -58,6 +59,12 @@ export default class extends Endpoint { // eslint- const query = this.queryService.makePaginationQuery(this.noteDraftsRepository.createQueryBuilder('drafts'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) .andWhere('drafts.userId = :meId', { meId: me.id }); + if (ps.scheduled === true) { + query.andWhere('drafts.isActuallyScheduled = true'); + } else if (ps.scheduled === false) { + query.andWhere('drafts.isActuallyScheduled = false'); + } + const drafts = await query .limit(ps.limit) .getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts index ee221fb765..2900e0cb0d 100644 --- a/packages/backend/src/server/api/endpoints/notes/drafts/update.ts +++ b/packages/backend/src/server/api/endpoints/notes/drafts/update.ts @@ -159,6 +159,24 @@ export const meta = { code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY', id: '215dbc76-336c-4d2a-9605-95766ba7dab0', }, + + tooManyScheduledNotes: { + message: 'You cannot create scheduled notes any more.', + code: 'TOO_MANY_SCHEDULED_NOTES', + id: '02f5df79-08ae-4a33-8524-f1503c8f6212', + }, + + scheduledAtRequired: { + message: 'scheduledAt is required when isActuallyScheduled is true.', + code: 'SCHEDULED_AT_REQUIRED', + id: 'fe9737d5-cc41-498c-af9d-149207307530', + }, + + scheduledAtMustBeInFuture: { + message: 'scheduledAt must be in the future.', + code: 'SCHEDULED_AT_MUST_BE_IN_FUTURE', + id: 'ed1a6673-d0d1-4364-aaae-9bf3f139cbc5', + }, }, limit: { @@ -171,14 +189,14 @@ export const paramDef = { type: 'object', properties: { draftId: { type: 'string', nullable: false, format: 'misskey:id' }, - visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' }, + visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'] }, visibleUserIds: { type: 'array', uniqueItems: true, items: { type: 'string', format: 'misskey:id', } }, cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 }, hashtag: { type: 'string', nullable: true, maxLength: 200 }, - localOnly: { type: 'boolean', default: false }, - reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, + localOnly: { type: 'boolean' }, + reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'] }, replyId: { type: 'string', format: 'misskey:id', nullable: true }, renoteId: { type: 'string', format: 'misskey:id', nullable: true }, channelId: { type: 'string', format: 'misskey:id', nullable: true }, @@ -194,7 +212,7 @@ export const paramDef = { fileIds: { type: 'array', uniqueItems: true, - minItems: 1, + minItems: 0, maxItems: 16, items: { type: 'string', format: 'misskey:id' }, }, @@ -215,6 +233,8 @@ export const paramDef = { }, required: ['choices'], }, + scheduledAt: { type: 'integer', nullable: true }, + isActuallyScheduled: { type: 'boolean' }, }, required: ['draftId'], } as const; @@ -228,22 +248,22 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { const draft = await this.noteDraftService.update(me, ps.draftId, { fileIds: ps.fileIds, - poll: ps.poll ? { - choices: ps.poll.choices, - multiple: ps.poll.multiple ?? false, - expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, - expiredAfter: ps.poll.expiredAfter ?? null, - } : undefined, - text: ps.text ?? null, - replyId: ps.replyId ?? undefined, - renoteId: ps.renoteId ?? undefined, - cw: ps.cw ?? null, - ...(ps.hashtag ? { hashtag: ps.hashtag } : {}), + pollChoices: ps.poll?.choices, + pollMultiple: ps.poll?.multiple, + pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null, + pollExpiredAfter: ps.poll?.expiredAfter, + text: ps.text, + replyId: ps.replyId, + renoteId: ps.renoteId, + cw: ps.cw, + hashtag: ps.hashtag, localOnly: ps.localOnly, reactionAcceptance: ps.reactionAcceptance, visibility: ps.visibility, - visibleUserIds: ps.visibleUserIds ?? [], - channelId: ps.channelId ?? undefined, + visibleUserIds: ps.visibleUserIds, + channelId: ps.channelId, + scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null, + isActuallyScheduled: ps.isActuallyScheduled, }).catch((err) => { if (err instanceof IdentifiableError) { switch (err.id) { @@ -285,6 +305,12 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.containsProhibitedWords); case '4de0363a-3046-481b-9b0f-feff3e211025': throw new ApiError(meta.errors.containsTooManyMentions); + case 'bacdf856-5c51-4159-b88a-804fa5103be5': + throw new ApiError(meta.errors.tooManyScheduledNotes); + case '94a89a43-3591-400a-9c17-dd166e71fdfa': + throw new ApiError(meta.errors.scheduledAtRequired); + case 'b34d0c1b-996f-4e34-a428-c636d98df457': + throw new ApiError(meta.errors.scheduledAtMustBeInFuture); default: throw err; } diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 1c73edf08e..7fa8004209 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -91,6 +91,7 @@ export default class extends Endpoint { // eslint- qb.orWhere(new Brackets(qb => { qb.where('note.text IS NOT NULL'); qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); })); })); } diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 2c8459525a..0a3602df20 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -18,6 +18,8 @@ import { QueryService } from '@/core/QueryService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -46,7 +48,7 @@ export const meta = { bothWithRepliesAndWithFiles: { message: 'Specifying both withReplies and withFiles is not supported', code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', - id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f' + id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f', }, }, } as const; @@ -79,9 +81,6 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - private noteEntityService: NoteEntityService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, @@ -89,6 +88,8 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private queryService: QueryService, private userFollowingService: UserFollowingService, + private channelMutingService: ChannelMutingService, + private channelFollowingService: ChannelFollowingService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, ) { super(meta, paramDef, async (ps, me) => { @@ -196,11 +197,13 @@ export default class extends Endpoint { // eslint- withReplies: boolean, }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.id, - }, - }); + + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + const followingChannelIds = await this.channelFollowingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x))); const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { @@ -219,9 +222,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - if (followingChannels.length > 0) { - const followingChannelIds = followingChannels.map(x => x.followeeId); - + if (followingChannelIds.length > 0) { query.andWhere(new Brackets(qb => { qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); qb.orWhere('note.channelId IS NULL'); @@ -230,6 +231,13 @@ export default class extends Endpoint { // eslint- query.andWhere('note.channelId IS NULL'); } + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + if (!ps.withReplies) { query.andWhere(new Brackets(qb => { qb diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index ee61ab43da..ec9e52cf04 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -76,6 +77,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -157,7 +159,19 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBaseNoteFilteringQuery(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + const mutedChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutedChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL') + .orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds }); + })); + } + } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 05ffdc1f97..e775bdb7fd 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -66,7 +66,7 @@ export default class extends Endpoint { // eslint- .orWhere(':meIdAsList <@ note.visibleUserIds'); })) // Avoid scanning primary key index - .orderBy('CONCAT(note.id)', 'DESC') + .orderBy('CONCAT(note.id)', (ps.sinceDate || ps.sinceId) ? 'ASC' : 'DESC') .innerJoinAndSelect('note.user', 'user') .leftJoinAndSelect('note.reply', 'reply') .leftJoinAndSelect('note.renote', 'renote') diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index cae0e752da..a41de25ddf 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -29,10 +29,16 @@ export const meta = { id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', }, - signinRequired: { - message: 'Signin required.', - code: 'SIGNIN_REQUIRED', - id: '8e75455b-738c-471d-9f80-62693f33372e', + contentRestrictedByUser: { + message: 'Content restricted by user. Please sign in to view.', + code: 'CONTENT_RESTRICTED_BY_USER', + id: 'fbcc002d-37d9-4944-a6b0-d9e29f2d33ab', + }, + + contentRestrictedByServer: { + message: 'Content restricted by server settings. Please sign in to view.', + code: 'CONTENT_RESTRICTED_BY_SERVER', + id: '145f88d2-b03d-4087-8143-a78928883c4b', }, }, } as const; @@ -61,15 +67,15 @@ export default class extends Endpoint { // eslint- }); if (note.user!.requireSigninToViewContents && me == null) { - throw new ApiError(meta.errors.signinRequired); + throw new ApiError(meta.errors.contentRestrictedByUser); } if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) { - throw new ApiError(meta.errors.signinRequired); + throw new ApiError(meta.errors.contentRestrictedByServer); } if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) { - throw new ApiError(meta.errors.signinRequired); + throw new ApiError(meta.errors.contentRestrictedByServer); } return await this.noteEntityService.pack(note, me, { diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts index 29c6aa7434..7c0dddb827 100644 --- a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -59,7 +59,7 @@ export default class extends Endpoint { // eslint- throw err; }); - const mutedNotes = await this.notesRepository.find({ + const _mutedNotes = await this.notesRepository.find({ where: [{ id: note.threadId ?? note.id, }, { diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 1f3631ae3d..fe9c412be4 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js'; +import type { NotesRepository, MiMeta } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; export const meta = { tags: ['notes'], @@ -61,15 +63,14 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - private noteEntityService: NoteEntityService, private activeUsersChart: ActiveUsersChart, private idService: IdService, private cacheService: CacheService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private userFollowingService: UserFollowingService, + private channelMutingService: ChannelMutingService, + private channelFollowingService: ChannelFollowingService, private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { @@ -140,11 +141,13 @@ export default class extends Endpoint { // eslint- private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.id, - }, - }); + + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + const followingChannelIds = await this.channelFollowingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x))); //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -154,15 +157,14 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - if (followees.length > 0 && followingChannels.length > 0) { + if (followees.length > 0 && followingChannelIds.length > 0) { // ユーザー・チャンネルともにフォローあり const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - const followingChannelIds = followingChannels.map(x => x.followeeId); query.andWhere(new Brackets(qb => { qb .where(new Brackets(qb2 => { qb2 - .where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) + .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) .andWhere('note.channelId IS NULL'); })) .orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); @@ -170,22 +172,32 @@ export default class extends Endpoint { // eslint- } else if (followees.length > 0) { // ユーザーフォローのみ(チャンネルフォローなし) const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - query - .andWhere('note.channelId IS NULL') - .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - } else if (followingChannels.length > 0) { - // チャンネルフォローのみ(ユーザーフォローなし) - const followingChannelIds = followingChannels.map(x => x.followeeId); query.andWhere(new Brackets(qb => { qb + .andWhere('note.channelId IS NULL') + .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + if (mutingChannelIds.length > 0) { + qb.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + } + })); + } else if (followingChannelIds.length > 0) { + // チャンネルフォローのみ(ユーザーフォローなし) + query.andWhere(new Brackets(qb => { + qb + // renoteChannelIdは見る必要が無い + // ・HTLに流れてくるチャンネル=フォローしているチャンネル + // ・HTLにフォロー外のチャンネルが流れるのは、フォローしているユーザがそのチャンネル投稿をリノートした場合のみ + // つまり、ユーザフォローしてない前提のこのブロックでは見る必要が無い .where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }) .orWhere('note.userId = :meId', { meId: me.id }); })); } else { // フォローなし - query - .andWhere('note.channelId IS NULL') - .andWhere('note.userId = :meId', { meId: me.id }); + query.andWhere(new Brackets(qb => { + qb + .andWhere('note.channelId IS NULL') + .andWhere('note.userId = :meId', { meId: me.id }); + })); } query.andWhere(new Brackets(qb => { @@ -242,6 +254,7 @@ export default class extends Endpoint { // eslint- qb.orWhere(new Brackets(qb => { qb.orWhere('note.text IS NOT NULL'); qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); })); })); } diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index e9a6a36b02..cd7d46007c 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -95,7 +95,6 @@ export default class extends Endpoint { // eslint- if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; const params = new URLSearchParams(); - params.append('auth_key', this.serverSettings.deeplAuthKey); params.append('text', note.text); params.append('target_lang', targetLang); @@ -104,6 +103,7 @@ export default class extends Endpoint { // eslint- const res = await this.httpRequestService.send(endpoint, { method: 'POST', headers: { + 'Authorization': `DeepL-Auth-Key ${this.serverSettings.deeplAuthKey}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json, */*', }, diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 614cd9204d..c0c8653f7b 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -84,6 +85,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -187,6 +189,17 @@ export default class extends Endpoint { // eslint- this.queryService.generateBaseNoteFilteringQuery(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + // -- ミュートされたチャンネルのリノート対策 + const mutedChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutedChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL') + .orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds }); + })); + } + if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { qb.orWhere('note.userId != :meId', { meId: me.id }); @@ -223,6 +236,7 @@ export default class extends Endpoint { // eslint- qb.orWhere(new Brackets(qb => { qb.orWhere('note.text IS NOT NULL'); qb.orWhere('note.fileIds != \'{}\''); + qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)'); })); })); } diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index e8a760e9f8..4515c016a8 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, RolesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; @@ -12,6 +13,7 @@ import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -68,6 +70,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, private fanoutTimelineService: FanoutTimelineService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -101,6 +104,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + // -- ミュートされたチャンネル対策 + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBaseNoteFilteringQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index 8301c85f2e..0e8dc73ad9 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -4,7 +4,6 @@ */ import * as os from 'node:os'; -import si from 'systeminformation'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { MiMeta } from '@/models/_.js'; @@ -93,6 +92,8 @@ export default class extends Endpoint { // eslint- }, }; + const si = await import('systeminformation'); + const memStats = await si.mem(); const fsStats = await si.fsSize(); diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 047f9a053b..3b3352858f 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -86,7 +86,7 @@ export const paramDef = { sinceDate: { type: 'integer' }, untilDate: { type: 'integer' }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, - birthday: { ...birthdaySchema, nullable: true }, + birthday: { ...birthdaySchema, nullable: true, description: '@deprecated use get-following-birthday-users instead.' }, }, }, ], @@ -146,15 +146,16 @@ export default class extends Endpoint { // eslint- .andWhere('following.followerId = :userId', { userId: user.id }) .innerJoinAndSelect('following.followee', 'followee'); + // @deprecated use get-following-birthday-users instead. if (ps.birthday) { - try { - const birthday = ps.birthday.substring(5, 10); - const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile'); - birthdayUserQuery.select('user_profile.userId') - .where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`); + query.innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId'); - query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`); - } catch (err) { + try { + const birthday = ps.birthday.split('-'); + birthday.shift(); // 年の部分を削除 + // なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応 + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: parseInt(birthday.join('')) }); + } catch (_) { throw new ApiError(meta.errors.birthdayInvalid); } } diff --git a/packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts b/packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts new file mode 100644 index 0000000000..124114244e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/get-following-birthday-users.ts @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + FollowingsRepository, + UserProfilesRepository, +} from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import type { Packed } from '@/misc/json-schema.js'; + +export const meta = { + tags: ['users'], + + requireCredential: true, + kind: 'read:account', + + description: 'Find users who have a birthday on the specified range.', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id', + }, + birthday: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer', default: 0 }, + birthday: { + oneOf: [{ + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, { + type: 'object', + properties: { + begin: { + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, + end: { + type: 'object', + properties: { + month: { type: 'integer', minimum: 1, maximum: 12 }, + day: { type: 'integer', minimum: 1, maximum: 31 }, + }, + required: ['month', 'day'], + }, + }, + required: ['begin', 'end'], + }], + }, + }, + required: ['birthday'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + @Inject(DI.followingsRepository) + private followingsRepository: FollowingsRepository, + + private userEntityService: UserEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.followingsRepository + .createQueryBuilder('following') + .andWhere('following.followerId = :userId', { userId: me.id }) + .innerJoin(this.userProfilesRepository.metadata.targetName, 'followeeProfile', 'followeeProfile.userId = following.followeeId'); + + if (Object.hasOwn(ps.birthday, 'begin') && Object.hasOwn(ps.birthday, 'end')) { + const range = ps.birthday as { begin: { month: number; day: number }; end: { month: number; day: number }; }; + + // 誕生日は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)でインデックスが効くようになっているので、その形式に変換 + const begin = range.begin.month * 100 + range.begin.day; + const end = range.end.month * 100 + range.end.day; + + if (begin <= end) { + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND :end', { begin, end }); + } else { + // 12/31 から 1/1 の範囲を取得するために OR で対応 + query.andWhere(new Brackets(qb => { + qb.where('get_birthday_date(followeeProfile.birthday) BETWEEN :begin AND 1231', { begin }); + qb.orWhere('get_birthday_date(followeeProfile.birthday) BETWEEN 101 AND :end', { end }); + })); + } + } else { + const { month, day } = ps.birthday as { month: number; day: number }; + // なぜか get_birthday_date() = :birthday だとインデックスが効かないので、BETWEEN で対応 + query.andWhere('get_birthday_date(followeeProfile.birthday) BETWEEN :birthday AND :birthday', { birthday: month * 100 + day }); + } + + query.select('following.followeeId', 'user_id'); + query.addSelect('get_birthday_date(followeeProfile.birthday)', 'birthday_date'); + query.orderBy('birthday_date', 'ASC'); + + const birthdayUsers = await query + .offset(ps.offset).limit(ps.limit) + .getRawMany<{ birthday_date: number; user_id: string }>(); + + const users = new Map>(( + await this.userEntityService.packMany( + birthdayUsers.map(u => u.user_id), + me, + { schema: 'UserLite' }, + ) + ).map(u => [u.id, u])); + + return birthdayUsers + .map(item => { + const birthday = new Date(); + birthday.setHours(0, 0, 0, 0); + // item.birthday_date は mmdd の形式の最大4桁の数字(例: 8月30日 → 830)で出力されるので、日付に戻してDateオブジェクトに設定 + birthday.setMonth(Math.floor(item.birthday_date / 100) - 1, item.birthday_date % 100); + + if (birthday.getTime() < new Date().setHours(0, 0, 0, 0)) { + birthday.setFullYear(new Date().getFullYear() + 1); + } + + const birthdayStr = `${birthday.getFullYear()}-${(birthday.getMonth() + 1).toString().padStart(2, '0')}-${(birthday.getDate()).toString().padStart(2, '0')}`; + return { + id: item.user_id, + birthday: birthdayStr, + user: users.get(item.user_id), + }; + }) + .filter(item => item.user != null) + .map(item => item as { id: string; birthday: string; user: Packed<'UserLite'> }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts index 8756801fe4..c6d477a92f 100644 --- a/packages/backend/src/server/api/endpoints/users/lists/show.ts +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -22,7 +22,26 @@ export const meta = { res: { type: 'object', optional: false, nullable: false, - ref: 'UserList', + allOf: [ + { + type: 'object', + ref: 'UserList', + }, + { + type: 'object', + optional: false, nullable: false, + properties: { + likedCount: { + type: 'number', + optional: true, nullable: false, + }, + isLiked: { + type: 'boolean', + optional: true, nullable: false, + }, + }, + }, + ], }, errors: { diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index 5832790a61..b9710250cf 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -16,6 +16,7 @@ import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { ApiError } from '@/server/api/error.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; export const meta = { tags: ['users', 'notes'], @@ -77,12 +78,12 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private noteEntityService: NoteEntityService, private queryService: QueryService, private cacheService: CacheService, private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -165,6 +166,11 @@ export default class extends Endpoint { // eslint- withFiles: boolean, withRenotes: boolean, }, me: MiLocalUser | null) { + const mutingChannelIds = me + ? await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)) + : []; const isSelf = me && (me.id === ps.userId); const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -177,14 +183,30 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.withChannelNotes) { - if (!isSelf) query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('channel.isSensitive = false'); + query.andWhere(new Brackets(qb => { + if (mutingChannelIds.length > 0) { + qb.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds: mutingChannelIds }); + } + + if (!isSelf) { + qb.andWhere(new Brackets(qb2 => { + qb2.orWhere('note.channelId IS NULL'); + qb2.orWhere('channel.isSensitive = false'); + })); + } })); } else { query.andWhere('note.channelId IS NULL'); } + // -- ミュートされたチャンネルのリノート対策 + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateBaseNoteFilteringQuery(query, me, { excludeAuthor: true, diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts index d6f1ecd8ed..d84a191f7a 100644 --- a/packages/backend/src/server/api/endpoints/users/reactions.ts +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -28,7 +28,7 @@ export const meta = { items: { type: 'object', optional: false, nullable: false, - ref: 'NoteReaction', + ref: 'NoteReactionWithNote', }, }, @@ -120,7 +120,7 @@ export default class extends Endpoint { // eslint- return true; }); - return await this.noteReactionEntityService.packMany(reactions, me, { withNote: true }); + return await this.noteReactionEntityService.packManyWithNote(reactions, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/verify-email.ts b/packages/backend/src/server/api/endpoints/verify-email.ts new file mode 100644 index 0000000000..e069ed59f2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/verify-email.ts @@ -0,0 +1,66 @@ +/* + * 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 type { UserProfilesRepository } from '@/models/_.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { ApiError } from '../error.js'; + +export const meta = { + requireCredential: false, + + tags: ['account'], + + errors: { + noSuchCode: { + message: 'No such code.', + code: 'NO_SUCH_CODE', + id: '97c1f576-e4b8-4b8a-a6dc-9cb65e7f6f85', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + code: { type: 'string' }, + }, + required: ['code'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private userEntityService: UserEntityService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps) => { + const profile = await this.userProfilesRepository.findOneBy({ + emailVerifyCode: ps.code, + }); + + if (profile == null) { + throw new ApiError(meta.errors.noSuchCode); + } + + await this.userProfilesRepository.update({ userId: profile.userId }, { + emailVerified: true, + emailVerifyCode: null, + }); + + this.globalEventService.publishMainStream(profile.userId, 'meUpdated', await this.userEntityService.pack(profile.userId, { id: profile.userId }, { + schema: 'MeDetailed', + includeSecrets: true, + })); + }); + } +} + diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index 1cdcbebd1a..0714f61294 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -9,9 +9,8 @@ import { refs } from '@/misc/json-schema.js'; export function convertSchemaToOpenApiSchema(schema: Schema, type: 'param' | 'res', includeSelfRef: boolean): any { // optional, nullable, refはスキーマ定義に含まれないので分離しておく - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { optional, nullable, ref, selfRef, ..._res }: any = schema; - const res = deepClone(_res); + const { optional, nullable, ref, selfRef, ...res1 }: any = schema; + const res = deepClone(res1); if (schema.type === 'object' && schema.properties) { if (type === 'res') { diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index c0ef589dea..63ad9281b2 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -4,72 +4,54 @@ */ import { Injectable } from '@nestjs/common'; +import { HybridTimelineChannel } from './channels/hybrid-timeline.js'; +import { LocalTimelineChannel } from './channels/local-timeline.js'; +import { HomeTimelineChannel } from './channels/home-timeline.js'; +import { GlobalTimelineChannel } from './channels/global-timeline.js'; +import { MainChannel } from './channels/main.js'; +import { ChannelChannel } from './channels/channel.js'; +import { AdminChannel } from './channels/admin.js'; +import { ServerStatsChannel } from './channels/server-stats.js'; +import { QueueStatsChannel } from './channels/queue-stats.js'; +import { UserListChannel } from './channels/user-list.js'; +import { AntennaChannel } from './channels/antenna.js'; +import { DriveChannel } from './channels/drive.js'; +import { HashtagChannel } from './channels/hashtag.js'; +import { RoleTimelineChannel } from './channels/role-timeline.js'; +import { ChatUserChannel } from './channels/chat-user.js'; +import { ChatRoomChannel } from './channels/chat-room.js'; +import { ReversiChannel } from './channels/reversi.js'; +import { ReversiGameChannel } from './channels/reversi-game.js'; +import type { ChannelConstructor } from './channel.js'; import { bindThis } from '@/decorators.js'; -import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; -import { LocalTimelineChannelService } from './channels/local-timeline.js'; -import { HomeTimelineChannelService } from './channels/home-timeline.js'; -import { GlobalTimelineChannelService } from './channels/global-timeline.js'; -import { MainChannelService } from './channels/main.js'; -import { ChannelChannelService } from './channels/channel.js'; -import { AdminChannelService } from './channels/admin.js'; -import { ServerStatsChannelService } from './channels/server-stats.js'; -import { QueueStatsChannelService } from './channels/queue-stats.js'; -import { UserListChannelService } from './channels/user-list.js'; -import { AntennaChannelService } from './channels/antenna.js'; -import { DriveChannelService } from './channels/drive.js'; -import { HashtagChannelService } from './channels/hashtag.js'; -import { RoleTimelineChannelService } from './channels/role-timeline.js'; -import { ChatUserChannelService } from './channels/chat-user.js'; -import { ChatRoomChannelService } from './channels/chat-room.js'; -import { ReversiChannelService } from './channels/reversi.js'; -import { ReversiGameChannelService } from './channels/reversi-game.js'; -import { type MiChannelService } from './channel.js'; @Injectable() export class ChannelsService { constructor( - private mainChannelService: MainChannelService, - private homeTimelineChannelService: HomeTimelineChannelService, - private localTimelineChannelService: LocalTimelineChannelService, - private hybridTimelineChannelService: HybridTimelineChannelService, - private globalTimelineChannelService: GlobalTimelineChannelService, - private userListChannelService: UserListChannelService, - private hashtagChannelService: HashtagChannelService, - private roleTimelineChannelService: RoleTimelineChannelService, - private antennaChannelService: AntennaChannelService, - private channelChannelService: ChannelChannelService, - private driveChannelService: DriveChannelService, - private serverStatsChannelService: ServerStatsChannelService, - private queueStatsChannelService: QueueStatsChannelService, - private adminChannelService: AdminChannelService, - private chatUserChannelService: ChatUserChannelService, - private chatRoomChannelService: ChatRoomChannelService, - private reversiChannelService: ReversiChannelService, - private reversiGameChannelService: ReversiGameChannelService, ) { } @bindThis - public getChannelService(name: string): MiChannelService { + public getChannelConstructor(name: string): ChannelConstructor { switch (name) { - case 'main': return this.mainChannelService; - case 'homeTimeline': return this.homeTimelineChannelService; - case 'localTimeline': return this.localTimelineChannelService; - case 'hybridTimeline': return this.hybridTimelineChannelService; - case 'globalTimeline': return this.globalTimelineChannelService; - case 'userList': return this.userListChannelService; - case 'hashtag': return this.hashtagChannelService; - case 'roleTimeline': return this.roleTimelineChannelService; - case 'antenna': return this.antennaChannelService; - case 'channel': return this.channelChannelService; - case 'drive': return this.driveChannelService; - case 'serverStats': return this.serverStatsChannelService; - case 'queueStats': return this.queueStatsChannelService; - case 'admin': return this.adminChannelService; - case 'chatUser': return this.chatUserChannelService; - case 'chatRoom': return this.chatRoomChannelService; - case 'reversi': return this.reversiChannelService; - case 'reversiGame': return this.reversiGameChannelService; + case 'main': return MainChannel; + case 'homeTimeline': return HomeTimelineChannel; + case 'localTimeline': return LocalTimelineChannel; + case 'hybridTimeline': return HybridTimelineChannel; + case 'globalTimeline': return GlobalTimelineChannel; + case 'userList': return UserListChannel; + case 'hashtag': return HashtagChannel; + case 'roleTimeline': return RoleTimelineChannel; + case 'antenna': return AntennaChannel; + case 'channel': return ChannelChannel; + case 'drive': return DriveChannel; + case 'serverStats': return ServerStatsChannel; + case 'queueStats': return QueueStatsChannel; + case 'admin': return AdminChannel; + case 'chatUser': return ChatUserChannel; + case 'chatRoom': return ChatRoomChannel; + case 'reversi': return ReversiChannel; + case 'reversiGame': return ReversiGameChannel; default: throw new Error(`no such channel: ${name}`); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 8e28ab263b..5989409997 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -6,18 +6,39 @@ import * as WebSocket from 'ws'; import type { MiUser } from '@/models/User.js'; import type { MiAccessToken } from '@/models/AccessToken.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { NotificationService } from '@/core/NotificationService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { MiFollowing, MiUserProfile } from '@/models/_.js'; -import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; +import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; -import { isJsonObject } from '@/misc/json-value.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import type { ChannelsService } from './ChannelsService.js'; +import { isJsonObject } from '@/misc/json-value.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; +import type { ChannelConstructor } from './channel.js'; +import type { ChannelRequest } from './channel.js'; +import { ContextIdFactory, ModuleRef, REQUEST } from '@nestjs/core'; +import { Inject, Injectable, Scope } from '@nestjs/common'; +import { MainChannel } from '@/server/api/stream/channels/main.js'; +import { HomeTimelineChannel } from '@/server/api/stream/channels/home-timeline.js'; +import { LocalTimelineChannel } from '@/server/api/stream/channels/local-timeline.js'; +import { HybridTimelineChannel } from '@/server/api/stream/channels/hybrid-timeline.js'; +import { GlobalTimelineChannel } from '@/server/api/stream/channels/global-timeline.js'; +import { UserListChannel } from '@/server/api/stream/channels/user-list.js'; +import { HashtagChannel } from '@/server/api/stream/channels/hashtag.js'; +import { RoleTimelineChannel } from '@/server/api/stream/channels/role-timeline.js'; +import { AntennaChannel } from '@/server/api/stream/channels/antenna.js'; +import { ChannelChannel } from '@/server/api/stream/channels/channel.js'; +import { DriveChannel } from '@/server/api/stream/channels/drive.js'; +import { ServerStatsChannel } from '@/server/api/stream/channels/server-stats.js'; +import { QueueStatsChannel } from '@/server/api/stream/channels/queue-stats.js'; +import { AdminChannel } from '@/server/api/stream/channels/admin.js'; +import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js'; +import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js'; +import { ReversiChannel } from '@/server/api/stream/channels/reversi.js'; +import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js'; const MAX_CHANNELS_PER_CONNECTION = 32; @@ -25,6 +46,7 @@ const MAX_CHANNELS_PER_CONNECTION = 32; * Main stream connection */ // eslint-disable-next-line import/no-default-export +@Injectable({ scope: Scope.TRANSIENT }) export default class Connection { public user?: MiUser; public token?: MiAccessToken; @@ -35,6 +57,7 @@ export default class Connection { public userProfile: MiUserProfile | null = null; public following: Record | undefined> = {}; public followingChannels: Set = new Set(); + public mutingChannels: Set = new Set(); public userIdsWhoMeMuting: Set = new Set(); public userIdsWhoBlockingMe: Set = new Set(); public userIdsWhoMeMutingRenotes: Set = new Set(); @@ -42,25 +65,34 @@ export default class Connection { private fetchIntervalId: NodeJS.Timeout | null = null; constructor( - private channelsService: ChannelsService, + private moduleRef: ModuleRef, private notificationService: NotificationService, private cacheService: CacheService, private channelFollowingService: ChannelFollowingService, - - user: MiUser | null | undefined, - token: MiAccessToken | null | undefined, + private channelMutingService: ChannelMutingService, + @Inject(REQUEST) + request: ConnectionRequest, ) { - if (user) this.user = user; - if (token) this.token = token; + if (request.user) this.user = request.user; + if (request.token) this.token = request.token; } @bindThis public async fetch() { if (this.user == null) return; - const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([ + const [ + userProfile, + following, + followingChannels, + mutingChannels, + userIdsWhoMeMuting, + userIdsWhoBlockingMe, + userIdsWhoMeMutingRenotes, + ] = await Promise.all([ this.cacheService.userProfileCache.fetch(this.user.id), this.cacheService.userFollowingsCache.fetch(this.user.id), this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id), + this.channelMutingService.mutingChannelsCache.fetch(this.user.id), this.cacheService.userMutingsCache.fetch(this.user.id), this.cacheService.userBlockedCache.fetch(this.user.id), this.cacheService.renoteMutingsCache.fetch(this.user.id), @@ -68,6 +100,7 @@ export default class Connection { this.userProfile = userProfile; this.following = following; this.followingChannels = followingChannels; + this.mutingChannels = mutingChannels; this.userIdsWhoMeMuting = userIdsWhoMeMuting; this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; @@ -106,7 +139,7 @@ export default class Connection { try { obj = JSON.parse(data.toString()); - } catch (e) { + } catch (_) { return; } @@ -220,28 +253,34 @@ export default class Connection { * チャンネルに接続 */ @bindThis - public connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { + public async connectChannel(id: string, params: JsonObject | undefined, channel: string, pong = false) { if (this.channels.length >= MAX_CHANNELS_PER_CONNECTION) { return; } - const channelService = this.channelsService.getChannelService(channel); + const channelConstructor = this.getChannelConstructor(channel); - if (channelService.requireCredential && this.user == null) { + if (channelConstructor.requireCredential && this.user == null) { return; } - if (this.token && ((channelService.kind && !this.token.permission.some(p => p === channelService.kind)) - || (!channelService.kind && channelService.requireCredential))) { + if (this.token && ((channelConstructor.kind && !this.token.permission.some(p => p === channelConstructor.kind)) + || (!channelConstructor.kind && channelConstructor.requireCredential))) { return; } // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 - if (channelService.shouldShare && this.channels.some(c => c.chName === channel)) { + if (channelConstructor.shouldShare && this.channels.some(c => c.chName === channel)) { return; } - const ch: Channel = channelService.create(id, this); + const contextId = ContextIdFactory.create(); + this.moduleRef.registerRequestByContextId({ + id: id, + connection: this, + }, contextId); + const ch: Channel = await this.moduleRef.create(channelConstructor, contextId); + this.channels.push(ch); ch.init(params ?? {}); @@ -252,6 +291,33 @@ export default class Connection { } } + @bindThis + public getChannelConstructor(name: string): ChannelConstructor { + switch (name) { + case 'main': return MainChannel; + case 'homeTimeline': return HomeTimelineChannel; + case 'localTimeline': return LocalTimelineChannel; + case 'hybridTimeline': return HybridTimelineChannel; + case 'globalTimeline': return GlobalTimelineChannel; + case 'userList': return UserListChannel; + case 'hashtag': return HashtagChannel; + case 'roleTimeline': return RoleTimelineChannel; + case 'antenna': return AntennaChannel; + case 'channel': return ChannelChannel; + case 'drive': return DriveChannel; + case 'serverStats': return ServerStatsChannel; + case 'queueStats': return QueueStatsChannel; + case 'admin': return AdminChannel; + case 'chatUser': return ChatUserChannel; + case 'chatRoom': return ChatRoomChannel; + case 'reversi': return ReversiChannel; + case 'reversiGame': return ReversiGameChannel; + + default: + throw new Error(`no such channel: ${name}`); + } + } + /** * チャンネルから切断 * @param id チャンネルコネクションID @@ -294,3 +360,8 @@ export default class Connection { } } } + +export interface ConnectionRequest { + user: MiUser | null | undefined, + token: MiAccessToken | null | undefined, +} diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 686aea423c..86b073414d 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -6,7 +6,8 @@ import { bindThis } from '@/decorators.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; +import { isChannelRelated } from '@/misc/is-channel-related.js'; import type { Packed } from '@/misc/json-schema.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import type Connection from './Connection.js'; @@ -21,7 +22,7 @@ export default abstract class Channel { public abstract readonly chName: string; public static readonly shouldShare: boolean; public static readonly requireCredential: boolean; - public static readonly kind?: string | null; + public static readonly kind: string | null; protected get user() { return this.connection.user; @@ -55,6 +56,10 @@ export default abstract class Channel { return this.connection.followingChannels; } + protected get mutingChannels() { + return this.connection.mutingChannels; + } + protected get subscriber() { return this.connection.subscriber; } @@ -74,12 +79,15 @@ export default abstract class Channel { // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + // 流れてきたNoteがミュートしているチャンネルと関わる + if (isChannelRelated(note, this.mutingChannels)) return true; + return false; } - constructor(id: string, connection: Connection) { - this.id = id; - this.connection = connection; + constructor(request: ChannelRequest) { + this.id = request.id; + this.connection = request.connection; } public send(payload: { type: string, body: JsonValue }): void; @@ -103,9 +111,14 @@ export default abstract class Channel { public onMessage?(type: string, body: JsonValue): void; } -export type MiChannelService = { +export interface ChannelRequest { + id: string, + connection: Connection, +} + +export interface ChannelConstructor { + new(...args: any[]): Channel; shouldShare: boolean; requireCredential: T; kind: T extends true ? string : string | null | undefined; - create: (id: string, connection: Connection) => Channel; -}; +} diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts index 355d5dba21..821888cca0 100644 --- a/packages/backend/src/server/api/stream/channels/admin.ts +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -3,17 +3,26 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class AdminChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class AdminChannel extends Channel { public readonly chName = 'admin'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:admin:stream'; + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); + } + @bindThis public async init(params: JsonObject) { // Subscribe admin stream @@ -22,22 +31,3 @@ class AdminChannel extends Channel { }); } } - -@Injectable() -export class AdminChannelService implements MiChannelService { - public readonly shouldShare = AdminChannel.shouldShare; - public readonly requireCredential = AdminChannel.requireCredential; - public readonly kind = AdminChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): AdminChannel { - return new AdminChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index e08562fdf9..ece9d2c8b1 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -3,14 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class AntennaChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class AntennaChannel extends Channel { public readonly chName = 'antenna'; public static shouldShare = false; public static requireCredential = true as const; @@ -18,12 +20,12 @@ class AntennaChannel extends Channel { private antennaId: string; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onEvent = this.onEvent.bind(this); } @@ -55,24 +57,3 @@ class AntennaChannel extends Channel { this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent); } } - -@Injectable() -export class AntennaChannelService implements MiChannelService { - public readonly shouldShare = AntennaChannel.shouldShare; - public readonly requireCredential = AntennaChannel.requireCredential; - public readonly kind = AntennaChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): AntennaChannel { - return new AntennaChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index ac79c31854..1706b17526 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -3,27 +3,31 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ChannelChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ChannelChannel extends Channel { public readonly chName = 'channel'; public static shouldShare = false; public static requireCredential = false as const; private channelId: string; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -40,6 +44,10 @@ class ChannelChannel extends Channel { private async onNote(note: Packed<'Note'>) { if (note.channelId !== this.channelId) return; + if (note.user.requireSigninToViewContents && this.user == null) return; + if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return; + if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return; + if (this.isNoteMutedOrBlocked(note)) return; if (this.user && isRenotePacked(note) && !isQuotePacked(note)) { @@ -52,30 +60,38 @@ class ChannelChannel extends Channel { this.send('note', note); } + /* + * ミュートとブロックされてるを処理する + */ + protected override isNoteMutedOrBlocked(note: Packed<'Note'>): boolean { + // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return true; + + // 流れてきたNoteがミュートしているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; + // 流れてきたNoteがブロックされているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true; + + // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの + if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + + // このソケットで見ているチャンネルがミュートされていたとしても、チャンネルを直接見ている以上は流すようにしたい + // ただし、他のミュートしているチャンネルは流さないようにもしたい + // ノート自体のチャンネルIDはonNoteでチェックしているので、ここではリノートのチャンネルIDをチェックする + if ( + (note.renote) && + (note.renote.channelId !== this.channelId) && + (note.renote.channelId && this.mutingChannels.has(note.renote.channelId)) + ) { + return true; + } + + return false; + } + @bindThis public dispose() { // Unsubscribe events this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class ChannelChannelService implements MiChannelService { - public readonly shouldShare = ChannelChannel.shouldShare; - public readonly requireCredential = ChannelChannel.requireCredential; - public readonly kind = ChannelChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChannelChannel { - return new ChannelChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts index eda333dd30..7f949032e2 100644 --- a/packages/backend/src/server/api/stream/channels/chat-room.ts +++ b/packages/backend/src/server/api/stream/channels/chat-room.ts @@ -3,14 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; import { ChatService } from '@/core/ChatService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ChatRoomChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ChatRoomChannel extends Channel { public readonly chName = 'chatRoom'; public static shouldShare = false; public static requireCredential = true as const; @@ -18,12 +20,12 @@ class ChatRoomChannel extends Channel { private roomId: string; constructor( - private chatService: ChatService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private chatService: ChatService, ) { - super(id, connection); + super(request); } @bindThis @@ -55,24 +57,3 @@ class ChatRoomChannel extends Channel { this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent); } } - -@Injectable() -export class ChatRoomChannelService implements MiChannelService { - public readonly shouldShare = ChatRoomChannel.shouldShare; - public readonly requireCredential = ChatRoomChannel.requireCredential; - public readonly kind = ChatRoomChannel.kind; - - constructor( - private chatService: ChatService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChatRoomChannel { - return new ChatRoomChannel( - this.chatService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/chat-user.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts index 5323484ed7..36f3f67b28 100644 --- a/packages/backend/src/server/api/stream/channels/chat-user.ts +++ b/packages/backend/src/server/api/stream/channels/chat-user.ts @@ -3,14 +3,16 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; import { ChatService } from '@/core/ChatService.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ChatUserChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ChatUserChannel extends Channel { public readonly chName = 'chatUser'; public static shouldShare = false; public static requireCredential = true as const; @@ -18,12 +20,12 @@ class ChatUserChannel extends Channel { private otherId: string; constructor( - private chatService: ChatService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private chatService: ChatService, ) { - super(id, connection); + super(request); } @bindThis @@ -55,24 +57,3 @@ class ChatUserChannel extends Channel { this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); } } - -@Injectable() -export class ChatUserChannelService implements MiChannelService { - public readonly shouldShare = ChatUserChannel.shouldShare; - public readonly requireCredential = ChatUserChannel.requireCredential; - public readonly kind = ChatUserChannel.kind; - - constructor( - private chatService: ChatService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ChatUserChannel { - return new ChatUserChannel( - this.chatService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts index 03768f3d23..6f2eb2c8f9 100644 --- a/packages/backend/src/server/api/stream/channels/drive.ts +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -3,17 +3,26 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class DriveChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class DriveChannel extends Channel { public readonly chName = 'drive'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:account'; + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); + } + @bindThis public async init(params: JsonObject) { // Subscribe drive stream @@ -22,22 +31,3 @@ class DriveChannel extends Channel { }); } } - -@Injectable() -export class DriveChannelService implements MiChannelService { - public readonly shouldShare = DriveChannel.shouldShare; - public readonly requireCredential = DriveChannel.requireCredential; - public readonly kind = DriveChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): DriveChannel { - return new DriveChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index d7c781ad12..be6be1b1e7 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class GlobalTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class GlobalTimelineChannel extends Channel { public readonly chName = 'globalTimeline'; public static shouldShare = false; public static requireCredential = false as const; @@ -21,14 +23,14 @@ class GlobalTimelineChannel extends Channel { private withFiles: boolean; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -74,28 +76,3 @@ class GlobalTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class GlobalTimelineChannelService implements MiChannelService { - public readonly shouldShare = GlobalTimelineChannel.shouldShare; - public readonly requireCredential = GlobalTimelineChannel.requireCredential; - public readonly kind = GlobalTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): GlobalTimelineChannel { - return new GlobalTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index c911d63642..1456b4f262 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -3,28 +3,30 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class HashtagChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class HashtagChannel extends Channel { public readonly chName = 'hashtag'; public static shouldShare = false; public static requireCredential = false as const; private q: string[][]; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -62,24 +64,3 @@ class HashtagChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HashtagChannelService implements MiChannelService { - public readonly shouldShare = HashtagChannel.shouldShare; - public readonly requireCredential = HashtagChannel.requireCredential; - public readonly kind = HashtagChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HashtagChannel { - return new HashtagChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 157d9fc279..665c11b692 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -3,15 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class HomeTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class HomeTimelineChannel extends Channel { public readonly chName = 'homeTimeline'; public static shouldShare = false; public static requireCredential = true as const; @@ -20,12 +22,12 @@ class HomeTimelineChannel extends Channel { private withFiles: boolean; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -44,7 +46,10 @@ class HomeTimelineChannel extends Channel { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (note.channelId) { - if (!this.followingChannels.has(note.channelId)) return; + // そのチャンネルをフォローしていない + if (!this.followingChannels.has(note.channelId)) { + return; + } } else { // その投稿のユーザーをフォローしていなかったら弾く if (!isMe && !Object.hasOwn(this.following, note.userId)) return; @@ -95,24 +100,3 @@ class HomeTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HomeTimelineChannelService implements MiChannelService { - public readonly shouldShare = HomeTimelineChannel.shouldShare; - public readonly requireCredential = HomeTimelineChannel.requireCredential; - public readonly kind = HomeTimelineChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HomeTimelineChannel { - return new HomeTimelineChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index db5b4576be..54250d2a90 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,9 +11,11 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class HybridTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class HybridTimelineChannel extends Channel { public readonly chName = 'hybridTimeline'; public static shouldShare = false; public static requireCredential = true as const; @@ -23,14 +25,14 @@ class HybridTimelineChannel extends Channel { private withFiles: boolean; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -53,16 +55,25 @@ class HybridTimelineChannel extends Channel { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - // チャンネルの投稿ではなく、自分自身の投稿 または - // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または - // チャンネルの投稿ではなく、全体公開のローカルの投稿 または - // フォローしているチャンネルの投稿 の場合だけ - if (!( - (note.channelId == null && isMe) || - (note.channelId == null && Object.hasOwn(this.following, note.userId)) || - (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || - (note.channelId != null && this.followingChannels.has(note.channelId)) - )) return; + if (!note.channelId) { + // 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする) + // - 自分自身の投稿 + // - その投稿のユーザーをフォローしている + // - 全体公開のローカルの投稿 + if (!( + isMe || + Object.hasOwn(this.following, note.userId) || + (note.user.host == null && note.visibility === 'public') + )) { + return; + } + } else { + // 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする) + // - フォローしているチャンネルの投稿 + if (!this.followingChannels.has(note.channelId)) { + return; + } + } if (note.visibility === 'followers') { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; @@ -109,28 +120,3 @@ class HybridTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class HybridTimelineChannelService implements MiChannelService { - public readonly shouldShare = HybridTimelineChannel.shouldShare; - public readonly requireCredential = HybridTimelineChannel.requireCredential; - public readonly kind = HybridTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): HybridTimelineChannel { - return new HybridTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 3d7ed6acdb..b394e9663f 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,25 +11,27 @@ import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class LocalTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class LocalTimelineChannel extends Channel { public readonly chName = 'localTimeline'; - public static shouldShare = false; + public static shouldShare = false as const; public static requireCredential = false as const; private withRenotes: boolean; private withReplies: boolean; private withFiles: boolean; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private metaService: MetaService, private roleService: RoleService, private noteEntityService: NoteEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -84,28 +86,3 @@ class LocalTimelineChannel extends Channel { this.subscriber.off('notesStream', this.onNote); } } - -@Injectable() -export class LocalTimelineChannelService implements MiChannelService { - public readonly shouldShare = LocalTimelineChannel.shouldShare; - public readonly requireCredential = LocalTimelineChannel.requireCredential; - public readonly kind = LocalTimelineChannel.kind; - - constructor( - private metaService: MetaService, - private roleService: RoleService, - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): LocalTimelineChannel { - return new LocalTimelineChannel( - this.metaService, - this.roleService, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts index 525f24c105..2ce53ac288 100644 --- a/packages/backend/src/server/api/stream/channels/main.ts +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -3,26 +3,28 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class MainChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class MainChannel extends Channel { public readonly chName = 'main'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:account'; constructor( - private noteEntityService: NoteEntityService, + @Inject(REQUEST) + request: ChannelRequest, - id: string, - connection: Channel['connection'], + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); } @bindThis @@ -61,24 +63,3 @@ class MainChannel extends Channel { }); } } - -@Injectable() -export class MainChannelService implements MiChannelService { - public readonly shouldShare = MainChannel.shouldShare; - public readonly requireCredential = MainChannel.requireCredential; - public readonly kind = MainChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): MainChannel { - return new MainChannel( - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index 91b62255b4..a87863f26c 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -4,21 +4,26 @@ */ import Xev from 'xev'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; const ev = new Xev(); -class QueueStatsChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class QueueStatsChannel extends Channel { public readonly chName = 'queueStats'; public static shouldShare = true; public static requireCredential = false as const; - constructor(id: string, connection: Channel['connection']) { - super(id, connection); + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); //this.onStats = this.onStats.bind(this); //this.onMessage = this.onMessage.bind(this); } @@ -56,22 +61,3 @@ class QueueStatsChannel extends Channel { ev.removeListener('queueStats', this.onStats); } } - -@Injectable() -export class QueueStatsChannelService implements MiChannelService { - public readonly shouldShare = QueueStatsChannel.shouldShare; - public readonly requireCredential = QueueStatsChannel.requireCredential; - public readonly kind = QueueStatsChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): QueueStatsChannel { - return new QueueStatsChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index 7597a1cfa3..58fc16e98c 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -3,31 +3,32 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { MiReversiGame } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { ReversiService } from '@/core/ReversiService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; import { reversiUpdateKeys } from 'misskey-js'; +import { REQUEST } from '@nestjs/core'; -class ReversiGameChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ReversiGameChannel extends Channel { public readonly chName = 'reversiGame'; public static shouldShare = false; public static requireCredential = false as const; private gameId: MiReversiGame['id'] | null = null; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private reversiService: ReversiService, private reversiGameEntityService: ReversiGameEntityService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); } @bindThis @@ -107,25 +108,3 @@ class ReversiGameChannel extends Channel { } } -@Injectable() -export class ReversiGameChannelService implements MiChannelService { - public readonly shouldShare = ReversiGameChannel.shouldShare; - public readonly requireCredential = ReversiGameChannel.requireCredential; - public readonly kind = ReversiGameChannel.kind; - - constructor( - private reversiService: ReversiService, - private reversiGameEntityService: ReversiGameEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ReversiGameChannel { - return new ReversiGameChannel( - this.reversiService, - this.reversiGameEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/reversi.ts b/packages/backend/src/server/api/stream/channels/reversi.ts index 6e88939724..5eff73eeef 100644 --- a/packages/backend/src/server/api/stream/channels/reversi.ts +++ b/packages/backend/src/server/api/stream/channels/reversi.ts @@ -3,22 +3,24 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class ReversiChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ReversiChannel extends Channel { public readonly chName = 'reversi'; public static shouldShare = true; public static requireCredential = true as const; public static kind = 'read:account'; constructor( - id: string, - connection: Channel['connection'], + @Inject(REQUEST) + request: ChannelRequest, ) { - super(id, connection); + super(request); } @bindThis @@ -32,22 +34,3 @@ class ReversiChannel extends Channel { this.subscriber.off(`reversiStream:${this.user!.id}`, this.send); } } - -@Injectable() -export class ReversiChannelService implements MiChannelService { - public readonly shouldShare = ReversiChannel.shouldShare; - public readonly requireCredential = ReversiChannel.requireCredential; - public readonly kind = ReversiChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ReversiChannel { - return new ReversiChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/role-timeline.ts b/packages/backend/src/server/api/stream/channels/role-timeline.ts index fcfa26c38b..99e0b69023 100644 --- a/packages/backend/src/server/api/stream/channels/role-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/role-timeline.ts @@ -3,28 +3,30 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class RoleTimelineChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class RoleTimelineChannel extends Channel { public readonly chName = 'roleTimeline'; public static shouldShare = false; public static requireCredential = false as const; private roleId: string; constructor( + @Inject(REQUEST) + request: ChannelRequest, + private noteEntityService: NoteEntityService, private roleservice: RoleService, - - id: string, - connection: Channel['connection'], ) { - super(id, connection); + super(request); //this.onNote = this.onNote.bind(this); } @@ -60,26 +62,3 @@ class RoleTimelineChannel extends Channel { this.subscriber.off(`roleTimelineStream:${this.roleId}`, this.onEvent); } } - -@Injectable() -export class RoleTimelineChannelService implements MiChannelService { - public readonly shouldShare = RoleTimelineChannel.shouldShare; - public readonly requireCredential = RoleTimelineChannel.requireCredential; - public readonly kind = RoleTimelineChannel.kind; - - constructor( - private noteEntityService: NoteEntityService, - private roleservice: RoleService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): RoleTimelineChannel { - return new RoleTimelineChannel( - this.noteEntityService, - this.roleservice, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index ec5352d12d..aece5435b0 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -4,21 +4,26 @@ */ import Xev from 'xev'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; const ev = new Xev(); -class ServerStatsChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class ServerStatsChannel extends Channel { public readonly chName = 'serverStats'; public static shouldShare = true; public static requireCredential = false as const; - constructor(id: string, connection: Channel['connection']) { - super(id, connection); + constructor( + @Inject(REQUEST) + request: ChannelRequest, + ) { + super(request); //this.onStats = this.onStats.bind(this); //this.onMessage = this.onMessage.bind(this); } @@ -54,22 +59,3 @@ class ServerStatsChannel extends Channel { ev.removeListener('serverStats', this.onStats); } } - -@Injectable() -export class ServerStatsChannelService implements MiChannelService { - public readonly shouldShare = ServerStatsChannel.shouldShare; - public readonly requireCredential = ServerStatsChannel.requireCredential; - public readonly kind = ServerStatsChannel.kind; - - constructor( - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): ServerStatsChannel { - return new ServerStatsChannel( - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index 5bfd8fa68c..2f7345e150 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Scope } from '@nestjs/common'; import type { MiUserListMembership, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -11,9 +11,11 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { JsonObject } from '@/misc/json-value.js'; -import Channel, { type MiChannelService } from '../channel.js'; +import Channel, { type ChannelRequest } from '../channel.js'; +import { REQUEST } from '@nestjs/core'; -class UserListChannel extends Channel { +@Injectable({ scope: Scope.TRANSIENT }) +export class UserListChannel extends Channel { public readonly chName = 'userList'; public static shouldShare = false; public static requireCredential = false as const; @@ -24,14 +26,18 @@ class UserListChannel extends Channel { private withRenotes: boolean; constructor( + @Inject(DI.userListsRepository) private userListsRepository: UserListsRepository, - private userListMembershipsRepository: UserListMembershipsRepository, - private noteEntityService: NoteEntityService, - id: string, - connection: Channel['connection'], + @Inject(DI.userListMembershipsRepository) + private userListMembershipsRepository: UserListMembershipsRepository, + + @Inject(REQUEST) + request: ChannelRequest, + + private noteEntityService: NoteEntityService, ) { - super(id, connection); + super(request); //this.updateListUsers = this.updateListUsers.bind(this); //this.onNote = this.onNote.bind(this); } @@ -130,32 +136,3 @@ class UserListChannel extends Channel { clearInterval(this.listUsersClock); } } - -@Injectable() -export class UserListChannelService implements MiChannelService { - public readonly shouldShare = UserListChannel.shouldShare; - public readonly requireCredential = UserListChannel.requireCredential; - public readonly kind = UserListChannel.kind; - - constructor( - @Inject(DI.userListsRepository) - private userListsRepository: UserListsRepository, - - @Inject(DI.userListMembershipsRepository) - private userListMembershipsRepository: UserListMembershipsRepository, - - private noteEntityService: NoteEntityService, - ) { - } - - @bindThis - public create(id: string, connection: Channel['connection']): UserListChannel { - return new UserListChannel( - this.userListsRepository, - this.userListMembershipsRepository, - this.noteEntityService, - id, - connection, - ); - } -} diff --git a/packages/backend/src/server/file/FileServerDriveHandler.ts b/packages/backend/src/server/file/FileServerDriveHandler.ts new file mode 100644 index 0000000000..51b527b146 --- /dev/null +++ b/packages/backend/src/server/file/FileServerDriveHandler.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import rename from 'rename'; +import type { Config } from '@/config.js'; +import type { IImageStreamable } from '@/core/ImageProcessingService.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { attachStreamCleanup, handleRangeRequest, setFileResponseHeaders, getSafeContentType, needsCleanup } from './FileServerUtils.js'; +import type { FileServerFileResolver } from './FileServerFileResolver.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +export class FileServerDriveHandler { + constructor( + private config: Config, + private fileResolver: FileServerFileResolver, + private assetsPath: string, + private videoProcessingService: VideoProcessingService, + ) {} + + public async handle(request: FastifyRequest<{ Params: { key: string } }>, reply: FastifyReply) { + const key = request.params.key; + const file = await this.fileResolver.resolveFileByAccessKey(key); + + if (file.kind === 'not-found') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', this.assetsPath); + } + + if (file.kind === 'unavailable') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + if (file.kind === 'remote') { + let image: IImageStreamable | null = null; + + if (file.fileRole === 'thumbnail') { + if (isMimeImage(file.mime, 'sharp-convertible-image-with-bmp')) { + reply.header('Cache-Control', 'max-age=31536000, immutable'); + + const url = new URL(`${this.config.mediaProxy}/static.webp`); + url.searchParams.set('url', file.url); + url.searchParams.set('static', '1'); + + file.cleanup(); + return await reply.redirect(url.toString(), 301); + } else if (file.mime.startsWith('video/')) { + const externalThumbnail = this.videoProcessingService.getExternalVideoThumbnailUrl(file.url); + if (externalThumbnail) { + file.cleanup(); + return await reply.redirect(externalThumbnail, 301); + } + + image = await this.videoProcessingService.generateVideoThumbnail(file.path); + } + } + + if (file.fileRole === 'webpublic') { + if (['image/svg+xml'].includes(file.mime)) { + reply.header('Cache-Control', 'max-age=31536000, immutable'); + + const url = new URL(`${this.config.mediaProxy}/svg.webp`); + url.searchParams.set('url', file.url); + + file.cleanup(); + return await reply.redirect(url.toString(), 301); + } + } + + image ??= { + data: handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path), + ext: file.ext, + type: file.mime, + }; + + attachStreamCleanup(image.data, file.cleanup); + + reply.header('Content-Type', getSafeContentType(image.type)); + reply.header('Content-Length', file.file.size); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext), + ), + ); + return image.data; + } + + if (file.fileRole !== 'original') { + const filename = rename(file.filename, { + suffix: file.fileRole === 'thumbnail' ? '-thumb' : '-web', + extname: file.ext ? `.${file.ext}` : '.unknown', + }).toString(); + + setFileResponseHeaders(reply, { mime: file.mime, filename }); + return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + } else { + setFileResponseHeaders(reply, { mime: file.file.type, filename: file.filename, size: file.file.size }); + return handleRangeRequest(reply, request.headers.range as string | undefined, file.file.size, file.path); + } + } catch (e) { + if (file.kind === 'remote') file.cleanup(); + throw e; + } + } +} diff --git a/packages/backend/src/server/file/FileServerFileResolver.ts b/packages/backend/src/server/file/FileServerFileResolver.ts new file mode 100644 index 0000000000..687d486efd --- /dev/null +++ b/packages/backend/src/server/file/FileServerFileResolver.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import type { DriveFilesRepository, MiDriveFile } from '@/models/_.js'; +import { createTemp } from '@/misc/create-temp.js'; +import type { DownloadService } from '@/core/DownloadService.js'; +import type { FileInfoService } from '@/core/FileInfoService.js'; +import type { InternalStorageService } from '@/core/InternalStorageService.js'; + +export type DownloadedFileResult = { + kind: 'downloaded'; + mime: string; + ext: string | null; + path: string; + cleanup: () => void; + filename: string; +}; + +export type FileResolveResult = + | { kind: 'not-found' } + | { kind: 'unavailable' } + | { + kind: 'stored'; + fileRole: 'thumbnail' | 'webpublic' | 'original'; + file: MiDriveFile; + filename: string; + mime: string; + ext: string | null; + path: string; + } + | { + kind: 'remote'; + fileRole: 'thumbnail' | 'webpublic' | 'original'; + file: MiDriveFile; + filename: string; + url: string; + mime: string; + ext: string | null; + path: string; + cleanup: () => void; + }; + +export class FileServerFileResolver { + constructor( + private driveFilesRepository: DriveFilesRepository, + private fileInfoService: FileInfoService, + private downloadService: DownloadService, + private internalStorageService: InternalStorageService, + ) {} + + public async downloadAndDetectTypeFromUrl(url: string): Promise { + const [path, cleanup] = await createTemp(); + try { + const { filename } = await this.downloadService.downloadUrl(url, path); + + const { mime, ext } = await this.fileInfoService.detectType(path); + + return { + kind: 'downloaded', + mime, ext, + path, cleanup, + filename, + }; + } catch (e) { + cleanup(); + throw e; + } + } + + public async resolveFileByAccessKey(key: string): Promise { + // Fetch drive file + const file = await this.driveFilesRepository.createQueryBuilder('file') + .where('file.accessKey = :accessKey', { accessKey: key }) + .orWhere('file.thumbnailAccessKey = :thumbnailAccessKey', { thumbnailAccessKey: key }) + .orWhere('file.webpublicAccessKey = :webpublicAccessKey', { webpublicAccessKey: key }) + .getOne(); + + if (file == null) return { kind: 'not-found' }; + + const isThumbnail = file.thumbnailAccessKey === key; + const isWebpublic = file.webpublicAccessKey === key; + + if (!file.storedInternal) { + if (!(file.isLink && file.uri)) return { kind: 'unavailable' }; + const result = await this.downloadAndDetectTypeFromUrl(file.uri); + const { kind: _kind, ...downloaded } = result; + file.size = (await fs.promises.stat(downloaded.path)).size; // DB file.sizeは正確とは限らないので + return { + kind: 'remote', + ...downloaded, + url: file.uri, + fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', + file, + filename: file.name, + }; + } + + const path = this.internalStorageService.resolvePath(key); + + if (isThumbnail || isWebpublic) { + const { mime, ext } = await this.fileInfoService.detectType(path); + return { + kind: 'stored', + fileRole: isThumbnail ? 'thumbnail' : 'webpublic', + file, + filename: file.name, + mime, ext, + path, + }; + } + + return { + kind: 'stored', + fileRole: 'original', + file, + filename: file.name, + // 古いファイルは修正前のmimeを持っているのでできるだけ修正してあげる + mime: this.fileInfoService.fixMime(file.type), + ext: null, + path, + }; + } +} diff --git a/packages/backend/src/server/file/FileServerProxyHandler.ts b/packages/backend/src/server/file/FileServerProxyHandler.ts new file mode 100644 index 0000000000..41e8e47ba5 --- /dev/null +++ b/packages/backend/src/server/file/FileServerProxyHandler.ts @@ -0,0 +1,272 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import sharp from 'sharp'; +import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; +import type { Config } from '@/config.js'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { StatusError } from '@/misc/status-error.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import { correctFilename } from '@/misc/correct-filename.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +import { IImageStreamable, ImageProcessingService, webpDefault } from '@/core/ImageProcessingService.js'; +import { createRangeStream, attachStreamCleanup, needsCleanup } from './FileServerUtils.js'; +import type { DownloadedFileResult, FileResolveResult, FileServerFileResolver } from './FileServerFileResolver.js'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +type ProxySource = DownloadedFileResult | FileResolveResult; +type CleanupableFile = ProxySource & { cleanup: () => void }; +type AvailableFile = Exclude; +type ProxyQuery = { + emoji?: string; + avatar?: string; + static?: string; + preview?: string; + badge?: string; + origin?: string; + url?: string; +}; + +export class FileServerProxyHandler { + constructor( + private config: Config, + private fileResolver: FileServerFileResolver, + private assetsPath: string, + private imageProcessingService: ImageProcessingService, + ) {} + + public async handle(request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, reply: FastifyReply) { + const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url; + + if (typeof url !== 'string') { + reply.code(400); + return; + } + + // アバタークロップなど、どうしてもオリジンである必要がある場合 + const mustOrigin = 'origin' in request.query; + + if (this.config.externalMediaProxyEnabled && !mustOrigin) { + return await this.redirectToExternalProxy(request, reply); + } + + this.validateUserAgent(request); + + // Create temp file + const file = await this.getStreamAndTypeFromUrl(url); + if (file.kind === 'not-found') { + reply.code(404); + reply.header('Cache-Control', 'max-age=86400'); + return reply.sendFile('/dummy.png', this.assetsPath); + } + + if (file.kind === 'unavailable') { + reply.code(204); + reply.header('Cache-Control', 'max-age=86400'); + return; + } + + try { + const image = await this.processImage(file, request, reply); + + if (needsCleanup(file)) { + attachStreamCleanup(image.data, file.cleanup); + } + + reply.header('Content-Type', image.type); + reply.header('Cache-Control', 'max-age=31536000, immutable'); + reply.header('Content-Disposition', + contentDisposition( + 'inline', + correctFilename(file.filename, image.ext), + ), + ); + return image.data; + } catch (e) { + if (needsCleanup(file)) file.cleanup(); + throw e; + } + } + + /** + * 外部メディアプロキシにリダイレクトする + */ + private async redirectToExternalProxy( + request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, + reply: FastifyReply, + ) { + reply.header('Cache-Control', 'public, max-age=259200'); // 3 days + + const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`); + + for (const [key, value] of Object.entries(request.query)) { + url.searchParams.append(key, value); + } + + return reply.redirect(url.toString(), 301); + } + + /** + * User-Agent を検証する + */ + private validateUserAgent(request: FastifyRequest): void { + if (!request.headers['user-agent']) { + throw new StatusError('User-Agent is required', 400, 'User-Agent is required'); + } + if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) { + throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive'); + } + } + + /** + * 画像を処理してストリーム可能な形式に変換する + */ + private async processImage( + file: AvailableFile, + request: FastifyRequest<{ Params: { url: string }; Querystring: ProxyQuery }>, + reply: FastifyReply, + ): Promise { + const query = request.query; + + const requiresImageConversion = 'emoji' in query || 'avatar' in query || 'static' in query || 'preview' in query || 'badge' in query; + const isConvertibleImage = isMimeImage(file.mime, 'sharp-convertible-image-with-bmp'); + if (requiresImageConversion && !isConvertibleImage) { + throw new StatusError('Unexpected mime', 404); + } + + if ('emoji' in query || 'avatar' in query) { + return this.processEmojiOrAvatar(file, query); + } + + if ('static' in query) { + return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 498, 422); + } + + if ('preview' in query) { + return this.imageProcessingService.convertSharpToWebpStream(await sharpBmp(file.path, file.mime), 200, 200); + } + + if ('badge' in query) { + return this.processBadge(file); + } + + if (file.mime === 'image/svg+xml') { + return this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048); + } + + if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) { + throw new StatusError('Rejected type', 403, 'Rejected type'); + } + + return this.createDefaultStream(file, request, reply); + } + + /** + * 絵文字またはアバター用の画像を処理する + */ + private async processEmojiOrAvatar( + file: AvailableFile, + query: Pick, + ): Promise { + const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image-with-bmp'); + if (!isAnimationConvertibleImage && !('static' in query)) { + return { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } + + const data = (await sharpBmp(file.path, file.mime, { animated: !('static' in query) })) + .resize({ + height: 'emoji' in query ? 128 : 320, + withoutEnlargement: true, + }) + .webp(webpDefault); + + return { + data, + ext: 'webp', + type: 'image/webp', + }; + } + + /** + * バッジ用の画像を処理する + */ + private async processBadge(file: AvailableFile): Promise { + const mask = (await sharpBmp(file.path, file.mime)) + .resize(96, 96, { + fit: 'contain', + position: 'centre', + withoutEnlargement: false, + }) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .toColorspace('b-w'); + + const stats = await mask.clone().stats(); + + if (stats.entropy < 0.1) { + throw new StatusError('Skip to provide badge', 404); + } + + const data = sharp({ + create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(await mask.png().toBuffer(), 'eor'); + + return { + data: await data.png().toBuffer(), + ext: 'png', + type: 'image/png', + }; + } + + /** + * デフォルトのストリームを作成する(Range リクエスト対応) + */ + private createDefaultStream( + file: AvailableFile, + request: FastifyRequest, + reply: FastifyReply, + ): IImageStreamable { + if (request.headers.range && 'file' in file && file.file.size > 0) { + const { stream, start, end, chunksize } = createRangeStream(request.headers.range as string, file.file.size, file.path); + + reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + + return { + data: stream, + ext: file.ext, + type: file.mime, + }; + } + + return { + data: fs.createReadStream(file.path), + ext: file.ext, + type: file.mime, + }; + } + + private async getStreamAndTypeFromUrl(url: string): Promise { + if (url.startsWith(`${this.config.url}/files/`)) { + const key = url.replace(`${this.config.url}/files/`, '').split('/').shift(); + if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key'); + + return await this.fileResolver.resolveFileByAccessKey(key); + } + + return await this.fileResolver.downloadAndDetectTypeFromUrl(url); + } +} diff --git a/packages/backend/src/server/file/FileServerUtils.ts b/packages/backend/src/server/file/FileServerUtils.ts new file mode 100644 index 0000000000..c5995a2cca --- /dev/null +++ b/packages/backend/src/server/file/FileServerUtils.ts @@ -0,0 +1,107 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { contentDisposition } from '@/misc/content-disposition.js'; +import type { IImageStreamable } from '@/core/ImageProcessingService.js'; +import type { FastifyReply } from 'fastify'; + +export type RangeStream = { + stream: fs.ReadStream; + start: number; + end: number; + chunksize: number; +}; + +/** + * Range リクエストに対応したストリームを作成する + */ +export function createRangeStream(rangeHeader: string, size: number, path: string): RangeStream { + const parts = rangeHeader.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + let end = parts[1] ? parseInt(parts[1], 10) : size - 1; + if (end > size) { + end = size - 1; + } + const chunksize = end - start + 1; + + return { + stream: fs.createReadStream(path, { start, end }), + start, + end, + chunksize, + }; +} + +/** + * ストリームにcleanupハンドラを設定する + * ストリームでない場合は即座にcleanupを実行する + */ +export function attachStreamCleanup(data: IImageStreamable['data'], cleanup: () => void): void { + if ('pipe' in data && typeof data.pipe === 'function') { + data.on('end', cleanup); + data.on('close', cleanup); + } else { + cleanup(); + } +} + +/** + * MIME タイプがブラウザセーフかどうかに応じて Content-Type を返す + */ +export function getSafeContentType(mime: string): string { + return FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : 'application/octet-stream'; +} + +/** + * Range リクエストを処理してストリームを返す + * Range ヘッダーがない場合は通常のストリームを返す + */ +export function handleRangeRequest( + reply: FastifyReply, + rangeHeader: string | undefined, + size: number, + path: string, +): fs.ReadStream { + if (rangeHeader && size > 0) { + const { stream, start, end, chunksize } = createRangeStream(rangeHeader, size, path); + reply.header('Content-Range', `bytes ${start}-${end}/${size}`); + reply.header('Accept-Ranges', 'bytes'); + reply.header('Content-Length', chunksize); + reply.code(206); + return stream; + } + return fs.createReadStream(path); +} + +export type FileResponseOptions = { + mime: string; + filename: string; + size?: number; + cacheControl?: string; +}; + +/** + * ファイルレスポンス用の共通ヘッダーを設定する + */ +export function setFileResponseHeaders( + reply: FastifyReply, + options: FileResponseOptions, +): void { + reply.header('Content-Type', getSafeContentType(options.mime)); + reply.header('Cache-Control', options.cacheControl ?? 'max-age=31536000, immutable'); + reply.header('Content-Disposition', contentDisposition('inline', options.filename)); + if (options.size !== undefined) { + reply.header('Content-Length', options.size); + } +} + +/** + * cleanup が必要なファイルかどうかを判定する型ガード + */ +export function needsCleanup void }>(file: T): file is T & { cleanup: () => void } { + return 'cleanup' in file && typeof file.cleanup === 'function'; +} diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index cdd7102666..47f4bf947d 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -6,18 +6,15 @@ import dns from 'node:dns/promises'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { JSDOM } from 'jsdom'; +import * as htmlParser from 'node-html-parser'; import httpLinkHeader from 'http-link-header'; import ipaddr from 'ipaddr.js'; import oauth2orize, { type OAuth2, AuthorizationError, ValidateFunctionArity2, OAuth2Req, MiddlewareRequest } from 'oauth2orize'; import oauth2Pkce from 'oauth2orize-pkce'; import fastifyCors from '@fastify/cors'; -import fastifyView from '@fastify/view'; -import pug from 'pug'; import bodyParser from 'body-parser'; import fastifyExpress from '@fastify/express'; import { verifyChallenge } from 'pkce-challenge'; -import { mf2 } from 'microformats-parser'; import { permissions as kinds } from 'misskey-js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -32,6 +29,8 @@ import { MemoryKVCache } from '@/misc/cache.js'; import { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; import { StatusError } from '@/misc/status-error.js'; +import { HtmlTemplateService } from '@/server/web/HtmlTemplateService.js'; +import { OAuthPage } from '@/server/web/views/oauth.js'; import type { ServerResponse } from 'node:http'; import type { FastifyInstance } from 'fastify'; @@ -98,45 +97,109 @@ interface ClientInformation { logo: string | null; } -// https://indieauth.spec.indieweb.org/#client-information-discovery -// "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, -// and if there is an [h-app] with a url property matching the client_id URL, -// then it should use the name and icon and display them on the authorization prompt." -// (But we don't display any icon for now) -// https://indieauth.spec.indieweb.org/#redirect-url -// "The client SHOULD publish one or more tags or Link HTTP headers with a rel attribute -// of redirect_uri at the client_id URL. -// Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST -// look for an exact match of the given redirect_uri in the request against the list of -// redirect_uris discovered after resolving any relative URLs." +function parseMicroformats(doc: htmlParser.HTMLElement, baseUrl: string, id: string): { name: string | null; logo: string | null; } { + let name: string | null = null; + let logo: string | null = null; + + const hApp = doc.querySelector('.h-app'); + if (hApp == null) return { name, logo }; + + const nameEl = hApp.querySelector('.p-name'); + if (nameEl != null) { + const href = nameEl.attributes.href || nameEl.attributes.src; + if (href != null && new URL(href, baseUrl).toString() === new URL(id).toString()) { + name = nameEl.textContent.trim(); + } + } + + const logoEl = hApp.querySelector('.u-logo'); + if (logoEl != null) { + const href = logoEl.attributes.href || logoEl.attributes.src; + if (href != null) { + logo = new URL(href, baseUrl).toString(); + } + } + + return { name, logo }; +} + async function discoverClientInformation(logger: Logger, httpRequestService: HttpRequestService, id: string): Promise { try { const res = await httpRequestService.send(id); - const redirectUris: string[] = []; + const redirectUris: string[] = []; + let name = id; + let logo: string | null = null; + + // https://indieauth.spec.indieweb.org/#redirect-url + // "The client SHOULD publish one or more tags or Link HTTP headers with a rel attribute + // of redirect_uri at the client_id URL. + // Authorization endpoints verifying that a redirect_uri is allowed for use by a client MUST + // look for an exact match of the given redirect_uri in the request against the list of + // redirect_uris discovered after resolving any relative URLs." const linkHeader = res.headers.get('link'); if (linkHeader) { redirectUris.push(...httpLinkHeader.parse(linkHeader).get('rel', 'redirect_uri').map(r => r.uri)); } - const text = await res.text(); - const fragment = JSDOM.fragment(text); + if (res.headers.get('content-type')?.includes('application/json')) { + // Client discovery via JSON document (11 July 2024 spec) + // https://indieauth.spec.indieweb.org/#client-metadata + // "Clients SHOULD have a JSON [RFC7159] document at their client_id URL containing + // client metadata defined in [RFC7591], the minimum properties for an IndieAuth + // client defined below." - redirectUris.push(...[...fragment.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.href)); + const json = await res.json() as { + client_id: string; + client_name?: string; + client_uri: string; + logo_uri?: string; + redirect_uris?: string[]; + }; - let name = id; - let logo: string | null = null; - if (text) { - const microformats = mf2(text, { baseUrl: res.url }); - const correspondingProperties = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id)); - if (correspondingProperties) { - const nameProperty = correspondingProperties.properties.name?.[0]; - if (typeof nameProperty === 'string') { - name = nameProperty; + // https://indieauth.spec.indieweb.org/#client-metadata-li-1 + // "The authorization server MUST verify that the client_id in the document matches the + // client_id of the URL where the document was retrieved." + if (json.client_id !== id) { + throw new AuthorizationError('client_id in the document does not match the client_id URL', 'invalid_request'); + } + + // https://indieauth.spec.indieweb.org/#client-metadata-li-1 + // "The client_uri MUST be a prefix of the client_id." + if (!json.client_uri || !id.startsWith(json.client_uri)) { + throw new AuthorizationError('client_uri is not a prefix of client_id', 'invalid_request'); + } + + if (typeof json.client_name === 'string') { + name = json.client_name; + } + + if (typeof json.logo_uri === 'string') { + // Since uri can be relative, resolve it against the document URL + logo = new URL(json.logo_uri, res.url).toString(); + } + + if (Array.isArray(json.redirect_uris)) { + redirectUris.push(...json.redirect_uris.filter((uri): uri is string => typeof uri === 'string')); + } + } else { + // Client discovery via HTML microformats (12 February 2022 spec) + // https://indieauth.spec.indieweb.org/20220212/#client-information-discovery + // "Authorization servers SHOULD support parsing the [h-app] Microformat from the client_id, + // and if there is an [h-app] with a url property matching the client_id URL, + // then it should use the name and icon and display them on the authorization prompt." + const text = await res.text(); + const doc = htmlParser.parse(`

${text}
`); + + redirectUris.push(...[...doc.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.attributes.href)); + + if (text) { + const microformats = parseMicroformats(doc, res.url, id); + if (typeof microformats.name === 'string') { + name = microformats.name; } - const logoProperty = correspondingProperties.properties.logo?.[0]; - if (typeof logoProperty === 'string') { - logo = logoProperty; + if (typeof microformats.logo === 'string') { + logo = microformats.logo; } } } @@ -152,6 +215,8 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt logger.error('Error while fetching client information', { err }); if (err instanceof StatusError) { throw new AuthorizationError('Failed to fetch client information', 'invalid_request'); + } else if (err instanceof AuthorizationError) { + throw err; } else { throw new AuthorizationError('Failed to parse client information', 'server_error'); } @@ -253,6 +318,7 @@ export class OAuth2ProviderService { private usersRepository: UsersRepository, private cacheService: CacheService, loggerService: LoggerService, + private htmlTemplateService: HtmlTemplateService, ) { this.#logger = loggerService.getLogger('oauth'); @@ -386,24 +452,16 @@ export class OAuth2ProviderService { this.#logger.info(`Rendering authorization page for "${oauth2.client.name}"`); reply.header('Cache-Control', 'no-store'); - return await reply.view('oauth', { + return await HtmlTemplateService.replyHtml(reply, OAuthPage({ + ...await this.htmlTemplateService.getCommonData(), transactionId: oauth2.transactionID, clientName: oauth2.client.name, - clientLogo: oauth2.client.logo, - scope: oauth2.req.scope.join(' '), - }); + clientLogo: oauth2.client.logo ?? undefined, + scope: oauth2.req.scope, + })); }); fastify.post('/decision', async () => { }); - fastify.register(fastifyView, { - root: fileURLToPath(new URL('../web/views', import.meta.url)), - engine: { pug }, - defaultContext: { - version: this.config.version, - config: this.config, - }, - }); - await fastify.register(fastifyExpress); fastify.use('/authorize', this.#server.authorize(((areq, done) => { (async (): Promise> => { diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index b515a0c0c8..24bc619e79 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -4,26 +4,22 @@ */ import { randomUUID } from 'node:crypto'; -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import sharp from 'sharp'; -import pug from 'pug'; import { In, IsNull } from 'typeorm'; import fastifyStatic from '@fastify/static'; -import fastifyView from '@fastify/view'; import fastifyProxy from '@fastify/http-proxy'; import vary from 'vary'; -import htmlSafeJsonStringify from 'htmlescape'; import type { Config } from '@/config.js'; -import { getNoteSummary } from '@/misc/get-note-summary.js'; import { DI } from '@/di-symbols.js'; import * as Acct from '@/misc/acct.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; -import { MetaEntityService } from '@/core/entities/MetaEntityService.js'; import { GalleryPostEntityService } from '@/core/entities/GalleryPostEntityService.js'; import { ClipEntityService } from '@/core/entities/ClipEntityService.js'; import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; @@ -42,26 +38,60 @@ import type { } from '@/models/_.js'; import type Logger from '@/logger.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js'; +import { htmlSafeJsonStringify } from '@/misc/json-stringify-html-safe.js'; import { bindThis } from '@/decorators.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; -import { RoleService } from '@/core/RoleService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { FeedService } from './FeedService.js'; import { UrlPreviewService } from './UrlPreviewService.js'; import { ClientLoggerService } from './ClientLoggerService.js'; -import type { FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; +import { HtmlTemplateService } from './HtmlTemplateService.js'; + +import { BasePage } from './views/base.js'; +import { UserPage } from './views/user.js'; +import { NotePage } from './views/note.js'; +import { PagePage } from './views/page.js'; +import { ClipPage } from './views/clip.js'; +import { FlashPage } from './views/flash.js'; +import { GalleryPostPage } from './views/gallery-post.js'; +import { ChannelPage } from './views/channel.js'; +import { ReversiGamePage } from './views/reversi-game.js'; +import { AnnouncementPage } from './views/announcement.js'; +import { BaseEmbed } from './views/base-embed.js'; +import { InfoCardPage } from './views/info-card.js'; +import { BiosPage } from './views/bios.js'; +import { CliPage } from './views/cli.js'; +import { FlushPage } from './views/flush.js'; +import { ErrorPage } from './views/error.js'; + +import type { FastifyError, FastifyInstance, FastifyPluginOptions, FastifyReply } from 'fastify'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -const staticAssets = `${_dirname}/../../../assets/`; -const clientAssets = `${_dirname}/../../../../frontend/assets/`; -const assets = `${_dirname}/../../../../../built/_frontend_dist_/`; -const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`; -const frontendViteOut = `${_dirname}/../../../../../built/_frontend_vite_/`; -const frontendEmbedViteOut = `${_dirname}/../../../../../built/_frontend_embed_vite_/`; -const tarball = `${_dirname}/../../../../../built/tarball/`; +let rootDir = _dirname; +// 見つかるまで上に遡る +while (!fs.existsSync(resolve(rootDir, 'packages'))) { + const parentDir = dirname(rootDir); + if (parentDir === rootDir) { + throw new Error('Cannot find root directory'); + } + rootDir = parentDir; +} + +const backendRootDir = resolve(rootDir, 'packages/backend'); +const frontendRootDir = resolve(rootDir, 'packages/frontend'); + +const staticAssets = resolve(backendRootDir, 'assets'); +const clientAssets = resolve(frontendRootDir, 'assets'); +const assets = resolve(rootDir, 'built/_frontend_dist_'); +const swAssets = resolve(rootDir, 'built/_sw_dist_'); +const fluentEmojisDir = resolve(rootDir, 'fluent-emojis/dist'); +const twemojiDir = resolve(backendRootDir, 'node_modules/@discordapp/twemoji/dist/svg'); +const frontendViteOut = resolve(rootDir, 'built/_frontend_vite_'); +const frontendEmbedViteOut = resolve(rootDir, 'built/_frontend_embed_vite_'); +const tarball = resolve(rootDir, 'built/tarball'); @Injectable() export class ClientServerService { @@ -108,7 +138,6 @@ export class ClientServerService { private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, private pageEntityService: PageEntityService, - private metaEntityService: MetaEntityService, private galleryPostEntityService: GalleryPostEntityService, private clipEntityService: ClipEntityService, private channelEntityService: ChannelEntityService, @@ -116,7 +145,7 @@ export class ClientServerService { private announcementEntityService: AnnouncementEntityService, private urlPreviewService: UrlPreviewService, private feedService: FeedService, - private roleService: RoleService, + private htmlTemplateService: HtmlTemplateService, private clientLoggerService: ClientLoggerService, ) { //this.createServer = this.createServer.bind(this); @@ -182,35 +211,9 @@ export class ClientServerService { return (manifest); } - @bindThis - private async generateCommonPugData(meta: MiMeta) { - return { - instanceName: meta.name ?? 'Misskey', - icon: meta.iconUrl, - appleTouchIcon: meta.app512IconUrl, - themeColor: meta.themeColor, - serverErrorImageUrl: meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg', - infoImageUrl: meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg', - notFoundImageUrl: meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg', - instanceUrl: this.config.url, - metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(meta)), - now: Date.now(), - federationEnabled: this.meta.federation !== 'none', - }; - } - @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.register(fastifyView, { - root: _dirname + '/views', - engine: { - pug: pug, - }, - defaultContext: { - version: this.config.version, - config: this.config, - }, - }); + const configUrl = new URL(this.config.url); fastify.addHook('onRequest', (request, reply, done) => { // クリックジャッキング防止のためiFrameの中に入れられないようにする @@ -220,6 +223,7 @@ export class ClientServerService { //#region vite assets if (this.config.frontendEmbedManifestExists) { + console.log(`[ClientServerService] Using built frontend vite assets. ${frontendViteOut}`); fastify.register((fastify, options, done) => { fastify.register(fastifyStatic, { root: frontendViteOut, @@ -239,7 +243,7 @@ export class ClientServerService { done(); }); } else { - const configUrl = new URL(this.config.url); + console.log('[ClientServerService] Proxying to Vite dev server.'); const urlOriginWithoutPort = configUrl.origin.replace(/:\d+$/, ''); const port = (process.env.VITE_PORT ?? '5173'); @@ -311,7 +315,7 @@ export class ClientServerService { reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - return await reply.sendFile(path, `${_dirname}/../../../../../fluent-emojis/dist/`, { + return reply.sendFile(path, fluentEmojisDir, { maxAge: ms('30 days'), }); }); @@ -326,7 +330,7 @@ export class ClientServerService { reply.header('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); - return await reply.sendFile(path, `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, { + return reply.sendFile(path, twemojiDir, { maxAge: ms('30 days'), }); }); @@ -340,7 +344,7 @@ export class ClientServerService { } const mask = await sharp( - `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`, + `${twemojiDir}/${path.replace('.png', '')}.svg`, { density: 1000 }, ) .resize(488, 488) @@ -413,16 +417,15 @@ export class ClientServerService { //#endregion - const renderBase = async (reply: FastifyReply, data: { [key: string]: any } = {}) => { + const renderBase = async (reply: FastifyReply, data: Partial[0]> = {}) => { reply.header('Cache-Control', 'public, max-age=30'); - return await reply.view('base', { - img: this.meta.bannerUrl, - url: this.config.url, + return await HtmlTemplateService.replyHtml(reply, BasePage({ + img: this.meta.bannerUrl ?? undefined, title: this.meta.name ?? 'Misskey', - desc: this.meta.description, - ...await this.generateCommonPugData(this.meta), + desc: this.meta.description ?? undefined, + ...await this.htmlTemplateService.getCommonData(), ...data, - }); + })); }; // URL preview endpoint @@ -504,11 +507,6 @@ export class ClientServerService { ) ) { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - const me = profile.fields - ? profile.fields - .filter(filed => filed.value != null && filed.value.match(/^https?:/)) - .map(field => field.value) - : []; reply.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { @@ -521,15 +519,15 @@ export class ClientServerService { userProfile: profile, }); - return await reply.view('user', { - user, profile, me, - avatarUrl: _user.avatarUrl, + return await HtmlTemplateService.replyHtml(reply, UserPage({ + user: _user, + profile, sub: request.params.sub, - ...await this.generateCommonPugData(this.meta), - clientCtx: htmlSafeJsonStringify({ + ...await this.htmlTemplateService.getCommonData(), + clientCtxJson: htmlSafeJsonStringify({ user: _user, }), - }); + })); } else { // リモートユーザーなので // モデレータがAPI経由で参照可能にするために404にはしない @@ -580,17 +578,14 @@ export class ClientServerService { reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noai'); } - return await reply.view('note', { + return await HtmlTemplateService.replyHtml(reply, NotePage({ note: _note, profile, - avatarUrl: _note.user.avatarUrl, - // TODO: Let locale changeable by instance setting - summary: getNoteSummary(_note), - ...await this.generateCommonPugData(this.meta), - clientCtx: htmlSafeJsonStringify({ + ...await this.htmlTemplateService.getCommonData(), + clientCtxJson: htmlSafeJsonStringify({ note: _note, }), - }); + })); } else { return await renderBase(reply); } @@ -623,12 +618,11 @@ export class ClientServerService { reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noai'); } - return await reply.view('page', { + return await HtmlTemplateService.replyHtml(reply, PagePage({ page: _page, profile, - avatarUrl: _page.user.avatarUrl, - ...await this.generateCommonPugData(this.meta), - }); + ...await this.htmlTemplateService.getCommonData(), + })); } else { return await renderBase(reply); } @@ -648,12 +642,11 @@ export class ClientServerService { reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noai'); } - return await reply.view('flash', { + return await HtmlTemplateService.replyHtml(reply, FlashPage({ flash: _flash, profile, - avatarUrl: _flash.user.avatarUrl, - ...await this.generateCommonPugData(this.meta), - }); + ...await this.htmlTemplateService.getCommonData(), + })); } else { return await renderBase(reply); } @@ -673,15 +666,14 @@ export class ClientServerService { reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noai'); } - return await reply.view('clip', { + return await HtmlTemplateService.replyHtml(reply, ClipPage({ clip: _clip, profile, - avatarUrl: _clip.user.avatarUrl, - ...await this.generateCommonPugData(this.meta), - clientCtx: htmlSafeJsonStringify({ + ...await this.htmlTemplateService.getCommonData(), + clientCtxJson: htmlSafeJsonStringify({ clip: _clip, }), - }); + })); } else { return await renderBase(reply); } @@ -699,12 +691,11 @@ export class ClientServerService { reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noai'); } - return await reply.view('gallery-post', { - post: _post, + return await HtmlTemplateService.replyHtml(reply, GalleryPostPage({ + galleryPost: _post, profile, - avatarUrl: _post.user.avatarUrl, - ...await this.generateCommonPugData(this.meta), - }); + ...await this.htmlTemplateService.getCommonData(), + })); } else { return await renderBase(reply); } @@ -719,10 +710,10 @@ export class ClientServerService { if (channel) { const _channel = await this.channelEntityService.pack(channel); reply.header('Cache-Control', 'public, max-age=15'); - return await reply.view('channel', { + return await HtmlTemplateService.replyHtml(reply, ChannelPage({ channel: _channel, - ...await this.generateCommonPugData(this.meta), - }); + ...await this.htmlTemplateService.getCommonData(), + })); } else { return await renderBase(reply); } @@ -737,10 +728,10 @@ export class ClientServerService { if (game) { const _game = await this.reversiGameEntityService.packDetail(game); reply.header('Cache-Control', 'public, max-age=3600'); - return await reply.view('reversi-game', { - game: _game, - ...await this.generateCommonPugData(this.meta), - }); + return await HtmlTemplateService.replyHtml(reply, ReversiGamePage({ + reversiGame: _game, + ...await this.htmlTemplateService.getCommonData(), + })); } else { return await renderBase(reply); } @@ -756,10 +747,10 @@ export class ClientServerService { if (announcement) { const _announcement = await this.announcementEntityService.pack(announcement); reply.header('Cache-Control', 'public, max-age=3600'); - return await reply.view('announcement', { + return await HtmlTemplateService.replyHtml(reply, AnnouncementPage({ announcement: _announcement, - ...await this.generateCommonPugData(this.meta), - }); + ...await this.htmlTemplateService.getCommonData(), + })); } else { return await renderBase(reply); } @@ -792,13 +783,13 @@ export class ClientServerService { const _user = await this.userEntityService.pack(user); reply.header('Cache-Control', 'public, max-age=3600'); - return await reply.view('base-embed', { + return await HtmlTemplateService.replyHtml(reply, BaseEmbed({ title: this.meta.name ?? 'Misskey', - ...await this.generateCommonPugData(this.meta), - embedCtx: htmlSafeJsonStringify({ + ...await this.htmlTemplateService.getCommonData(), + embedCtxJson: htmlSafeJsonStringify({ user: _user, }), - }); + })); }); fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => { @@ -818,13 +809,13 @@ export class ClientServerService { const _note = await this.noteEntityService.pack(note, null, { detail: true }); reply.header('Cache-Control', 'public, max-age=3600'); - return await reply.view('base-embed', { + return await HtmlTemplateService.replyHtml(reply, BaseEmbed({ title: this.meta.name ?? 'Misskey', - ...await this.generateCommonPugData(this.meta), - embedCtx: htmlSafeJsonStringify({ + ...await this.htmlTemplateService.getCommonData(), + embedCtxJson: htmlSafeJsonStringify({ note: _note, }), - }); + })); }); fastify.get<{ Params: { clip: string; } }>('/embed/clips/:clip', async (request, reply) => { @@ -839,55 +830,66 @@ export class ClientServerService { const _clip = await this.clipEntityService.pack(clip); reply.header('Cache-Control', 'public, max-age=3600'); - return await reply.view('base-embed', { + return await HtmlTemplateService.replyHtml(reply, BaseEmbed({ title: this.meta.name ?? 'Misskey', - ...await this.generateCommonPugData(this.meta), - embedCtx: htmlSafeJsonStringify({ + ...await this.htmlTemplateService.getCommonData(), + embedCtxJson: htmlSafeJsonStringify({ clip: _clip, }), - }); + })); }); fastify.get('/embed/*', async (request, reply) => { reply.removeHeader('X-Frame-Options'); reply.header('Cache-Control', 'public, max-age=3600'); - return await reply.view('base-embed', { + return await HtmlTemplateService.replyHtml(reply, BaseEmbed({ title: this.meta.name ?? 'Misskey', - ...await this.generateCommonPugData(this.meta), - }); + ...await this.htmlTemplateService.getCommonData(), + })); }); fastify.get('/_info_card_', async (request, reply) => { reply.removeHeader('X-Frame-Options'); - return await reply.view('info-card', { + return await HtmlTemplateService.replyHtml(reply, InfoCardPage({ version: this.config.version, - host: this.config.host, + config: this.config, meta: this.meta, - originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }), - originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), - }); + })); }); //#endregion fastify.get('/bios', async (request, reply) => { - return await reply.view('bios', { + return await HtmlTemplateService.replyHtml(reply, BiosPage({ version: this.config.version, - }); + })); }); fastify.get('/cli', async (request, reply) => { - return await reply.view('cli', { + return await HtmlTemplateService.replyHtml(reply, CliPage({ version: this.config.version, - }); + })); }); - const override = (source: string, target: string, depth = 0) => - [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); - fastify.get('/flush', async (request, reply) => { - return await reply.view('flush'); + let sendHeader = true; + + if (request.headers['origin']) { + const originURL = new URL(request.headers['origin']); + if (originURL.protocol !== 'https:') { // Clear-Site-Data only supports https + sendHeader = false; + } + if (originURL.host !== configUrl.host) { + sendHeader = false; + } + } + + if (sendHeader) { + reply.header('Clear-Site-Data', '"*"'); + } + reply.header('Set-Cookie', 'http-flush-failed=1; Path=/flush; Max-Age=60'); + return await HtmlTemplateService.replyHtml(reply, FlushPage()); }); // streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる @@ -901,7 +903,7 @@ export class ClientServerService { return await renderBase(reply); }); - fastify.setErrorHandler(async (error, request, reply) => { + fastify.setErrorHandler(async (error, request, reply) => { const errId = randomUUID(); this.clientLoggerService.logger.error(`Internal error occurred in ${request.routeOptions.url}: ${error.message}`, { path: request.routeOptions.url, @@ -913,10 +915,10 @@ export class ClientServerService { }); reply.code(500); reply.header('Cache-Control', 'max-age=10, must-revalidate'); - return await reply.view('error', { + return await HtmlTemplateService.replyHtml(reply, ErrorPage({ code: error.code, id: errId, - }); + })); }); done(); diff --git a/packages/backend/src/server/web/HtmlTemplateService.ts b/packages/backend/src/server/web/HtmlTemplateService.ts new file mode 100644 index 0000000000..8ff985530d --- /dev/null +++ b/packages/backend/src/server/web/HtmlTemplateService.ts @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promises as fsp } from 'node:fs'; +import { languages } from 'i18n/const'; +import { Injectable, Inject } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { htmlSafeJsonStringify } from '@/misc/json-stringify-html-safe.js'; +import { MetaEntityService } from '@/core/entities/MetaEntityService.js'; +import type { FastifyReply } from 'fastify'; +import type { Config } from '@/config.js'; +import type { MiMeta } from '@/models/Meta.js'; +import type { CommonData } from './views/_.js'; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const frontendVitePublic = `${_dirname}/../../../../frontend/public/`; +const frontendEmbedVitePublic = `${_dirname}/../../../../frontend-embed/public/`; + +@Injectable() +export class HtmlTemplateService { + private frontendBootloadersFetched = false; + public frontendBootloaderJs: string | null = null; + public frontendBootloaderCss: string | null = null; + public frontendEmbedBootloaderJs: string | null = null; + public frontendEmbedBootloaderCss: string | null = null; + + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.meta) + private meta: MiMeta, + + private metaEntityService: MetaEntityService, + ) { + } + + @bindThis + private async prepareFrontendBootloaders() { + if (this.frontendBootloadersFetched) return; + this.frontendBootloadersFetched = true; + + const [bootJs, bootCss, embedBootJs, embedBootCss] = await Promise.all([ + fsp.readFile(`${frontendVitePublic}loader/boot.js`, 'utf-8').catch(() => null), + fsp.readFile(`${frontendVitePublic}loader/style.css`, 'utf-8').catch(() => null), + fsp.readFile(`${frontendEmbedVitePublic}loader/boot.js`, 'utf-8').catch(() => null), + fsp.readFile(`${frontendEmbedVitePublic}loader/style.css`, 'utf-8').catch(() => null), + ]); + + if (bootJs != null) { + this.frontendBootloaderJs = bootJs; + } + + if (bootCss != null) { + this.frontendBootloaderCss = bootCss; + } + + if (embedBootJs != null) { + this.frontendEmbedBootloaderJs = embedBootJs; + } + + if (embedBootCss != null) { + this.frontendEmbedBootloaderCss = embedBootCss; + } + } + + @bindThis + public async getCommonData(): Promise { + await this.prepareFrontendBootloaders(); + + return { + version: this.config.version, + config: this.config, + langs: [...languages], + instanceName: this.meta.name ?? 'Misskey', + icon: this.meta.iconUrl, + appleTouchIcon: this.meta.app512IconUrl, + themeColor: this.meta.themeColor, + serverErrorImageUrl: this.meta.serverErrorImageUrl ?? 'https://xn--931a.moe/assets/error.jpg', + infoImageUrl: this.meta.infoImageUrl ?? 'https://xn--931a.moe/assets/info.jpg', + notFoundImageUrl: this.meta.notFoundImageUrl ?? 'https://xn--931a.moe/assets/not-found.jpg', + instanceUrl: this.config.url, + metaJson: htmlSafeJsonStringify(await this.metaEntityService.packDetailed(this.meta)), + now: Date.now(), + federationEnabled: this.meta.federation !== 'none', + frontendBootloaderJs: this.frontendBootloaderJs, + frontendBootloaderCss: this.frontendBootloaderCss, + frontendEmbedBootloaderJs: this.frontendEmbedBootloaderJs, + frontendEmbedBootloaderCss: this.frontendEmbedBootloaderCss, + }; + } + + public static async replyHtml(reply: FastifyReply, html: string | Promise) { + reply.header('Content-Type', 'text/html; charset=utf-8'); + const _html = await html; + return reply.send(_html); + } +} diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index b9a4015031..bd1dbb430c 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -4,8 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { summaly } from '@misskey-dev/summaly'; -import { SummalyResult } from '@misskey-dev/summaly/built/summary.js'; +import type { SummalyResult } from '@misskey-dev/summaly/built/summary.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; @@ -113,7 +112,7 @@ export class UrlPreviewService { } } - private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise { + private async fetchSummary(url: string, meta: MiMeta, lang?: string): Promise { const agent = this.config.proxy ? { http: this.httpRequestService.httpAgent, @@ -121,6 +120,8 @@ export class UrlPreviewService { } : undefined; + const { summaly } = await import('@misskey-dev/summaly'); + return summaly(url, { followRedirects: this.meta.urlPreviewAllowRedirect, lang: lang ?? 'ja-JP', diff --git a/packages/backend/src/server/web/views/_.ts b/packages/backend/src/server/web/views/_.ts new file mode 100644 index 0000000000..ac7418f362 --- /dev/null +++ b/packages/backend/src/server/web/views/_.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Config } from '@/config.js'; + +export const comment = ``; + +export const defaultDescription = '✨🌎✨ A interplanetary communication platform ✨🚀✨'; + +export type MinimumCommonData = { + version: string; + config: Config; +}; + +export type CommonData = MinimumCommonData & { + langs: string[]; + instanceName: string; + icon: string | null; + appleTouchIcon: string | null; + themeColor: string | null; + serverErrorImageUrl: string; + infoImageUrl: string; + notFoundImageUrl: string; + instanceUrl: string; + now: number; + federationEnabled: boolean; + frontendBootloaderJs: string | null; + frontendBootloaderCss: string | null; + frontendEmbedBootloaderJs: string | null; + frontendEmbedBootloaderCss: string | null; + metaJson?: string; + clientCtxJson?: string; +}; + +export type CommonPropsMinimum> = MinimumCommonData & T; + +export type CommonProps> = CommonData & T; diff --git a/packages/backend/src/server/web/views/_splash.tsx b/packages/backend/src/server/web/views/_splash.tsx new file mode 100644 index 0000000000..ea79b8d61d --- /dev/null +++ b/packages/backend/src/server/web/views/_splash.tsx @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function Splash(props: { + icon?: string | null; +}) { + return ( +
+ +
+ + + + + + + + + + +
+
+ ); +} diff --git a/packages/backend/src/server/web/views/announcement.pug b/packages/backend/src/server/web/views/announcement.pug deleted file mode 100644 index 7a4052e8a4..0000000000 --- a/packages/backend/src/server/web/views/announcement.pug +++ /dev/null @@ -1,21 +0,0 @@ -extends ./base - -block vars - - const title = announcement.title; - - const description = announcement.text.length > 100 ? announcement.text.slice(0, 100) + '…' : announcement.text; - - const url = `${config.url}/announcements/${announcement.id}`; - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content=description) - -block og - meta(property='og:type' content='article') - meta(property='og:title' content= title) - meta(property='og:description' content= description) - meta(property='og:url' content= url) - if announcement.imageUrl - meta(property='og:image' content=announcement.imageUrl) - meta(property='twitter:card' content='summary_large_image') diff --git a/packages/backend/src/server/web/views/announcement.tsx b/packages/backend/src/server/web/views/announcement.tsx new file mode 100644 index 0000000000..bc1c808177 --- /dev/null +++ b/packages/backend/src/server/web/views/announcement.tsx @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Packed } from '@/misc/json-schema.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; + +export function AnnouncementPage(props: CommonProps<{ + announcement: Packed<'Announcement'>; +}>) { + const description = props.announcement.text.length > 100 ? props.announcement.text.slice(0, 100) + '…' : props.announcement.text; + + function ogBlock() { + return ( + <> + + + + + {props.announcement.imageUrl ? ( + <> + + + + ) : null} + + ); + } + + return ( + + + ); +} diff --git a/packages/backend/src/server/web/views/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug deleted file mode 100644 index 29de86b8b6..0000000000 --- a/packages/backend/src/server/web/views/base-embed.pug +++ /dev/null @@ -1,71 +0,0 @@ -block vars - -block loadClientEntry - - const entry = config.frontendEmbedEntry; - -doctype html - -html(class='embed') - - head - meta(charset='utf-8') - meta(name='application-name' content='Misskey') - meta(name='referrer' content='origin') - meta(name='theme-color' content= themeColor || '#86b300') - meta(name='theme-color-orig' content= themeColor || '#86b300') - meta(property='og:site_name' content= instanceName || 'Misskey') - meta(property='instance_url' content= instanceUrl) - meta(name='viewport' content='width=device-width, initial-scale=1') - meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no') - link(rel='icon' href= icon || '/favicon.ico') - link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png') - - if !config.frontendEmbedManifestExists - script(type="module" src="/embed_vite/@vite/client") - - if Array.isArray(entry.css) - each href in entry.css - link(rel='stylesheet' href=`/embed_vite/${href}`) - - title - block title - = title || 'Misskey' - - block meta - meta(name='robots' content='noindex') - - style - include ../style.embed.css - - script. - var VERSION = "#{version}"; - var CLIENT_ENTRY = !{JSON.stringify(entry.file)}; - - script(type='application/json' id='misskey_meta' data-generated-at=now) - != metaJson - - script(type='application/json' id='misskey_embedCtx' data-generated-at=now) - != embedCtx - - script - include ../boot.embed.js - - body - noscript: p - | JavaScriptを有効にしてください - br - | Please turn on your JavaScript - div#splash - img#splashIcon(src= icon || '/static-assets/splash.png') - div#splashSpinner - - - - - - - - - - - block content diff --git a/packages/backend/src/server/web/views/base-embed.tsx b/packages/backend/src/server/web/views/base-embed.tsx new file mode 100644 index 0000000000..011b66592e --- /dev/null +++ b/packages/backend/src/server/web/views/base-embed.tsx @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { comment } from '@/server/web/views/_.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Splash } from '@/server/web/views/_splash.js'; +import type { PropsWithChildren, Children } from '@kitajs/html'; + +export function BaseEmbed(props: PropsWithChildren>) { + const now = Date.now(); + + // 変数名をsafeで始めることでエラーをスキップ + const safeMetaJson = props.metaJson; + const safeEmbedCtxJson = props.embedCtxJson; + + return ( + <> + {''} + {comment} + + + + + + + + + + + + + + + {!props.config.frontendEmbedManifestExists ? : null} + + {props.config.frontendEmbedEntry.css != null ? props.config.frontendEmbedEntry.css.map((href) => ( + + )) : null} + + {props.titleSlot ?? {props.title || 'Misskey'}} + + {props.metaSlot} + + + + {props.frontendEmbedBootloaderCss != null ? : } + + + + {safeMetaJson != null ? : null} + {safeEmbedCtxJson != null ? : null} + + {props.frontendEmbedBootloaderJs != null ? : } + + + + + {props.children} + + + + ); +} + diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug deleted file mode 100644 index a76c75fe5c..0000000000 --- a/packages/backend/src/server/web/views/base.pug +++ /dev/null @@ -1,100 +0,0 @@ -block vars - -block loadClientEntry - - const entry = config.frontendEntry; - - const baseUrl = config.url; - -doctype html - -// - - - _____ _ _ - | |_|___ ___| |_ ___ _ _ - | | | | |_ -|_ -| '_| -_| | | - |_|_|_|_|___|___|_,_|___|_ | - |___| - Thank you for using Misskey! - If you are reading this message... how about joining the development? - https://github.com/misskey-dev/misskey - - -html - - head - meta(charset='utf-8') - meta(name='application-name' content='Misskey') - meta(name='referrer' content='origin') - meta(name='theme-color' content= themeColor || '#86b300') - meta(name='theme-color-orig' content= themeColor || '#86b300') - meta(property='og:site_name' content= instanceName || 'Misskey') - meta(property='instance_url' content= instanceUrl) - meta(name='viewport' content='width=device-width, initial-scale=1') - meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no') - link(rel='icon' href= icon || '/favicon.ico') - link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png') - link(rel='manifest' href='/manifest.json') - link(rel='search' type='application/opensearchdescription+xml' title=(title || "Misskey") href=`${baseUrl}/opensearch.xml`) - link(rel='prefetch' href=serverErrorImageUrl) - link(rel='prefetch' href=infoImageUrl) - link(rel='prefetch' href=notFoundImageUrl) - - if !config.frontendManifestExists - script(type="module" src="/vite/@vite/client") - - if Array.isArray(entry.css) - each href in entry.css - link(rel='stylesheet' href=`/vite/${href}`) - - title - block title - = title || 'Misskey' - - if noindex - meta(name='robots' content='noindex') - - block desc - meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') - - block meta - - block og - meta(property='og:title' content= title || 'Misskey') - meta(property='og:description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') - meta(property='og:image' content= img) - meta(property='twitter:card' content='summary') - - style - include ../style.css - - script. - var VERSION = "#{version}"; - var CLIENT_ENTRY = !{JSON.stringify(entry.file)}; - - script(type='application/json' id='misskey_meta' data-generated-at=now) - != metaJson - - script(type='application/json' id='misskey_clientCtx' data-generated-at=now) - != clientCtx - - script - include ../boot.js - - body - noscript: p - | JavaScriptを有効にしてください - br - | Please turn on your JavaScript - div#splash - img#splashIcon(src= icon || '/static-assets/splash.png') - div#splashSpinner - - - - - - - - - - - block content diff --git a/packages/backend/src/server/web/views/base.tsx b/packages/backend/src/server/web/views/base.tsx new file mode 100644 index 0000000000..6fa3395fb8 --- /dev/null +++ b/packages/backend/src/server/web/views/base.tsx @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { comment, defaultDescription } from '@/server/web/views/_.js'; +import { Splash } from '@/server/web/views/_splash.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import type { PropsWithChildren, Children } from '@kitajs/html'; + +export function Layout(props: PropsWithChildren>) { + const now = Date.now(); + + // 変数名をsafeで始めることでエラーをスキップ + const safeMetaJson = props.metaJson; + const safeClientCtxJson = props.clientCtxJson; + + return ( + <> + {''} + {comment} + + + + + + + + + + + + + + + + {props.serverErrorImageUrl != null ? : null} + {props.infoImageUrl != null ? : null} + {props.notFoundImageUrl != null ? : null} + + {!props.config.frontendManifestExists ? : null} + + {props.config.frontendEntry.css != null ? props.config.frontendEntry.css.map((href) => ( + + )) : null} + + {props.titleSlot ?? {props.title || 'Misskey'}} + + {props.noindex ? : null} + + {props.descSlot ?? (props.desc != null ? : null)} + + {props.metaSlot} + + {props.ogSlot ?? ( + <> + + + {props.img != null ? : null} + + + )} + + {props.frontendBootloaderCss != null ? : } + + + + {safeMetaJson != null ? : null} + {safeClientCtxJson != null ? : null} + + {props.frontendBootloaderJs != null ? : } + + + + + {props.children} + + + + ); +} + +export { Layout as BasePage }; + diff --git a/packages/backend/src/server/web/views/bios.pug b/packages/backend/src/server/web/views/bios.pug deleted file mode 100644 index 39a151a29b..0000000000 --- a/packages/backend/src/server/web/views/bios.pug +++ /dev/null @@ -1,20 +0,0 @@ -doctype html - -html - - head - meta(charset='utf-8') - meta(name='application-name' content='Misskey') - title Misskey Repair Tool - style - include ../bios.css - script - include ../bios.js - - body - header - h1 Misskey Repair Tool #{version} - main - div.tabs - button#ls edit local storage - div#content diff --git a/packages/backend/src/server/web/views/bios.tsx b/packages/backend/src/server/web/views/bios.tsx new file mode 100644 index 0000000000..9010de8d75 --- /dev/null +++ b/packages/backend/src/server/web/views/bios.tsx @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function BiosPage(props: { + version: string; +}) { + return ( + <> + {''} + + + + + Misskey Repair Tool + + + + +
+

Misskey Repair Tool {props.version}

+
+
+
+ +
+
+
+ + + + + ); +} diff --git a/packages/backend/src/server/web/views/channel.pug b/packages/backend/src/server/web/views/channel.pug deleted file mode 100644 index c514025e0b..0000000000 --- a/packages/backend/src/server/web/views/channel.pug +++ /dev/null @@ -1,19 +0,0 @@ -extends ./base - -block vars - - const title = channel.name; - - const url = `${config.url}/channels/${channel.id}`; - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content= channel.description) - -block og - meta(property='og:type' content='article') - meta(property='og:title' content= title) - meta(property='og:description' content= channel.description) - meta(property='og:url' content= url) - meta(property='og:image' content= channel.bannerUrl) - meta(property='twitter:card' content='summary') diff --git a/packages/backend/src/server/web/views/channel.tsx b/packages/backend/src/server/web/views/channel.tsx new file mode 100644 index 0000000000..7d8123ea85 --- /dev/null +++ b/packages/backend/src/server/web/views/channel.tsx @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Packed } from '@/misc/json-schema.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; + +export function ChannelPage(props: CommonProps<{ + channel: Packed<'Channel'>; +}>) { + + function ogBlock() { + return ( + <> + + + {props.channel.description != null ? : null} + + {props.channel.bannerUrl ? ( + <> + + + + ) : null} + + ); + } + + return ( + + + ); +} diff --git a/packages/backend/src/server/web/views/cli.pug b/packages/backend/src/server/web/views/cli.pug deleted file mode 100644 index d2cf7c4335..0000000000 --- a/packages/backend/src/server/web/views/cli.pug +++ /dev/null @@ -1,21 +0,0 @@ -doctype html - -html - - head - meta(charset='utf-8') - meta(name='application-name' content='Misskey') - title Misskey Cli - style - include ../cli.css - script - include ../cli.js - - body - header - h1 Misskey Cli #{version} - main - div#form - textarea#text - button#submit submit - div#tl diff --git a/packages/backend/src/server/web/views/cli.tsx b/packages/backend/src/server/web/views/cli.tsx new file mode 100644 index 0000000000..009d982b35 --- /dev/null +++ b/packages/backend/src/server/web/views/cli.tsx @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function CliPage(props: { + version: string; +}) { + return ( + <> + {''} + + + + + Misskey CLI Tool + + + + + +
+

Misskey CLI {props.version}

+
+
+
+ + +
+
+
+ + + + + ); +} diff --git a/packages/backend/src/server/web/views/clip.pug b/packages/backend/src/server/web/views/clip.pug deleted file mode 100644 index 5a0018803a..0000000000 --- a/packages/backend/src/server/web/views/clip.pug +++ /dev/null @@ -1,35 +0,0 @@ -extends ./base - -block vars - - const user = clip.user; - - const title = clip.name; - - const url = `${config.url}/clips/${clip.id}`; - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content= clip.description) - -block og - meta(property='og:type' content='article') - meta(property='og:title' content= title) - meta(property='og:description' content= clip.description) - meta(property='og:url' content= url) - meta(property='og:image' content= avatarUrl) - meta(property='twitter:card' content='summary') - -block meta - if profile.noCrawle - meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') - - meta(name='misskey:user-username' content=user.username) - meta(name='misskey:user-id' content=user.id) - meta(name='misskey:clip-id' content=clip.id) - - // todo - if user.twitter - meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/src/server/web/views/clip.tsx b/packages/backend/src/server/web/views/clip.tsx new file mode 100644 index 0000000000..c3cc505e35 --- /dev/null +++ b/packages/backend/src/server/web/views/clip.tsx @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Packed } from '@/misc/json-schema.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; + +export function ClipPage(props: CommonProps<{ + clip: Packed<'Clip'>; + profile: MiUserProfile; +}>) { + function ogBlock() { + return ( + <> + + + {props.clip.description != null ? : null} + + {props.clip.user.avatarUrl ? ( + <> + + + + ) : null} + + ); + } + + function metaBlock() { + return ( + <> + {props.profile.noCrawle ? : null} + {props.profile.preventAiLearning ? ( + <> + + + + ) : null} + + + + + ); + } + + return ( + + + ); +} diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug deleted file mode 100644 index 6a78d1878c..0000000000 --- a/packages/backend/src/server/web/views/error.pug +++ /dev/null @@ -1,71 +0,0 @@ -doctype html - -// - - - _____ _ _ - | |_|___ ___| |_ ___ _ _ - | | | | |_ -|_ -| '_| -_| | | - |_|_|_|_|___|___|_,_|___|_ | - |___| - Thank you for using Misskey! - If you are reading this message... how about joining the development? - https://github.com/misskey-dev/misskey - - -html - - head - meta(charset='utf-8') - meta(name='viewport' content='width=device-width, initial-scale=1') - meta(name='application-name' content='Misskey') - meta(name='referrer' content='origin') - - title - block title - = 'An error has occurred... | Misskey' - - style - include ../error.css - - script - include ../error.js - -body - svg.icon-warning(xmlns="http://www.w3.org/2000/svg", viewBox="0 0 24 24", stroke-width="2", stroke="currentColor", fill="none", stroke-linecap="round", stroke-linejoin="round") - path(stroke="none", d="M0 0h24v24H0z", fill="none") - path(d="M12 9v2m0 4v.01") - path(d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75") - - h1(data-i18n="title") Failed to initialize Misskey - - button.button-big(onclick="location.reload();") - span.button-label-big(data-i18n-reload) Reload - - p(data-i18n="serverError") If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID. - - div#errors - code. - ERROR CODE: #{code} - ERROR ID: #{id} - - p - b(data-i18n="solution") The following actions may solve the problem. - - p(data-i18n="solution1") Update your os and browser - p(data-i18n="solution2") Disable an adblocker - p(data-i18n="solution3") Clear your browser cache - p(data-i18n="solution4") (Tor Browser) Set dom.webaudio.enabled to true - - details(style="color: #86b300;") - summary(data-i18n="otherOption") Other options - a(href="/flush") - button.button-small - span.button-label-small(data-i18n="otherOption1") Clear preferences and cache - br - a(href="/cli") - button.button-small - span.button-label-small(data-i18n="otherOption2") Start the simple client - br - a(href="/bios") - button.button-small - span.button-label-small(data-i18n="otherOption3") Start the repair tool diff --git a/packages/backend/src/server/web/views/error.tsx b/packages/backend/src/server/web/views/error.tsx new file mode 100644 index 0000000000..9d0e60aa30 --- /dev/null +++ b/packages/backend/src/server/web/views/error.tsx @@ -0,0 +1,89 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { comment } from '@/server/web/views/_.js'; +import type { CommonPropsMinimum } from '@/server/web/views/_.js'; + +export function ErrorPage(props: { + title?: string; + code: string; + id: string; +}) { + return ( + <> + {''} + {comment} + + + + + + + {props.title ?? 'An error has occurred... | Misskey'} + + + + + + + + + +

Failed to initialize Misskey

+ + + +

+ If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID. +

+ +
+ + ERROR CODE: {props.code}
+ ERROR ID: {props.id} +
+
+ +

The following actions may solve the problem.

+ +

Update your os and browser

+

Disable an adblocker

+

Clear your browser cache

+

(Tor Browser) Set dom.webaudio.enabled to true

+ +
+ Other options + + + + + + + + + +
+ + + + ); +} diff --git a/packages/backend/src/server/web/views/flash.pug b/packages/backend/src/server/web/views/flash.pug deleted file mode 100644 index 1549aa7906..0000000000 --- a/packages/backend/src/server/web/views/flash.pug +++ /dev/null @@ -1,35 +0,0 @@ -extends ./base - -block vars - - const user = flash.user; - - const title = flash.title; - - const url = `${config.url}/play/${flash.id}`; - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content= flash.summary) - -block og - meta(property='og:type' content='article') - meta(property='og:title' content= title) - meta(property='og:description' content= flash.summary) - meta(property='og:url' content= url) - meta(property='og:image' content= avatarUrl) - meta(property='twitter:card' content='summary') - -block meta - if profile.noCrawle - meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') - - meta(name='misskey:user-username' content=user.username) - meta(name='misskey:user-id' content=user.id) - meta(name='misskey:flash-id' content=flash.id) - - // todo - if user.twitter - meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/src/server/web/views/flash.tsx b/packages/backend/src/server/web/views/flash.tsx new file mode 100644 index 0000000000..25a6b2c0ae --- /dev/null +++ b/packages/backend/src/server/web/views/flash.tsx @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Packed } from '@/misc/json-schema.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; + +export function FlashPage(props: CommonProps<{ + flash: Packed<'Flash'>; + profile: MiUserProfile; +}>) { + function ogBlock() { + return ( + <> + + + + + {props.flash.user.avatarUrl ? ( + <> + + + + ) : null} + + ); + } + + function metaBlock() { + return ( + <> + {props.profile.noCrawle ? : null} + {props.profile.preventAiLearning ? ( + <> + + + + ) : null} + + + + + ); + } + + return ( + + + ); +} diff --git a/packages/backend/src/server/web/views/flush.pug b/packages/backend/src/server/web/views/flush.pug deleted file mode 100644 index a73a45212f..0000000000 --- a/packages/backend/src/server/web/views/flush.pug +++ /dev/null @@ -1,47 +0,0 @@ -doctype html - -html - #msg - script. - const msg = document.getElementById('msg'); - const successText = `\nSuccess Flush! Back to Misskey\n成功しました。Misskeyを開き直してください。`; - - message('Start flushing.'); - - (async function() { - try { - localStorage.clear(); - message('localStorage cleared.'); - - const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => { - const delidb = indexedDB.deleteDatabase(name); - delidb.onsuccess = () => res(message(`indexedDB "${name}" cleared. (${i + 1}/${arr.length})`)); - delidb.onerror = e => rej(e) - })); - - await Promise.all(idbPromises); - - if (navigator.serviceWorker.controller) { - navigator.serviceWorker.controller.postMessage('clear'); - await navigator.serviceWorker.getRegistrations() - .then(registrations => { - return Promise.all(registrations.map(registration => registration.unregister())); - }) - .catch(e => { throw new Error(e) }); - } - - message(successText); - } catch (e) { - message(`\n${e}\n\nFlush Failed. Please retry.\n失敗しました。もう一度試してみてください。`); - message(`\nIf you retry more than 3 times, clear the browser cache or contact to instance admin.\n3回以上試しても失敗する場合、ブラウザのキャッシュを消去し、それでもだめならインスタンス管理者に連絡してみてください。\n`) - - console.error(e); - setTimeout(() => { - location = '/'; - }, 10000) - } - })(); - - function message(text) { - msg.insertAdjacentHTML('beforeend', `

[${(new Date()).toString()}] ${text.replace(/\n/g,'
')}

`) - } diff --git a/packages/backend/src/server/web/views/flush.tsx b/packages/backend/src/server/web/views/flush.tsx new file mode 100644 index 0000000000..f3fdc8fcb0 --- /dev/null +++ b/packages/backend/src/server/web/views/flush.tsx @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function FlushPage(props?: {}) { + return ( + <> + {''} + + + + + Clear preferences and cache + + +
+ + + + + ); +} diff --git a/packages/backend/src/server/web/views/gallery-post.pug b/packages/backend/src/server/web/views/gallery-post.pug deleted file mode 100644 index 9ae25d9ac8..0000000000 --- a/packages/backend/src/server/web/views/gallery-post.pug +++ /dev/null @@ -1,41 +0,0 @@ -extends ./base - -block vars - - const user = post.user; - - const title = post.title; - - const url = `${config.url}/gallery/${post.id}`; - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content= post.description) - -block og - meta(property='og:type' content='article') - meta(property='og:title' content= title) - meta(property='og:description' content= post.description) - meta(property='og:url' content= url) - if post.isSensitive - meta(property='og:image' content= avatarUrl) - meta(property='twitter:card' content='summary') - else - meta(property='og:image' content= post.files[0].thumbnailUrl) - meta(property='twitter:card' content='summary_large_image') - -block meta - if user.host || profile.noCrawle - meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') - - meta(name='misskey:user-username' content=user.username) - meta(name='misskey:user-id' content=user.id) - - // todo - if user.twitter - meta(name='twitter:creator' content=`@${user.twitter.screenName}`) - - if !user.host - link(rel='alternate' href=url type='application/activity+json') diff --git a/packages/backend/src/server/web/views/gallery-post.tsx b/packages/backend/src/server/web/views/gallery-post.tsx new file mode 100644 index 0000000000..2bec2de930 --- /dev/null +++ b/packages/backend/src/server/web/views/gallery-post.tsx @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Packed } from '@/misc/json-schema.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; + +export function GalleryPostPage(props: CommonProps<{ + galleryPost: Packed<'GalleryPost'>; + profile: MiUserProfile; +}>) { + function ogBlock() { + return ( + <> + + + {props.galleryPost.description != null ? : null} + + {props.galleryPost.isSensitive && props.galleryPost.user.avatarUrl ? ( + <> + + + + ) : null} + {!props.galleryPost.isSensitive && props.galleryPost.files != null ? ( + <> + + + + ) : null} + + ); + } + + function metaBlock() { + return ( + <> + {props.profile.noCrawle ? : null} + {props.profile.preventAiLearning ? ( + <> + + + + ) : null} + + + + + ); + } + + return ( + + + ); +} diff --git a/packages/backend/src/server/web/views/info-card.pug b/packages/backend/src/server/web/views/info-card.pug deleted file mode 100644 index 2a4954ec8b..0000000000 --- a/packages/backend/src/server/web/views/info-card.pug +++ /dev/null @@ -1,50 +0,0 @@ -doctype html - -html - - head - meta(charset='utf-8') - meta(name='application-name' content='Misskey') - title= meta.name || host - style. - html, body { - margin: 0; - padding: 0; - min-height: 100vh; - background: #fff; - } - - #a { - display: block; - } - - #banner { - background-size: cover; - background-position: center center; - } - - #title { - display: inline-block; - margin: 24px; - padding: 0.5em 0.8em; - color: #fff; - background: rgba(0, 0, 0, 0.5); - font-weight: bold; - font-size: 1.3em; - } - - #content { - overflow: auto; - color: #353c3e; - } - - #description { - margin: 24px; - } - - body - a#a(href=`https://${host}` target="_blank") - header#banner(style=`background-image: url(${meta.bannerUrl})`) - div#title= meta.name || host - div#content - div#description!= meta.description diff --git a/packages/backend/src/server/web/views/info-card.tsx b/packages/backend/src/server/web/views/info-card.tsx new file mode 100644 index 0000000000..27be4c69e8 --- /dev/null +++ b/packages/backend/src/server/web/views/info-card.tsx @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { comment, CommonPropsMinimum } from '@/server/web/views/_.js'; +import type { MiMeta } from '@/models/Meta.js'; + +export function InfoCardPage(props: CommonPropsMinimum<{ + meta: MiMeta; +}>) { + // 変数名をsafeで始めることでエラーをスキップ + const safeDescription = props.meta.description; + + return ( + <> + {''} + {comment} + + + + + + {props.meta.name ?? props.config.url} + + + + + + +
+
{safeDescription}
+
+ + + + ); +} diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug deleted file mode 100644 index ea1993aed0..0000000000 --- a/packages/backend/src/server/web/views/note.pug +++ /dev/null @@ -1,62 +0,0 @@ -extends ./base - -block vars - - const user = note.user; - - const title = user.name ? `${user.name} (@${user.username}${user.host ? `@${user.host}` : ''})` : `@${user.username}${user.host ? `@${user.host}` : ''}`; - - const url = `${config.url}/notes/${note.id}`; - - const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null; - - const images = (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive) - - const videos = (note.files || []).filter(file => file.type.startsWith('video/') && !file.isSensitive) - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content= summary) - -block og - meta(property='og:type' content='article') - meta(property='og:title' content= title) - meta(property='og:description' content= summary) - meta(property='og:url' content= url) - if videos.length - each video in videos - meta(property='og:video:url' content= video.url) - meta(property='og:video:secure_url' content= video.url) - meta(property='og:video:type' content= video.type) - // FIXME: add width and height - // FIXME: add embed player for Twitter - if images.length - meta(property='twitter:card' content='summary_large_image') - each image in images - meta(property='og:image' content= image.url) - else - meta(property='twitter:card' content='summary') - meta(property='og:image' content= avatarUrl) - - -block meta - if user.host || isRenote || profile.noCrawle - meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') - - meta(name='misskey:user-username' content=user.username) - meta(name='misskey:user-id' content=user.id) - meta(name='misskey:note-id' content=note.id) - - // todo - if user.twitter - meta(name='twitter:creator' content=`@${user.twitter.screenName}`) - - if note.prev - link(rel='prev' href=`${config.url}/notes/${note.prev}`) - if note.next - link(rel='next' href=`${config.url}/notes/${note.next}`) - - if federationEnabled - if !user.host - link(rel='alternate' href=url type='application/activity+json') - if note.uri - link(rel='alternate' href=note.uri type='application/activity+json') diff --git a/packages/backend/src/server/web/views/note.tsx b/packages/backend/src/server/web/views/note.tsx new file mode 100644 index 0000000000..803c3d2537 --- /dev/null +++ b/packages/backend/src/server/web/views/note.tsx @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Packed } from '@/misc/json-schema.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; +import { isRenotePacked } from '@/misc/is-renote.js'; +import { getNoteSummary } from '@/misc/get-note-summary.js'; + +export function NotePage(props: CommonProps<{ + note: Packed<'Note'>; + profile: MiUserProfile; +}>) { + const title = props.note.user.name ? `${props.note.user.name} (@${props.note.user.username}${props.note.user.host ? `@${props.note.user.host}` : ''})` : `@${props.note.user.username}${props.note.user.host ? `@${props.note.user.host}` : ''}` + const isRenote = isRenotePacked(props.note); + const images = (props.note.files ?? []).filter(f => f.type.startsWith('image/')); + const videos = (props.note.files ?? []).filter(f => f.type.startsWith('video/')); + const summary = getNoteSummary(props.note); + + function ogBlock() { + return ( + <> + + + + + {videos.map(video => ( + <> + + + + {video.thumbnailUrl ? : null} + {video.properties.width != null ? : null} + {video.properties.height != null ? : null} + + ))} + {images.length > 0 ? ( + <> + + {images.map(image => ( + <> + + {image.properties.width != null ? : null} + {image.properties.height != null ? : null} + + ))} + + ) : ( + <> + + + + )} + + ); + } + + function metaBlock() { + return ( + <> + {props.note.user.host != null || isRenote || props.profile.noCrawle ? : null} + {props.profile.preventAiLearning ? ( + <> + + + + ) : null} + + + + + {props.federationEnabled ? ( + <> + {props.note.user.host == null ? : null} + {props.note.uri != null ? : null} + + ) : null} + + ); + } + + return ( + + ) +} diff --git a/packages/backend/src/server/web/views/oauth.pug b/packages/backend/src/server/web/views/oauth.pug deleted file mode 100644 index 4195ccc3a3..0000000000 --- a/packages/backend/src/server/web/views/oauth.pug +++ /dev/null @@ -1,11 +0,0 @@ -extends ./base - -block meta - //- Should be removed by the page when it loads, so that it won't needlessly - //- stay when user navigates away via the navigation bar - //- XXX: Remove navigation bar in auth page? - meta(name='misskey:oauth:transaction-id' content=transactionId) - meta(name='misskey:oauth:client-name' content=clientName) - if clientLogo - meta(name='misskey:oauth:client-logo' content=clientLogo) - meta(name='misskey:oauth:scope' content=scope) diff --git a/packages/backend/src/server/web/views/oauth.tsx b/packages/backend/src/server/web/views/oauth.tsx new file mode 100644 index 0000000000..d12b0d15fd --- /dev/null +++ b/packages/backend/src/server/web/views/oauth.tsx @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; + +export function OAuthPage(props: CommonProps<{ + transactionId: string; + clientName: string; + clientLogo?: string; + scope: string[]; +}>) { + + //- Should be removed by the page when it loads, so that it won't needlessly + //- stay when user navigates away via the navigation bar + //- XXX: Remove navigation bar in auth page? + function metaBlock() { + return ( + <> + + + {props.clientLogo ? : null} + + + ); + } + + return ( + + + ); +} diff --git a/packages/backend/src/server/web/views/page.pug b/packages/backend/src/server/web/views/page.pug deleted file mode 100644 index 03c50eca8a..0000000000 --- a/packages/backend/src/server/web/views/page.pug +++ /dev/null @@ -1,35 +0,0 @@ -extends ./base - -block vars - - const user = page.user; - - const title = page.title; - - const url = `${config.url}/@${user.username}/pages/${page.name}`; - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content= page.summary) - -block og - meta(property='og:type' content='article') - meta(property='og:title' content= title) - meta(property='og:description' content= page.summary) - meta(property='og:url' content= url) - meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : avatarUrl) - meta(property='twitter:card' content= page.eyeCatchingImage ? 'summary_large_image' : 'summary') - -block meta - if profile.noCrawle - meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') - - meta(name='misskey:user-username' content=user.username) - meta(name='misskey:user-id' content=user.id) - meta(name='misskey:page-id' content=page.id) - - // todo - if user.twitter - meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/src/server/web/views/page.tsx b/packages/backend/src/server/web/views/page.tsx new file mode 100644 index 0000000000..d0484612df --- /dev/null +++ b/packages/backend/src/server/web/views/page.tsx @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Packed } from '@/misc/json-schema.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; + +export function PagePage(props: CommonProps<{ + page: Packed<'Page'>; + profile: MiUserProfile; +}>) { + function ogBlock() { + return ( + <> + + + {props.page.summary != null ? : null} + + {props.page.eyeCatchingImage != null ? ( + <> + + + + ) : props.page.user.avatarUrl ? ( + <> + + + + ) : null} + + ); + } + + function metaBlock() { + return ( + <> + {props.profile.noCrawle ? : null} + {props.profile.preventAiLearning ? ( + <> + + + + ) : null} + + + + + ); + } + + return ( + + + ); +} diff --git a/packages/backend/src/server/web/views/reversi-game.pug b/packages/backend/src/server/web/views/reversi-game.pug deleted file mode 100644 index 0b5ffb2bb0..0000000000 --- a/packages/backend/src/server/web/views/reversi-game.pug +++ /dev/null @@ -1,20 +0,0 @@ -extends ./base - -block vars - - const user1 = game.user1; - - const user2 = game.user2; - - const title = `${user1.username} vs ${user2.username}`; - - const url = `${config.url}/reversi/g/${game.id}`; - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content='⚫⚪Misskey Reversi⚪⚫') - -block og - meta(property='og:type' content='article') - meta(property='og:title' content= title) - meta(property='og:description' content='⚫⚪Misskey Reversi⚪⚫') - meta(property='og:url' content= url) - meta(property='twitter:card' content='summary') diff --git a/packages/backend/src/server/web/views/reversi-game.tsx b/packages/backend/src/server/web/views/reversi-game.tsx new file mode 100644 index 0000000000..22609311fd --- /dev/null +++ b/packages/backend/src/server/web/views/reversi-game.tsx @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Packed } from '@/misc/json-schema.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; + +export function ReversiGamePage(props: CommonProps<{ + reversiGame: Packed<'ReversiGameDetailed'>; +}>) { + const title = `${props.reversiGame.user1.username} vs ${props.reversiGame.user2.username}`; + const description = `⚫⚪Misskey Reversi⚪⚫`; + + function ogBlock() { + return ( + <> + + + + + + + ); + } + + return ( + + + ); +} diff --git a/packages/backend/src/server/web/views/user.pug b/packages/backend/src/server/web/views/user.pug deleted file mode 100644 index b9f740f5b6..0000000000 --- a/packages/backend/src/server/web/views/user.pug +++ /dev/null @@ -1,44 +0,0 @@ -extends ./base - -block vars - - const title = user.name ? `${user.name} (@${user.username}${user.host ? `@${user.host}` : ''})` : `@${user.username}${user.host ? `@${user.host}` : ''}`; - - const url = `${config.url}/@${(user.host ? `${user.username}@${user.host}` : user.username)}`; - -block title - = `${title} | ${instanceName}` - -block desc - meta(name='description' content= profile.description) - -block og - meta(property='og:type' content='blog') - meta(property='og:title' content= title) - meta(property='og:description' content= profile.description) - meta(property='og:url' content= url) - meta(property='og:image' content= avatarUrl) - meta(property='twitter:card' content='summary') - -block meta - if user.host || profile.noCrawle - meta(name='robots' content='noindex') - if profile.preventAiLearning - meta(name='robots' content='noimageai') - meta(name='robots' content='noai') - - meta(name='misskey:user-username' content=user.username) - meta(name='misskey:user-id' content=user.id) - - if profile.twitter - meta(name='twitter:creator' content=`@${profile.twitter.screenName}`) - - if !sub - if federationEnabled - if !user.host - link(rel='alternate' href=`${config.url}/users/${user.id}` type='application/activity+json') - if user.uri - link(rel='alternate' href=user.uri type='application/activity+json') - if profile.url - link(rel='alternate' href=profile.url type='text/html') - - each m in me - link(rel='me' href=`${m}`) diff --git a/packages/backend/src/server/web/views/user.tsx b/packages/backend/src/server/web/views/user.tsx new file mode 100644 index 0000000000..76c2633ab9 --- /dev/null +++ b/packages/backend/src/server/web/views/user.tsx @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Packed } from '@/misc/json-schema.js'; +import type { MiUserProfile } from '@/models/UserProfile.js'; +import type { CommonProps } from '@/server/web/views/_.js'; +import { Layout } from '@/server/web/views/base.js'; + +export function UserPage(props: CommonProps<{ + user: Packed<'UserDetailed'>; + profile: MiUserProfile; + sub?: string; +}>) { + const title = props.user.name ? `${props.user.name} (@${props.user.username}${props.user.host ? `@${props.user.host}` : ''})` : `@${props.user.username}${props.user.host ? `@${props.user.host}` : ''}`; + const me = props.profile.fields + ? props.profile.fields + .filter(field => field.value != null && field.value.match(/^https?:/)) + .map(field => field.value) + : []; + + function ogBlock() { + return ( + <> + + + {props.user.description != null ? : null} + + + + + ); + } + + function metaBlock() { + return ( + <> + {props.user.host != null || props.profile.noCrawle ? : null} + {props.profile.preventAiLearning ? ( + <> + + + + ) : null} + + + + {props.sub == null && props.federationEnabled ? ( + <> + {props.user.host == null ? : null} + {props.user.uri != null ? : null} + {props.profile.url != null ? : null} + + ) : null} + + {me.map((url) => ( + + ))} + + ); + } + + return ( + + + ); +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index b20f2a2179..24654b0017 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -12,6 +12,8 @@ * quote - 投稿が引用Renoteされた * reaction - 投稿にリアクションされた * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した + * scheduledNotePosted - 予約したノートが投稿された + * scheduledNotePostFailed - 予約したノートの投稿に失敗した * receiveFollowRequest - フォローリクエストされた * followRequestAccepted - 自分の送ったフォローリクエストが承認された * roleAssigned - ロールが付与された @@ -32,6 +34,8 @@ export const notificationTypes = [ 'quote', 'reaction', 'pollEnded', + 'scheduledNotePosted', + 'scheduledNotePostFailed', 'receiveFollowRequest', 'followRequestAccepted', 'roleAssigned', diff --git a/packages/backend/test-federation/.config/dummy.yml b/packages/backend/test-federation/.config/dummy.yml new file mode 100644 index 0000000000..841cab9783 --- /dev/null +++ b/packages/backend/test-federation/.config/dummy.yml @@ -0,0 +1,2 @@ +url: https://example.com/ +port: 3000 diff --git a/packages/backend/test-federation/.config/example.config.json b/packages/backend/test-federation/.config/example.config.json new file mode 100644 index 0000000000..2035d1a200 --- /dev/null +++ b/packages/backend/test-federation/.config/example.config.json @@ -0,0 +1,29 @@ +{ + "url": "https://${HOST}/", + "port": 3000, + "db": { + "host": "db.${HOST}", + "port": 5432, + "db": "misskey", + "user": "postgres", + "pass": "postgres" + }, + "dbReplications": false, + "trustProxy": true, + "redis": { + "host": "redis.test", + "port": 6379 + }, + "id": "aidx", + "proxyBypassHosts": [ + "api.deepl.com", + "api-free.deepl.com", + "www.recaptcha.net", + "hcaptcha.com", + "challenges.cloudflare.com" + ], + "allowedPrivateNetworks": [ + "127.0.0.1/32", + "172.20.0.0/16" + ] +} diff --git a/packages/backend/test-federation/.config/example.default.yml b/packages/backend/test-federation/.config/example.default.yml deleted file mode 100644 index fd20613885..0000000000 --- a/packages/backend/test-federation/.config/example.default.yml +++ /dev/null @@ -1,22 +0,0 @@ -url: https://${HOST}/ -port: 3000 -db: - host: db.${HOST} - port: 5432 - db: misskey - user: postgres - pass: postgres -dbReplications: false -redis: - host: redis.test - port: 6379 -id: 'aidx' -proxyBypassHosts: - - api.deepl.com - - api-free.deepl.com - - www.recaptcha.net - - hcaptcha.com - - challenges.cloudflare.com -allowedPrivateNetworks: - - 127.0.0.1/32 - - 172.20.0.0/16 diff --git a/packages/backend/test-federation/compose.a.yml b/packages/backend/test-federation/compose.a.yml index 6a305b404c..ec9a2cf2af 100644 --- a/packages/backend/test-federation/compose.a.yml +++ b/packages/backend/test-federation/compose.a.yml @@ -37,8 +37,8 @@ services: - internal_network_a volumes: - type: bind - source: ./.config/a.test.default.yml - target: /misskey/.config/default.yml + source: ./.config/a.test.config.json + target: /misskey/built/._config_.json read_only: true db.a.test: @@ -50,7 +50,7 @@ services: volumes: - type: bind source: ./volumes/db.a - target: /var/lib/postgresql/data + target: /var/lib/postgresql bind: create_host_path: true diff --git a/packages/backend/test-federation/compose.b.yml b/packages/backend/test-federation/compose.b.yml index 1158b53bae..9221934406 100644 --- a/packages/backend/test-federation/compose.b.yml +++ b/packages/backend/test-federation/compose.b.yml @@ -37,8 +37,8 @@ services: - internal_network_b volumes: - type: bind - source: ./.config/b.test.default.yml - target: /misskey/.config/default.yml + source: ./.config/b.test.config.json + target: /misskey/built/._config_.json read_only: true db.b.test: @@ -50,7 +50,7 @@ services: volumes: - type: bind source: ./volumes/db.b - target: /var/lib/postgresql/data + target: /var/lib/postgresql bind: create_host_path: true diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml index 3d2ed21337..ac93b24b87 100644 --- a/packages/backend/test-federation/compose.tpl.yml +++ b/packages/backend/test-federation/compose.tpl.yml @@ -21,6 +21,10 @@ services: - type: bind source: ../../../built target: /misskey/built + read_only: false + - type: bind + source: ./.config/dummy.yml + target: /misskey/.config/default.yml read_only: true - type: bind source: ../assets @@ -30,6 +34,10 @@ services: source: ../built target: /misskey/packages/backend/built read_only: true + - type: bind + source: ../src-js + target: /misskey/packages/backend/src-js + read_only: true - type: bind source: ../migration target: /misskey/packages/backend/migration @@ -42,6 +50,10 @@ services: source: ../package.json target: /misskey/packages/backend/package.json read_only: true + - type: bind + source: ../scripts/compile_config.js + target: /misskey/packages/backend/scripts/compile_config.js + read_only: true - type: bind source: ../../misskey-js/built target: /misskey/packages/misskey-js/built @@ -50,6 +62,14 @@ services: source: ../../misskey-js/package.json target: /misskey/packages/misskey-js/package.json read_only: true + - type: bind + source: ../../i18n/built + target: /misskey/packages/i18n/built + read_only: true + - type: bind + source: ../../i18n/package.json + target: /misskey/packages/i18n/package.json + read_only: true - type: bind source: ../../misskey-reversi/built target: /misskey/packages/misskey-reversi/built @@ -95,7 +115,7 @@ services: retries: 20 db: - image: postgres:15-alpine + image: postgres:18-alpine env_file: - ./.config/docker.env volumes: diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml index 330cc33854..4d1b4b0d60 100644 --- a/packages/backend/test-federation/compose.yml +++ b/packages/backend/test-federation/compose.yml @@ -54,6 +54,10 @@ services: source: ../jest.js target: /misskey/packages/backend/jest.js read_only: true + - type: bind + source: ../scripts/compile_config.js + target: /misskey/packages/backend/scripts/compile_config.js + read_only: true - type: bind source: ../../misskey-js/built target: /misskey/packages/misskey-js/built @@ -62,6 +66,14 @@ services: source: ../../misskey-js/package.json target: /misskey/packages/misskey-js/package.json read_only: true + - type: bind + source: ../../i18n/built + target: /misskey/packages/i18n/built + read_only: true + - type: bind + source: ../../i18n/package.json + target: /misskey/packages/i18n/package.json + read_only: true - type: bind source: ../../../package.json target: /misskey/package.json @@ -131,7 +143,7 @@ services: bash -c " npm install -g pnpm pnpm -F backend i --frozen-lockfile - pnpm exec tsc -p ./packages/backend/test-federation + pnpm exec tsgo -p ./packages/backend/test-federation node ./packages/backend/test-federation/built/daemon.js " diff --git a/packages/backend/test-federation/setup.sh b/packages/backend/test-federation/setup.sh index 1bc3a2a87c..15aa2eee7f 100644 --- a/packages/backend/test-federation/setup.sh +++ b/packages/backend/test-federation/setup.sh @@ -28,7 +28,7 @@ function generate { -days 500 if [ ! -f .config/docker.env ]; then cp .config/example.docker.env .config/docker.env; fi if [ ! -f .config/$1.conf ]; then sed "s/\${HOST}/$1/g" .config/example.conf > .config/$1.conf; fi - if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.default.yml > .config/$1.default.yml; fi + if [ ! -f .config/$1.default.yml ]; then sed "s/\${HOST}/$1/g" .config/example.config.json > .config/$1.config.json; fi } generate a.test diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts index 7e24bb7904..6f09f13f17 100644 --- a/packages/backend/test-federation/test/utils.ts +++ b/packages/backend/test-federation/test/utils.ts @@ -68,7 +68,6 @@ async function createAdmin(host: Host): Promise { ADMIN_CACHE.set(host, { id: res.id, - // @ts-expect-error FIXME: openapi-typescript generates incorrect response type for this endpoint, so ignore this i: res.token, }); return res as Misskey.entities.SignupResponse; @@ -235,30 +234,26 @@ export async function isFired[0]) => boolean, params?: Misskey.Channels[C]['params'], ): Promise { - return new Promise(async (resolve, reject) => { - const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + try { const connection = stream.useChannel(channel, params); - connection.on(type as any, ((msg: any) => { - if (cond(msg)) { - stream.close(); - clearTimeout(timer); - resolve(true); - } - }) as any); - let timer: NodeJS.Timeout | undefined; - - await trigger().then(() => { - timer = setTimeout(() => { - stream.close(); - resolve(false); - }, 500); - }).catch(err => { - stream.close(); - clearTimeout(timer); - reject(err); + const receivePromise = new Promise((resolve) => { + connection.on(type as never, ((msg: any) => { + if (cond(msg)) { + resolve(true); + } + }) as any); }); - }); + + await trigger(); + return await Promise.race([ + receivePromise, + sleep(500).then(() => false), + ]); + } finally { + stream.close(); + } }; export async function isNoteUpdatedEventFired( @@ -268,30 +263,27 @@ export async function isNoteUpdatedEventFired( trigger: () => Promise, cond: (msg: Parameters[0]) => boolean, ): Promise { - return new Promise(async (resolve, reject) => { - const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + const stream = new Misskey.Stream(`wss://${host}`, { token: user.i }, { WebSocket }); + try { stream.send('s', { id: noteId }); - stream.on('noteUpdated', msg => { - if (cond(msg)) { - stream.close(); - clearTimeout(timer); - resolve(true); - } + + const receivePromise = new Promise((resolve) => { + stream.on('noteUpdated', msg => { + if (cond(msg)) { + resolve(true); + } + }); }); - let timer: NodeJS.Timeout | undefined; + await trigger(); - await trigger().then(() => { - timer = setTimeout(() => { - stream.close(); - resolve(false); - }, 500); - }).catch(err => { - stream.close(); - clearTimeout(timer); - reject(err); - }); - }); + return await Promise.race([ + receivePromise, + sleep(500).then(() => false), + ]); + } finally { + stream.close(); + } }; export async function assertNotificationReceived( diff --git a/packages/backend/test-federation/tsconfig.json b/packages/backend/test-federation/tsconfig.json index 3a1cb3b9f3..8e74a62e81 100644 --- a/packages/backend/test-federation/tsconfig.json +++ b/packages/backend/test-federation/tsconfig.json @@ -13,12 +13,12 @@ /* Language and Environment */ "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ + "jsx": "react-jsx", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + "jsxImportSource": "@kitajs/html", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ diff --git a/packages/backend/test-server/.swcrc b/packages/backend/test-server/.swcrc index eeac7eabc6..3859603da3 100644 --- a/packages/backend/test-server/.swcrc +++ b/packages/backend/test-server/.swcrc @@ -13,7 +13,7 @@ "experimental": { "keepImportAssertions": true }, - "baseUrl": "../built", + "baseUrl": "../src-js", "paths": { "@/*": ["*"] }, diff --git a/packages/backend/test-server/tsconfig.json b/packages/backend/test-server/tsconfig.json index 10313699c2..7ed7c10ed7 100644 --- a/packages/backend/test-server/tsconfig.json +++ b/packages/backend/test-server/tsconfig.json @@ -23,6 +23,8 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html", "rootDir": "../src", "baseUrl": "./", "paths": { diff --git a/packages/backend/test/compose.yml b/packages/backend/test/compose.yml index 6593fc33dd..4f1dba6428 100644 --- a/packages/backend/test/compose.yml +++ b/packages/backend/test/compose.yml @@ -5,9 +5,17 @@ services: - "127.0.0.1:56312:6379" dbtest: - image: postgres:15 + image: postgres:18 ports: - "127.0.0.1:54312:5432" environment: POSTGRES_DB: "test-misskey" POSTGRES_HOST_AUTH_METHOD: trust + + meilisearchtest: + image: getmeili/meilisearch:v1.3.4 + ports: + - "127.0.0.1:57712:7700" + environment: + - MEILI_NO_ANALYTICS=true + - MEILI_ENV=development diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index 1bbacd065b..70a5c9579e 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -69,6 +69,9 @@ describe('アンテナ', () => { let userMutingAlice: User; let userMutedByAlice: User; + let testChannel: misskey.entities.Channel; + let testMutedChannel: misskey.entities.Channel; + beforeAll(async () => { root = await signup({ username: 'root' }); alice = await signup({ username: 'alice' }); @@ -120,6 +123,10 @@ describe('アンテナ', () => { userMutedByAlice = await signup({ username: 'userMutedByAlice' }); await post(userMutedByAlice, { text: 'test' }); await api('mute/create', { userId: userMutedByAlice.id }, alice); + + testChannel = (await api('channels/create', { name: 'test' }, root)).body; + testMutedChannel = (await api('channels/create', { name: 'test-muted' }, root)).body; + await api('channels/mute/create', { channelId: testMutedChannel.id }, alice); }, 1000 * 60 * 10); beforeEach(async () => { @@ -605,6 +612,20 @@ describe('アンテナ', () => { { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, ], }, + { + label: 'チャンネルノートも含む', + parameters: () => ({ src: 'all' }), + posts: [ + { note: (): Promise => post(bob, { text: `test ${keyword}`, channelId: testChannel.id }), included: true }, + ], + }, + { + label: 'ミュートしてるチャンネルは含まない', + parameters: () => ({ src: 'all' }), + posts: [ + { note: (): Promise => post(bob, { text: `test ${keyword}`, channelId: testMutedChannel.id }) }, + ], + }, ])('が取得できること($label)', async ({ parameters, posts }) => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index 570cc61c4b..fe9a217ee8 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -363,14 +363,11 @@ describe('クリップ', () => { const clipLimit = DEFAULT_POLICIES.clipLimit; const clips = await createMany({}, clipLimit); const res = await list({ - parameters: { limit: 1 }, // FIXME: 無視されて11全部返ってくる + parameters: { limit: clips.length }, }); - // 返ってくる配列には順序保障がないのでidでソートして厳密比較 - assert.deepStrictEqual( - res.sort(compareBy(s => s.id)), - clips.sort(compareBy(s => s.id)), - ); + // 作成responseの配列には順序保障がないのでidでソートして厳密比較 + assert.deepStrictEqual(res.toReversed(), clips.sort(compareBy(s => s.id))); }); test('の一覧が取得できる(空)', async () => { diff --git a/packages/backend/test/e2e/exports.ts b/packages/backend/test/e2e/exports.ts index 4bcecc9716..19433f3c88 100644 --- a/packages/backend/test/e2e/exports.ts +++ b/packages/backend/test/e2e/exports.ts @@ -16,7 +16,7 @@ describe('export-clips', () => { let bob: misskey.entities.SignupResponse; // XXX: Any better way to get the result? - async function pollFirstDriveFile() { + async function pollFirstDriveFile(): Promise { while (true) { const files = (await api('drive/files', {}, alice)).body; if (!files.length) { @@ -168,7 +168,36 @@ describe('export-clips', () => { assert.strictEqual(exported[1].clipNotes[0].note.text, 'baz2'); }); - test('Clipping other user\'s note', async () => { + test('Clipping other user\'s note (followers only notes are excluded when not following)', async () => { + const res = await api('clips/create', { + name: 'kawaii', + description: 'kawaii', + }, alice); + assert.strictEqual(res.status, 200); + const clip = res.body; + + const note = await post(bob, { + text: 'baz', + visibility: 'followers', + }); + + const res2 = await api('clips/add-note', { + clipId: clip.id, + noteId: note.id, + }, alice); + assert.strictEqual(res2.status, 204); + + const res3 = await api('i/export-clips', {}, alice); + assert.strictEqual(res3.status, 204); + + const exported = await pollFirstDriveFile(); + assert.strictEqual(exported[0].clipNotes.length, 0); + }); + + test('Clipping other user\'s note (followers only notes are included when following)', async () => { + // Alice follows Bob + await api('following/create', { userId: bob.id }, alice); + const res = await api('clips/create', { name: 'kawaii', description: 'kawaii', diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index bef98893c6..f00843de10 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -73,7 +73,7 @@ describe('Webリソース', () => { }; const metaTag = (res: SimpleGetResponse, key: string, superkey = 'name'): string => { - return res.body.window.document.querySelector('meta[' + superkey + '="' + key + '"]')?.content; + return res.body.querySelector('meta[' + superkey + '="' + key + '"]')?.attributes.content; }; beforeAll(async () => { diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index f639f90ea6..67a9026eb5 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -19,7 +19,7 @@ import { ResourceOwnerPassword, } from 'simple-oauth2'; import pkceChallenge from 'pkce-challenge'; -import { JSDOM } from 'jsdom'; +import * as htmlParser from 'node-html-parser'; import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'; import { api, port, sendEnvUpdateRequest, signup } from '../utils.js'; import type * as misskey from 'misskey-js'; @@ -28,6 +28,7 @@ const host = `http://127.0.0.1:${port}`; const clientPort = port + 1; const redirect_uri = `http://127.0.0.1:${clientPort}/redirect`; +const redirect_uri2 = `http://127.0.0.1:${clientPort}/redirect2`; const basicAuthParams: AuthorizationParamsExtended = { redirect_uri, @@ -73,11 +74,11 @@ const clientConfig: ModuleOptions<'client_id'> = { }; function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined, clientLogo: string | undefined } { - const fragment = JSDOM.fragment(html); + const doc = htmlParser.parse(`
${html}
`); return { - transactionId: fragment.querySelector('meta[name="misskey:oauth:transaction-id"]')?.content, - clientName: fragment.querySelector('meta[name="misskey:oauth:client-name"]')?.content, - clientLogo: fragment.querySelector('meta[name="misskey:oauth:client-logo"]')?.content, + transactionId: doc.querySelector('meta[name="misskey:oauth:transaction-id"]')?.attributes.content, + clientName: doc.querySelector('meta[name="misskey:oauth:client-name"]')?.attributes.content, + clientLogo: doc.querySelector('meta[name="misskey:oauth:client-logo"]')?.attributes.content, }; } @@ -148,7 +149,7 @@ function assertIndirectError(response: Response, error: string): void { async function assertDirectError(response: Response, status: number, error: string): Promise { assert.strictEqual(response.status, status); - const data = await response.json(); + const data = await response.json() as any; assert.strictEqual(data.error, error); } @@ -704,7 +705,7 @@ describe('OAuth', () => { const response = await fetch(new URL('.well-known/oauth-authorization-server', host)); assert.strictEqual(response.status, 200); - const body = await response.json(); + const body = await response.json() as any; assert.strictEqual(body.issuer, 'http://misskey.local'); assert.ok(body.scopes_supported.includes('write:notes')); }); @@ -807,65 +808,19 @@ describe('OAuth', () => { }); }); - // https://indieauth.spec.indieweb.org/#client-information-discovery describe('Client Information Discovery', () => { - describe('Redirection', () => { - const tests: Record void> = { - 'Read HTTP header': reply => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - }, - 'Mixed links': reply => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - - -
Misklient - `); - }, - 'Multiple items in Link header': reply => { - reply.header('Link', '; rel="redirect_uri",; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - }, - 'Multiple items in HTML': reply => { - reply.send(` - - - -
Misklient - `); - }, - }; - - for (const [title, replyFunc] of Object.entries(tests)) { - test(title, async () => { - sender = replyFunc; - - const client = new AuthorizationCode(clientConfig); - - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - }); - } - - test('No item', async () => { + // https://indieauth.spec.indieweb.org/#client-information-discovery + describe('JSON client metadata (11 July 2024)', () => { + test('Read JSON document', async () => { sender = (reply): void => { - reply.send(` - -
Misklient - `); + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/`, + client_name: 'Misklient JSON', + logo_uri: '/logo.png', + redirect_uris: ['/redirect'], + }); }; const client = new AuthorizationCode(clientConfig); @@ -877,119 +832,294 @@ describe('OAuth', () => { code_challenge: 'code', code_challenge_method: 'S256', } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient JSON'); + assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); + }); - // direct error because there's no redirect URI to ping + test('Merge Link header redirect_uri with JSON redirect_uris', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/`, + client_name: 'Misklient JSON', + redirect_uris: ['/redirect'], + }); + }; + + const client = new AuthorizationCode(clientConfig); + + const ok1 = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(ok1.status, 200); + + const ok2 = await fetch(client.authorizeURL({ + redirect_uri: redirect_uri2, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(ok2.status, 200); + }); + + test('Reject when client_id does not match retrieved URL', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/mismatch`, + client_uri: `http://127.0.0.1:${clientPort}/`, + redirect_uris: ['/redirect'], + }); + }; + + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + await assertDirectError(response, 400, 'invalid_request'); + }); + + test('Reject when client_uri is not a prefix of client_id', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/no-prefix/`, + redirect_uris: ['/redirect'], + }); + }; + + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + await assertDirectError(response, 400, 'invalid_request'); + }); + + test('Reject when JSON metadata has no redirect_uris and no Link header', async () => { + sender = (reply): void => { + reply.header('content-type', 'application/json'); + reply.send({ + client_id: `http://127.0.0.1:${clientPort}/`, + client_uri: `http://127.0.0.1:${clientPort}/`, + client_name: 'Misklient JSON', + }); + }; + + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); await assertDirectError(response, 400, 'invalid_request'); }); }); - test('Disallow loopback', async () => { - await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' }); + // https://indieauth.spec.indieweb.org/20220212/#client-information-discovery + describe('HTML link client metadata (12 Feb 2022)', () => { + describe('Redirection', () => { + const tests: Record void> = { + 'Read HTTP header': reply => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + }, + 'Mixed links': reply => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + + +
Misklient + `); + }, + 'Multiple items in Link header': reply => { + reply.header('Link', '; rel="redirect_uri",; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + }, + 'Multiple items in HTML': reply => { + reply.send(` + + + +
Misklient + `); + }, + }; - const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - await assertDirectError(response, 400, 'invalid_request'); - }); + for (const [title, replyFunc] of Object.entries(tests)) { + test(title, async () => { + sender = replyFunc; - test('Missing name', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(); - }; + const client = new AuthorizationCode(clientConfig); - const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + }); + } - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); - }); + test('No item', async () => { + sender = (reply): void => { + reply.send(` + +
Misklient + `); + }; - test('With Logo', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - - - `); - reply.send(); - }; + const client = new AuthorizationCode(clientConfig); - const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - const meta = getMeta(await response.text()); - assert.strictEqual(meta.clientName, 'Misklient'); - assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); - }); + // direct error because there's no redirect URI to ping + await assertDirectError(response, 400, 'invalid_request'); + }); + }); - test('Missing Logo', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - reply.send(); - }; - const client = new AuthorizationCode(clientConfig); + test('Disallow loopback', async () => { + await sendEnvUpdateRequest({ key: 'MISSKEY_TEST_CHECK_IP_RANGE', value: '1' }); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - const meta = getMeta(await response.text()); - assert.strictEqual(meta.clientName, 'Misklient'); - assert.strictEqual(meta.clientLogo, undefined); - }); + const client = new AuthorizationCode(clientConfig); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + await assertDirectError(response, 400, 'invalid_request'); + }); - test('Mismatching URL in h-app', async () => { - sender = (reply): void => { - reply.header('Link', '; rel="redirect_uri"'); - reply.send(` - -
Misklient - `); - reply.send(); - }; + test('Missing name', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(); + }; - const client = new AuthorizationCode(clientConfig); + const client = new AuthorizationCode(clientConfig); - const response = await fetch(client.authorizeURL({ - redirect_uri, - scope: 'write:notes', - state: 'state', - code_challenge: 'code', - code_challenge_method: 'S256', - } as AuthorizationParamsExtended)); - assert.strictEqual(response.status, 200); - assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + }); + + test('With Logo', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + + + `); + reply.send(); + }; + + const client = new AuthorizationCode(clientConfig); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient'); + assert.strictEqual(meta.clientLogo, `http://127.0.0.1:${clientPort}/logo.png`); + }); + + test('Missing Logo', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + reply.send(); + }; + + const client = new AuthorizationCode(clientConfig); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + const meta = getMeta(await response.text()); + assert.strictEqual(meta.clientName, 'Misklient'); + assert.strictEqual(meta.clientLogo, undefined); + }); + + test('Mismatching URL in h-app', async () => { + sender = (reply): void => { + reply.header('Link', '; rel="redirect_uri"'); + reply.send(` + +
Misklient + `); + reply.send(); + }; + + const client = new AuthorizationCode(clientConfig); + + const response = await fetch(client.authorizeURL({ + redirect_uri, + scope: 'write:notes', + state: 'state', + code_challenge: 'code', + code_challenge_method: 'S256', + } as AuthorizationParamsExtended)); + assert.strictEqual(response.status, 200); + assert.strictEqual(getMeta(await response.text()).clientName, `http://127.0.0.1:${clientPort}/`); + }); }); }); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 4f7d1a4d69..4fd826100d 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -3,14 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + // How to run: // pnpm jest -- e2e/timelines.ts import * as assert from 'assert'; import { setTimeout } from 'node:timers/promises'; +import { entities } from 'misskey-js'; import { Redis } from 'ioredis'; -import { SignupResponse, Note, UserList } from 'misskey-js/entities.js'; -import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl } from '../utils.js'; +import { SignupResponse, Note } from 'misskey-js/entities.js'; +import { api, initTestDb, post, randomString, sendEnvUpdateRequest, signup, uploadUrl, UserToken } from '../utils.js'; import { loadConfig } from '@/config.js'; function genHost() { @@ -20,12 +23,72 @@ function genHost() { let redisForTimelines: Redis; let root: SignupResponse; +async function renote(noteId: string, user: UserToken): Promise { + return await api('notes/create', { renoteId: noteId }, user).then(it => it.body.createdNote); +} + +async function createChannel(name: string, user: UserToken): Promise { + return (await api('channels/create', { name }, user)).body; +} + +async function followChannel(channelId: string, user: UserToken) { + return await api('channels/follow', { channelId }, user); +} + +async function muteChannel(channelId: string, user: UserToken) { + await api('channels/mute/create', { channelId }, user); +} + +async function createList(name: string, user: UserToken): Promise { + return (await api('users/lists/create', { name }, user)).body; +} + +async function pushList(listId: string, pushUserIds: string[] = [], user: UserToken) { + for (const userId of pushUserIds) { + await api('users/lists/push', { listId, userId }, user); + } + await setTimeout(500); +} + +async function createRole(name: string, user: UserToken): Promise { + return (await api('admin/roles/create', { + name, + description: '', + color: '#000000', + iconUrl: '', + target: 'manual', + condFormula: {}, + isPublic: true, + isModerator: false, + isAdministrator: false, + isExplorable: true, + asBadge: false, + canEditMembersByModerator: false, + displayOrder: 0, + policies: {}, + }, user)).body; +} + +async function assignRole(roleId: string, userId: string, user: UserToken) { + await api('admin/roles/assign', { userId, roleId }, user); +} + describe('Timelines', () => { + let root: UserToken; + beforeAll(async () => { redisForTimelines = new Redis(loadConfig().redisForTimelines); root = await signup({ username: 'root' }); }, 1000 * 60 * 2); + // afterEach(async () => { + // // テスト中に作ったノートをきれいにする。 + // // ユーザも作っているが、時間差で動く通知系処理などがあり、このタイミングで消すとエラー落ちするので消さない(ノートさえ消えていれば支障はない) + // const db = await initTestDb(true); + // await db.query('DELETE FROM "note"'); + // await db.query('DELETE FROM "channel"'); + // }); + describe.each([ { enableFanoutTimeline: true }, { enableFanoutTimeline: false }, @@ -611,6 +674,280 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); + describe('Channel', () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', 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); + }); + + test('チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', 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), true); + }); + + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', 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); + }); + + test('チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', 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), true); + }); + + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', 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); + }); + + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', 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); + }); + + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', 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); + }); + + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', 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); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); + test('FTT: ローカルユーザーの HTL にはプッシュされる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); @@ -1012,41 +1349,277 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); - describe('凍結', () => { - let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; - let aliceNote: Note, bobNote: Note, carolNote: Note; + describe('Channel', () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); - beforeAll(async () => { - [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + const channel = await createChannel('channel', bob); - aliceNote = await post(alice, { text: 'hi' }); - bobNote = await post(bob, { text: 'yo' }); - carolNote = await post(carol, { text: 'kon\'nichiwa' }); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); await waitForPushToTl(); - }); - - test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - await api('admin/suspend-user', { userId: carol.id }, root); - await setTimeout(100); const res = await api('notes/local-timeline', { limit: 100 }, alice); - 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); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { - await api('admin/unsuspend-user', { userId: carol.id }, root); - await setTimeout(100); + test('チャンネルフォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); const res = await api('notes/local-timeline', { limit: 100 }, alice); - 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), true); - assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'kon\'nichiwa'); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); }); }); }); @@ -1091,7 +1664,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ + if (!enableFanoutTimeline) return; const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1127,7 +1701,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ + if (!enableFanoutTimeline) return; const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); @@ -1149,7 +1724,8 @@ describe('Timelines', () => { }); test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ + if (!enableFanoutTimeline) return; const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1225,7 +1801,8 @@ describe('Timelines', () => { }); test('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { - /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ if (!enableFanoutTimeline) return; + /* FIXME: https://github.com/misskey-dev/misskey/issues/12065 */ + if (!enableFanoutTimeline) return; const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1269,6 +1846,280 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); + describe('Channel', () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); + describe('凍結', () => { /* * bob = 未フォローのローカルユーザー (凍結対象でない) @@ -1607,45 +2458,309 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - describe('凍結', () => { - let alice: SignupResponse, bob: SignupResponse, carol: SignupResponse; - let aliceNote: Note, bobNote: Note, carolNote: Note; - let list: UserList; + describe('Channel', () => { + test('チャンネル未フォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); - beforeAll(async () => { - [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + const list = await createList('list', alice); - list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + const channel = await createChannel('channel', bob); - await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); - await api('users/lists/push', { listId: list.id, userId: carol.id }, alice); - aliceNote = await post(alice, { text: 'hi' }); - bobNote = await post(bob, { text: 'yo' }); - carolNote = await post(carol, { text: 'kon\'nichiwa' }); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); await waitForPushToTl(); - await api('admin/suspend-user', { userId: carol.id }, root); - await setTimeout(100); + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test('凍結後に凍結されたユーザーのノートは見えなくなる', async () => { - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + test('チャンネルフォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test('凍結解除後に凍結されていたユーザーのノートは見えるようになる', async () => { - await api('admin/unsuspend-user', { userId: carol.id }, root); - await setTimeout(100); + test('チャンネル未フォロー + リスインしてる = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); - const res = await api('notes/user-list-timeline', { listId: list.id }, alice); + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); - assert.strictEqual(res.body.some(note => note.id === aliceNote.id), false); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); - assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてる = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてる = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてる = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); }); }); }); @@ -1937,8 +3052,276 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id, untilId: noteUntil.id }); assert.deepStrictEqual(res.body, [note3, note2, note1]); }); + + describe('Channel', () => { + test('チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); }); + describe('Channel TL', () => { + test('閲覧中チャンネルのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('閲覧中チャンネルとは別チャンネルのノートは含まれない', async() => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + const channel2 = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel2.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('閲覧中チャンネルのノートにリノートが含まれる', async() => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await post(bob, { channelId: channel.id, renoteId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('閲覧中チャンネルとは別チャンネルからのリノートが含まれる', async() => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + const channel2 = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel2.id }); + const bobRenote = await post(bob, { channelId: channel.id, renoteId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('閲覧中チャンネルに自分の他人への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id, channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + + test('閲覧中チャンネルに他人の自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi', channelId: channel.id }); + const bobNote = await post(bob, { text: 'ok', replyId: aliceNote.id, channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('閲覧中チャンネルにミュートしているユーザのノートは含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('mute/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('閲覧中チャンネルにこちらをブロックしているユーザのノートは含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('blocking/create', { userId: alice.id }, bob); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('閲覧中チャンネルをミュートしていてもノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('閲覧中チャンネルをミュートしていても、同チャンネルのリノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await post(bob, { channelId: channel.id, renoteId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('閲覧中チャンネルをミュートしていても、同チャンネルのリプライが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await post(bob, { channelId: channel.id, replyId: bobNote.id, text: 'ho' }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('閲覧中チャンネルとは別チャンネルをミュートしているとき、そのチャンネルからのリノートは含まれない', async() => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + const channel2 = await createChannel('channel', bob); + await muteChannel(channel2.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel2.id }); + const bobRenote = await post(bob, { channelId: channel.id, renoteId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); // TODO: リノートミュート済みユーザーのテスト // TODO: ページネーションのテスト }); diff --git a/packages/backend/test/jest.setup.ts b/packages/backend/test/jest.setup.ts index 7c6dd6a55f..9185f58acb 100644 --- a/packages/backend/test/jest.setup.ts +++ b/packages/backend/test/jest.setup.ts @@ -9,3 +9,4 @@ beforeAll(async () => { await initTestDb(false); await sendEnvResetRequest(); }); + diff --git a/packages/backend/test/jest.setup.unit.cjs b/packages/backend/test/jest.setup.unit.cjs new file mode 100644 index 0000000000..dd879c81c8 --- /dev/null +++ b/packages/backend/test/jest.setup.unit.cjs @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +module.exports = async () => { + // DBはUTC(っぽい)ので、テスト側も合わせておく + process.env.TZ = 'UTC'; + process.env.NODE_ENV = 'test'; +}; diff --git a/packages/backend/test/resources/dummy-for-file-server-service.png b/packages/backend/test/resources/dummy-for-file-server-service.png new file mode 100644 index 0000000000..39332b0c1b Binary files /dev/null and b/packages/backend/test/resources/dummy-for-file-server-service.png differ diff --git a/packages/backend/test/tsconfig.json b/packages/backend/test/tsconfig.json index 2b562acda8..a2a86c696e 100644 --- a/packages/backend/test/tsconfig.json +++ b/packages/backend/test/tsconfig.json @@ -23,7 +23,8 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, - "baseUrl": "./", + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html", "paths": { "@/*": ["../src/*"] }, diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index 0b24f109f8..b3f7f426fe 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -26,7 +26,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; +import type { MockMetadata } from 'jest-mock'; const moduleMocker = new ModuleMocker(global); @@ -84,7 +84,7 @@ describe('AnnouncementService', () => { log: jest.fn(), }; } else if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } diff --git a/packages/backend/test/unit/ApMfmService.ts b/packages/backend/test/unit/ApMfmService.ts index e81a321c9b..93efa5d7d3 100644 --- a/packages/backend/test/unit/ApMfmService.ts +++ b/packages/backend/test/unit/ApMfmService.ts @@ -9,7 +9,6 @@ import { Test } from '@nestjs/testing'; import { CoreModule } from '@/core/CoreModule.js'; import { ApMfmService } from '@/core/activitypub/ApMfmService.js'; import { GlobalModule } from '@/GlobalModule.js'; -import { MiNote } from '@/models/Note.js'; describe('ApMfmService', () => { let apMfmService: ApMfmService; @@ -31,7 +30,7 @@ describe('ApMfmService', () => { const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); assert.equal(noMisskeyContent, true, 'noMisskeyContent'); - assert.equal(content, '

テキスト @mention 🍊 ​:emoji:​ https://example.com

', 'content'); + assert.equal(content, 'テキスト @mention 🍊 ​:emoji:​ https://example.com', 'content'); }); test('Provide _misskey_content for MFM', () => { @@ -43,7 +42,7 @@ describe('ApMfmService', () => { const { content, noMisskeyContent } = apMfmService.getNoteHtml(note); assert.equal(noMisskeyContent, false, 'noMisskeyContent'); - assert.equal(content, '

foo

', 'content'); + assert.equal(content, 'foo', 'content'); }); }); }); diff --git a/packages/backend/test/unit/CaptchaService.ts b/packages/backend/test/unit/CaptchaService.ts index 51b70b05a1..24bb81118e 100644 --- a/packages/backend/test/unit/CaptchaService.ts +++ b/packages/backend/test/unit/CaptchaService.ts @@ -446,7 +446,7 @@ describe('CaptchaService', () => { if (!res.success) { expect(res.error.code).toBe(code); } - expect(metaService.update).not.toBeCalled(); + expect(metaService.update).not.toHaveBeenCalled(); } describe('invalidParameters', () => { diff --git a/packages/backend/test/unit/ChannelFollowingService.ts b/packages/backend/test/unit/ChannelFollowingService.ts new file mode 100644 index 0000000000..2d3196f2f4 --- /dev/null +++ b/packages/backend/test/unit/ChannelFollowingService.ts @@ -0,0 +1,235 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable */ + +import { afterEach, beforeEach, describe, expect } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { + type ChannelFollowingsRepository, + ChannelsRepository, + DriveFilesRepository, + MiChannel, + MiChannelFollowing, + MiDriveFile, + MiUser, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ChannelFollowingService } from "@/core/ChannelFollowingService.js"; +import { MiLocalUser } from "@/models/User.js"; + +describe('ChannelFollowingService', () => { + let app: TestingModule; + let service: ChannelFollowingService; + let channelsRepository: ChannelsRepository; + let channelFollowingsRepository: ChannelFollowingsRepository; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let driveFilesRepository: DriveFilesRepository; + let idService: IdService; + + let alice: MiLocalUser; + let bob: MiLocalUser; + let channel1: MiChannel; + let channel2: MiChannel; + let channel3: MiChannel; + let driveFile1: MiDriveFile; + let driveFile2: MiDriveFile; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + username: 'username', + usernameLower: 'username', + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createChannel(data: Partial = {}) { + return await channelsRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createChannelFollowing(data: Partial = {}) { + return await channelFollowingsRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelFollowingsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function fetchChannelFollowing() { + return await channelFollowingsRepository.findBy({}); + } + + async function createDriveFile(data: Partial = {}) { + return await driveFilesRepository + .insert({ + id: idService.gen(), + md5: 'md5', + name: 'name', + size: 0, + type: 'type', + storedInternal: false, + url: 'url', + ...data, + }) + .then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + providers: [ + GlobalEventService, + IdService, + ChannelFollowingService, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(ChannelFollowingService); + idService = app.get(IdService); + channelsRepository = app.get(DI.channelsRepository); + channelFollowingsRepository = app.get(DI.channelFollowingsRepository); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + driveFilesRepository = app.get(DI.driveFilesRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = { ...await createUser({ username: 'alice' }), host: null, uri: null }; + bob = { ...await createUser({ username: 'bob' }), host: null, uri: null }; + driveFile1 = await createDriveFile(); + driveFile2 = await createDriveFile(); + channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id }); + channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id }); + channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id }); + }); + + afterEach(async () => { + await channelFollowingsRepository.deleteAll(); + await channelsRepository.deleteAll(); + await userProfilesRepository.deleteAll(); + await usersRepository.deleteAll(); + }); + + describe('list', () => { + test('default', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[0].userId).toBe(alice.id); + expect(followings[0].user).toBeFalsy(); + expect(followings[0].bannerId).toBe(driveFile1.id); + expect(followings[0].banner).toBeFalsy(); + expect(followings[1].id).toBe(channel2.id); + expect(followings[1].userId).toBe(alice.id); + expect(followings[1].user).toBeFalsy(); + expect(followings[1].bannerId).toBe(driveFile2.id); + expect(followings[1].banner).toBeFalsy(); + }); + + test('idOnly', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }, { idOnly: true }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[1].id).toBe(channel2.id); + }); + + test('joinUser', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }, { joinUser: true }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[0].user).toEqual(alice); + expect(followings[0].banner).toBeFalsy(); + expect(followings[1].id).toBe(channel2.id); + expect(followings[1].user).toEqual(alice); + expect(followings[1].banner).toBeFalsy(); + }); + + test('joinBannerFile', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[0].user).toBeFalsy(); + expect(followings[0].banner).toEqual(driveFile1); + expect(followings[1].id).toBe(channel2.id); + expect(followings[1].user).toBeFalsy(); + expect(followings[1].banner).toEqual(driveFile2); + }); + }); + + describe('follow', () => { + test('default', async () => { + await service.follow(alice, channel1); + + const followings = await fetchChannelFollowing(); + + expect(followings).toHaveLength(1); + expect(followings[0].followeeId).toBe(channel1.id); + expect(followings[0].followerId).toBe(alice.id); + }); + }); + + describe('unfollow', () => { + test('default', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + + await service.unfollow(alice, channel1); + + const followings = await fetchChannelFollowing(); + + expect(followings).toHaveLength(0); + }); + }); +}); diff --git a/packages/backend/test/unit/ChannelMutingService.ts b/packages/backend/test/unit/ChannelMutingService.ts new file mode 100644 index 0000000000..6916701d1f --- /dev/null +++ b/packages/backend/test/unit/ChannelMutingService.ts @@ -0,0 +1,336 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable */ + +import { afterEach, beforeEach, describe, expect } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { + ChannelMutingRepository, + ChannelsRepository, + DriveFilesRepository, + MiChannel, + MiChannelMuting, + MiDriveFile, + MiUser, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { setTimeout } from 'node:timers/promises'; + +describe('ChannelMutingService', () => { + let app: TestingModule; + let service: ChannelMutingService; + let channelsRepository: ChannelsRepository; + let channelMutingRepository: ChannelMutingRepository; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let driveFilesRepository: DriveFilesRepository; + let idService: IdService; + + let alice: MiUser; + let bob: MiUser; + let channel1: MiChannel; + let channel2: MiChannel; + let channel3: MiChannel; + let driveFile1: MiDriveFile; + let driveFile2: MiDriveFile; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + username: 'username', + usernameLower: 'username', + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createChannel(data: Partial = {}) { + return await channelsRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createChannelMuting(data: Partial = {}) { + return await channelMutingRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelMutingRepository.findOneByOrFail(x.identifiers[0])); + } + + async function fetchChannelMuting() { + return await channelMutingRepository.findBy({}); + } + + async function createDriveFile(data: Partial = {}) { + return await driveFilesRepository + .insert({ + id: idService.gen(), + md5: 'md5', + name: 'name', + size: 0, + type: 'type', + storedInternal: false, + url: 'url', + ...data, + }) + .then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + providers: [ + GlobalEventService, + IdService, + ChannelMutingService, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(ChannelMutingService); + idService = app.get(IdService); + channelsRepository = app.get(DI.channelsRepository); + channelMutingRepository = app.get(DI.channelMutingRepository); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + driveFilesRepository = app.get(DI.driveFilesRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = await createUser({ username: 'alice' }); + bob = await createUser({ username: 'bob' }); + driveFile1 = await createDriveFile(); + driveFile2 = await createDriveFile(); + channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id }); + channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id }); + channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id }); + }); + + afterEach(async () => { + await channelMutingRepository.deleteAll(); + await channelsRepository.deleteAll(); + await userProfilesRepository.deleteAll(); + await usersRepository.deleteAll(); + }); + + describe('list', () => { + test('default', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[0].userId).toBe(alice.id); + expect(mutings[0].user).toBeFalsy(); + expect(mutings[0].bannerId).toBe(driveFile1.id); + expect(mutings[0].banner).toBeFalsy(); + expect(mutings[1].id).toBe(channel2.id); + expect(mutings[1].userId).toBe(alice.id); + expect(mutings[1].user).toBeFalsy(); + expect(mutings[1].bannerId).toBe(driveFile2.id); + expect(mutings[1].banner).toBeFalsy(); + }); + + test('withoutExpires', async () => { + const now = new Date(); + const past = new Date(now); + const future = new Date(now); + past.setMinutes(past.getMinutes() - 1); + future.setMinutes(future.getMinutes() + 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null }); + await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future }); + + const mutings = await service.list({ requestUserId: alice.id }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel2.id); + expect(mutings[1].id).toBe(channel3.id); + }); + + test('idOnly', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[1].id).toBe(channel2.id); + }); + + test('withoutExpires-idOnly', async () => { + const now = new Date(); + const past = new Date(now); + const future = new Date(now); + past.setMinutes(past.getMinutes() - 1); + future.setMinutes(future.getMinutes() + 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null }); + await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future }); + + const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel2.id); + expect(mutings[1].id).toBe(channel3.id); + }); + + test('joinUser', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }, { joinUser: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[0].user).toEqual(alice); + expect(mutings[0].banner).toBeFalsy(); + expect(mutings[1].id).toBe(channel2.id); + expect(mutings[1].user).toEqual(alice); + expect(mutings[1].banner).toBeFalsy(); + }); + + test('joinBannerFile', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[0].user).toBeFalsy(); + expect(mutings[0].banner).toEqual(driveFile1); + expect(mutings[1].id).toBe(channel2.id); + expect(mutings[1].user).toBeFalsy(); + expect(mutings[1].banner).toEqual(driveFile2); + }); + }); + + describe('findExpiredMutings', () => { + test('default', async () => { + const now = new Date(); + const future = new Date(now); + const past = new Date(now); + future.setMinutes(now.getMinutes() + 1); + past.setMinutes(now.getMinutes() - 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past }); + + const mutings = await service.findExpiredMutings(); + + expect(mutings).toHaveLength(2); + expect(mutings[0].channelId).toBe(channel1.id); + expect(mutings[1].channelId).toBe(channel3.id); + }); + }); + + describe('isMuted', () => { + test('isMuted: true', async () => { + // キャッシュを読むのでServiceの機能を使って登録し、キャッシュを作成する + await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id }); + await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id }); + + await setTimeout(500); + + const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id }); + + expect(result).toBe(true); + }); + + test('isMuted: false', async () => { + await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id }); + + await setTimeout(500); + + const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id }); + + expect(result).toBe(false); + }); + }); + + describe('mute', () => { + test('default', async () => { + await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id }); + + const muting = await fetchChannelMuting(); + expect(muting).toHaveLength(1); + expect(muting[0].channelId).toBe(channel1.id); + }); + }); + + describe('unmute', () => { + test('default', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + + let muting = await fetchChannelMuting(); + expect(muting).toHaveLength(1); + expect(muting[0].channelId).toBe(channel1.id); + + await service.unmute({ requestUserId: alice.id, targetChannelId: channel1.id }); + + muting = await fetchChannelMuting(); + expect(muting).toHaveLength(0); + }); + }); + + describe('eraseExpiredMutings', () => { + test('default', async () => { + const now = new Date(); + const future = new Date(now); + const past = new Date(now); + future.setMinutes(now.getMinutes() + 1); + past.setMinutes(now.getMinutes() - 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past }); + + await service.eraseExpiredMutings(); + + const mutings = await fetchChannelMuting(); + expect(mutings).toHaveLength(1); + expect(mutings[0].channelId).toBe(channel2.id); + }); + }); +}); diff --git a/packages/backend/test/unit/DriveService.ts b/packages/backend/test/unit/DriveService.ts index 964c65ccaa..48b108fbba 100644 --- a/packages/backend/test/unit/DriveService.ts +++ b/packages/backend/test/unit/DriveService.ts @@ -53,7 +53,7 @@ describe('DriveService', () => { s3Mock.on(DeleteObjectCommand) .rejects(new InvalidObjectState({ $metadata: {}, message: '' })); - await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrowError(Error); + await expect(driveService.deleteObjectStorageFile('unexpected')).rejects.toThrow(Error); }); test('delete a file with no valid key', async () => { diff --git a/packages/backend/test/unit/FileInfoService.ts b/packages/backend/test/unit/FileInfoService.ts index 29bd03a201..28a2a971f4 100644 --- a/packages/backend/test/unit/FileInfoService.ts +++ b/packages/backend/test/unit/FileInfoService.ts @@ -17,7 +17,7 @@ import { FileInfo, FileInfoService } from '@/core/FileInfoService.js'; import { AiService } from '@/core/AiService.js'; import { LoggerService } from '@/core/LoggerService.js'; import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; +import type { MockMetadata } from 'jest-mock'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -34,7 +34,7 @@ describe('FileInfoService', () => { delete fi.sensitive; delete fi.blurhash; delete fi.porn; - + return fi; } @@ -54,7 +54,7 @@ describe('FileInfoService', () => { // return { }; //} if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts index 7350da3cae..2f5f3745de 100644 --- a/packages/backend/test/unit/MfmService.ts +++ b/packages/backend/test/unit/MfmService.ts @@ -24,25 +24,25 @@ describe('MfmService', () => { describe('toHtml', () => { test('br', () => { const input = 'foo\nbar\nbaz'; - const output = '

foo
bar
baz

'; + const output = 'foo
bar
baz'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); test('br alt', () => { const input = 'foo\r\nbar\rbaz'; - const output = '

foo
bar
baz

'; + const output = 'foo
bar
baz'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); test('Do not generate unnecessary span', () => { const input = 'foo $[tada bar]'; - const output = '

foo bar

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

Hello, world!

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

<p>Hello, world!</p>

'; + const output = '
<p>Hello, world!</p>
'; assert.equal(mfmService.toHtml(mfm.parse(input)), output); }); }); @@ -118,7 +118,7 @@ describe('MfmService', () => { assert.deepStrictEqual(mfmService.fromHtml('

a Misskey(ミス キー) b c

'), 'a Misskey(ミス キー) b c'); assert.deepStrictEqual( mfmService.fromHtml('

a Misskey(ミスキー)Misskey(ミス キー)Misskey(ミスキー) b

'), - 'a Misskey(ミスキー)Misskey(ミス キー)Misskey(ミスキー) b' + 'a Misskey(ミスキー)Misskey(ミス キー)Misskey(ミスキー) b', ); }); diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts index 23f409420e..f3d3d1da99 100644 --- a/packages/backend/test/unit/NoteCreateService.ts +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -61,6 +61,7 @@ describe('NoteCreateService', () => { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + renoteChannelId: null, }; const poll: IPoll = { diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index 074430dd31..bee580d0c7 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -9,7 +9,7 @@ import { jest } from '@jest/globals'; import { Test } from '@nestjs/testing'; import { ModuleMocker } from 'jest-mock'; import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; +import type { MockMetadata } from 'jest-mock'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { IdService } from '@/core/IdService.js'; @@ -45,7 +45,7 @@ describe('RelayService', () => { return { deliver: jest.fn() }; } if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 306836ea43..9b17b1fbb9 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -6,12 +6,12 @@ process.env.NODE_ENV = 'test'; import { setTimeout } from 'node:timers/promises'; -import { jest } from '@jest/globals'; +import { describe, jest } from '@jest/globals'; import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; import * as lolex from '@sinonjs/fake-timers'; import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; +import type { MockMetadata } from 'jest-mock'; import { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import { @@ -104,6 +104,8 @@ describe('RoleService', () => { beforeEach(async () => { clock = lolex.install({ + // https://github.com/sinonjs/sinon/issues/2620 + toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[], now: new Date(), shouldClearNativeTimers: true, }); @@ -135,7 +137,7 @@ describe('RoleService', () => { return { fetch: jest.fn() }; } if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } @@ -158,16 +160,75 @@ describe('RoleService', () => { afterEach(async () => { clock.uninstall(); + /** + * Delete meta and roleAssignment first to avoid deadlock due to schema dependencies + * https://github.com/misskey-dev/misskey/issues/16783 + */ + await app.get(DI.metasRepository).createQueryBuilder().delete().execute(); + await roleAssignmentsRepository.createQueryBuilder().delete().execute(); await Promise.all([ - app.get(DI.metasRepository).createQueryBuilder().delete().execute(), usersRepository.createQueryBuilder().delete().execute(), rolesRepository.createQueryBuilder().delete().execute(), - roleAssignmentsRepository.createQueryBuilder().delete().execute(), ]); await app.close(); }); + describe('getUserAssigns', () => { + test('アサインされたロールを取得できる', async () => { + const user = await createUser(); + const role1 = await createRole({ name: 'a' }); + const role2 = await createRole({ name: 'b' }); + + await roleService.assign(user.id, role1.id); + await roleService.assign(user.id, role2.id); + + const assigns = await roleService.getUserAssigns(user.id); + expect(assigns).toHaveLength(2); + expect(assigns.some(a => a.roleId === role1.id)).toBe(true); + expect(assigns.some(a => a.roleId === role2.id)).toBe(true); + }); + + test('アサインされたロールの有効/期限切れパターンを取得できる', async () => { + const user = await createUser(); + const roleNoExpiry = await createRole({ name: 'no-expires' }); + const roleNotExpired = await createRole({ name: 'not-expired' }); + const roleExpired = await createRole({ name: 'expired' }); + + // expiresAtなし + await roleService.assign(user.id, roleNoExpiry.id); + + // expiresAtあり(期限切れでない) + const future = new Date(Date.now() + 1000 * 60 * 60); // +1 hour + await roleService.assign(user.id, roleNotExpired.id, future); + + // expiresAtあり(期限切れ) + await assignRole({ userId: user.id, roleId: roleExpired.id, expiresAt: new Date(Date.now() - 1000) }); + + const assigns = await roleService.getUserAssigns(user.id); + expect(assigns.some(a => a.roleId === roleNoExpiry.id)).toBe(true); + expect(assigns.some(a => a.roleId === roleNotExpired.id)).toBe(true); + expect(assigns.some(a => a.roleId === roleExpired.id)).toBe(false); + }); + }); + + describe('getUserRoles', () => { + test('アサインされたロールとコンディショナルロールの両方が取得できる', async () => { + const user = await createUser(); + const manualRole = await createRole({ name: 'manual role' }); + const conditionalRole = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }); + await roleService.assign(user.id, manualRole.id); + await roleService.assign(user.id, conditionalRole.id); + + const roles = await roleService.getUserRoles(user.id); + expect(roles.some(r => r.id === manualRole.id)).toBe(true); + expect(roles.some(r => r.id === conditionalRole.id)).toBe(true); + }); + }); + describe('getUserPolicies', () => { test('instance default policies', async () => { const user = await createUser(); @@ -280,6 +341,112 @@ describe('RoleService', () => { const resultAfter25hAgain = await roleService.getUserPolicies(user.id); expect(resultAfter25hAgain.canManageCustomEmojis).toBe(true); }); + + test('role with no policy set', async () => { + const user = await createUser(); + const roleWithPolicy = await createRole({ + name: 'roleWithPolicy', + policies: { + pinLimit: { + useDefault: false, + priority: 0, + value: 10, + }, + }, + }); + const roleWithoutPolicy = await createRole({ + name: 'roleWithoutPolicy', + policies: {}, // ポリシーが空 + }); + await roleService.assign(user.id, roleWithPolicy.id); + await roleService.assign(user.id, roleWithoutPolicy.id); + meta.policies = { + pinLimit: 5, + }; + + const result = await roleService.getUserPolicies(user.id); + + // roleWithoutPolicy は default 値 (5) を使い、roleWithPolicy の 10 と比較して大きい方が採用される + expect(result.pinLimit).toBe(10); + }); + }); + + describe('getUserBadgeRoles', () => { + test('手動アサイン済みのバッジロールのみが返る', async () => { + const user = await createUser(); + const badgeRole = await createRole({ name: 'badge', asBadge: true }); + const normalRole = await createRole({ name: 'normal', asBadge: false }); + + await roleService.assign(user.id, badgeRole.id); + await roleService.assign(user.id, normalRole.id); + + const roles = await roleService.getUserBadgeRoles(user.id); + expect(roles.some(r => r.id === badgeRole.id)).toBe(true); + expect(roles.some(r => r.id === normalRole.id)).toBe(false); + }); + + test('コンディショナルなバッジロールが条件一致で返る', async () => { + const user = await createUser({ isBot: true }); + const condBadgeRole = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }, { asBadge: true, name: 'cond-badge' }); + const condNonBadgeRole = await createConditionalRole({ + id: aidx(), + type: 'isBot', + }, { asBadge: false, name: 'cond-non-badge' }); + + const roles = await roleService.getUserBadgeRoles(user.id); + expect(roles.some(r => r.id === condBadgeRole.id)).toBe(true); + expect(roles.some(r => r.id === condNonBadgeRole.id)).toBe(false); + }); + + test('roleAssignedTo 条件のバッジロール: アサイン有無で変化する', async () => { + const [user1, user2] = await Promise.all([createUser(), createUser()]); + const manualRole = await createRole({ name: 'manual' }); + const condBadgeRole = await createConditionalRole({ + id: aidx(), + type: 'roleAssignedTo', + roleId: manualRole.id, + }, { asBadge: true, name: 'assigned-badge' }); + + await roleService.assign(user2.id, manualRole.id); + + const [roles1, roles2] = await Promise.all([ + roleService.getUserBadgeRoles(user1.id), + roleService.getUserBadgeRoles(user2.id), + ]); + expect(roles1.some(r => r.id === condBadgeRole.id)).toBe(false); + expect(roles2.some(r => r.id === condBadgeRole.id)).toBe(true); + }); + + test('期限切れのバッジロールは除外される', async () => { + const user = await createUser(); + const roleNoExpiry = await createRole({ name: 'no-exp', asBadge: true }); + const roleNotExpired = await createRole({ name: 'not-expired', asBadge: true }); + const roleExpired = await createRole({ name: 'expired', asBadge: true }); + + // expiresAt なし + await roleService.assign(user.id, roleNoExpiry.id); + + // expiresAt あり(期限切れでない) + const future = new Date(Date.now() + 1000 * 60 * 60); // +1 hour + await roleService.assign(user.id, roleNotExpired.id, future); + + // expiresAt あり(期限切れ) + await assignRole({ userId: user.id, roleId: roleExpired.id, expiresAt: new Date(Date.now() - 1000) }); + + const rolesBefore = await roleService.getUserBadgeRoles(user.id); + expect(rolesBefore.some(r => r.id === roleNoExpiry.id)).toBe(true); + expect(rolesBefore.some(r => r.id === roleNotExpired.id)).toBe(true); + expect(rolesBefore.some(r => r.id === roleExpired.id)).toBe(false); + + // 時間経過で roleNotExpired を失効させる + clock.tick('02:00:00'); + const rolesAfter = await roleService.getUserBadgeRoles(user.id); + expect(rolesAfter.some(r => r.id === roleNoExpiry.id)).toBe(true); + expect(rolesAfter.some(r => r.id === roleNotExpired.id)).toBe(false); + }); }); describe('getModeratorIds', () => { @@ -413,9 +580,9 @@ describe('RoleService', () => { expect(result).toEqual([modeUser1.id, modeUser2.id, rootUser.id]); }); - test('root has moderator role', async () => { - const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createRoot(), + test('includeAdmins = false, includeRoot = true, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -424,9 +591,11 @@ describe('RoleService', () => { 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: rootUser.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({ @@ -434,12 +603,12 @@ describe('RoleService', () => { includeRoot: true, excludeExpire: false, }); - expect(result).toEqual([modeUser1.id, rootUser.id]); + expect(result).toEqual([modeUser1.id, modeUser2.id, rootUser.id]); }); - test('root has administrator role', async () => { - const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createRoot(), + test('includeAdmins = true, includeRoot = true, excludeExpire = false', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -448,9 +617,11 @@ describe('RoleService', () => { await Promise.all([ assignRole({ userId: adminUser1.id, roleId: role1.id }), - assignRole({ userId: rootUser.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({ @@ -458,12 +629,12 @@ describe('RoleService', () => { includeRoot: true, excludeExpire: false, }); - expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]); + expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id, rootUser.id]); }); - test('root has moderator role(expire)', async () => { - const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ - createUser(), createUser(), createUser(), createRoot(), + test('includeAdmins = true, includeRoot = true, excludeExpire = true', async () => { + const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), createRoot(), ]); const role1 = await createRole({ name: 'admin', isAdministrator: true }); @@ -472,17 +643,71 @@ describe('RoleService', () => { await Promise.all([ assignRole({ userId: adminUser1.id, roleId: role1.id }), - assignRole({ userId: modeUser1.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), - assignRole({ userId: rootUser.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + 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({ - includeAdmins: false, + includeAdmins: true, includeRoot: true, excludeExpire: true, }); - expect(result).toEqual([rootUser.id]); + expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]); + }); + }); + + describe('getAdministratorIds', () => { + test('should return only user IDs with administrator roles', async () => { + const adminUser1 = await createUser(); + const adminUser2 = await createUser(); + const normalUser = await createUser(); + const moderatorUser = await createUser(); + + const adminRole = await createRole({ name: 'admin', isAdministrator: true, isModerator: false }); + const moderatorRole = await createRole({ name: 'moderator', isModerator: true, isAdministrator: false }); + const normalRole = await createRole({ name: 'normal', isAdministrator: false, isModerator: false }); + + await roleService.assign(adminUser1.id, adminRole.id); + await roleService.assign(adminUser2.id, adminRole.id); + await roleService.assign(moderatorUser.id, moderatorRole.id); + await roleService.assign(normalUser.id, normalRole.id); + + const adminIds = await roleService.getAdministratorIds(); + + // sort for deterministic order + adminIds.sort(); + const expectedIds = [adminUser1.id, adminUser2.id].sort(); + + expect(adminIds).toEqual(expectedIds); + }); + + test('should return an empty array if no users have administrator roles', async () => { + const normalUser = await createUser(); + const normalRole = await createRole({ name: 'normal', isAdministrator: false }); + await roleService.assign(normalUser.id, normalRole.id); + + const adminIds = await roleService.getAdministratorIds(); + + expect(adminIds).toHaveLength(0); + }); + + test('should return an empty array if there are no administrator roles defined', async () => { + await createUser(); // create user to ensure not empty db + const adminIds = await roleService.getAdministratorIds(); + expect(adminIds).toHaveLength(0); + }); + + // TODO: rootユーザーは現在実装に含まれていないため、テストもそれに倣う + test('should not include the root user', async () => { + const rootUser = await createUser(); + meta.rootUserId = rootUser.id; + + const adminIds = await roleService.getAdministratorIds(); + + expect(adminIds).not.toContain(rootUser.id); }); }); diff --git a/packages/backend/test/unit/S3Service.ts b/packages/backend/test/unit/S3Service.ts index 151f3b826a..6e7e5a8b59 100644 --- a/packages/backend/test/unit/S3Service.ts +++ b/packages/backend/test/unit/S3Service.ts @@ -72,7 +72,7 @@ describe('S3Service', () => { Bucket: 'fake', Key: 'fake', Body: 'x', - })).rejects.toThrowError(Error); + })).rejects.toThrow(Error); }); test('upload a large file error', async () => { @@ -82,7 +82,7 @@ describe('S3Service', () => { Bucket: 'fake', Key: 'fake', Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ - })).rejects.toThrowError(Error); + })).rejects.toThrow(Error); }); }); }); diff --git a/packages/backend/test/unit/SearchService.ts b/packages/backend/test/unit/SearchService.ts new file mode 100644 index 0000000000..6e17bef1c3 --- /dev/null +++ b/packages/backend/test/unit/SearchService.ts @@ -0,0 +1,483 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import type { Index, MeiliSearch } from 'meilisearch'; +import { type Config, loadConfig } from '@/config.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { SearchService } from '@/core/SearchService.js'; +import { CacheService } from '@/core/CacheService.js'; +import { IdService } from '@/core/IdService.js'; +import { DI } from '@/di-symbols.js'; +import { + type BlockingsRepository, + type ChannelsRepository, + type FollowingsRepository, + type MutingsRepository, + type NotesRepository, + type UserProfilesRepository, + type UsersRepository, + type MiChannel, + type MiNote, + type MiUser, +} from '@/models/_.js'; + +describe('SearchService', () => { + type TestContext = { + app: TestingModule; + service: SearchService; + cacheService: CacheService; + idService: IdService; + mutingsRepository: MutingsRepository; + blockingsRepository: BlockingsRepository; + usersRepository: UsersRepository; + userProfilesRepository: UserProfilesRepository; + notesRepository: NotesRepository; + channelsRepository: ChannelsRepository; + followingsRepository: FollowingsRepository; + indexer?: (note: MiNote) => Promise; + }; + + const meilisearchSettings = { + searchableAttributes: [ + 'text', + 'cw', + ], + sortableAttributes: [ + 'createdAt', + ], + filterableAttributes: [ + 'createdAt', + 'userId', + 'userHost', + 'channelId', + 'tags', + ], + typoTolerance: { + enabled: false, + }, + pagination: { + maxTotalHits: 10000, + }, + }; + + async function buildContext(configOverride?: Config): Promise { + const builder = Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + }); + + if (configOverride) { + builder.overrideProvider(DI.config).useValue(configOverride); + } + + const app = await builder.compile(); + + app.enableShutdownHooks(); + + return { + app, + service: app.get(SearchService), + cacheService: app.get(CacheService), + idService: app.get(IdService), + mutingsRepository: app.get(DI.mutingsRepository), + blockingsRepository: app.get(DI.blockingsRepository), + usersRepository: app.get(DI.usersRepository), + userProfilesRepository: app.get(DI.userProfilesRepository), + notesRepository: app.get(DI.notesRepository), + channelsRepository: app.get(DI.channelsRepository), + followingsRepository: app.get(DI.followingsRepository), + }; + } + + async function cleanupContext(ctx: TestContext) { + await ctx.notesRepository.createQueryBuilder().delete().execute(); + await ctx.mutingsRepository.createQueryBuilder().delete().execute(); + await ctx.blockingsRepository.createQueryBuilder().delete().execute(); + await ctx.followingsRepository.createQueryBuilder().delete().execute(); + await ctx.channelsRepository.createQueryBuilder().delete().execute(); + await ctx.userProfilesRepository.createQueryBuilder().delete().execute(); + await ctx.usersRepository.createQueryBuilder().delete().execute(); + } + + async function createUser(ctx: TestContext, data: Partial = {}) { + const id = ctx.idService.gen(); + const username = data.username ?? `user_${id}`; + const usernameLower = data.usernameLower ?? username.toLowerCase(); + + const user = await ctx.usersRepository + .insert({ + id, + username, + usernameLower, + ...data, + }) + .then(x => ctx.usersRepository.findOneByOrFail(x.identifiers[0])); + + await ctx.userProfilesRepository.insert({ + userId: id, + }); + + return user; + } + + async function createChannel(ctx: TestContext, user: MiUser, data: Partial = {}) { + const id = ctx.idService.gen(); + const channel = await ctx.channelsRepository + .insert({ + id, + userId: user.id, + name: data.name ?? `channel_${id}`, + ...data, + }) + .then(x => ctx.channelsRepository.findOneByOrFail(x.identifiers[0])); + + return channel; + } + + async function createNote(ctx: TestContext, user: MiUser, data: Partial = {}, time?: number) { + const id = time == null ? ctx.idService.gen() : ctx.idService.gen(time); + const note = await ctx.notesRepository + .insert({ + id, + text: 'hello', + userId: user.id, + userHost: user.host, + visibility: 'public', + tags: [], + ...data, + }) + .then(x => ctx.notesRepository.findOneByOrFail(x.identifiers[0])); + + if (ctx.indexer) { + await ctx.indexer(note); + } + + return note; + } + + async function createFollowing(ctx: TestContext, follower: MiUser, followee: MiUser) { + await ctx.followingsRepository.insert({ + id: ctx.idService.gen(), + followerId: follower.id, + followeeId: followee.id, + followerHost: follower.host, + followeeHost: followee.host, + }); + } + + function clearUserCaches(ctx: TestContext, userId: MiUser['id']) { + ctx.cacheService.userMutingsCache.delete(userId); + ctx.cacheService.userBlockedCache.delete(userId); + ctx.cacheService.userBlockingCache.delete(userId); + } + + async function createMuting(ctx: TestContext, muter: MiUser, mutee: MiUser) { + await ctx.mutingsRepository.insert({ + id: ctx.idService.gen(), + muterId: muter.id, + muteeId: mutee.id, + }); + clearUserCaches(ctx, muter.id); + } + + async function createBlocking(ctx: TestContext, blocker: MiUser, blockee: MiUser) { + await ctx.blockingsRepository.insert({ + id: ctx.idService.gen(), + blockerId: blocker.id, + blockeeId: blockee.id, + }); + clearUserCaches(ctx, blocker.id); + clearUserCaches(ctx, blockee.id); + } + + function defineSearchNoteTests( + getCtx: () => TestContext, + { + supportsFollowersVisibility, + sinceIdOrder, + }: { + supportsFollowersVisibility: boolean; + sinceIdOrder: 'asc' | 'desc'; + }, + ) { + describe('searchNote', () => { + test('filters notes by visibility (followers only visible to followers)', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const publicNote = await createNote(ctx, author, { text: 'hello public', visibility: 'public' }); + const followersNote = await createNote(ctx, author, { text: 'hello followers', visibility: 'followers' }); + + const beforeFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + expect(beforeFollow.map(note => note.id)).toEqual([publicNote.id]); + + await createFollowing(ctx, me, author); + + const afterFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + const expectedIds = supportsFollowersVisibility + ? [followersNote.id, publicNote.id] + : [publicNote.id]; + expect(afterFollow.map(note => note.id).sort()).toEqual(expectedIds.sort()); + }); + + test('filters out suspended users via base note filtering', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const active = await createUser(ctx, { username: 'active', usernameLower: 'active', host: null }); + const suspended = await createUser(ctx, { username: 'suspended', usernameLower: 'suspended', host: null, isSuspended: true }); + + const activeNote = await createNote(ctx, active, { text: 'hello active', visibility: 'public' }); + await createNote(ctx, suspended, { text: 'hello suspended', visibility: 'public' }); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([activeNote.id]); + }); + + test('filters by userId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const alice = await createUser(ctx, { username: 'alice', usernameLower: 'alice', host: null }); + const bob = await createUser(ctx, { username: 'bob', usernameLower: 'bob', host: null }); + + const aliceNote = await createNote(ctx, alice, { text: 'hello alice', visibility: 'public' }); + await createNote(ctx, bob, { text: 'hello bob', visibility: 'public' }); + + const result = await ctx.service.searchNote('hello', me, { userId: alice.id }, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([aliceNote.id]); + }); + + test('filters by channelId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + const channelA = await createChannel(ctx, author, { name: 'channel-a' }); + const channelB = await createChannel(ctx, author, { name: 'channel-b' }); + + const channelNote = await createNote(ctx, author, { text: 'hello channel', channelId: channelA.id, visibility: 'public' }); + await createNote(ctx, author, { text: 'hello other', channelId: channelB.id, visibility: 'public' }); + + const result = await ctx.service.searchNote('hello', me, { channelId: channelA.id }, { limit: 10 }); + expect(result.map(note => note.id)).toEqual([channelNote.id]); + }); + + test('filters by host', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const local = await createUser(ctx, { username: 'local', usernameLower: 'local', host: null }); + const remote = await createUser(ctx, { username: 'remote', usernameLower: 'remote', host: 'example.com' }); + + const localNote = await createNote(ctx, local, { text: 'hello local', visibility: 'public' }); + const remoteNote = await createNote(ctx, remote, { text: 'hello remote', visibility: 'public', userHost: 'example.com' }); + + const localResult = await ctx.service.searchNote('hello', me, { host: '.' }, { limit: 10 }); + expect(localResult.map(note => note.id)).toEqual([localNote.id]); + + const remoteResult = await ctx.service.searchNote('hello', me, { host: 'example.com' }, { limit: 10 }); + expect(remoteResult.map(note => note.id)).toEqual([remoteNote.id]); + }); + + describe('muting and blocking', () => { + test('filters out muted users', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const muted = await createUser(ctx, { username: 'muted', usernameLower: 'muted', host: null }); + const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null }); + + await createNote(ctx, muted, { text: 'hello muted', visibility: 'public' }); + const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' }); + + await createMuting(ctx, me, muted); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + + expect(result.map(note => note.id)).toEqual([otherNote.id]); + }); + + test('filters out users who block me', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const blocker = await createUser(ctx, { username: 'blocker', usernameLower: 'blocker', host: null }); + const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null }); + + await createNote(ctx, blocker, { text: 'hello blocker', visibility: 'public' }); + const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' }); + + await createBlocking(ctx, blocker, me); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + + expect(result.map(note => note.id)).toEqual([otherNote.id]); + }); + + test('filters no out users I block', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const blocked = await createUser(ctx, { username: 'blocked', usernameLower: 'blocked', host: null }); + const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null }); + + const blockedNote = await createNote(ctx, blocked, { text: 'hello blocked', visibility: 'public' }); + const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' }); + + await createBlocking(ctx, me, blocked); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 }); + expect(result.map(note => note.id).sort()).toEqual([otherNote.id, blockedNote.id].sort()); + }); + }); + + describe('pagination', () => { + test('paginates with sinceId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 3000; + const t2 = Date.now() - 2000; + const t3 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello' }, t3); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id }); + + const expected = sinceIdOrder === 'asc' + ? [note2.id, note3.id] + : [note3.id, note2.id]; + expect(result.map(note => note.id)).toEqual(expected); + }); + + test('paginates with untilId', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 3000; + const t2 = Date.now() - 2000; + const t3 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello' }, t3); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, untilId: note3.id }); + + expect(result.map(note => note.id)).toEqual([note2.id, note1.id]); + }); + + test('paginates with sinceId and untilId together', async () => { + const ctx = getCtx(); + const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null }); + const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null }); + + const t1 = Date.now() - 4000; + const t2 = Date.now() - 3000; + const t3 = Date.now() - 2000; + const t4 = Date.now() - 1000; + + const note1 = await createNote(ctx, author, { text: 'hello' }, t1); + const note2 = await createNote(ctx, author, { text: 'hello' }, t2); + const note3 = await createNote(ctx, author, { text: 'hello' }, t3); + const note4 = await createNote(ctx, author, { text: 'hello' }, t4); + + const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id, untilId: note4.id }); + + expect(result.map(note => note.id)).toEqual([note3.id, note2.id]); + }); + }); + }); + } + + describe('sqlLike', () => { + let ctx: TestContext; + + beforeAll(async () => { + ctx = await buildContext(); + }); + + afterAll(async () => { + await ctx.app.close(); + }); + + afterEach(async () => { + await cleanupContext(ctx); + }); + + defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: true, sinceIdOrder: 'asc' }); + }); + + describe('meilisearch', () => { + let ctx: TestContext; + let meilisearch: MeiliSearch; + let meilisearchIndex: Index; + let meiliConfig: Config; + + beforeAll(async () => { + const baseConfig = loadConfig(); + meiliConfig = { + ...baseConfig, + fulltextSearch: { + provider: 'meilisearch', + }, + meilisearch: { + host: '127.0.0.1', + port: '57712', + apiKey: '', + index: 'test-search-service', + scope: 'global', + ssl: false, + }, + }; + + ctx = await buildContext(meiliConfig); + meilisearch = ctx.app.get(DI.meilisearch) as MeiliSearch; + meilisearchIndex = meilisearch.index(`${meiliConfig.meilisearch!.index}---notes`); + + const settingsTask = await meilisearchIndex.updateSettings(meilisearchSettings); + await meilisearch.tasks.waitForTask(settingsTask.taskUid); + + const clearTask = await meilisearchIndex.deleteAllDocuments(); + await meilisearch.tasks.waitForTask(clearTask.taskUid); + + ctx.indexer = async (note: MiNote) => { + if (note.text == null && note.cw == null) return; + if (!['home', 'public'].includes(note.visibility)) return; + if (meiliConfig.meilisearch?.scope === 'local' && note.userHost != null) return; + + const task = await meilisearchIndex.addDocuments([{ + id: note.id, + createdAt: ctx.idService.parse(note.id).date.getTime(), + userId: note.userId, + userHost: note.userHost, + channelId: note.channelId, + cw: note.cw, + text: note.text, + tags: note.tags, + }], { + primaryKey: 'id', + }); + await meilisearch.tasks.waitForTask(task.taskUid); + }; + }); + + afterAll(async () => { + await ctx.app.close(); + }); + + afterEach(async () => { + await cleanupContext(ctx); + const clearTask = await meilisearchIndex.deleteAllDocuments(); + await meilisearch.tasks.waitForTask(clearTask.taskUid); + }); + + defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: false, sinceIdOrder: 'desc' }); + }); +}); diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts index 0687ed8437..8ef46024ac 100644 --- a/packages/backend/test/unit/SigninWithPasskeyApiService.ts +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -9,7 +9,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { FastifyReply, FastifyRequest } from 'fastify'; import { AuthenticationResponseJSON } from '@simplewebauthn/types'; import { HttpHeader } from 'fastify/types/utils.js'; -import { MockFunctionMetadata, ModuleMocker } from 'jest-mock'; +import { MockMetadata, ModuleMocker } from 'jest-mock'; import { MiUser } from '@/models/User.js'; import { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; @@ -95,7 +95,7 @@ describe('SigninWithPasskeyApiService', () => { ], }).useMocker((token) => { if (typeof token === 'function') { - const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const mockMetadata = moduleMocker.getMetadata(token) as MockMetadata; const Mock = moduleMocker.generateFromMetadata(mockMetadata); return new Mock(); } diff --git a/packages/backend/test/unit/chart.ts b/packages/backend/test/unit/chart.ts index 9dedd3a79d..364a2c2fbd 100644 --- a/packages/backend/test/unit/chart.ts +++ b/packages/backend/test/unit/chart.ts @@ -9,6 +9,7 @@ import * as assert from 'assert'; import { jest } from '@jest/globals'; import * as lolex from '@sinonjs/fake-timers'; import { DataSource } from 'typeorm'; +import * as Redis from 'ioredis'; import TestChart from '@/core/chart/charts/test.js'; import TestGroupedChart from '@/core/chart/charts/test-grouped.js'; import TestUniqueChart from '@/core/chart/charts/test-unique.js'; @@ -18,16 +19,16 @@ import { entity as TestGroupedChartEntity } from '@/core/chart/charts/entities/t import { entity as TestUniqueChartEntity } from '@/core/chart/charts/entities/test-unique.js'; import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js'; import { loadConfig } from '@/config.js'; -import type { AppLockService } from '@/core/AppLockService.js'; import Logger from '@/logger.js'; describe('Chart', () => { const config = loadConfig(); - const appLockService = { - getChartInsertLock: () => () => Promise.resolve(() => {}), - } as unknown as jest.Mocked; let db: DataSource | undefined; + let redisClient = { + set: () => Promise.resolve('OK'), + get: () => Promise.resolve(null), + } as unknown as jest.Mocked; let testChart: TestChart; let testGroupedChart: TestGroupedChart; @@ -64,12 +65,14 @@ describe('Chart', () => { await db.initialize(); const logger = new Logger('chart'); // TODO: モックにする - testChart = new TestChart(db, appLockService, logger); - testGroupedChart = new TestGroupedChart(db, appLockService, logger); - testUniqueChart = new TestUniqueChart(db, appLockService, logger); - testIntersectionChart = new TestIntersectionChart(db, appLockService, logger); + testChart = new TestChart(db, redisClient, logger); + testGroupedChart = new TestGroupedChart(db, redisClient, logger); + testUniqueChart = new TestUniqueChart(db, redisClient, logger); + testIntersectionChart = new TestIntersectionChart(db, redisClient, logger); clock = lolex.install({ + // https://github.com/sinonjs/sinon/issues/2620 + toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[], now: new Date(Date.UTC(2000, 0, 1, 0, 0, 0)), shouldClearNativeTimers: true, }); diff --git a/packages/backend/test/unit/entities/DriveFileEntityService.ts b/packages/backend/test/unit/entities/DriveFileEntityService.ts new file mode 100644 index 0000000000..2e416326ee --- /dev/null +++ b/packages/backend/test/unit/entities/DriveFileEntityService.ts @@ -0,0 +1,227 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { afterAll, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import type { DriveFilesRepository, DriveFoldersRepository, UsersRepository } from '@/models/_.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { genAidx } from '@/misc/id/aidx.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; + +const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip; + +describe('DriveFileEntityService', () => { + let app: TestingModule; + let service: DriveFileEntityService; + let driveFolderEntityService: DriveFolderEntityService; + let driveFilesRepository: DriveFilesRepository; + let driveFoldersRepository: DriveFoldersRepository; + let usersRepository: UsersRepository; + let idCounter = 0; + + const userEntityServiceMock = { + packMany: jest.fn(async (users: Array) => { + return users.map(u => ({ + id: typeof u === 'string' ? u : u.id, + username: 'user', + })); + }), + pack: jest.fn(async (user: string | { id: string }) => { + return { + id: typeof user === 'string' ? user : user.id, + username: 'user', + }; + }), + }; + + const nextId = () => genAidx(Date.now() + (idCounter++)); + + const createUser = async () => { + const un = secureRndstr(16); + const id = nextId(); + await usersRepository.insert({ + id, + username: un, + usernameLower: un.toLowerCase(), + }); + return usersRepository.findOneByOrFail({ id }); + }; + + const createFolder = async (name: string, parentId: string | null) => { + const id = nextId(); + await driveFoldersRepository.insert({ + id, + name, + userId: null, + parentId, + }); + return driveFoldersRepository.findOneByOrFail({ id }); + }; + + const createFile = async (folderId: string | null, userId: string | null) => { + const id = nextId(); + await driveFilesRepository.insert({ + id, + userId, + userHost: null, + md5: secureRndstr(32), + name: `file-${id}`, + type: 'text/plain', + size: 1, + comment: null, + blurhash: null, + properties: {}, + storedInternal: true, + url: `https://example.com/${id}`, + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey: null, + thumbnailAccessKey: null, + webpublicAccessKey: null, + uri: null, + src: null, + folderId, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: false, + requestHeaders: null, + requestIp: null, + }); + return driveFilesRepository.findOneByOrFail({ id }); + }; + + beforeAll(async () => { + const moduleBuilder = Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }); + moduleBuilder.overrideProvider(UserEntityService).useValue(userEntityServiceMock as any); + + app = await moduleBuilder.compile(); + await app.init(); + app.enableShutdownHooks(); + + service = app.get(DriveFileEntityService); + driveFolderEntityService = app.get(DriveFolderEntityService); + driveFilesRepository = app.get(DI.driveFilesRepository); + driveFoldersRepository = app.get(DI.driveFoldersRepository); + usersRepository = app.get(DI.usersRepository); + }); + + beforeEach(() => { + userEntityServiceMock.packMany.mockClear(); + userEntityServiceMock.pack.mockClear(); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('pack', () => { + test('detail: false', async () => { + const user = await createUser(); + const folder = await createFolder('pack-root', null); + const file = await createFile(folder.id, user.id); + + const packed = await service.pack(file, { detail: false, self: true }) as any; + expect(packed.id).toBe(file.id); + expect(packed.folder).toBeNull(); + expect(packed.user).toBeNull(); + expect(packed.userId).toBeNull(); + }); + + test('detail: true', async () => { + const folder = await createFolder('pack-parent', null); + const child = await createFolder('pack-child', folder.id); + const file = await createFile(child.id, null); + + const packed = await service.pack(file, { detail: true, self: true }) as any; + expect(packed.folder?.id).toBe(child.id); + expect(packed.folder?.parent?.id).toBe(folder.id); + }); + }); + + describe('packNullable', () => { + test('returns null for missing', async () => { + const packed = await service.packNullable('non-existent' as any, { detail: false }); + expect(packed).toBeNull(); + }); + + test('uses packedUser hint when withUser', async () => { + const user = await createUser(); + const file = await createFile(null, user.id); + + const packed = await service.packNullable(file, { withUser: true, self: true }, { + packedUser: { id: user.id, username: 'hint' } as any, + }); + expect(packed?.user?.id).toBe(user.id); + expect(packed?.user?.username).toBe('hint'); + }); + }); + + describe('packMany', () => { + test('withUser: true uses deduped packMany', async () => { + const user = await createUser(); + const fileA = await createFile(null, user.id); + const fileB = await createFile(null, user.id); + + const packed = await service.packMany([fileA, fileB], { withUser: true, self: true }); + expect(packed.length).toBe(2); + expect(userEntityServiceMock.packMany).toHaveBeenCalledTimes(1); + expect(userEntityServiceMock.packMany.mock.calls[0]?.[0]?.length).toBe(1); + expect(packed[0]?.user?.id).toBe(user.id); + }); + + test('detail: true packs folder', async () => { + const folder = await createFolder('packmany-root', null); + const file = await createFile(folder.id, null); + + const packed = await service.packMany([file], { detail: true, self: true }); + expect(packed[0]?.folder?.id).toBe(folder.id); + expect(packed[0]?.folder?.parent).toBeUndefined(); + }); + + test('detail: true uses DriveFolderEntityService pack', async () => { + const folder = await createFolder('packmany-folder', null); + const file = await createFile(folder.id, null); + const packSpy = jest.spyOn(driveFolderEntityService, 'pack'); + + await service.packMany([file], { detail: true, self: true }); + expect(packSpy).toHaveBeenCalled(); + packSpy.mockRestore(); + }); + }); + + describeBenchmark('benchmark', () => { + test('packMany', async () => { + const user = await createUser(); + const folders = []; + for (let i = 0; i < 100; i++) { + folders.push(await createFolder(`bench-${i}`, null)); + } + const files = []; + for (const folder of folders) { + for (let j = 0; j < 20; j++) { + files.push(await createFile(folder.id, user.id)); + } + } + + const start = Date.now(); + await service.packMany(files, { detail: true, withUser: true, self: true }); + const elapsed = Date.now() - start; + + console.log(`DriveFileEntityService.packMany benchmark: ${elapsed}ms`); + }); + }); +}); diff --git a/packages/backend/test/unit/entities/DriveFolderEntityService.ts b/packages/backend/test/unit/entities/DriveFolderEntityService.ts new file mode 100644 index 0000000000..299ee5f42b --- /dev/null +++ b/packages/backend/test/unit/entities/DriveFolderEntityService.ts @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import { afterAll, beforeAll, describe, expect, test } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import type { DriveFilesRepository, DriveFoldersRepository } from '@/models/_.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { DriveFolderEntityService } from '@/core/entities/DriveFolderEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { genAidx } from '@/misc/id/aidx.js'; +import { secureRndstr } from '@/misc/secure-rndstr.js'; + +const describeBenchmark = process.env.RUN_BENCHMARKS === '1' ? describe : describe.skip; + +describe('DriveFolderEntityService', () => { + let app: TestingModule; + let service: DriveFolderEntityService; + let driveFoldersRepository: DriveFoldersRepository; + let driveFilesRepository: DriveFilesRepository; + let idCounter = 0; + + const nextId = () => genAidx(Date.now() + (idCounter++)); + + const createFolder = async (name: string, parentId: string | null) => { + const id = nextId(); + await driveFoldersRepository.insert({ + id, + name, + userId: null, + parentId, + }); + return driveFoldersRepository.findOneByOrFail({ id }); + }; + + const createFile = async (folderId: string | null) => { + const id = nextId(); + await driveFilesRepository.insert({ + id, + userId: null, + userHost: null, + md5: secureRndstr(32), + name: `file-${id}`, + type: 'text/plain', + size: 1, + comment: null, + blurhash: null, + properties: {}, + storedInternal: true, + url: `https://example.com/${id}`, + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey: null, + thumbnailAccessKey: null, + webpublicAccessKey: null, + uri: null, + src: null, + folderId, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: false, + requestHeaders: null, + requestIp: null, + }); + }; + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + }).compile(); + await app.init(); + app.enableShutdownHooks(); + + service = app.get(DriveFolderEntityService); + driveFoldersRepository = app.get(DI.driveFoldersRepository); + driveFilesRepository = app.get(DI.driveFilesRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('pack', () => { + test('detail: false', async () => { + const root = await createFolder('root', null); + const child = await createFolder('child', root.id); + + const packed = await service.pack(child, { detail: false }) as any; + expect(packed.id).toBe(child.id); + expect(packed.parentId).toBe(root.id); + expect(packed.parent).toBeUndefined(); + expect(packed.foldersCount).toBeUndefined(); + expect(packed.filesCount).toBeUndefined(); + }); + + test('detail: true', async () => { + const root = await createFolder('root-detail', null); + const child = await createFolder('child-detail', root.id); + await createFolder('grandchild-detail', child.id); + await createFile(child.id); + await createFile(child.id); + + const packed = await service.pack(child, { detail: true }) as any; + expect(packed.id).toBe(child.id); + expect(packed.foldersCount).toBe(1); + expect(packed.filesCount).toBe(2); + expect(packed.parent?.id).toBe(root.id); + expect(packed.parent?.parent).toBeUndefined(); + }); + + test('detail: true reaches root for deep hierarchy', async () => { + const root = await createFolder('root-deep', null); + const level1 = await createFolder('level-1', root.id); + const level2 = await createFolder('level-2', level1.id); + const level3 = await createFolder('level-3', level2.id); + const level4 = await createFolder('level-4', level3.id); + const level5 = await createFolder('level-5', level4.id); + + const packed = await service.pack(level5, { detail: true }) as any; + expect(packed.id).toBe(level5.id); + expect(packed.parent?.id).toBe(level4.id); + expect(packed.parent?.parent?.id).toBe(level3.id); + expect(packed.parent?.parent?.parent?.id).toBe(level2.id); + expect(packed.parent?.parent?.parent?.parent?.id).toBe(level1.id); + expect(packed.parent?.parent?.parent?.parent?.parent?.id).toBe(root.id); + expect(packed.parent?.parent?.parent?.parent?.parent?.parent).toBeUndefined(); + }); + }); + + describe('packMany', () => { + test('preserves order and packs parents', async () => { + const root = await createFolder('root-many', null); + const childA = await createFolder('child-a', root.id); + const childB = await createFolder('child-b', root.id); + await createFolder('child-a-sub', childA.id); + await createFile(childA.id); + + const packed = await service.packMany([childB, childA], { detail: true }) as any; + expect(packed[0].id).toBe(childB.id); + expect(packed[1].id).toBe(childA.id); + expect(packed[0].parent?.id).toBe(root.id); + expect(packed[1].parent?.id).toBe(root.id); + expect(packed[0].filesCount).toBe(0); + expect(packed[1].filesCount).toBe(1); + expect(packed[0].foldersCount).toBe(0); + expect(packed[1].foldersCount).toBe(1); + }); + }); + + describeBenchmark('benchmark', () => { + test('packMany', async () => { + const root = await createFolder('bench-root', null); + const folders = []; + for (let i = 0; i < 200; i++) { + folders.push(await createFolder(`bench-${i}`, root.id)); + } + + const start = Date.now(); + await service.packMany(folders, { detail: true }); + const elapsed = Date.now() - start; + console.log(`DriveFolderEntityService.packMany benchmark: ${elapsed}ms`); + }); + }); +}); diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 74d17abcb6..3c628d8298 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -44,6 +44,7 @@ const base: MiNote = { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + renoteChannelId: null, }; describe('misc:is-renote', () => { diff --git a/packages/backend/test/unit/misc/should-hide-note-by-time.ts b/packages/backend/test/unit/misc/should-hide-note-by-time.ts new file mode 100644 index 0000000000..1c463c82c6 --- /dev/null +++ b/packages/backend/test/unit/misc/should-hide-note-by-time.ts @@ -0,0 +1,136 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, test, beforeEach, afterEach } from '@jest/globals'; +import * as lolex from '@sinonjs/fake-timers'; +import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js'; + +describe('misc:should-hide-note-by-time', () => { + let clock: lolex.InstalledClock; + const epoch = Date.UTC(2000, 0, 1, 0, 0, 0); + + beforeEach(() => { + clock = lolex.install({ + // https://github.com/sinonjs/sinon/issues/2620 + toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[], + now: new Date(epoch), + shouldClearNativeTimers: true, + }); + }); + + afterEach(() => { + clock.uninstall(); + }); + + describe('hiddenBefore が null または undefined の場合', () => { + test('hiddenBefore が null のときは false を返す(非表示機能が有効でない)', () => { + const createdAt = new Date(epoch - 86400000); // 1 day ago + expect(shouldHideNoteByTime(null, createdAt)).toBe(false); + }); + + test('hiddenBefore が undefined のときは false を返す(非表示機能が有効でない)', () => { + const createdAt = new Date(epoch - 86400000); // 1 day ago + expect(shouldHideNoteByTime(undefined, createdAt)).toBe(false); + }); + }); + + describe('相対時間モード (hiddenBefore <= 0)', () => { + test('閾値内に作成されたノートは false を返す(作成からの経過時間がまだ短い→表示)', () => { + const hiddenBefore = -86400; // 1 day in seconds + const createdAt = new Date(epoch - 3600000); // 1 hour ago + expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(false); + }); + + test('閾値を超えて作成されたノートは true を返す(指定期間以上経過している→非表示)', () => { + const hiddenBefore = -86400; // 1 day in seconds + const createdAt = new Date(epoch - 172800000); // 2 days ago + expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true); + }); + + test('ちょうど閾値で作成されたノートは true を返す(閾値に達したら非表示)', () => { + const hiddenBefore = -86400; // 1 day in seconds + const createdAt = new Date(epoch - 86400000); // exactly 1 day ago + expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true); + }); + + test('異なる相対時間値で判定できる(1時間設定と3時間設定の異なる結果)', () => { + const createdAt = new Date(epoch - 7200000); // 2 hours ago + expect(shouldHideNoteByTime(-3600, createdAt)).toBe(true); // 1時間経過→非表示 + expect(shouldHideNoteByTime(-10800, createdAt)).toBe(false); // 3時間未経過→表示 + }); + + test('ISO 8601 形式の文字列の createdAt に対応できる(文字列でも正しく判定)', () => { + const createdAtString = new Date(epoch - 86400000).toISOString(); + const hiddenBefore = -86400; // 1 day in seconds + expect(shouldHideNoteByTime(hiddenBefore, createdAtString)).toBe(true); + }); + + test('hiddenBefore が 0 の場合に対応できる(0秒以上経過で非表示→ほぼ全て非表示)', () => { + const hiddenBefore = 0; + const createdAt = new Date(epoch - 1); // 1ms ago + expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true); + }); + }); + + describe('絶対時間モード (hiddenBefore > 0)', () => { + test('閾値タイムスタンプより後に作成されたノートは false を返す(指定日時より後→表示)', () => { + const thresholdSeconds = Math.floor(epoch / 1000); + const createdAt = new Date(epoch + 3600000); // 1 hour from epoch + expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(false); + }); + + test('閾値タイムスタンプより前に作成されたノートは true を返す(指定日時より前→非表示)', () => { + const thresholdSeconds = Math.floor(epoch / 1000); + const createdAt = new Date(epoch - 3600000); // 1 hour ago + expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true); + }); + + test('ちょうど閾値タイムスタンプで作成されたノートは true を返す(指定日時に達したら非表示)', () => { + const thresholdSeconds = Math.floor(epoch / 1000); + const createdAt = new Date(epoch); // exactly epoch + expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true); + }); + + test('ISO 8601 形式の文字列の createdAt に対応できる(文字列でも正しく判定)', () => { + const thresholdSeconds = Math.floor(epoch / 1000); + const createdAtString = new Date(epoch - 3600000).toISOString(); + expect(shouldHideNoteByTime(thresholdSeconds, createdAtString)).toBe(true); + }); + + test('異なる閾値タイムスタンプで判定できる(2021年設定と現在より1時間前設定の異なる結果)', () => { + const thresholdSeconds = Math.floor((epoch - 86400000) / 1000); // 1 day ago + const createdAtBefore = new Date(epoch - 172800000); // 2 days ago + const createdAtAfter = new Date(epoch - 3600000); // 1 hour ago + expect(shouldHideNoteByTime(thresholdSeconds, createdAtBefore)).toBe(true); // 閾値より前→非表示 + expect(shouldHideNoteByTime(thresholdSeconds, createdAtAfter)).toBe(false); // 閾値より後→表示 + }); + }); + + describe('エッジケース', () => { + test('相対時間モードで非常に古いノートに対応できる(非常に古い→閾値超→非表示)', () => { + const hiddenBefore = -1; // hide notes older than 1 second + const createdAt = new Date(epoch - 1000000); // very old + expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true); + }); + + test('相対時間モードで非常に新しいノートに対応できる(非常に新しい→閾値未満→表示)', () => { + const hiddenBefore = -86400; // 1 day + const createdAt = new Date(epoch - 1); // 1ms ago + expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(false); + }); + + test('大きなタイムスタンプ値に対応できる(未来の日時を指定→現在のノートは全て非表示)', () => { + const thresholdSeconds = Math.floor(epoch / 1000) + 86400; // 1 day from epoch + const createdAt = new Date(epoch); // created epoch + expect(shouldHideNoteByTime(thresholdSeconds, createdAt)).toBe(true); + }); + + test('小さな相対時間値に対応できる(1秒設定で2秒前→非表示)', () => { + const hiddenBefore = -1; // 1 second + const createdAt = new Date(epoch - 2000); // 2 seconds ago + expect(shouldHideNoteByTime(hiddenBefore, createdAt)).toBe(true); + }); + }); +}); diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts index 211846eef2..01a36c9fef 100644 --- a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts +++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -141,6 +141,8 @@ describe('CheckModeratorsActivityProcessorService', () => { beforeEach(async () => { clock = lolex.install({ + // https://github.com/sinonjs/sinon/issues/2620 + toFake: Object.keys(lolex.timers).filter((key) => !['nextTick', 'queueMicrotask'].includes(key)) as lolex.FakeMethod[], now: new Date(baseDate), shouldClearNativeTimers: true, }); diff --git a/packages/backend/test/unit/server/FileServerService.ts b/packages/backend/test/unit/server/FileServerService.ts new file mode 100644 index 0000000000..c88175c5c7 --- /dev/null +++ b/packages/backend/test/unit/server/FileServerService.ts @@ -0,0 +1,770 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import fastifyStatic from '@fastify/static'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, expect, test } from '@jest/globals'; +import sharp from 'sharp'; +import { DataSource, type Repository } from 'typeorm'; +import { initTestDb, randomString } from '../../utils.js'; +import type { AiService } from '@/core/AiService.js'; +import { DownloadService } from '@/core/DownloadService.js'; +import { FileInfoService } from '@/core/FileInfoService.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { ImageProcessingService } from '@/core/ImageProcessingService.js'; +import { InternalStorageService } from '@/core/InternalStorageService.js'; +import { IdService } from '@/core/IdService.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import { VideoProcessingService } from '@/core/VideoProcessingService.js'; +import { loadConfig, type Config } from '@/config.js'; +import { MiDriveFile } from '@/models/DriveFile.js'; +import { FileServerService } from '@/server/FileServerService.js'; + +const dummyPath = path.resolve('test/resources/dummy-for-file-server-service.png'); +const dummySize = fs.statSync(dummyPath).size; +const dummyBuffer = fs.readFileSync(dummyPath); +const svgBuffer = Buffer.from('', 'utf8'); +const textBuffer = Buffer.from('dummy text', 'utf8'); + +async function createRemoteFileServer() { + const flatPngBuffer = await sharp({ + create: { width: 8, height: 8, channels: 3, background: { r: 0, g: 0, b: 0 } }, + }).png().toBuffer(); + const server = Fastify(); + + server.get('/dummy.png', async (_request, reply) => { + reply.header('Content-Type', 'image/png'); + reply.header('Content-Length', String(dummyBuffer.length)); + return reply.send(dummyBuffer); + }); + + server.get('/dummy.svg', async (_request, reply) => { + reply.header('Content-Type', 'image/svg+xml'); + reply.header('Content-Length', String(svgBuffer.length)); + return reply.send(svgBuffer); + }); + + server.get('/dummy.txt', async (_request, reply) => { + reply.header('Content-Type', 'text/plain'); + reply.header('Content-Length', String(textBuffer.length)); + return reply.send(textBuffer); + }); + + server.get('/flat.png', async (_request, reply) => { + reply.header('Content-Type', 'image/png'); + reply.header('Content-Length', String(flatPngBuffer.length)); + return reply.send(flatPngBuffer); + }); + + const baseUrl = await server.listen({ port: 0, host: '127.0.0.1' }); + + return { + server, + pngUrl: `${baseUrl}/dummy.png`, + svgUrl: `${baseUrl}/dummy.svg`, + textUrl: `${baseUrl}/dummy.txt`, + flatPngUrl: `${baseUrl}/flat.png`, + }; +} + +describe('FileServerService', () => { + let db: DataSource; + let fastify: FastifyInstance; + let externalFastify: FastifyInstance; + let driveFilesRepository: Repository; + let internalStorageService: InternalStorageService; + let idService: IdService; + let config: Config; + let fileServerService: FileServerService; + let externalFileServerService: FileServerService; + let remoteServer: FastifyInstance; + let remotePngUrl: string; + let remoteSvgUrl: string; + let remoteTextUrl: string; + let remoteFlatPngUrl: string; + const storedPaths: string[] = []; + let createdFallbackAssets = false; + let fallbackAssetsDir = ''; + + function writeInternalFile(key: string) { + const dest = internalStorageService.resolvePath(key); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(dummyPath, dest); + storedPaths.push(dest); + } + + async function insertDriveFile(params: { + accessKey: string; + thumbnailAccessKey?: string | null; + webpublicAccessKey?: string | null; + storedInternal: boolean; + isLink: boolean; + uri?: string | null; + name?: string; + type?: string; + size?: number; + }) { + const accessKey = params.accessKey; + const url = params.uri ?? `${config.url}/files/${accessKey}`; + await driveFilesRepository.insert({ + id: idService.gen(), + userId: null, + userHost: null, + md5: '00000000000000000000000000000000', + name: params.name ?? 'dummy.png', + type: params.type ?? 'image/png', + size: params.size ?? dummySize, + comment: null, + blurhash: null, + properties: {}, + storedInternal: params.storedInternal, + url, + thumbnailUrl: null, + webpublicUrl: null, + webpublicType: null, + accessKey, + thumbnailAccessKey: params.thumbnailAccessKey ?? null, + webpublicAccessKey: params.webpublicAccessKey ?? null, + uri: params.uri ?? null, + src: null, + folderId: null, + isSensitive: false, + maybeSensitive: false, + maybePorn: false, + isLink: params.isLink, + requestHeaders: {}, + requestIp: null, + }); + } + + beforeAll(async () => { + config = loadConfig(); + db = await initTestDb(false); + driveFilesRepository = db.getRepository(MiDriveFile); + + const loggerService = new LoggerService(); + const aiService = { + detectSensitive: async () => null, + } as unknown as AiService; + const fileInfoService = new FileInfoService(aiService, loggerService); + const httpRequestService = new HttpRequestService(config); + const downloadService = new DownloadService(config, httpRequestService, loggerService); + const imageProcessingService = new ImageProcessingService(); + const videoProcessingService = new VideoProcessingService(config, imageProcessingService); + internalStorageService = new InternalStorageService(config); + idService = new IdService(config); + fileServerService = new FileServerService( + config, + driveFilesRepository as any, + fileInfoService, + downloadService, + imageProcessingService, + videoProcessingService, + internalStorageService, + loggerService, + ); + + fastify = Fastify(); + await fastify.register(fastifyStatic, { + root: path.resolve('src/server/assets'), + serve: false, + }); + fileServerService.createServer(fastify, {}, () => {}); + await fastify.ready(); + + const externalConfig = { + ...config, + mediaProxy: 'https://media-proxy.test', + externalMediaProxyEnabled: true, + } as Config; + externalFileServerService = new FileServerService( + externalConfig, + driveFilesRepository as any, + fileInfoService, + downloadService, + imageProcessingService, + videoProcessingService, + internalStorageService, + loggerService, + ); + externalFastify = Fastify(); + await externalFastify.register(fastifyStatic, { + root: path.resolve('src/server/assets'), + serve: false, + }); + externalFileServerService.createServer(externalFastify, {}, () => {}); + await externalFastify.ready(); + + const remoteServerInfo = await createRemoteFileServer(); + remoteServer = remoteServerInfo.server; + remotePngUrl = remoteServerInfo.pngUrl; + remoteSvgUrl = remoteServerInfo.svgUrl; + remoteTextUrl = remoteServerInfo.textUrl; + remoteFlatPngUrl = remoteServerInfo.flatPngUrl; + + fallbackAssetsDir = path.resolve('src/server/file/assets'); + if (!fs.existsSync(fallbackAssetsDir)) { + fs.mkdirSync(fallbackAssetsDir, { recursive: true }); + fs.copyFileSync(dummyPath, path.join(fallbackAssetsDir, 'dummy.png')); + createdFallbackAssets = true; + } + }); + + afterEach(async () => { + await driveFilesRepository.createQueryBuilder().delete().execute(); + for (const filePath of storedPaths) { + try { + fs.unlinkSync(filePath); + } catch { + // NOP + } + } + storedPaths.length = 0; + }); + + afterAll(async () => { + await fastify.close(); + await externalFastify.close(); + await remoteServer.close(); + await db.destroy(); + if (createdFallbackAssets) { + fs.rmSync(fallbackAssetsDir, { recursive: true, force: true }); + } + }); + + describe('GET /files/app-default.jpg', () => { + test('GET /files/app-default.jpg ヘッダを検証する', async () => { + const prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'test'; + + try { + const res = await fastify.inject({ + method: 'GET', + url: '/files/app-default.jpg', + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-type']).toBe('image/jpeg'); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + } finally { + process.env.NODE_ENV = prevNodeEnv; + } + }); + + test('GET /files/app-default.jpg development で CORS を許可する', async () => { + const prevNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + try { + const res = await fastify.inject({ + method: 'GET', + url: '/files/app-default.jpg', + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe('*'); + } finally { + process.env.NODE_ENV = prevNodeEnv; + } + }); + + test('GET /files/app-default.jpg?x=1 クエリを除去してリダイレクトする', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/files/app-default.jpg?x=1', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe('/files/app-default.jpg'); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + }); + }); + + describe('GET /files/:key', () => { + test('GET /files/:key 404 のときダミー画像を返す', async () => { + const accessKey = randomString(); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(404); + expect(res.headers['cache-control']).toBe('max-age=86400'); + }); + + test('GET /files/:key 画像配信ヘッダを検証する', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['content-length']).toBe(String(dummySize)); + expect(res.headers['content-disposition'] ?? '').toMatch(/^inline;/); + }); + + test('GET /files/:key Range で部分配信する', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + headers: { + range: 'bytes=0-3', + }, + }); + + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`); + expect(res.headers['accept-ranges']).toBe('bytes'); + expect(res.headers['content-length']).toBe('4'); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + }); + + test('GET /files/:key Range の終端を補正する', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + headers: { + range: 'bytes=0-999999', + }, + }); + + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe(`bytes 0-${dummySize - 1}/${dummySize}`); + expect(res.headers['accept-ranges']).toBe('bytes'); + expect(res.headers['content-length']).toBe(String(dummySize)); + }); + + test('GET /files/:key thumbnail の Range で部分配信する', async () => { + const accessKey = randomString(); + const thumbnailKey = randomString(); + writeInternalFile(thumbnailKey); + await insertDriveFile({ + accessKey, + thumbnailAccessKey: thumbnailKey, + storedInternal: true, + isLink: false, + name: 'sample.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${thumbnailKey}`, + headers: { + range: 'bytes=0-3', + }, + }); + + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe(`bytes 0-3/${dummySize}`); + expect(res.headers['accept-ranges']).toBe('bytes'); + expect(res.headers['content-length']).toBe('4'); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + }); + + test('GET /files/:key thumbnail のファイル名を整形する', async () => { + const accessKey = randomString(); + const thumbnailKey = randomString(); + writeInternalFile(thumbnailKey); + await insertDriveFile({ + accessKey, + thumbnailAccessKey: thumbnailKey, + storedInternal: true, + isLink: false, + name: 'sample.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${thumbnailKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('sample-thumb.png'); + }); + + test('GET /files/:key webpublic のファイル名を整形する', async () => { + const accessKey = randomString(); + const webpublicKey = randomString(); + writeInternalFile(webpublicKey); + await insertDriveFile({ + accessKey, + webpublicAccessKey: webpublicKey, + storedInternal: true, + isLink: false, + name: 'sample.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${webpublicKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('sample-web.png'); + }); + + test('GET /files/:key browsersafe でない MIME は octet-stream になる', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + type: 'application/x-msdownload', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/octet-stream'); + }); + + test('GET /files/:key 204 のときキャッシュ制御を返す', async () => { + const accessKey = randomString(); + await insertDriveFile({ + accessKey, + storedInternal: false, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(204); + expect(res.headers['cache-control']).toBe('max-age=86400'); + }); + + test('GET /files/:key 外部リンクを取得して配信する', async () => { + const accessKey = randomString(); + await insertDriveFile({ + accessKey, + storedInternal: false, + isLink: true, + uri: remotePngUrl, + name: 'remote.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-length']).toBe(String(dummyBuffer.length)); + expect(res.headers['content-disposition'] ?? '').toContain('remote.png'); + }); + + test('GET /files/:key 外部リンクを Range で部分配信する', async () => { + const accessKey = randomString(); + await insertDriveFile({ + accessKey, + storedInternal: false, + isLink: true, + uri: remotePngUrl, + name: 'remote.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${accessKey}`, + headers: { + range: 'bytes=0-3', + }, + }); + + expect(res.statusCode).toBe(206); + expect(res.headers['content-range']).toBe(`bytes 0-3/${dummyBuffer.length}`); + expect(res.headers['accept-ranges']).toBe('bytes'); + expect(res.headers['content-length']).toBe(String(dummyBuffer.length)); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + }); + + test('GET /files/:key thumbnail は mediaProxy/static.webp にリダイレクトする', async () => { + const accessKey = randomString(); + const thumbnailKey = randomString(); + await insertDriveFile({ + accessKey, + thumbnailAccessKey: thumbnailKey, + storedInternal: false, + isLink: true, + uri: remotePngUrl, + name: 'remote.png', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${thumbnailKey}`, + }); + + expect(res.statusCode).toBe(301); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers.location).toContain(`${config.mediaProxy}/static.webp`); + expect(res.headers.location).toContain('static=1'); + }); + + test('GET /files/:key webpublic svg は mediaProxy/svg.webp にリダイレクトする', async () => { + const accessKey = randomString(); + const webpublicKey = randomString(); + await insertDriveFile({ + accessKey, + webpublicAccessKey: webpublicKey, + storedInternal: false, + isLink: true, + uri: remoteSvgUrl, + name: 'vector.svg', + type: 'image/svg+xml', + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/files/${webpublicKey}`, + }); + + expect(res.statusCode).toBe(301); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers.location).toContain(`${config.mediaProxy}/svg.webp`); + }); + }); + + describe('GET /files/:key/*', () => { + test('GET /files/:key/* 正規の /files/:key にリダイレクトする', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/files/testkey/extra/path', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers.location).toBe(`${config.url}/files/testkey`); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + }); + }); + + describe('GET /proxy/:url*', () => { + test('GET /proxy/:url* 外部メディアプロキシへリダイレクトする', async () => { + const res = await externalFastify.inject({ + method: 'GET', + url: '/proxy/path-part?url=https%3A%2F%2Fexample.com%2Fimg.png&static=1', + }); + + expect(res.statusCode).toBe(301); + expect(res.headers['cache-control']).toBe('public, max-age=259200'); + expect(res.headers.location).toContain('https://media-proxy.test/'); + expect(res.headers.location).toContain('url=https%3A%2F%2Fexample.com%2Fimg.png'); + expect(res.headers.location).toContain('static=1'); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + }); + + test('GET /proxy/:url* misskey User-Agent を拒否する', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png', + headers: { + 'user-agent': 'misskey/1.0', + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.headers['cache-control']).toBe('max-age=300'); + }); + + test('GET /proxy/:url* origin 指定時は User-Agent 必須を検証する', async () => { + const res = await fastify.inject({ + method: 'GET', + url: '/proxy/any?url=https%3A%2F%2Fexample.com%2Fimg.png&origin=1', + headers: { + 'user-agent': '', + }, + }); + + expect(res.statusCode).toBe(400); + expect(res.headers['cache-control']).toBe('max-age=300'); + expect(res.headers.location).toBeUndefined(); + expect(res.headers['content-security-policy']).toBe('default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\''); + }); + + test('GET /proxy/:url* emoji 指定で非画像は 404 を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}&emoji=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(404); + expect(res.headers['cache-control']).toBe('max-age=300'); + }); + + test('GET /proxy/:url* 非画像は 403 を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remoteTextUrl)}`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(403); + expect(res.headers['cache-control']).toBe('max-age=300'); + }); + + test('GET /proxy/:url* emoji static で webp を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&emoji=1&static=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp'); + }); + + test('GET /proxy/:url* avatar static で webp を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&avatar=1&static=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp'); + }); + + test('GET /proxy/:url* static で webp を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&static=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp'); + }); + + test('GET /proxy/:url* preview で webp を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remotePngUrl)}&preview=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png.webp'); + }); + + test('GET /proxy/:url* svg を webp に変換する', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remoteSvgUrl)}`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/webp'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.svg.webp'); + }); + + test('GET /proxy/:url* badge で低エントロピー画像は 404 を返す', async () => { + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(remoteFlatPngUrl)}&badge=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(404); + expect(res.headers['cache-control']).toBe('max-age=300'); + }); + + test('GET /proxy/:url* 画像をそのまま返す', async () => { + const accessKey = randomString(); + writeInternalFile(accessKey); + await insertDriveFile({ + accessKey, + storedInternal: true, + isLink: false, + }); + + const res = await fastify.inject({ + method: 'GET', + url: `/proxy/any?url=${encodeURIComponent(`${config.url}/files/${accessKey}`)}&origin=1`, + headers: { + 'user-agent': 'Mozilla/5.0', + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('image/png'); + expect(res.headers['cache-control']).toBe('max-age=31536000, immutable'); + expect(res.headers['content-disposition'] ?? '').toContain('dummy.png'); + }); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index ace614115c..f91fb7f9b1 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -10,8 +10,8 @@ import { randomUUID } from 'node:crypto'; import { inspect } from 'node:util'; import WebSocket, { ClientOptions } from 'ws'; import fetch, { File, RequestInit, type Headers } from 'node-fetch'; +import * as htmlParser from 'node-html-parser'; import { DataSource } from 'typeorm'; -import { JSDOM } from 'jsdom'; import { type Response } from 'node-fetch'; import Fastify from 'fastify'; import { entities } from '../src/postgres.js'; @@ -316,8 +316,12 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO : new URL(path, new URL('resources/', import.meta.url)); const formData = new FormData(); - formData.append('file', blob ?? - new File([new Uint8Array(await readFile(absPath))], basename(absPath.toString()))); + formData.append( + 'file', + blob ?? new Blob([new Uint8Array(await readFile(absPath))]), + basename(absPath.toString()), + ); + formData.append('force', 'true'); if (name) { formData.append('name', name); @@ -400,37 +404,28 @@ export function connectStream(user: UserToken, } export const waitFire = async (user: UserToken, channel: C, trgr: () => any, cond: (msg: Record) => boolean, params?: misskey.Channels[C]['params']) => { - return new Promise(async (res, rej) => { - let timer: NodeJS.Timeout | null = null; + let ws: WebSocket | undefined; - let ws: WebSocket; - try { - ws = await connectStream(user, channel, msg => { + try { + let callback: (msg: Record) => void; + const receivedPromise = new Promise((resolve) => { + callback = (msg: Record) => { if (cond(msg)) { - ws.close(); - if (timer) clearTimeout(timer); - res(true); + resolve(true); } - }, params); - } catch (e) { - rej(e); - } + }; + }); - if (!ws!) return; + ws = await connectStream(user, channel, callback!, params); + await trgr(); - timer = setTimeout(() => { - ws.close(); - res(false); - }, 3000); - - try { - await trgr(); - } catch (e) { - ws.close(); - if (timer) clearTimeout(timer); - rej(e); - } - }); + return await Promise.race([ + receivedPromise, + new Promise((r) => setTimeout(() => r(), 3000)).then(() => false), + ]); + } finally { + if (ws) ws.close(); + } }; /** @@ -464,7 +459,7 @@ export function makeStreamCatcher( export type SimpleGetResponse = { status: number, - body: any | JSDOM | null, + body: any | null, type: string | null, location: string | null }; @@ -495,7 +490,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()) : + htmlTypes.includes(res.headers.get('content-type') ?? '') ? htmlParser.parse(await res.text()) : await bodyExtractor(res); return { diff --git a/packages/backend/tsconfig.json b/packages/backend/tsconfig.json index 2b15a5cc7a..dac56f25de 100644 --- a/packages/backend/tsconfig.json +++ b/packages/backend/tsconfig.json @@ -23,12 +23,16 @@ "emitDecoratorMetadata": true, "resolveJsonModule": true, "isolatedModules": true, + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html", "rootDir": "./src", - "baseUrl": "./", "paths": { "@/*": ["./src/*"] }, "outDir": "./built", + "plugins": [ + {"name": "@kitajs/ts-html-plugin"} + ], "types": [ "node" ], @@ -43,7 +47,8 @@ }, "compileOnSave": false, "include": [ - "./src/**/*.ts" + "./src/**/*.ts", + "./src/**/*.tsx" ], "exclude": [ "./src/**/*.test.ts" diff --git a/packages/frontend-builder/eslint.config.js b/packages/frontend-builder/eslint.config.js index a13490c97f..7337abe14c 100644 --- a/packages/frontend-builder/eslint.config.js +++ b/packages/frontend-builder/eslint.config.js @@ -34,7 +34,6 @@ export default [ }, }, rules: { - '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], diff --git a/packages/frontend-builder/locale-inliner.ts b/packages/frontend-builder/locale-inliner.ts index 9bef465eeb..191d7250a6 100644 --- a/packages/frontend-builder/locale-inliner.ts +++ b/packages/frontend-builder/locale-inliner.ts @@ -10,7 +10,7 @@ import { collectModifications } from './locale-inliner/collect-modifications.js' import { applyWithLocale } from './locale-inliner/apply-with-locale.js'; import { blankLogger } from './logger.js'; import type { Logger } from './logger.js'; -import type { Locale } from '../../locales/index.js'; +import type { Locale } from 'i18n'; import type { Manifest as ViteManifest } from 'vite'; export class LocaleInliner { diff --git a/packages/frontend-builder/locale-inliner/apply-with-locale.ts b/packages/frontend-builder/locale-inliner/apply-with-locale.ts index 5e601cdf12..78851d3029 100644 --- a/packages/frontend-builder/locale-inliner/apply-with-locale.ts +++ b/packages/frontend-builder/locale-inliner/apply-with-locale.ts @@ -5,7 +5,7 @@ import MagicString from 'magic-string'; import { assertNever } from '../utils.js'; -import type { Locale, ILocale } from '../../../locales/index.js'; +import type { ILocale, Locale } from 'i18n'; import type { TextModification } from '../locale-inliner.js'; import type { Logger } from '../logger.js'; diff --git a/packages/frontend-builder/package.json b/packages/frontend-builder/package.json index 5fdd25b32d..8e77c0f5c3 100644 --- a/packages/frontend-builder/package.json +++ b/packages/frontend-builder/package.json @@ -3,7 +3,7 @@ "type": "module", "scripts": { "eslint": "eslint './**/*.{js,jsx,ts,tsx}'", - "typecheck": "tsc --noEmit", + "typecheck": "tsgo --noEmit", "lint": "pnpm typecheck && pnpm eslint" }, "exports": { @@ -11,15 +11,15 @@ }, "devDependencies": { "@types/estree": "1.0.8", - "@types/node": "22.17.0", - "@typescript-eslint/eslint-plugin": "8.38.0", - "@typescript-eslint/parser": "8.38.0", - "rollup": "4.46.2", - "typescript": "5.9.2" + "@types/node": "24.10.4", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "rollup": "4.54.0" }, "dependencies": { + "i18n": "workspace:*", "estree-walker": "3.0.3", - "magic-string": "0.30.17", - "vite": "7.0.6" + "magic-string": "0.30.21", + "vite": "7.3.0" } } diff --git a/packages/frontend-builder/tsconfig.json b/packages/frontend-builder/tsconfig.json index 9250b2f3da..ab943fded4 100644 --- a/packages/frontend-builder/tsconfig.json +++ b/packages/frontend-builder/tsconfig.json @@ -17,7 +17,6 @@ "noImplicitReturns": true, "esModuleInterop": true, "verbatimModuleSyntax": true, - "baseUrl": ".", "typeRoots": [ "./@types", "./node_modules/@types" diff --git a/packages/frontend-embed/build.ts b/packages/frontend-embed/build.ts index 737233a4d0..4e1f588802 100644 --- a/packages/frontend-embed/build.ts +++ b/packages/frontend-embed/build.ts @@ -2,7 +2,7 @@ import * as fs from 'fs/promises'; import url from 'node:url'; import path from 'node:path'; import { execa } from 'execa'; -import locales from '../../locales/index.js'; +import locales from 'i18n'; import { LocaleInliner } from '../frontend-builder/locale-inliner.js' import { createLogger } from '../frontend-builder/logger'; diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js index 179d811e77..d1ca70617b 100644 --- a/packages/frontend-embed/eslint.config.js +++ b/packages/frontend-embed/eslint.config.js @@ -41,14 +41,75 @@ export default [ }, }, rules: { - '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-empty-interface': ['error', { allowSingleExtends: true, }], 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], - // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため - // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため - 'id-denylist': ['error', 'window', 'e'], + // window ... グローバルスコープと衝突し、予期せぬ結果を招くため + // e ... error や event など、複数のキーワードの頭文字であり分かりにくいため + // close ... window.closeと衝突 or 紛らわしい + // open ... window.openと衝突 or 紛らわしい + // fetch ... window.fetchと衝突 or 紛らわしい + // location ... window.locationと衝突 or 紛らわしい + // document ... window.documentと衝突 or 紛らわしい + // history ... window.historyと衝突 or 紛らわしい + // scroll ... window.scrollと衝突 or 紛らわしい + // setTimeout ... window.setTimeoutと衝突 or 紛らわしい + // setInterval ... window.setIntervalと衝突 or 紛らわしい + // clearTimeout ... window.clearTimeoutと衝突 or 紛らわしい + // clearInterval ... window.clearIntervalと衝突 or 紛らわしい + 'id-denylist': ['error', 'window', 'e', 'close', 'open', 'fetch', 'location', 'document', 'history', 'scroll', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'], + 'no-restricted-globals': [ + 'error', + { + 'name': 'open', + 'message': 'Use `window.open`.', + }, + { + 'name': 'close', + 'message': 'Use `window.close`.', + }, + { + 'name': 'fetch', + 'message': 'Use `window.fetch`.', + }, + { + 'name': 'location', + 'message': 'Use `window.location`.', + }, + { + 'name': 'document', + 'message': 'Use `window.document`.', + }, + { + 'name': 'history', + 'message': 'Use `window.history`.', + }, + { + 'name': 'scroll', + 'message': 'Use `window.scroll`.', + }, + { + 'name': 'setTimeout', + 'message': 'Use `window.setTimeout`.', + }, + { + 'name': 'setInterval', + 'message': 'Use `window.setInterval`.', + }, + { + 'name': 'clearTimeout', + 'message': 'Use `window.clearTimeout`.', + }, + { + 'name': 'clearInterval', + 'message': 'Use `window.clearInterval`.', + }, + { + 'name': 'name', + 'message': 'Use `window.name`. もしくは name という変数名を定義し忘れている', + }, + ], 'no-shadow': ['warn'], 'vue/attributes-order': ['error', { alphabetical: false, @@ -83,7 +144,15 @@ export default [ '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/html-self-closing': ['error', { + html: { + void: 'any', + normal: 'never', + component: 'any', + }, + svg: 'any', + math: 'any', + }], 'vue/singleline-html-element-content-newline': 'off', 'vue/v-on-event-hyphenation': ['error', 'never', { autofix: true, diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index f9d1330ae5..6af553e745 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -12,61 +12,56 @@ "dependencies": { "@discordapp/twemoji": "16.0.1", "@rollup/plugin-json": "6.1.0", - "@rollup/plugin-replace": "6.0.2", - "@rollup/pluginutils": "5.2.0", + "@rollup/plugin-replace": "6.0.3", + "@rollup/pluginutils": "5.3.0", "@twemoji/parser": "16.0.0", - "@vitejs/plugin-vue": "6.0.1", - "@vue/compiler-sfc": "3.5.18", - "astring": "1.9.0", + "@vitejs/plugin-vue": "6.0.3", "buraha": "0.0.1", "estree-walker": "3.0.3", "frontend-shared": "workspace:*", + "i18n": "workspace:*", "icons-subsetter": "workspace:*", "json5": "2.2.3", "mfm-js": "0.25.0", "misskey-js": "workspace:*", "punycode.js": "2.3.1", - "rollup": "4.46.2", - "sass": "1.89.2", - "shiki": "3.9.1", + "rollup": "4.54.0", + "sass": "1.97.1", + "shiki": "3.20.0", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.16", - "tsconfig-paths": "4.2.0", - "typescript": "5.9.2", - "uuid": "11.1.0", - "vite": "7.0.6", - "vue": "3.5.18" + "uuid": "13.0.0", + "vite": "7.3.0", + "vue": "3.5.26" }, "devDependencies": { - "@misskey-dev/summaly": "5.2.3", - "@tabler/icons-webfont": "3.34.1", + "@misskey-dev/summaly": "5.2.5", + "@tabler/icons-webfont": "3.35.0", "@testing-library/vue": "8.1.0", "@types/estree": "1.0.8", - "@types/micromatch": "4.0.9", - "@types/node": "22.17.0", + "@types/micromatch": "4.0.10", + "@types/node": "24.10.4", "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.38.0", - "@typescript-eslint/parser": "8.38.0", - "@vitest/coverage-v8": "3.2.4", - "@vue/runtime-core": "3.5.18", + "@typescript-eslint/eslint-plugin": "8.50.1", + "@typescript-eslint/parser": "8.50.1", + "@vitest/coverage-v8": "4.0.16", + "@vue/runtime-core": "3.5.26", "acorn": "8.15.0", - "cross-env": "10.0.0", + "cross-env": "10.1.0", "eslint-plugin-import": "2.32.0", - "eslint-plugin-vue": "10.4.0", - "fast-glob": "3.3.3", - "happy-dom": "18.0.1", + "eslint-plugin-vue": "10.6.2", + "happy-dom": "20.0.11", "intersection-observer": "0.12.2", "micromatch": "4.0.8", - "msw": "2.10.4", - "nodemon": "3.1.10", - "prettier": "3.6.2", - "start-server-and-test": "2.0.12", - "tsx": "4.20.3", + "msw": "2.12.6", + "nodemon": "3.1.11", + "prettier": "3.7.4", + "start-server-and-test": "2.1.3", + "tsx": "4.21.0", "vite-plugin-turbosnap": "1.0.3", - "vue-component-type-helpers": "3.0.5", + "vue-component-type-helpers": "3.2.1", "vue-eslint-parser": "10.2.0", - "vue-tsc": "3.0.5" + "vue-tsc": "3.2.1" } } diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/frontend-embed/public/loader/boot.js similarity index 98% rename from packages/backend/src/server/web/boot.embed.js rename to packages/frontend-embed/public/loader/boot.js index 022ff064ad..9b3d27873b 100644 --- a/packages/backend/src/server/web/boot.embed.js +++ b/packages/frontend-embed/public/loader/boot.js @@ -55,7 +55,7 @@ //#region Script async function importAppScript() { - await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/_boot_.ts') + await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/boot.ts') .catch(async e => { console.error(e); renderError('APP_IMPORT'); @@ -70,6 +70,8 @@ importAppScript(); }); } + + localStorage.setItem('lang', lang); //#endregion async function addStyle(styleText) { diff --git a/packages/backend/src/server/web/style.embed.css b/packages/frontend-embed/public/loader/style.css similarity index 100% rename from packages/backend/src/server/web/style.embed.css rename to packages/frontend-embed/public/loader/style.css diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts index 9d69437c30..961cbcef66 100644 --- a/packages/frontend-embed/src/boot.ts +++ b/packages/frontend-embed/src/boot.ts @@ -33,7 +33,7 @@ import type { Theme } from '@/theme.js'; console.log('Misskey Embed'); //#region Embedパラメータの取得・パース -const params = new URLSearchParams(location.search); +const params = new URLSearchParams(window.location.search); const embedParams = parseEmbedParams(params); if (_DEV_) console.log(embedParams); //#endregion @@ -81,7 +81,7 @@ storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload }); //#endregion // サイズの制限 -document.documentElement.style.maxWidth = '500px'; +window.document.documentElement.style.maxWidth = '500px'; // iframeIdの設定 function setIframeIdHandler(event: MessageEvent) { @@ -114,16 +114,16 @@ app.provide(DI.embedParams, embedParams); const rootEl = ((): HTMLElement => { const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; - const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); + const currentRoot = window.document.getElementById(MISSKEY_MOUNT_DIV_ID); if (currentRoot) { console.warn('multiple import detected'); return currentRoot; } - const root = document.createElement('div'); + const root = window.document.createElement('div'); root.id = MISSKEY_MOUNT_DIV_ID; - document.body.appendChild(root); + window.document.body.appendChild(root); return root; })(); @@ -159,7 +159,7 @@ console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hu //#endregion function removeSplash() { - const splash = document.getElementById('splash'); + const splash = window.document.getElementById('splash'); if (splash) { splash.style.opacity = '0'; splash.style.pointerEvents = 'none'; diff --git a/packages/frontend-embed/src/components/EmAvatar.vue b/packages/frontend-embed/src/components/EmAvatar.vue index 58c35c8ef0..3f91e14403 100644 --- a/packages/frontend-embed/src/components/EmAvatar.vue +++ b/packages/frontend-embed/src/components/EmAvatar.vue @@ -9,16 +9,16 @@ SPDX-License-Identifier: AGPL-3.0-only
-
-
-
+
+
+
-
-
-
+
+
+
diff --git a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue index 0bff048ce4..be18ce79d5 100644 --- a/packages/frontend-embed/src/components/EmImgWithBlurhash.vue +++ b/packages/frontend-embed/src/components/EmImgWithBlurhash.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -19,7 +19,7 @@ import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurha const canvasPromise = new Promise(resolve => { // テスト環境で Web Worker インスタンスは作成できない if (import.meta.env.MODE === 'test') { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); @@ -34,7 +34,7 @@ const canvasPromise = new Promise(resol ); resolve(workers); } else { - const canvas = document.createElement('canvas'); + const canvas = window.document.createElement('canvas'); canvas.width = 64; canvas.height = 64; resolve(canvas); diff --git a/packages/frontend-embed/src/components/EmInstanceTicker.vue b/packages/frontend-embed/src/components/EmInstanceTicker.vue index 4a116e317a..7add3bb53f 100644 --- a/packages/frontend-embed/src/components/EmInstanceTicker.vue +++ b/packages/frontend-embed/src/components/EmInstanceTicker.vue @@ -29,7 +29,7 @@ const props = defineProps<{ // if no instance data is given, this is for the local instance const instance = props.instance ?? { name: serverMetadata.name, - themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content, + themeColor: (window.document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content, }; const faviconUrl = computed(() => props.instance ? mediaProxy.getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : mediaProxy.getProxiedImageUrlNullable(serverMetadata.iconUrl, 'preview') ?? '/favicon.ico'); diff --git a/packages/frontend-embed/src/components/EmMediaImage.vue b/packages/frontend-embed/src/components/EmMediaImage.vue index 94f0268da4..8cb90247fc 100644 --- a/packages/frontend-embed/src/components/EmMediaImage.vue +++ b/packages/frontend-embed/src/components/EmMediaImage.vue @@ -64,7 +64,7 @@ const url = computed(() => (props.raw) : props.image.thumbnailUrl, ); -async function onclick(ev: MouseEvent) { +async function onclick(ev: PointerEvent) { if (hide.value) { ev.stopPropagation(); hide.value = false; diff --git a/packages/frontend-embed/src/components/EmMention.vue b/packages/frontend-embed/src/components/EmMention.vue index b5aaa95894..0a8ac9c05a 100644 --- a/packages/frontend-embed/src/components/EmMention.vue +++ b/packages/frontend-embed/src/components/EmMention.vue @@ -27,7 +27,7 @@ const canonical = props.host === localHost ? `@${props.username}` : `@${props.us const url = `/${canonical}`; -const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-mention')); +const bg = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-mention')); bg.setAlpha(0.1); const bgCss = bg.toRgbString(); diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts index 1f9ce9d4f4..5b9a53bbc2 100644 --- a/packages/frontend-embed/src/components/EmMfm.ts +++ b/packages/frontend-embed/src/components/EmMfm.ts @@ -299,7 +299,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext items.value.size === 0); const error = ref(false); -const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : document.body); +const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body); const visibility = useDocumentVisibility(); @@ -353,7 +353,7 @@ watch(visibility, () => { BACKGROUND_PAUSE_WAIT_SEC * 1000); } else { // 'visible' if (timerForSetPause) { - clearTimeout(timerForSetPause); + window.clearTimeout(timerForSetPause); timerForSetPause = null; } else { isPausingUpdate = false; @@ -447,11 +447,11 @@ onBeforeMount(() => { init().then(() => { if (props.pagination.reversed) { nextTick(() => { - setTimeout(toBottom, 800); + window.setTimeout(toBottom, 800); // scrollToBottomでmoreFetchingボタンが画面外まで出るまで // more = trueを遅らせる - setTimeout(() => { + window.setTimeout(() => { moreFetching.value = false; }, 2000); }); @@ -461,11 +461,11 @@ onBeforeMount(() => { onBeforeUnmount(() => { if (timerForSetPause) { - clearTimeout(timerForSetPause); + window.clearTimeout(timerForSetPause); timerForSetPause = null; } if (preventAppearFetchMoreTimer.value) { - clearTimeout(preventAppearFetchMoreTimer.value); + window.clearTimeout(preventAppearFetchMoreTimer.value); preventAppearFetchMoreTimer.value = null; } scrollObserver.value?.disconnect(); diff --git a/packages/frontend-embed/src/components/I18n.vue b/packages/frontend-embed/src/components/I18n.vue index b621110ec9..9866e50958 100644 --- a/packages/frontend-embed/src/components/I18n.vue +++ b/packages/frontend-embed/src/components/I18n.vue @@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkAnimBg.fragment.glsl b/packages/frontend/src/components/MkAnimBg.fragment.glsl new file mode 100644 index 0000000000..d40872bb7a --- /dev/null +++ b/packages/frontend/src/components/MkAnimBg.fragment.glsl @@ -0,0 +1,111 @@ +#version 300 es +precision mediump float; + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +vec3 mod289(vec3 x) { + return x - floor(x * (1.0 / 289.0)) * 289.0; +} + +vec2 mod289(vec2 x) { + return x - floor(x * (1.0 / 289.0)) * 289.0; +} + +vec3 permute(vec3 x) { + return mod289(((x*34.0)+1.0)*x); +} + +float snoise(vec2 v) { + const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439); + + vec2 i = floor(v + dot(v, C.yy)); + vec2 x0 = v - i + dot(i, C.xx); + + vec2 i1; + i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = x0.xyxy + C.xxzz; + x12.xy -= i1; + + i = mod289(i); + vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0)); + + vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0); + m = m*m; + m = m*m; + + vec3 x = 2.0 * fract(p * C.www) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + vec3 g; + g.x = a0.x * x0.x + h.x * x0.y; + g.yz = a0.yz * x12.xz + h.yz * x12.yw; + return 130.0 * dot(m, g); +} + +in vec2 in_uv; +uniform float u_time; +uniform vec2 u_resolution; +uniform float u_spread; +uniform float u_speed; +uniform float u_warp; +uniform float u_focus; +uniform float u_itensity; +out vec4 out_color; + +float circle(in vec2 _pos, in vec2 _origin, in float _radius) { + float SPREAD = 0.7 * u_spread; + float SPEED = 0.00055 * u_speed; + float WARP = 1.5 * u_warp; + float FOCUS = 1.15 * u_focus; + + vec2 dist = _pos - _origin; + + float distortion = snoise(vec2( + _pos.x * 1.587 * WARP + u_time * SPEED * 0.5, + _pos.y * 1.192 * WARP + u_time * SPEED * 0.3 + )) * 0.5 + 0.5; + + float feather = 0.01 + SPREAD * pow(distortion, FOCUS); + + return 1.0 - smoothstep( + _radius - (_radius * feather), + _radius + (_radius * feather), + dot( dist, dist ) * 4.0 + ); +} + +void main() { + vec3 green = vec3(1.0) - vec3(153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0); + vec3 purple = vec3(1.0) - vec3(195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0); + vec3 orange = vec3(1.0) - vec3(255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0); + + float ratio = u_resolution.x / u_resolution.y; + + vec2 uv = vec2(in_uv.x, in_uv.y / ratio) * 0.5 + 0.5; + + vec3 color = vec3(0.0); + + float greenMix = snoise(in_uv * 1.31 + u_time * 0.8 * 0.00017) * 0.5 + 0.5; + float purpleMix = snoise(in_uv * 1.26 + u_time * 0.8 * -0.0001) * 0.5 + 0.5; + float orangeMix = snoise(in_uv * 1.34 + u_time * 0.8 * 0.00015) * 0.5 + 0.5; + + float alphaOne = 0.35 + 0.65 * pow(snoise(vec2(u_time * 0.00012, uv.x)) * 0.5 + 0.5, 1.2); + float alphaTwo = 0.35 + 0.65 * pow(snoise(vec2((u_time + 1561.0) * 0.00014, uv.x )) * 0.5 + 0.5, 1.2); + float alphaThree = 0.35 + 0.65 * pow(snoise(vec2((u_time + 3917.0) * 0.00013, uv.x )) * 0.5 + 0.5, 1.2); + + color += vec3(circle(uv, vec2(0.22 + sin(u_time * 0.000201) * 0.06, 0.80 + cos(u_time * 0.000151) * 0.06), 0.15)) * alphaOne * (purple * purpleMix + orange * orangeMix); + color += vec3(circle(uv, vec2(0.90 + cos(u_time * 0.000166) * 0.06, 0.42 + sin(u_time * 0.000138) * 0.06), 0.18)) * alphaTwo * (green * greenMix + purple * purpleMix); + color += vec3(circle(uv, vec2(0.19 + sin(u_time * 0.000112) * 0.06, 0.25 + sin(u_time * 0.000192) * 0.06), 0.09)) * alphaThree * (orange * orangeMix); + + color *= u_itensity + 1.0 * pow(snoise(vec2(in_uv.y + u_time * 0.00013, in_uv.x + u_time * -0.00009)) * 0.5 + 0.5, 2.0); + + vec3 inverted = vec3(1.0) - color; + out_color = vec4(color, max(max(color.x, color.y), color.z)); +} diff --git a/packages/frontend/src/components/MkAnimBg.vertex.glsl b/packages/frontend/src/components/MkAnimBg.vertex.glsl new file mode 100644 index 0000000000..56d6b017b1 --- /dev/null +++ b/packages/frontend/src/components/MkAnimBg.vertex.glsl @@ -0,0 +1,15 @@ +#version 300 es + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +in vec2 position; +uniform vec2 u_scale; +out vec2 in_uv; + +void main() { + gl_Position = vec4(position, 0.0, 1.0); + in_uv = position / u_scale; +} diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index e57fbcdee3..bcdc604bb8 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -10,6 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts index 627cb0c4ff..743bdda032 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkAnnouncementDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAnnouncementDialog.vue b/packages/frontend/src/components/MkAnnouncementDialog.vue index 81c92bfb5c..da0f618e95 100644 --- a/packages/frontend/src/components/MkAnnouncementDialog.vue +++ b/packages/frontend/src/components/MkAnnouncementDialog.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index 0a569b3beb..4420cc4f05 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import MkButton from './MkButton.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index a77ebd6ac5..854ed31ed5 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index f79989d882..d3ed222cdb 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -27,6 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only
+
+ + {{ i18n.ts.youAreAdmin }} +
@@ -48,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts index 78cb4120de..a97cb4fd2f 100644 --- a/packages/frontend/src/components/MkCropperDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkCropperDialog.stories.impl.ts @@ -4,7 +4,7 @@ */ import { HttpResponse, http } from 'msw'; -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { file } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkCropperDialog from './MkCropperDialog.vue'; @@ -38,7 +38,7 @@ export const Default = { }; }, args: { - file: file(), + imageFile: new File([], 'image.webp', { type: 'image/webp' }), aspectRatio: NaN, }, parameters: { diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 7f592fba79..1fad936d16 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -28,8 +28,8 @@ SPDX-License-Identifier: AGPL-3.0-only - diff --git a/packages/frontend/src/components/MkDrive.file.stories.impl.ts b/packages/frontend/src/components/MkDrive.file.stories.impl.ts index 933383775c..9981ee77ac 100644 --- a/packages/frontend/src/components/MkDrive.file.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.file.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import MkDrive_file from './MkDrive.file.vue'; import { file } from '../../.storybook/fakes.js'; diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 0eca85b3a6..e2858084c0 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -64,7 +64,7 @@ const isDragging = ref(false); const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`); -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { os.contextMenu(getDriveFileMenu(props.file, props.folder), ev); } diff --git a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts index e6c7c2f645..6fa8d2253f 100644 --- a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index d7dd12408c..6d93dfc0d4 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -57,7 +57,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'chosen', v: Misskey.entities.DriveFolder): void; (ev: 'unchose', v: Misskey.entities.DriveFolder): void; - (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder); + (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder): void; (ev: 'dragstart'): void; (ev: 'dragend'): void; }>(); @@ -231,17 +231,17 @@ function rename() { } function move() { - selectDriveFolder(null).then(folder => { - if (folder[0] && folder[0].id === props.folder.id) return; + selectDriveFolder(null).then(({ canceled, folders }) => { + if (canceled || (folders[0] && folders[0].id === props.folder.id)) return; misskeyApi('drive/folders/update', { folderId: props.folder.id, - parentId: folder[0] ? folder[0].id : null, + parentId: folders[0] ? folders[0].id : null, }).then(() => { globalEvents.emit('driveFoldersUpdated', [{ ...props.folder, - parentId: folder[0] ? folder[0].id : null, - parent: folder[0] ?? null, + parentId: folders[0] ? folders[0].id : null, + parent: folders[0] ?? null, }]); }); }); @@ -277,7 +277,7 @@ function setAsUploadFolder() { prefer.commit('uploadFolder', props.folder.id); } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { let menu: MenuItem[]; menu = [{ text: i18n.ts.openInWindow, diff --git a/packages/frontend/src/components/MkDrive.stories.impl.ts b/packages/frontend/src/components/MkDrive.stories.impl.ts index 4394eebfda..00930af380 100644 --- a/packages/frontend/src/components/MkDrive.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 8a33dfb842..2961bc5032 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -35,18 +35,18 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -60,14 +60,15 @@ SPDX-License-Identifier: AGPL-3.0-only @drop.prevent.stop="onDrop" @contextmenu.stop="onContextmenu" > -
+
+
+
{{ i18n.ts.loadMore }} - - + + + + + + + {{ i18n.ts.loadMore }} +
-
{{ i18n.ts['empty-draghover'] }}
+
{{ i18n.ts.dropHereToUpload }}
{{ i18n.ts.emptyDrive }}
{{ i18n.ts.emptyFolder }}
@@ -152,13 +184,16 @@ import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; import { Paginator } from '@/utility/paginator.js'; const props = withDefaults(defineProps<{ - initialFolder?: Misskey.entities.DriveFolder['id'] | null; + initialFolder?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id'] | null; type?: string; multiple?: boolean; select?: 'file' | 'folder' | null; + forceDisableInfiniteScroll?: boolean; }>(), { + initialFolder: null, multiple: false, select: null, + forceDisableInfiniteScroll: false, }); const emit = defineEmits<{ @@ -167,6 +202,10 @@ const emit = defineEmits<{ (ev: 'cd', v: Misskey.entities.DriveFolder | null): void; }>(); +const shouldEnableInfiniteScroll = computed(() => { + return prefer.r.enableInfiniteScroll.value && !props.forceDisableInfiniteScroll; +}); + const folder = ref(null); const hierarchyFolders = ref([]); @@ -201,10 +240,9 @@ const filesPaginator = markRaw(new Paginator('drive/files', { params: () => ({ // 自動でリロードしたくないためcomputedParamsは使わない folderId: folder.value ? folder.value.id : null, type: props.type, - sort: sortModeSelect.value, + sort: ['-createdAt', '+createdAt'].includes(sortModeSelect.value) ? null : sortModeSelect.value, }), })); - const foldersPaginator = markRaw(new Paginator('drive/folders', { limit: 30, canFetchDetection: 'limit', @@ -213,7 +251,18 @@ const foldersPaginator = markRaw(new Paginator('drive/folders', { }), })); +const canFetchFiles = computed(() => !fetching.value && (filesPaginator.order.value === 'oldest' ? filesPaginator.canFetchNewer.value : filesPaginator.canFetchOlder.value)); + +async function fetchMoreFiles() { + if (filesPaginator.order.value === 'oldest') { + filesPaginator.fetchNewer(); + } else { + filesPaginator.fetchOlder(); + } +} + const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month'); +const shouldBeGroupedByDate = computed(() => ['+createdAt', '-createdAt'].includes(sortModeSelect.value)); watch(folder, () => emit('cd', folder.value)); watch(sortModeSelect, () => { @@ -222,10 +271,10 @@ watch(sortModeSelect, () => { async function initialize() { fetching.value = true; - await Promise.all([ - foldersPaginator.init(), - filesPaginator.init(), - ]); + await foldersPaginator.reload(); + filesPaginator.initialDirection = sortModeSelect.value === '-createdAt' ? 'newer' : 'older'; + filesPaginator.order.value = sortModeSelect.value === '-createdAt' ? 'oldest' : 'newest'; + await filesPaginator.reload(); fetching.value = false; } @@ -293,7 +342,7 @@ function onDragleave() { draghover.value = false; } -function onDrop(ev: DragEvent) { +function onDrop(ev: DragEvent): void | boolean { draghover.value = false; if (!ev.dataTransfer) return; @@ -363,7 +412,7 @@ function onDrop(ev: DragEvent) { //#endregion } -function onUploadRequested(files: File[], folder: Misskey.entities.DriveFolder | null) { +function onUploadRequested(files: File[], folder?: Misskey.entities.DriveFolder | null) { os.launchUploader(files, { folderId: folder?.id ?? null, }); @@ -444,7 +493,7 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) { }); } -function onFileClick(ev: MouseEvent, file: Misskey.entities.DriveFile) { +function onFileClick(ev: PointerEvent, file: Misskey.entities.DriveFile) { if (ev.shiftKey) { isEditMode.value = true; } @@ -516,7 +565,7 @@ function cd(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder folder.value = folderToMove; hierarchyFolders.value = []; - const dive = folderToDive => { + const dive = (folderToDive: Misskey.entities.DriveFolder) => { hierarchyFolders.value.unshift(folderToDive); if (folderToDive.parent) dive(folderToDive.parent); }; @@ -530,17 +579,19 @@ function cd(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder async function moveFilesBulk() { if (selectedFiles.value.length === 0) return; - const toFolder = await selectDriveFolder(folder.value ? folder.value.id : null); + const { canceled, folders } = await selectDriveFolder(folder.value ? folder.value.id : null); + + if (canceled) return; await os.apiWithDialog('drive/files/move-bulk', { fileIds: selectedFiles.value.map(f => f.id), - folderId: toFolder[0] ? toFolder[0].id : null, + folderId: folders[0] ? folders[0].id : null, }); globalEvents.emit('driveFilesUpdated', selectedFiles.value.map(x => ({ ...x, - folderId: toFolder[0] ? toFolder[0].id : null, - folder: toFolder[0] ?? null, + folderId: folders[0] ? folders[0].id : null, + folder: folders[0] ?? null, }))); } @@ -640,11 +691,11 @@ function getMenu() { return menu; } -function showMenu(ev: MouseEvent) { +function showMenu(ev: PointerEvent) { os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); } -function onContextmenu(ev: MouseEvent) { +function onContextmenu(ev: PointerEvent) { os.contextMenu(getMenu(), ev); } @@ -698,7 +749,7 @@ useGlobalEvent('driveFoldersDeleted', (folders) => { } }); -let connection: Misskey.ChannelConnection | null = null; +let connection: Misskey.IChannelConnection | null = null; onMounted(() => { if (store.s.realtimeMode) { @@ -801,6 +852,10 @@ onBeforeUnmount(() => { } } +.tipContainer:not(:empty) { + padding: 16px 32px; +} + .folders, .files { display: grid; @@ -810,6 +865,10 @@ onBeforeUnmount(() => { } @container (max-width: 600px) { + .tipContainer:not(:empty) { + padding: 16px; + } + .folders, .files { padding: 16px; diff --git a/packages/frontend/src/components/MkDriveFileThumbnail.vue b/packages/frontend/src/components/MkDriveFileThumbnail.vue index 88afdef114..3933421fc0 100644 --- a/packages/frontend/src/components/MkDriveFileThumbnail.vue +++ b/packages/frontend/src/components/MkDriveFileThumbnail.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only :forceBlurhash="forceBlurhash" /> (); const dialog = useTemplateRef('dialog'); -const selected = ref([]); +const selected = ref<(Misskey.entities.DriveFolder | null)[]>([]); function ok() { emit('done', selected.value); @@ -57,7 +57,7 @@ function cancel() { dialog.value?.close(); } -function onChangeSelection(v: Misskey.entities.DriveFolder[]) { +function onChangeSelection(v: (Misskey.entities.DriveFolder | null)[]) { selected.value = v; } diff --git a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue index d18fe0ed0c..9002669378 100644 --- a/packages/frontend/src/components/MkEmbedCodeGenDialog.vue +++ b/packages/frontend/src/components/MkEmbedCodeGenDialog.vue @@ -23,11 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only :enterFromClass="$style.transition_x_enterFrom" :leaveToClass="$style.transition_x_leaveTo" > -
-
- + + + +
@@ -94,17 +90,15 @@ import { url } from '@@/js/config.js'; import { embedRouteWithScrollbar } from '@@/js/embed-page.js'; import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js'; import MkModalWindow from '@/components/MkModalWindow.vue'; - +import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue'; import MkInput from '@/components/MkInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkButton from '@/components/MkButton.vue'; - import MkCode from '@/components/MkCode.vue'; import MkInfo from '@/components/MkInfo.vue'; - -import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; +import { useMkSelect } from '@/composables/use-mkselect.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; @@ -160,9 +154,20 @@ const embedPreviewUrl = computed(() => { const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(props.entity)); const header = ref(props.params?.header ?? true); -const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? undefined : 500); +const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500); + +const { + model: colorMode, + def: colorModeDef, +} = useMkSelect({ + items: [ + { value: 'auto', label: i18n.ts.syncDeviceDarkMode }, + { value: 'light', label: i18n.ts.light }, + { value: 'dark', label: i18n.ts.dark }, + ], + initialValue: props.params?.colorMode ?? 'auto', +}); -const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto'); const rounded = ref(props.params?.rounded ?? true); const border = ref(props.params?.border ?? true); @@ -297,20 +302,6 @@ onUnmounted(() => { height: 100%; } -.embedCodeGenInputRoot { - height: 100%; - display: grid; - grid-template-columns: 1fr 400px; -} - -.embedCodeGenPreviewRoot { - position: relative; - background-color: var(--MI_THEME-bg); - background-size: auto auto; - background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px); - cursor: not-allowed; -} - .embedCodeGenPreviewWrapper { display: flex; flex-direction: column; @@ -358,11 +349,6 @@ onUnmounted(() => { color-scheme: light dark; } -.embedCodeGenSettings { - padding: 24px; - overflow-y: scroll; -} - .embedCodeGenResultRoot { box-sizing: border-box; padding: 24px; @@ -403,11 +389,4 @@ onUnmounted(() => { .embedCodeGenResultButtons { margin: 0 auto; } - -@container (max-width: 800px) { - .embedCodeGenInputRoot { - grid-template-columns: 1fr; - grid-template-rows: 1fr 1fr; - } -} diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index ef515e471f..3ee32710e5 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -62,8 +62,8 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts index bf4158a2c8..cc934040f5 100644 --- a/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts +++ b/packages/frontend/src/components/MkEmojiPicker.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { action } from '@storybook/addon-actions'; +import { action } from 'storybook/actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; import type { StoryObj } from '@storybook/vue3'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index df05bcc94c..bf0f9d0130 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -152,7 +152,7 @@ const props = withDefaults(defineProps<{ asDrawer?: boolean; asWindow?: boolean; asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう - targetNote?: Misskey.entities.Note; + targetNote?: Misskey.entities.Note | null; }>(), { showPinned: true, }); @@ -326,7 +326,7 @@ watch(q, () => { for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { - if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) { + if (keywords.every(keyword => index[emoji.char]?.some(k => k.includes(keyword)))) { matches.add(emoji); if (matches.size >= max) break; } @@ -343,7 +343,7 @@ watch(q, () => { for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { - if (index[emoji.char].some(k => k.startsWith(newQ))) { + if (index[emoji.char]?.some(k => k.startsWith(newQ))) { matches.add(emoji); if (matches.size >= max) break; } @@ -360,7 +360,7 @@ watch(q, () => { for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) { for (const emoji of emojis) { - if (index[emoji.char].some(k => k.includes(newQ))) { + if (index[emoji.char]?.some(k => k.includes(newQ))) { matches.add(emoji); if (matches.size >= max) break; } @@ -412,13 +412,13 @@ function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeE } /** @see MkEmojiPicker.section.vue */ -function computeButtonTitle(ev: MouseEvent): void { +function computeButtonTitle(ev: PointerEvent): void { const elm = ev.target as HTMLElement; const emoji = elm.dataset.emoji as string; elm.title = getEmojiName(emoji); } -function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) { +function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: PointerEvent) { const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined; if (el && prefer.s.animation) { const rect = el.getBoundingClientRect(); @@ -530,47 +530,53 @@ defineExpose({ --eachSize: 50px; } + &.s4 { + --eachSize: 55px; + } + + &.s5 { + --eachSize: 60px; + } + &.w1 { - width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); - --columns: 1fr 1fr 1fr 1fr 1fr; + --columns: 5; } &.w2 { - width: calc((var(--eachSize) * 6) + (#{$pad} * 2)); - --columns: 1fr 1fr 1fr 1fr 1fr 1fr; + --columns: 6; } &.w3 { - width: calc((var(--eachSize) * 7) + (#{$pad} * 2)); - --columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + --columns: 7; } &.w4 { - width: calc((var(--eachSize) * 8) + (#{$pad} * 2)); - --columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + --columns: 8; } &.w5 { - width: calc((var(--eachSize) * 9) + (#{$pad} * 2)); - --columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr; + --columns: 9; } &.h1 { - height: calc((var(--eachSize) * 4) + (#{$pad} * 2)); + --rows: 4; } &.h2 { - height: calc((var(--eachSize) * 6) + (#{$pad} * 2)); + --rows: 6; } &.h3 { - height: calc((var(--eachSize) * 8) + (#{$pad} * 2)); + --rows: 8; } &.h4 { - height: calc((var(--eachSize) * 10) + (#{$pad} * 2)); + --rows: 10; } + width: calc((var(--eachSize) * var(--columns)) + (#{$pad} * 2)); + height: calc((var(--eachSize) * var(--rows)) + (#{$pad} * 2)); + &.asDrawer { width: 100% !important; @@ -585,7 +591,7 @@ defineExpose({ > .body { display: grid; - grid-template-columns: var(--columns); + grid-template-columns: repeat(var(--columns), 1fr); font-size: 30px; > .config { @@ -627,7 +633,7 @@ defineExpose({ ::v-deep(section) { > .body { display: grid; - grid-template-columns: var(--columns); + grid-template-columns: repeat(var(--columns), 1fr); font-size: 30px; > .item { diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 1627dc8760..0ff4e8f38d 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -44,11 +44,11 @@ import { prefer } from '@/preferences.js'; const props = withDefaults(defineProps<{ manualShowing?: boolean | null; - anchorElement?: HTMLElement; + anchorElement?: HTMLElement | null; showPinned?: boolean; pinnedEmojis?: string[], asReactionPicker?: boolean; - targetNote?: Misskey.entities.Note; + targetNote?: Misskey.entities.Note | null; choseAndClose?: boolean; }>(), { manualShowing: null, diff --git a/packages/frontend/src/components/MkExtensionInstaller.vue b/packages/frontend/src/components/MkExtensionInstaller.vue index c9d18ee731..3f7eb9bccd 100644 --- a/packages/frontend/src/components/MkExtensionInstaller.vue +++ b/packages/frontend/src/components/MkExtensionInstaller.vue @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -91,7 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
{{ i18n.ts.cancel }} @@ -101,6 +101,8 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkImageEffectorFxForm.vue b/packages/frontend/src/components/MkImageEffectorFxForm.vue index d7ab620132..723b5f093e 100644 --- a/packages/frontend/src/components/MkImageEffectorFxForm.vue +++ b/packages/frontend/src/components/MkImageEffectorFxForm.vue @@ -8,7 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only
+ v-model="params[k]" + > @@ -27,13 +28,9 @@ SPDX-License-Identifier: AGPL-3.0-only - + -
@@ -47,18 +44,18 @@ SPDX-License-Identifier: AGPL-3.0-only
- {{ i18n.ts._imageEffector.nothingToConfigure }} + {{ i18n.ts.nothingToConfigure }}
+ + diff --git a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts index 339e6d10f3..7da705a23f 100644 --- a/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts +++ b/packages/frontend/src/components/MkImgPreviewDialog.stories.impl.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { file } from '../../.storybook/fakes.js'; import MkImgPreviewDialog from './MkImgPreviewDialog.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkImgPreviewDialog.vue b/packages/frontend/src/components/MkImgPreviewDialog.vue index 3e6e4e0ec9..530b6c45db 100644 --- a/packages/frontend/src/components/MkImgPreviewDialog.vue +++ b/packages/frontend/src/components/MkImgPreviewDialog.vue @@ -11,15 +11,16 @@ SPDX-License-Identifier: AGPL-3.0-only @close="close" @esc="close" @click="close" + @closed="emit('closed')" >
- +
+ + diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 07d88d6575..368fa5be27 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -9,31 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
- - - - - - - - - - - - - - - - - - - - - - - - - + +
@@ -43,13 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only - - - - - - - +
@@ -84,9 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 584afff55c..0c73df4e2d 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
@@ -316,16 +316,27 @@ function onItemMouseLeave() { if (childCloseTimer) window.clearTimeout(childCloseTimer); } -async function showRadioOptions(item: MenuRadio, ev: Event) { +async function showRadioOptions(item: MenuRadio, ev: MouseEvent | PointerEvent | KeyboardEvent) { const children: MenuItem[] = Object.keys(item.options).map(key => { const value = item.options[key]; return { type: 'radioOption', text: key, action: () => { - item.ref = value; + if ('value' in item.ref) { + item.ref.value = value; + } else { + // @ts-expect-error リアクティビティは保たれる + item.ref = value; + } }, - active: computed(() => item.ref === value), + active: computed(() => { + if ('value' in item.ref) { + return item.ref.value === value; + } else { + return item.ref === value; + } + }), }; }); @@ -341,7 +352,7 @@ async function showRadioOptions(item: MenuRadio, ev: Event) { } } -async function showChildren(item: MenuParent, ev: Event) { +async function showChildren(item: MenuParent, ev: MouseEvent | PointerEvent | KeyboardEvent) { ev.stopPropagation(); const children: MenuItem[] = await (async () => { @@ -371,7 +382,7 @@ async function showChildren(item: MenuParent, ev: Event) { } } -function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) { +function clicked(fn: MenuAction, ev: PointerEvent, doClose = true) { fn(ev); if (!doClose) return; diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 06686ddfc0..92174d8ef7 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -91,7 +91,7 @@ const emit = defineEmits<{ (ev: 'opened'): void; (ev: 'click'): void; (ev: 'esc'): void; - (ev: 'close'): void; + (ev: 'close'): void; // TODO: (refactor) closing に改名する (ev: 'closed'): void; }>(); @@ -148,7 +148,6 @@ function close(opts: { useSendAnimation?: boolean } = {}) { useSendAnime.value = true; } - // eslint-disable-next-line vue/no-mutating-props if (props.anchorElement) props.anchorElement.style.pointerEvents = 'auto'; showing.value = false; emit('close'); @@ -185,8 +184,8 @@ const align = () => { const width = content.value!.offsetWidth; const height = content.value!.offsetHeight; - let left; - let top; + let left = 0; + let top = 0; const x = anchorRect.left + (fixed.value ? 0 : window.scrollX); const y = anchorRect.top + (fixed.value ? 0 : window.scrollY); @@ -319,7 +318,6 @@ const alignObserver = new ResizeObserver((entries, observer) => { onMounted(() => { watch(() => props.anchorElement, async () => { if (props.anchorElement) { - // eslint-disable-next-line vue/no-mutating-props props.anchorElement.style.pointerEvents = 'none'; } fixed.value = (type.value === 'drawer') || (getFixedContainer(props.anchorElement) != null); diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 471ebdd4af..88da145b09 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -5,13 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only