diff --git a/.config/cypress-devcontainer.yml b/.config/cypress-devcontainer.yml index 91dce35155..e75e32a17a 100644 --- a/.config/cypress-devcontainer.yml +++ b/.config/cypress-devcontainer.yml @@ -2,6 +2,19 @@ # Misskey configuration #━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# ┌────────────────────────┐ +#───┘ Initial Setup Password └───────────────────────────────────────────────────── + +# Password to initiate setting up admin account. +# It will not be used after the initial setup is complete. +# +# Be sure to change this when you set up Misskey via the Internet. +# +# The provider of the service who sets up Misskey on behalf of the customer should +# set this value to something unique when generating the Misskey config file, +# and provide it to the customer. +setupPassword: example_password_please_change_this_or_you_will_get_hacked + # ┌─────┐ #───┘ URL └───────────────────────────────────────────────────── @@ -207,5 +220,10 @@ allowedPrivateNetworks: [ '127.0.0.1/32' ] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 3f8e5734ce..1ffed00cc7 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -114,9 +114,27 @@ redis: # #prefix: example-prefix # #db: 1 -# ┌───────────────────────────┐ -#───┘ MeiliSearch configuration └───────────────────────────── +# ┌───────────────────────────────┐ +#───┘ Fulltext search configuration └───────────────────────────── +# These are the setting items for the full-text search provider. +fulltextSearch: + # You can select the ID generation method. + # - sqlLike (default) + # Use SQL-like search. + # This is a standard feature of PostgreSQL, so no special extensions are required. + # - sqlPgroonga + # Use pgroonga. + # You need to install pgroonga and configure it as a PostgreSQL extension. + # In addition to the above, you need to create a pgroonga index on the text column of the note table. + # see: https://pgroonga.github.io/tutorial/ + # - meilisearch + # Use Meilisearch. + # You need to install Meilisearch and configure. + provider: sqlLike + +# For Meilisearch settings. +# If you select "meilisearch" for "fulltextSearch.provider", it must be set. # You can set scope to local (default value) or global # (include notes from remote). @@ -217,5 +235,20 @@ signToActivityPubGet: true # '127.0.0.1/32' #] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 + +# Log settings +# logging: +# sql: +# # Outputs query parameters during SQL execution to the log. +# # default: false +# enableQueryParamLogging: false +# # Disable query truncation. If set to true, the full text of the query will be output to the log. +# # default: false +# disableQueryTruncation: false diff --git a/.config/example.yml b/.config/example.yml index 7080159117..71427c84bc 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -59,6 +59,20 @@ # # publishTarballInsteadOfProvideRepositoryUrl: true +# ┌────────────────────────┐ +#───┘ Initial Setup Password └───────────────────────────────────────────────────── + +# Password to initiate setting up admin account. +# It will not be used after the initial setup is complete. +# +# Be sure to change this when you set up Misskey via the Internet. +# +# The provider of the service who sets up Misskey on behalf of the customer should +# set this value to something unique when generating the Misskey config file, +# and provide it to the customer. +# +# setupPassword: example_password_please_change_this_or_you_will_get_hacked + # ┌─────┐ #───┘ URL └───────────────────────────────────────────────────── @@ -182,9 +196,27 @@ redis: # # You can specify more ioredis options... # #username: example-username -# ┌───────────────────────────┐ -#───┘ MeiliSearch configuration └───────────────────────────── +# ┌───────────────────────────────┐ +#───┘ Fulltext search configuration └───────────────────────────── +# These are the setting items for the full-text search provider. +fulltextSearch: + # You can select the ID generation method. + # - sqlLike (default) + # Use SQL-like search. + # This is a standard feature of PostgreSQL, so no special extensions are required. + # - sqlPgroonga + # Use pgroonga. + # You need to install pgroonga and configure it as a PostgreSQL extension. + # In addition to the above, you need to create a pgroonga index on the text column of the note table. + # see: https://pgroonga.github.io/tutorial/ + # - meilisearch + # Use Meilisearch. + # You need to install Meilisearch and configure. + provider: sqlLike + +# For Meilisearch settings. +# If you select "meilisearch" for "fulltextSearch.provider", it must be set. # You can set scope to local (default value) or global # (include notes from remote). @@ -302,8 +334,23 @@ signToActivityPubGet: true # '127.0.0.1/32' #] +# Disable automatic redirect for ActivityPub object lookup. (default: false) +# This is a strong defense against potential impersonation attacks if the viewer instance has inadequate validation. +# However it will make it impossible for other instances to lookup third-party user and notes through your URL. +#disallowExternalApRedirect: true + # Upload or download file size limits (bytes) #maxFileSize: 262144000 # PID File of master process #pidFile: /tmp/misskey.pid + +# Log settings +# logging: +# sql: +# # Outputs query parameters during SQL execution to the log. +# # default: false +# enableQueryParamLogging: false +# # Disable query truncation. If set to true, the full text of the query will be output to the log. +# # default: false +# disableQueryTruncation: false diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fbf959d449..c506c36f6b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,9 +5,11 @@ "workspaceFolder": "/workspace", "features": { "ghcr.io/devcontainers/features/node:1": { - "version": "20.16.0" + "version": "22.11.0" }, - "ghcr.io/devcontainers-contrib/features/corepack:1": {} + "ghcr.io/devcontainers-extra/features/pnpm:2": { + "version": "10.6.1" + } }, "forwardPorts": [3000], "postCreateCommand": "/bin/bash .devcontainer/init.sh", diff --git a/.devcontainer/init.sh b/.devcontainer/init.sh index e02a533c15..216292b082 100755 --- a/.devcontainer/init.sh +++ b/.devcontainer/init.sh @@ -7,8 +7,6 @@ sudo apt-get update sudo apt-get -y install libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libnss3 libxss1 libasound2 libxtst6 xauth xvfb git config --global --add safe.directory /workspace git submodule update --init -corepack install -corepack enable pnpm config set store-dir /home/node/.local/share/pnpm/store pnpm install --frozen-lockfile cp .devcontainer/devcontainer.yml .config/default.yml diff --git a/.github/ISSUE_TEMPLATE/01_bug-report.yml b/.github/ISSUE_TEMPLATE/01_bug-report.yml index 315e712c30..077855b5bf 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: 2024.x.x + * Misskey: 2025.x.x value: | * Model and OS of the device(s): * Browser: @@ -74,7 +74,7 @@ body: Examples: * Installation Method or Hosting Service: docker compose, k8s/docker, systemd, "Misskey install shell script", development environment - * Misskey: 2024.x.x + * Misskey: 2025.x.x * Node: 20.x.x * PostgreSQL: 15.x.x * Redis: 7.x.x diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d4678ec5e0..b93080278d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,7 +9,7 @@ updates: directory: "/" schedule: interval: daily - open-pull-requests-limit: 100 + open-pull-requests-limit: 0 # Add only the root, not each workspace item # https://github.com/dependabot/dependabot-core/issues/4993#issuecomment-1289133027 @@ -17,16 +17,13 @@ updates: directory: "/" schedule: interval: daily - open-pull-requests-limit: 10 + open-pull-requests-limit: 0 # List dependencies required to be updated together, sharing the same version numbers. # Those who simply have the common owner (e.g. @fastify) don't need to be listed. groups: aws-sdk: patterns: - "@aws-sdk/*" - bull-board: - patterns: - - "@bull-board/*" nestjs: patterns: - "@nestjs/*" diff --git a/.github/labeler.yml b/.github/labeler.yml index a77f73706b..b64d726d65 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -6,7 +6,7 @@ 'packages/backend:test': - any: - changed-files: - - any-glob-to-any-file: ['packages/backend/test/**/*'] + - any-glob-to-any-file: ['packages/backend/test/**/*', 'packages/backend/test-federation/**/*'] 'packages/frontend': - any: diff --git a/.github/misskey/test.yml b/.github/misskey/test.yml index 7a4aa4ae6c..3c807e8b9e 100644 --- a/.github/misskey/test.yml +++ b/.github/misskey/test.yml @@ -1,5 +1,7 @@ url: 'http://misskey.local' +setupPassword: example_password_please_change_this_or_you_will_get_hacked + # ローカルでテストするときにポートを被らないようにするためデフォルトのものとは変える(以下同じ) port: 61812 diff --git a/.github/workflows/api-misskey-js.yml b/.github/workflows/api-misskey-js.yml index 8380a3bb23..1c4bee2095 100644 --- a/.github/workflows/api-misskey-js.yml +++ b/.github/workflows/api-misskey-js.yml @@ -16,12 +16,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.2.2 - - run: corepack enable + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Setup Node.js - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version-file: '.node-version' cache: 'pnpm' diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 44cc1a04f2..52acbfebeb 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.1.1 + uses: actions/checkout@v4.2.2 - name: Setup Node.js - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.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 5afd7d2714..50a8c3ab34 100644 --- a/.github/workflows/check-misskey-js-autogen.yml +++ b/.github/workflows/check-misskey-js-autogen.yml @@ -18,9 +18,10 @@ jobs: if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }} steps: - name: checkout - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.2.2 with: submodules: true + persist-credentials: false ref: refs/pull/${{ github.event.pull_request.number }}/merge - name: setup pnpm @@ -28,7 +29,7 @@ jobs: - name: setup node id: setup-node - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version-file: '.node-version' cache: pnpm @@ -57,7 +58,7 @@ jobs: name: generated-misskey-js path: packages/misskey-js/generator/built/autogen - # pull_request_target safety: permissions: read-all, and there are no secrets used in this job + # pull_request_target safety: permissions: read-all, and no user codes are executed get-actual-misskey-js: runs-on: ubuntu-latest permissions: @@ -65,9 +66,10 @@ jobs: if: ${{ github.event.pull_request.mergeable == null || github.event.pull_request.mergeable == true }} steps: - name: checkout - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.2.2 with: submodules: true + persist-credentials: false ref: refs/pull/${{ github.event.pull_request.number }}/merge - name: Upload From Merged @@ -131,3 +133,7 @@ jobs: mode: delete message: "Thank you!" create_if_not_exists: false + + - name: Make failure if changes are detected + if: steps.check-changes.outputs.changes == 'true' + run: exit 1 diff --git a/.github/workflows/check-misskey-js-version.yml b/.github/workflows/check-misskey-js-version.yml index 99c29ac974..2b15cbee53 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.1.1 + uses: actions/checkout@v4.2.2 - 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 2579beb53a..bc6be308d1 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.1.1 + uses: actions/checkout@v4.2.2 - name: Check run: | counter=0 @@ -48,13 +48,15 @@ jobs: "packages/backend/migration" "packages/backend/src" "packages/backend/test" - "packages/frontend-shared/src" + "packages/frontend-shared/@types" + "packages/frontend-shared/js" "packages/frontend/.storybook" "packages/frontend/@types" "packages/frontend/lib" "packages/frontend/public" "packages/frontend/src" "packages/frontend/test" + "packages/frontend-embed/@types" "packages/frontend-embed/src" "packages/misskey-bubble-game/src" "packages/misskey-reversi/src" diff --git a/.github/workflows/check_copyright_year.yml b/.github/workflows/check_copyright_year.yml index 03dfcd0a0b..eaf922d4bc 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.1.1 + - uses: actions/checkout@v4.2.2 - 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 66b15beb91..46baf7421b 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.1.1 + uses: actions/checkout@v4.2.2 - name: Check allowed users id: check-allowed-users diff --git a/.github/workflows/docker-develop.yml b/.github/workflows/docker-develop.yml index ac2b1b4d35..56dedf273d 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.1.1 + uses: actions/checkout@v4.2.2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Docker Hub diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index db899ba386..eb98273ba0 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.1.1 + uses: actions/checkout@v4.2.2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Docker meta diff --git a/.github/workflows/dockle.yml b/.github/workflows/dockle.yml index c3dba4213d..3054607913 100644 --- a/.github/workflows/dockle.yml +++ b/.github/workflows/dockle.yml @@ -15,7 +15,7 @@ jobs: DOCKER_CONTENT_TRUST: 1 DOCKLE_VERSION: 0.4.14 steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 - name: Download and install dockle v${{ env.DOCKLE_VERSION }} run: | curl -L -o dockle.deb "https://github.com/goodwithtech/dockle/releases/download/v${DOCKLE_VERSION}/dockle_${DOCKLE_VERSION}_Linux-64bit.deb" diff --git a/.github/workflows/get-api-diff.yml b/.github/workflows/get-api-diff.yml index 1bcaa0d9c4..3244a39156 100644 --- a/.github/workflows/get-api-diff.yml +++ b/.github/workflows/get-api-diff.yml @@ -17,7 +17,7 @@ jobs: strategy: matrix: - node-version: [20.16.0] + node-version: [22.11.0] api-json-name: [api-base.json, api-head.json] include: - api-json-name: api-base.json @@ -26,18 +26,17 @@ jobs: ref: refs/pull/${{ github.event.number }}/merge steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: ref: ${{ matrix.ref }} submodules: true - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3064b0f6f4..361bd697e5 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,6 +12,8 @@ on: - packages/frontend-embed/** - packages/sw/** - packages/misskey-js/** + - packages/misskey-bubble-game/** + - packages/misskey-reversi/** - packages/shared/eslint.config.js - .github/workflows/lint.yml pull_request: @@ -22,22 +24,24 @@ on: - packages/frontend-embed/** - packages/sw/** - packages/misskey-js/** + - packages/misskey-bubble-game/** + - packages/misskey-reversi/** - packages/shared/eslint.config.js - .github/workflows/lint.yml jobs: pnpm_install: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 submodules: true - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 + - uses: actions/setup-node@v4.2.0 with: node-version-file: '.node-version' cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile lint: @@ -53,23 +57,25 @@ jobs: - frontend-embed - sw - misskey-js + - misskey-bubble-game + - misskey-reversi env: eslint-cache-version: v1 eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }} steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 submodules: true - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 + - uses: actions/setup-node@v4.2.0 with: node-version-file: '.node-version' cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - name: Restore eslint cache - uses: actions/cache@v4.0.2 + uses: actions/cache@v4.2.2 with: path: ${{ env.eslint-cache-path }} key: eslint-${{ env.eslint-cache-version }}-${{ matrix.workspace }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ github.ref_name }}-${{ github.sha }} @@ -87,16 +93,16 @@ jobs: - sw - misskey-js steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 submodules: true - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 + - uses: actions/setup-node@v4.2.0 with: node-version-file: '.node-version' cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - run: pnpm --filter misskey-js run build if: ${{ matrix.workspace == 'backend' || matrix.workspace == 'sw' }} diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index 6bc8860a11..4c0de376d2 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -14,15 +14,15 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: fetch-depth: 0 submodules: true - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4.0.4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 + - uses: actions/setup-node@v4.2.0 with: node-version-file: '.node-version' cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - run: cd locales && node verify.js diff --git a/.github/workflows/ok-to-test.yml b/.github/workflows/ok-to-test.yml deleted file mode 100644 index 8362c006eb..0000000000 --- a/.github/workflows/ok-to-test.yml +++ /dev/null @@ -1,36 +0,0 @@ -# If someone with write access comments "/ok-to-test" on a pull request, emit a repository_dispatch event -name: Ok To Test - -on: - issue_comment: - types: [created] - -jobs: - ok-to-test: - runs-on: ubuntu-latest - # Only run for PRs, not issue comments - if: ${{ github.event.issue.pull_request }} - steps: - # Generate a GitHub App installation access token from an App ID and private key - # To create a new GitHub App: - # https://developer.github.com/apps/building-github-apps/creating-a-github-app/ - # See app.yml for an example app manifest - - name: Generate token - id: generate_token - uses: tibdex/github-app-token@v2 - with: - app_id: ${{ secrets.DEPLOYBOT_APP_ID }} - private_key: ${{ secrets.DEPLOYBOT_PRIVATE_KEY }} - - - name: Slash Command Dispatch - uses: peter-evans/slash-command-dispatch@v4 - env: - TOKEN: ${{ steps.generate_token.outputs.token }} - with: - token: ${{ env.TOKEN }} # GitHub App installation access token - # token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} # PAT or OAuth token will also work - reaction-token: ${{ secrets.GITHUB_TOKEN }} - issue-type: pull-request - commands: deploy - named-args: true - permission: write diff --git a/.github/workflows/on-release-created.yml b/.github/workflows/on-release-created.yml index ffaf7bc038..aa32f2cb3b 100644 --- a/.github/workflows/on-release-created.yml +++ b/.github/workflows/on-release-created.yml @@ -17,23 +17,22 @@ jobs: strategy: matrix: - node-version: [20.16.0] + node-version: [22.11.0] steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: submodules: true - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' registry-url: 'https://registry.npmjs.org' - name: Publish package run: | - corepack enable pnpm i --frozen-lockfile pnpm build pnpm --filter misskey-js publish --access public --no-git-checks --provenance diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml deleted file mode 100644 index 964d24c3d7..0000000000 --- a/.github/workflows/pr-preview-deploy.yml +++ /dev/null @@ -1,92 +0,0 @@ -# Run secret-dependent integration tests only after /deploy approval -on: - repository_dispatch: - types: [deploy-command] - -name: Deploy preview environment - -jobs: - # Repo owner has commented /deploy on a (fork-based) pull request - deploy-preview-environment: - runs-on: ubuntu-latest - if: - github.event.client_payload.slash_command.sha != '' && - contains(github.event.client_payload.pull_request.head.sha, github.event.client_payload.slash_command.sha) - steps: - - uses: actions/github-script@v7.0.1 - id: check-id - env: - number: ${{ github.event.client_payload.pull_request.number }} - job: ${{ github.job }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - result-encoding: string - script: | - const { data: pull } = await github.rest.pulls.get({ - ...context.repo, - pull_number: process.env.number - }); - const ref = pull.head.sha; - - const { data: checks } = await github.rest.checks.listForRef({ - ...context.repo, - ref - }); - - const check = checks.check_runs.filter(c => c.name === process.env.job); - - return check[0].id; - - - uses: actions/github-script@v7.0.1 - env: - check_id: ${{ steps.check-id.outputs.result }} - details_url: ${{ github.server_url }}/${{ github.repository }}/runs/${{ github.run_id }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - await github.rest.checks.update({ - ...context.repo, - check_run_id: process.env.check_id, - status: 'in_progress', - details_url: process.env.details_url - }); - - # Check out merge commit - - name: Fork based /deploy checkout - uses: actions/checkout@v4.1.1 - with: - ref: 'refs/pull/${{ github.event.client_payload.pull_request.number }}/merge' - - # - - name: Context - uses: okteto/context@latest - with: - token: ${{ secrets.OKTETO_TOKEN }} - - - name: Deploy preview environment - uses: ikuradon/deploy-preview@latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - name: pr-${{ github.event.client_payload.pull_request.number }}-syuilo - timeout: 15m - - # Update check run called "integration-fork" - - uses: actions/github-script@v7.0.1 - id: update-check-run - if: ${{ always() }} - env: - # Conveniently, job.status maps to https://developer.github.com/v3/checks/runs/#update-a-check-run - conclusion: ${{ job.status }} - check_id: ${{ steps.check-id.outputs.result }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { data: result } = await github.rest.checks.update({ - ...context.repo, - check_run_id: process.env.check_id, - status: 'completed', - conclusion: process.env.conclusion - }); - - return result; diff --git a/.github/workflows/pr-preview-destroy.yml b/.github/workflows/pr-preview-destroy.yml deleted file mode 100644 index 8967eb2f94..0000000000 --- a/.github/workflows/pr-preview-destroy.yml +++ /dev/null @@ -1,54 +0,0 @@ -# file: .github/workflows/preview-closed.yaml -on: - pull_request: - types: - - closed - -name: Destroy preview environment - -jobs: - destroy-preview-environment: - runs-on: ubuntu-latest - steps: - - uses: actions/github-script@v7.0.1 - id: check-conclusion - env: - number: ${{ github.event.number }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - result-encoding: string - script: | - const { data: pull } = await github.rest.pulls.get({ - ...context.repo, - pull_number: process.env.number - }); - const ref = pull.head.sha; - - const { data: checks } = await github.rest.checks.listForRef({ - ...context.repo, - ref - }); - - const check = checks.check_runs.filter(c => c.name === 'deploy-preview-environment'); - - if (check.length === 0) { - return; - } - - const { data: result } = await github.rest.checks.get({ - ...context.repo, - check_run_id: check[0].id, - }); - - return result.conclusion; - - name: Context - if: steps.check-conclusion.outputs.result == 'success' - uses: okteto/context@latest - with: - token: ${{ secrets.OKTETO_TOKEN }} - - - name: Destroy preview environment - if: steps.check-conclusion.outputs.result == 'success' - uses: okteto/destroy-preview@latest - with: - name: pr-${{ github.event.number }}-syuilo diff --git a/.github/workflows/release-with-dispatch.yml b/.github/workflows/release-with-dispatch.yml index ed2f822269..d750001b71 100644 --- a/.github/workflows/release-with-dispatch.yml +++ b/.github/workflows/release-with-dispatch.yml @@ -60,13 +60,13 @@ jobs: ### General - - + ### Client - - + ### Server - - + use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} indent: ${{ vars.INDENT }} secrets: @@ -86,6 +86,7 @@ jobs: draft_prerelease_channel: alpha ready_start_prerelease_channel: beta prerelease_channel: ${{ inputs.start-rc && 'rc' || '' }} + reset_number_on_channel_change: true secrets: RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/release-with-ready.yml b/.github/workflows/release-with-ready.yml deleted file mode 100644 index e863b5e2e8..0000000000 --- a/.github/workflows/release-with-ready.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: "Release Manager: release RC when ready for review" - -on: - pull_request: - types: [ready_for_review] - -env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - -permissions: - contents: write - issues: write - pull-requests: write - -jobs: - check: - runs-on: ubuntu-latest - outputs: - head: ${{ steps.get_pr.outputs.head }} - base: ${{ steps.get_pr.outputs.base }} - steps: - - uses: actions/checkout@v4 - # PR情報を取得 - - name: Get PR - run: | - pr_json=$(gh pr view "$PR_NUMBER" --json isDraft,headRefName,baseRefName) - echo "head=$(echo $pr_json | jq -r '.headRefName')" >> $GITHUB_OUTPUT - echo "base=$(echo $pr_json | jq -r '.baseRefName')" >> $GITHUB_OUTPUT - id: get_pr - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - release: - uses: misskey-dev/release-manager-actions/.github/workflows/create-prerelease.yml@v2 - needs: check - if: needs.check.outputs.head == github.event.repository.default_branch && needs.check.outputs.base == vars.STABLE_BRANCH - with: - pr_number: ${{ github.event.pull_request.number }} - user: 'github-actions[bot]' - package_jsons_to_rewrite: ${{ vars.PACKAGE_JSONS_TO_REWRITE }} - use_external_app_to_release: ${{ vars.USE_RELEASE_APP == 'true' }} - indent: ${{ vars.INDENT }} - draft_prerelease_channel: alpha - ready_start_prerelease_channel: beta - secrets: - RELEASE_APP_ID: ${{ secrets.RELEASE_APP_ID }} - RELEASE_APP_PRIVATE_KEY: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} diff --git a/.github/workflows/report-api-diff.yml b/.github/workflows/report-api-diff.yml index 9fd1e28f01..1170f898ce 100644 --- a/.github/workflows/report-api-diff.yml +++ b/.github/workflows/report-api-diff.yml @@ -79,15 +79,13 @@ jobs: if (( "$DIFF_BYTES" <= 1 )); then echo '差分はありません。' >> ./output.md else - cat <<- EOF >> ./output.md -
- 差分はこちら - - \`\`\`diff - $(cat ./api.json.diff) - \`\`\` -
- EOF + echo '
' >> ./output.md + echo '差分はこちら' >> ./output.md + echo >> ./output.md + echo '```diff' >> ./output.md + cat ./api.json.diff >> ./output.md + echo '```' >> ./output.md + echo '
' >> .output.md fi echo "$FOOTER" >> ./output.md diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index c02f38ee0b..07f196b7b8 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -5,28 +5,32 @@ on: branches: - master - develop - - dev/storybook8 # for testing pull_request_target: branches-ignore: # Since pull requests targets master mostly is the "develop" branch. # Storybook CI is checked on the "push" event of "develop" branch so it would cause a duplicate build. # This is a waste of chromatic build quota, so we don't run storybook CI on pull requests targets master. - master + # Neither Dependabot nor Renovate will change the actual behavior for components. + - dependabot/** + - renovate/** jobs: build: + # chromatic is not likely to be available for fork repositories, so we disable for fork repositories. + if: github.repository == 'misskey-dev/misskey' runs-on: ubuntu-latest env: NODE_OPTIONS: "--max_old_space_size=7168" steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 if: github.event_name != 'pull_request_target' with: fetch-depth: 0 submodules: true - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 if: github.event_name == 'pull_request_target' with: fetch-depth: 0 @@ -38,19 +42,18 @@ jobs: run: | echo "base=$(git rev-list --parents -n1 HEAD | cut -d" " -f2)" >> $GITHUB_OUTPUT git checkout $(git rev-list --parents -n1 HEAD | cut -d" " -f3) - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Use Node.js 20.x - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version-file: '.node-version' cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml - - name: Build misskey-js - run: pnpm --filter misskey-js build + - name: Build dependent packages + run: pnpm -F misskey-js -F misskey-bubble-game -F misskey-reversi build - name: Build storybook run: pnpm --filter frontend build-storybook - name: Publish to Chromatic diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index d95d6676f9..69652621ca 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -10,19 +10,22 @@ on: # for permissions - packages/misskey-js/** - .github/workflows/test-backend.yml + - .github/misskey/test.yml pull_request: paths: - packages/backend/** # for permissions - packages/misskey-js/** - .github/workflows/test-backend.yml + - .github/misskey/test.yml jobs: unit: + name: Unit tests (backend) runs-on: ubuntu-latest strategy: matrix: - node-version: [20.16.0] + node-version: [22.11.0] services: postgres: @@ -38,19 +41,31 @@ jobs: - 56312:6379 steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: submodules: true - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Install FFmpeg - uses: FedericoCarboni/setup-ffmpeg@v3 + run: | + for i in {1..3}; do + echo "Attempt $i: Installing FFmpeg..." + curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \ + tar -xf ffmpeg.tar.xz && \ + mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \ + mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \ + rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \ + break || sleep 10 + if [ $i -eq 3 ]; then + echo "Failed to install FFmpeg after 3 attempts" + exit 1 + fi + done - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml @@ -61,17 +76,18 @@ jobs: - name: Test run: pnpm --filter backend test-and-coverage - name: Upload to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/backend/coverage/coverage-final.json e2e: + name: E2E tests (backend) runs-on: ubuntu-latest strategy: matrix: - node-version: [20.16.0] + node-version: [22.11.0] services: postgres: @@ -87,17 +103,16 @@ jobs: - 56312:6379 steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: submodules: true - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml @@ -108,7 +123,7 @@ jobs: - name: Test run: pnpm --filter backend test-and-coverage:e2e - name: Upload to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/backend/coverage/coverage-final.json diff --git a/.github/workflows/test-federation.yml b/.github/workflows/test-federation.yml new file mode 100644 index 0000000000..93588b54b9 --- /dev/null +++ b/.github/workflows/test-federation.yml @@ -0,0 +1,88 @@ +name: Test (federation) + +on: + push: + branches: + - master + - develop + paths: + - packages/backend/** + - packages/misskey-js/** + - .github/workflows/test-federation.yml + pull_request: + paths: + - packages/backend/** + - packages/misskey-js/** + - .github/workflows/test-federation.yml + +jobs: + test: + name: Federation test + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22.11.0] + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 + - name: Install FFmpeg + run: | + for i in {1..3}; do + echo "Attempt $i: Installing FFmpeg..." + curl -s -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz && \ + tar -xf ffmpeg.tar.xz && \ + mv ffmpeg-*-static/ffmpeg /usr/local/bin/ && \ + mv ffmpeg-*-static/ffprobe /usr/local/bin/ && \ + rm -rf ffmpeg.tar.xz ffmpeg-*-static/ && \ + break || sleep 10 + if [ $i -eq 3 ]; then + echo "Failed to install FFmpeg after 3 attempts" + exit 1 + fi + done + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4.2.0 + with: + node-version: ${{ matrix.node-version }} + cache: 'pnpm' + - name: Build Misskey + run: | + pnpm i --frozen-lockfile + pnpm build + - name: Setup + run: | + cd packages/backend/test-federation + bash ./setup.sh + sudo chmod 644 ./certificates/*.test.key + - name: Start servers + id: start_servers + continue-on-error: true + # https://github.com/docker/compose/issues/1294#issuecomment-374847206 + run: | + cd packages/backend/test-federation + docker compose up -d --scale tester=0 + - name: Print start_servers error + if: ${{ steps.start_servers.outcome == 'failure' }} + run: | + cd packages/backend/test-federation + docker compose logs | tail -n 300 + exit 1 + - name: Test + id: test + continue-on-error: true + run: | + cd packages/backend/test-federation + docker compose run --no-deps tester + - name: Log + if: ${{ steps.test.outcome == 'failure' }} + run: | + cd packages/backend/test-federation + docker compose logs + exit 1 + - name: Stop servers + run: | + cd packages/backend/test-federation + docker compose down diff --git a/.github/workflows/test-frontend.yml b/.github/workflows/test-frontend.yml index c68e1a8ef1..14a754c190 100644 --- a/.github/workflows/test-frontend.yml +++ b/.github/workflows/test-frontend.yml @@ -12,6 +12,7 @@ on: # for e2e - packages/backend/** - .github/workflows/test-frontend.yml + - .github/misskey/test.yml pull_request: paths: - packages/frontend/** @@ -20,26 +21,27 @@ on: # for e2e - packages/backend/** - .github/workflows/test-frontend.yml + - .github/misskey/test.yml jobs: vitest: + name: Unit tests (frontend) runs-on: ubuntu-latest strategy: matrix: - node-version: [20.16.0] + node-version: [22.11.0] steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: submodules: true - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml @@ -50,18 +52,19 @@ jobs: - name: Test run: pnpm --filter frontend test-and-coverage - name: Upload Coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/frontend/coverage/coverage-final.json e2e: + name: E2E tests (frontend) runs-on: ubuntu-latest strategy: fail-fast: false matrix: - node-version: [20.16.0] + node-version: [22.11.0] browser: [chrome] services: @@ -78,7 +81,7 @@ jobs: - 56312:6379 steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: submodules: true # https://github.com/cypress-io/cypress-docker-images/issues/150 @@ -87,14 +90,13 @@ jobs: # if: ${{ matrix.browser == 'firefox' }} #- uses: browser-actions/setup-firefox@latest # if: ${{ matrix.browser == 'firefox' }} - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - name: Copy Configure run: cp .github/misskey/test.yml .config diff --git a/.github/workflows/test-misskey-js.yml b/.github/workflows/test-misskey-js.yml index 63e81f8c92..29b6c6172b 100644 --- a/.github/workflows/test-misskey-js.yml +++ b/.github/workflows/test-misskey-js.yml @@ -16,22 +16,24 @@ on: - .github/workflows/test-misskey-js.yml jobs: test: + name: Unit tests (misskey.js) runs-on: ubuntu-latest strategy: matrix: - node-version: [20.16.0] + node-version: [22.11.0] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - name: Checkout - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.2.2 - - run: corepack enable + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' @@ -51,7 +53,7 @@ jobs: CI: true - name: Upload Coverage - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./packages/misskey-js/coverage/coverage-final.json diff --git a/.github/workflows/test-production.yml b/.github/workflows/test-production.yml index 0abc09c5a6..205eae2399 100644 --- a/.github/workflows/test-production.yml +++ b/.github/workflows/test-production.yml @@ -12,24 +12,24 @@ env: jobs: production: + name: Production build runs-on: ubuntu-latest strategy: matrix: - node-version: [20.16.0] + node-version: [22.11.0] steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: submodules: true - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - - run: corepack enable - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml diff --git a/.github/workflows/validate-api-json.yml b/.github/workflows/validate-api-json.yml index f809af1063..f84efa4821 100644 --- a/.github/workflows/validate-api-json.yml +++ b/.github/workflows/validate-api-json.yml @@ -1,4 +1,4 @@ -name: Test (backend) +name: api.json validation on: push: @@ -18,22 +18,21 @@ jobs: strategy: matrix: - node-version: [20.16.0] + node-version: [22.11.0] steps: - - uses: actions/checkout@v4.1.1 + - uses: actions/checkout@v4.2.2 with: submodules: true - - name: Install pnpm - uses: pnpm/action-setup@v4 + - name: Setup pnpm + uses: pnpm/action-setup@v4.1.0 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4.0.4 + uses: actions/setup-node@v4.2.0 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - name: Install Redocly CLI run: npm i -g @redocly/cli - - run: corepack enable - run: pnpm i --frozen-lockfile - name: Check pnpm-lock.yaml run: git diff --exit-code pnpm-lock.yaml diff --git a/.gitignore b/.gitignore index b270d5cb3a..ac7502f384 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ coverage !/.config/docker_example.env !/.config/cypress-devcontainer.yml docker-compose.yml -compose.yml +./compose.yml .devcontainer/compose.yml !/.devcontainer/compose.yml @@ -68,6 +68,8 @@ misskey-assets # Vite temporary files vite.config.js.timestamp-* vite.config.ts.timestamp-* +vite.config.local-dev.js.timestamp-* +vite.config.local-dev.ts.timestamp-* # blender backups *.blend1 diff --git a/.node-version b/.node-version index 8ce7030825..7af24b7ddb 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -20.16.0 +22.11.0 diff --git a/.npmrc b/.npmrc index c42da845b4..daebfd5218 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,3 @@ engine-strict = true +save-exact = true +shell-emulator = true diff --git a/CHANGELOG.md b/CHANGELOG.md index fea386129b..ffe1f03cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +1,431 @@ +## 2025.3.2 + +### General +- Feat: チャットがリニューアルして復活しました(beta) + - 既存のDM機能よりも便利で効率的な実装になっています + - チャットを受け付ける相手を制限可能です + - 誰でも / フォローユーザーのみ / フォロワーのみ / 相互のみ / 受け付けない から選択できます + - 自分からメッセージを送った相手とは上記の設定に関わらずチャット可能です + - チャット機能を開放するかどうかをロールで制御可能です + - ルームを作成して、複数人でのチャットも可能です + - 過去自分が送ったメッセージ・自分に送られたメッセージの検索が可能です + - 参加中のルームをミュートして通知が来ないように設定可能です + - メッセージにはリアクションも可能です +- Enhance: セキュリティを強化するため、ジョブキューのダッシュボード(bull-board)統合が削除されました。 + - Misskeyネイティブでダッシュボードを実装予定です +- Enhance: ミュートしているユーザーをユーザー検索の結果から除外するように + +### Client +- Feat: 設定の管理が強化されました + - 内部処理が一新され、安定性とパフォーマンスが向上しました + - 全てのクライアント設定がエクスポート(バックアップ)/インポート対象に含まれるようになりました + - プラグイン、テーマ、クライアントに追加されたすべてのアカウント情報も含まれるようになりました + - 自動で設定データをサーバーにバックアップできるように + - 設定→設定のプロファイル→自動バックアップ で有効にできます + - 新しいデバイスからログインしたり、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能) + - 任意の設定項目をデバイス間で同期できるように + - 設定項目の「...」メニュー→「デバイス間で同期」 + - 同期をオンにした際にサーバーに保存された値とローカルの値が競合する場合はどちらを優先するか選択できます + - 任意の設定項目を初期値にリセットできるように + - 設定項目の「...」メニュー→「初期値にリセット」 + - アカウントごとに設定値が分離される設定とそうでないクライアント設定が混在していた(かつ分離するかどうかを設定不可だった)のを、基本的に一律でクライアント全体に適用されるようにし、個別でアカウントごとに異なる設定を行えるように + - 設定項目の「...」メニュー→「アカウントで上書き」をオンにすることで、設定値をそのアカウントでだけ適用するようにできます + - ログアウトすると設定データもブラウザから消去されるようになりプライバシーが向上しました + - 再度ログインすればサーバーのバックアップから設定データを復元可能です + - エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました + - 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です +- Feat: 画面を重ねて表示するオプションを実装(実験的) + - 設定 → その他 → 実験的機能 → Enable stacking router view +- Enhance: プラグインの管理が強化されました + - インストール/アンインストール/設定の変更時にリロード不要になりました +- Enhance: ログアウト時、ブラウザに保存されたWebクライアントのデータを全て消去するように +- Enhance: デッキUIでカラム間のマージンを設定できるように +- Enhance: デッキUIでデッキメニューの位置を設定できるように +- Enhance: デッキUIでナビゲーションバーの位置を設定できるように +- Enhance: アイコンのスクロール追従を無効化してパフォーマンス向上できるように +- Enhance: CWの注釈テキストが入力されていない場合, Postボタンを非アクティブに +- Enhance: CWを無効にした場合, 注釈テキストが最大入力文字数を超えていても投稿できるように +- Enhance: テーマ設定画面のデザインを改善 +- Enhance: 投稿フォームの設定メニューを改良 + - 投稿フォームをリセットできるように + - 文字数カウントを復活 +- Enhance: 2段階認証時のリカバリーコードのファイル名にサーバーURLを含めるように +- Enhance: 全体的なブラッシュアップ +- Enhance 全体的なパフォーマンス向上 +- Fix: 読み込み直後にスクロールしようとすると途中で止まる場合があるのを修正 +- Fix: テーマ切り替え時に一部の色が変わらない問題を修正 +- NOTE: 構造上クラシックUIを新しいデザインシステムに移行することが困難なため、クラシックUIが削除されました + - デッキUIでカラムを中央寄せにし、メインカラムの左右にウィジェットカラムを配置し、ナビゲーションバーを上部に表示することである程度クラシックUIを再現できます +- Fix: Unicode絵文字とカスタム絵文字の名前が重複したときにカスタム絵文字がオートコンプリートにサジェストされない問題を修正 + +### Server +- Enhance 全体的なパフォーマンス向上 +- Fix: プロフィール追加情報で無効なURLに入力された場合に照会エラーを出るのを修正 +- Fix: ActivityPubリクエストURLチェック実装は仕様に従っていないのを修正 +- Fix: 連合無しモードでも外部から照会可能だった問題を修正 +- Fix: テスト用WebHookのペイロードの`emojis`パラメータが実際のものと異なる問題を修正 + +## 2025.3.1 + +### General +- pnpmをv10に更新 +- Corepackを削除 + +### Client +- Feat: 設定の検索を追加(実験的) +- Enhance: 設定項目の再配置 + +### Server +- Fix: DBマイグレーション際にシステムアカウントのユーザーID判定が正しくない問題を修正 +- Fix: user.featured列が状況によってJSON文字列になっていたのを修正 + + +## 2025.3.0 + +### General +- Enhance: プロキシアカウントをシステムアカウントとして作成するように +- Enhance: OAuthで外部アプリからロゴが提供されている場合、それを表示できるように + 書式は https://indieauth.spec.indieweb.org/20220212/#example-2 に準じます。 +- Fix: システムアカウントが削除できる問題を修正 + +### Client +- Enhance: モデレーターがセンシティブ設定を変更する際に確認ダイアログを出すように +- Enhance: 「UIのアニメーションを減らす」で画面上のエフェクトも減らせるように +- Enhance: 投稿フォームにおける、メディアの添付可能個数のカウントを反転しました + - これまでの表示は`添付可能残り個数/上限数`でしたが、`添付個数/上限数`としました +- Fix: フォローされたときのメッセージがちらつくことがある問題を修正 +- Fix: 投稿ダイアログがサイズ限界を超えた際にスクロールできない問題を修正 + +### Server +- Fix: 特定のケースでActivityPubの処理がデッドロックになることがあるのを修正 +- Fix: S3互換オブジェクトストレージでファイルのアップロードに失敗することがある問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/895) + + +## 2025.2.1 + +### General +- Feat: アクセストークン発行時に通知するように +- Feat: 実験的なGoogleAnalyticsサポートを追加 +- 依存関係の更新 + +### Client +- Feat: 投稿フォームで画像をプレビュー可能に +- Enhance: 投稿フォームの「迷惑になる可能性があります」のダイアログを表示する条件においてCWを考慮するように +- Enhance: アンテナ、リスト等の名前をカラム名のデフォルト値にするように `#13992` +- Enhance: クライアントエラー画面の多言語対応 +- Enhance: 開発者モードでメニューからファイルIDをコピー出来るように `#15441' +- Enhance: ノートに埋め込まれたメディアのコンテキストメニューから管理者用のファイル管理画面を開けるように ( #15440 ) +- Enhance: リアクションする際に確認ダイアログを表示できるように +- Enhance: コントロールパネルのユーザ検索で入力された情報をページ遷移で損なわないように `#15437` +- Enhance: CWの注釈で入力済みの文字数を表示 +- Enhance: ノート検索ページのデザイン調整 + (Cherry-picked from https://github.com/taiyme/misskey/pull/273) +- Fix: ノートページで、クリップ一覧が表示されないことがある問題を修正 +- Fix: コンディショナルロールを手動で割り当てできる導線を削除 `#13529` +- Fix: 埋め込みプレイヤーから外部ページに移動できない問題を修正 +- Fix: Play の再読込時に UI が以前の状態を引き継いでしまう問題を修正 `#14378` +- Fix: カスタム絵文字管理画面(beta)にてisSensitive/localOnlyの絞り込みが上手くいかない問題の修正 ( #15445 ) +- Fix: ユーザのサジェスト中に@を入力してもサジェスト結果が消えないように `#14385` +- Fix: CWの注釈が100文字を超えている場合、ノート投稿ボタンを非アクティブに +- Fix: テーマ選択で現在のテーマが初期表示されていない問題を修正 +- 翻訳の更新 + +### Server +- Enhance: 成り済まし対策として、ActivityPub照会された時にリモートのリダイレクトを拒否できるように (config.disallowExternalApRedirect) +- Fix: `following/invalidate`でフォロワーを解除しようとしているユーザーの情報を返すように +- Fix: オブジェクトストレージの設定でPrefixを設定していなかった場合nullまたは空文字になる問題を修正 +- Fix: HTTPプロキシとその除外設定を行った状態でカスタム絵文字の一括インポートをしたとき、除外設定が効かないのを修正( #8766 ) +- Fix: pgroongaでの検索時にはじめのキーワードのみが検索に使用される問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/886) +- Fix: メールアドレスの形式が正しくなければ以降の処理を行わないように +- Fix: `update-meta`でobjectStoragePrefixにS3_SAFEかつURL-safeでない文字列を使えないように +- Fix: クリップの説明欄を更新する際に空にできない問題を修正 +- Fix: フォロワーではないユーザーにリノートもしくは返信された場合にノートのDeleteアクティビティが送られていない問題を修正 + +## 2025.2.0 + +### General +- Fix: Docker のビルドに失敗する問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/883) + +### Client +- Fix: パスキーでパスワードレスログインが出来ない問題を修正 +- Fix: 一部環境でセンシティブなファイルを含むノートの非表示が効かない問題 +- Fix: データセーバー有効時にもユーザーページの「ファイル」タブで画像が読み込まれてしまう問題を修正 +- Fix: MFMの `sparkle` エフェクトが正しく表示されない問題を修正 +- Fix: ページのURLにスラッシュが含まれている場合にページが正しく表示されない問題を修正 +- Fix: デッキのプロファイルが新規作成できない問題を修正 +- Fix: セキュリティに関する修正 +- ローカライゼーションの更新 +- Playが実装されたため、ページ機能の「ソースを見る」は削除されました + +### Server +- Enhance: ページのURLに使用可能な文字を限定するように +- Fix: 個別お知らせページのmetaタグ出力の条件が間違っていたのを修正 + +## 2025.1.0 + +### Note +- [重要] ノート検索プロバイダの追加に伴い、configファイル(default.ymlなど)の構成が少し変わります. + - 新しい設定項目"fulltextSearch.provider"が追加されました. sqlLike, sqlPgroonga, meilisearchのいずれかを設定出来ます. + - すでにMeilisearchをお使いの場合、 **"fulltextSearch.provider"を"meilisearch"に設定する必要** があります. + - 詳細は #14730 および `.config/example.yml` または `.config/docker_example.yml`の'Fulltext search configuration'をご参照願います. +- 【開発者向け】従来の開発モードでHMRが機能しない問題が修正されたため、バックエンド・フロントエンド分離型の開発モードが削除されました。開発環境においてconfigの変更が必要となる可能性があります。 + +### General +- Feat: カスタム絵文字管理画面をリニューアル #10996 + * β版として公開のため、旧画面も引き続き利用可能です + +### Client +- Enhance: PC画面でチャンネルが複数列で表示されるように + (Cherry-picked from https://github.com/Otaku-Social/maniakey/pull/13) +- Enhance: 照会に失敗した場合、その理由を表示するように +- Enhance: ワードミュートで検知されたワードを表示できるように +- Enhance: リモートのノートのリンクをコピーできるように +- Enhance: 連合がホワイトリスト化・無効化されているサーバー向けのデザイン修正 +- Enhance: AiScriptのセーブデータを明示的に削除する関数`Mk:remove`を追加 +- Enhance: ノートの添付ファイルを一覧で遡れる「ファイル」タブを追加 + (Based on https://github.com/Otaku-Social/maniakey/pull/14) +- Enhance: AiScriptの拡張API関数において引数の型チェックをより厳格に +- Enhance: クエリパラメータでuiを一時的に変更できるように #15240 +- Enhance: リモート絵文字のインポート時に詳細を確認できるように #15336 +- Fix: 画面サイズが変わった際にナビゲーションバーが自動で折りたたまれない問題を修正 +- Fix: サーバー情報メニューに区切り線が不足していたのを修正 +- Fix: ノートがログインしているユーザーしか見れない場合にログインダイアログを閉じるとその後の動線がなくなる問題を修正 +- Fix: 公開範囲がホームのノートの埋め込みウィジェットが読み込まれない問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/803) +- Fix: 絵文字管理画面で一部の絵文字が表示されない問題を修正 +- Fix: プラグイン `register_note_view_interruptor` でノートのサーバー情報の書き換えができない問題を修正 +- Fix: Botプロテクションの設定変更時は実際に検証を通過しないと保存できないように( #15137 ) +- Fix: ノート検索が使用できない場合でもチャンネルのノート検索欄がでていた問題を修正 +- Fix: `Ui:C:select`で値の変更が画面に反映されない問題を修正 +- Fix: MiAuth認可画面で、認可処理に失敗した場合でもコールバックURLに遷移してしまう問題を修正 + (Cherry-picked from https://github.com/TeamNijimiss/misskey/commit/800359623e41a662551d774de15b0437b6849bb4) +- Fix: ノート作成画面でファイルの添付可能個数を超えてもノートボタンが押せていた問題を修正 +- Fix: 「アカウントを管理」画面で、ユーザー情報の取得に失敗したアカウント(削除されたアカウントなど)が表示されない問題を修正 +- Fix: MacOSでChrome系ブラウザを使用している場合に、Misskeyを閉じた際に他のタブのオーディオ機能と干渉する問題を修正 +- Fix: 言語データのキャッシュ状況によっては、埋め込みウィジェットが正しく起動しない問題を修正 +- Fix: 「削除して編集」でノートの引用を解除出来なかった問題を修正( #14476 ) +- Fix: RSSウィジェットが正しく表示されない問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/857) +- Fix: ワードミュートの保存失敗時にAPIエラーが握りつぶされる事があるのを修正 +- Fix: アンケートでリモートの絵文字が正しく描画できない問題の修正 + (Cherry-picked from https://github.com/yojo-art/cherrypick/pull/153) +- Fix: 非ログイン時のサーバー概要画面のメニューボタンが押せないことがあるのを修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/656) +- Fix: URLにはじめから`#pswp`が含まれている場合に画像ビューワーがブラウザの戻るボタンで閉じられない問題を修正 +- Fix: ロール作成画面で設定できるアイコンデコレーションの最大取付個数を16に制限 +- Fix: Firefox Nightlyなどでアイコンが読み込めない問題を修正 + +### Server +- Enhance: pg_bigmが利用できるよう、ノートの検索をILIKE演算子でなくLIKE演算子でLOWER()をかけたテキストに対して行うように +- Enhance: ノート検索の選択肢としてpgroongaに対応 ( #14730 ) +- Enhance: チャート更新時にDBに同時接続しないように + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/830) +- Enhance: config(default.yml)からSQLログ全文を出力するか否かを設定可能に ( #15266 ) +- Fix: ユーザーのプロフィール画面をアドレス入力などで直接表示した際に概要タブの描画に失敗する問題の修正( #15032 ) +- Fix: 起動前の疎通チェックが機能しなくなっていた問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/737) +- Fix: ノートの閲覧にログイン必須にしてもFeedでノートが表示されてしまう問題を修正 +- Fix: 絵文字の連合でライセンス欄を相互にやり取りするように ( #10859, #14109 ) +- Fix: ロックダウンされた期間指定のノートがStreaming経由でLTLに出現するのを修正 ( #15200 ) +- Fix: disableClustering設定時の初期化ロジックを調整( #15223 ) +- Fix: URLとURIが異なるエンティティの照会に失敗する問題を修正( #15039 ) +- Fix: ActivityPubリクエストかどうかの判定が正しくない問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/869) +- Fix: `/api/pages/update`にて`name`を指定せずにリクエストするとエラーが発生する問題を修正 +- Fix: AIセンシティブ判定が arm64 環境で動作しない問題を修正 +- Fix: 非Misskey系のソフトウェアからHTML``タグを含むノートを受信した場合、MFMの読み仮名(ルビ)文法に変換して表示 +- Fix: 連合OFFで投稿されたノートに対する冗長な処理を抑止 ( #15018 ) +- Fix: `/api.json`のレスポンスが2回目のリクエスト以降おかしくなる問題を修正 + +### Misskey.js +- Feat: allow setting `binaryType` of WebSocket connection + +## 2024.11.0 + +### Note +- Node.js 20.xは非推奨になりました。Node.js 22.x (LTS)の利用を推奨します。 + - なお、Node.js 23.xは対応していません。 +- DockerのNode.jsが22.11.0に更新されました + +### General +- Feat: コンテンツの表示にログインを必須にできるように +- Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように +- Enhance: 依存関係の更新 +- Enhance: l10nの更新 +- Fix: お知らせ作成時に画像URL入力欄を空欄に変更できないのを修正 ( #14976 ) + +### Client +- Enhance: Bull DashboardでRelationship Queueの状態も確認できるように + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/751) +- Enhance: ドライブでソートができるように +- Enhance: アイコンデコレーション管理画面の改善 +- Enhance: 「単なるラッキー」の取得条件を変更 +- Enhance: 投稿フォームでEscキーを押したときIME入力中ならフォームを閉じないように( #10866 ) +- Enhance: MiAuth, OAuthの認可画面の改善 + - どのアカウントで認証しようとしているのかがわかるように + - 認証するアカウントを切り替えられるように +- Enhance: Self-XSS防止用の警告を追加 +- Enhance: カタルーニャ語 (ca-ES) に対応 +- Enhance: 個別お知らせページではMetaタグを出力するように +- Enhance: ノート詳細画面にロールのバッジを表示 +- Enhance: 過去に送信したフォローリクエストを確認できるように + (Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/663) +- Enhance: サイドバーを簡単に展開・折りたたみできるように ( #14981 ) +- Enhance: リノートメニューに「リノートの詳細」を追加 +- Enhance: 非ログイン状態でMisskeyを開いた際のパフォーマンスを向上 +- Fix: 通知の範囲指定の設定項目が必要ない通知設定でも範囲指定の設定がでている問題を修正 +- Fix: Turnstileが失敗・期限切れした際にも成功扱いとなってしまう問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/768) +- Fix: デッキのタイムラインカラムで「センシティブなファイルを含むノートを表示」設定が使用できなかった問題を修正 +- Fix: Encode RSS urls with escape sequences before fetching allowing query parameters to be used +- Fix: リンク切れを修正 +- Fix: ノート投稿ボタンにホバー時のスタイルが適用されていないのを修正 + (Cherry-picked from https://github.com/taiyme/misskey/pull/305) +- Fix: メールアドレス登録有効化時の「完了」ダイアログボックスの表示条件を修正 +- Fix: 画面幅が狭い環境でデザインが崩れる問題を修正 + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/815) +- Fix: TypeScriptの型チェック対象ファイルを限定してビルドを高速化するように + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/725) + +### Server +- Enhance: DockerのNode.jsを22.11.0に更新 +- Enhance: 起動前の疎通チェックで、DBとメイン以外のRedisの疎通確認も行うように + (Based on https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/588) + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/715) +- Enhance: リモートユーザーの照会をオリジナルにリダイレクトするように +- Fix: sharedInboxが無いActorに紐づくリモートユーザーを照会できない +- Fix: Aproving request from GtS appears with some delay +- Fix: フォロワーへのメッセージの絵文字をemojisに含めるように +- Fix: Nested proxy requestsを検出した際にブロックするように + [ghsa-gq5q-c77c-v236](https://github.com/misskey-dev/misskey/security/advisories/ghsa-gq5q-c77c-v236) +- Fix: 招待コードの発行可能な残り数算出に使用すべきロールポリシーの値が違う問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/706) +- Fix: 連合への配信時に、acctの大小文字が区別されてしまい正しくメンションが処理されないことがある問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/711) +- Fix: ローカルユーザーへのメンションを含むノートが連合される際に正しいURLに変換されないことがある問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/712) +- Fix: FTT無効時にユーザーリストタイムラインが使用できない問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/709) +- Fix: User Webhookテスト機能のMock Payloadを修正 +- Fix: アカウント削除のモデレーションログが動作していないのを修正 (#14996) +- Fix: リノートミュートが新規投稿通知に対して作用していなかった問題を修正 +- Fix: Inboxの処理で生じるエラーを誤ってActivityとして処理することがある問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/730) +- Fix: セキュリティに関する修正 + +### Misskey.js +- Fix: Stream初期化時、別途WebSocketを指定する場合の型定義を修正 + +## 2024.10.1 + +### Note +- スパム対策として、モデレータ権限を持つユーザのアクティビティが7日以上確認できない場合は自動的に招待制へと切り替え(コントロールパネル -> モデレーション -> "誰でも新規登録できるようにする"をオフに変更)るようになりました。 ( #13437 ) + - 切り替わった際はモデレーターへお知らせとして通知されます。登録をオープンな状態で継続したい場合は、コントロールパネルから再度設定を行ってください。 + +### General +- Feat: ユーザーの名前に禁止ワードを設定できるように + +### Client +- Enhance: タイムライン表示時のパフォーマンスを向上 +- Enhance: アーカイブした個人宛のお知らせを表示・編集できるように +- Enhance: l10nの更新 +- Fix: メールアドレス不要でCaptchaが有効な場合にアカウント登録完了後自動でのログインに失敗する問題を修正 + +### Server +- Feat: モデレータ権限を持つユーザが全員7日間活動しなかった場合は自動的に招待制へと切り替えるように ( #13437 ) +- Enhance: 個人宛のお知らせは「わかった」を押すと自動的にアーカイブされるように +- Fix: `admin/emoji/update`エンドポイントのidのみ指定した時不正なエラーが発生するバグを修正 +- Fix: RBT有効時、リノートのリアクションが反映されない問題を修正 +- Fix: キューのエラーログを簡略化するように + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/649) + +## 2024.10.0 + +### Note +- セキュリティ向上のため、サーバー初期設定時に使用する初期パスワードを設定できるようになりました。今後Misskeyサーバーを新たに設置する際には、初回の起動前にコンフィグファイルの`setupPassword`をコメントアウトし、初期パスワードを設定することをおすすめします。(すでに初期設定を完了しているサーバーについては、この変更に伴い対応する必要はありません) + - ホスティングサービスを運営している場合は、コンフィグファイルを構築する際に`setupPassword`をランダムな値に設定し、ユーザーに通知するようにシステムを更新することをおすすめします。 + - なお、初期パスワードが設定されていない場合でも初期設定を行うことが可能です(UI上で初期パスワードの入力欄を空欄にすると続行できます)。 +- ユーザーデータを読み込む際の型が一部変更されました。 + - `twoFactorEnabled`, `usePasswordLessLogin`, `securityKeys`: 自分とモデレーター以外のユーザーからは取得できなくなりました + +### General +- Feat: サーバー初期設定時に初期パスワードを設定できるように +- Feat: 通報にモデレーションノートを残せるように +- Feat: 通報の解決種別を設定できるように +- Enhance: 通報の解決と転送を個別に行えるように +- Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました +- Enhance: 依存関係の更新 +- Enhance: l10nの更新 +- Enhance: Playの「人気」タブで10件以上表示可能に #14399 +- Fix: 連合のホワイトリストが正常に登録されない問題を修正 + +### Client +- Enhance: デザインの調整 +- Enhance: ログイン画面の認証フローを改善 +- Fix: クライアント上での時間ベースの実績獲得動作が実績獲得後も発動していた問題を修正 + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/657) + +### Server +- Enhance: セキュリティ向上のため、ログイン時にメール通知を行うように +- Enhance: 自分とモデレーター以外のユーザーから二要素認証関連のデータが取得できないように +- Enhance: 通報および通報解決時に送出されるSystemWebhookにユーザ情報を含めるように ( #14697 ) +- Fix: `admin/abuse-user-reports`エンドポイントのスキーマが間違っていた問題を修正 + ## 2024.9.0 ### General -- Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445) -- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように - -### Client - Feat: ノート単体・ユーザーのノート・クリップのノートの埋め込み機能 - 埋め込みコードやウェブサイトへの実装方法の詳細は https://misskey-hub.net/docs/for-users/features/embed/ をご覧ください +- Feat: パスキーでログインボタンを実装 (#14574) +- Feat: フォローされた際のメッセージを設定できるように +- Feat: 連合をホワイトリスト制にできるように +- Feat: UserWebhookとSystemWebhookのテスト送信機能を追加 (#14445) +- Feat: モデレーターはユーザーにかかわらずファイルが添付されているノートを検索できるように + (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/680) +- Feat: データエクスポートが完了した際に通知を発行するように +- Enhance: ユーザーによるコンテンツインポートの可否をロールポリシーで制御できるように +- Enhance: 依存関係の更新 +- Enhance: l10nの更新 + +### Client - Enhance: サイズ制限を超過するファイルをアップロードしようとした際にエラーを出すように - Enhance: アイコンデコレーション管理画面にプレビューを追加 - Enhance: コントロールパネル内のファイル一覧でセンシティブなファイルを区別しやすく - Enhance: ScratchpadにUIインスペクターを追加 +- Enhance: Play編集画面の項目の並びを少しリデザイン +- Enhance: 各種メニューをドロワー表示するかどうか設定可能に +- Enhance: AiScriptのMk:C:containerのオプションに`borderStyle`と`borderRadius`を追加 +- Enhance: CWでも絵文字をクリックしてメニューを表示できるように - Fix: サーバーメトリクスが2つ以上あるとリロード直後の表示がおかしくなる問題を修正 +- Fix: コントロールパネル内のAp requests内のチャートの表示がおかしかった問題を修正 - Fix: 月の違う同じ日はセパレータが表示されないのを修正 +- Fix: タッチ画面でレンジスライダーを操作するとツールチップが複数表示される問題を修正 + (Cherry-picked from https://github.com/taiyme/misskey/pull/265) - Fix: 縦横比が極端なカスタム絵文字を表示する際にレイアウトが崩れる箇所があるのを修正 (Cherry-picked from https://github.com/MisskeyIO/misskey/pull/725) - Fix: 設定変更時のリロード確認ダイアログが複数個表示されることがある問題を修正 - Fix: ファイルの詳細ページのファイルの説明で改行が正しく表示されない問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/bde6bb0bd2e8b0d027e724d2acdb8ae0585a8110) -- Fix: Unicode絵文字とカスタム絵文字の名前が重複したときにカスタム絵文字がオートコンプリートにサジェストされない問題を修正 +- Fix: 一部画面のページネーションが動作しにくくなっていたのを修正 ( #12766 , #11449 ) ### Server -- Feat: Misskey® Reactions Buffering Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に +- Feat: Misskey® Reactions Boost Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に - Fix: アンテナの書き込み時にキーワードが与えられなかった場合のエラーをApiErrorとして投げるように - この変更により、公式フロントエンドでは入力の不備が内部エラーとして報告される代わりに一般的なエラーダイアログで報告されます - Fix: ファイルがサイズの制限を超えてアップロードされた際にエラーを返さなかった問題を修正 - Fix: 外部ページを解析する際に、ページに紐づけられた関連リソースも読み込まれてしまう問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/26e0412fbb91447c37e8fb06ffb0487346063bb8) -- Fix: `Retry-After`ヘッダーが送信されなかった問題を修正 +- Fix: Continue importing from file if single emoji import fails +- Fix: `Retry-After`ヘッダーが送信されなかった問題を修正 (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/8a982c61c01909e7540ff1be9f019df07c3f0624) +- Fix: サーバーサイドのDOM解析完了時にリソースを開放するように + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/634) +- Fix: ``を追って照会するのはOKレスポンスが返却された場合のみに + (Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/633) +- Fix: メールにスタイルが適用されていなかった問題を修正 ## 2024.8.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72c84c2f18..a3aedfa9eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -64,9 +64,29 @@ Thank you for your PR! Before creating a PR, please check the following: Thanks for your cooperation 🤗 +### Additional things for ActivityPub payload changes +*This section is specific to misskey-dev implementation. Other fork or implementation may take different way. A significant difference is that non-"misskey-dev" extension is not described in the misskey-hub's document.* + +If PR includes changes to ActivityPub payload, please reflect it in [misskey-hub's document](https://github.com/misskey-dev/misskey-hub-next/blob/master/content/ns.md) by sending PR. + +The name of purporsed extension property (referred as "extended property" in later) to ActivityPub shall be prefixed by `_misskey_`. (i.e. `_misskey_quote`) + +The extended property in `packages/backend/src/core/activitypub/type.ts` **must** be declared as optional because ActivityPub payloads that comes from older Misskey or other implementation may not contain it. + +The extended property must be included in the context definition. Context is defined in `packages/backend/src/core/activitypub/misc/contexts.ts`. +The key shall be same as the name of extended property, and the value shall be same as "short IRI". + +"Short IRI" is defined in misskey-hub's document, but usually takes form of `misskey:`. (i.e. `misskey:_misskey_quote`) + +One should not add property that has defined before by other implementation, or add custom variant value to "well-known" property. + ## Reviewers guide Be willing to comment on the good points and not just the things you want fixed 💯 +読んでおくといいやつ +- https://blog.lacolaco.net/posts/1e2cf439b3c2/ +- https://konifar-zatsu.hatenadiary.jp/entry/2024/11/05/192421 + ### Review perspective - Scope - Are the goals of the PR clear? @@ -81,6 +101,22 @@ Be willing to comment on the good points and not just the things you want fixed - Are there any omissions or gaps? - Does it check for anomalies? +## Security Advisory +### For reporter +Thank you for your reporting! + +If you can also create a patch to fix the vulnerability, please create a PR on the private fork. + +> [!note] +> There is a GitHub bug that prevents merging if a PR not following the develop branch of upstream, so please keep follow the develop branch. + +### For misskey-dev member +修正PRがdevelopに追従されていないとマージできないので、マージできなかったら + +> Could you merge or rebase onto upstream develop branch? + +などと伝える。 + ## Deploy The `/deploy` command by issue comment can be used to deploy the contents of a PR to the preview environment. ``` @@ -116,7 +152,8 @@ You can improve our translations with your Crowdin account. Your changes in Crowdin are automatically submitted as a PR (with the title "New Crowdin translations") to the repository. The owner [@syuilo](https://github.com/syuilo) merges the PR into the develop branch before the next release. -If your language is not listed in Crowdin, please open an issue. +If your language is not listed in Crowdin, please open an issue. We will add it to Crowdin. +For newly added languages, once the translation progress per language exceeds 70%, it will be officially introduced into Misskey and made available to users. ![Crowdin](https://d322cqt584bo4o.cloudfront.net/misskey/localized.svg) @@ -160,52 +197,51 @@ pnpm dev command. - Server-side source files and automatically builds them if they are modified. Automatically start the server process(es). -- Vite HMR (just the `vite` command) is available. The behavior may be different from production. - Service Worker is watched by esbuild. -- The front end can be viewed by accessing `http://localhost:5173`. -- The backend listens on the port configured with `port` in .config/default.yml. -If you have not changed it from the default, it will be "http://localhost:3000". -If "port" in .config/default.yml is set to something other than 3000, you need to change the proxy settings in packages/frontend/vite.config.local-dev.ts. - -### `MK_DEV_PREFER=backend pnpm dev` -pnpm dev has another mode with `MK_DEV_PREFER=backend`. - -``` -MK_DEV_PREFER=backend pnpm dev -``` - -- This mode is closer to the production environment than the default mode. -- Vite runs behind the backend (the backend will proxy Vite at /vite). +- Vite HMR (just the `vite` command) is available. The behavior may be different from production. +- Vite runs behind the backend (the backend will proxy Vite at /vite and /embed_vite except for websocket used for HMR). - You can see Misskey by accessing `http://localhost:3000` (Replace `3000` with the port configured with `port` in .config/default.yml). -- To change the port of Vite, specify with `VITE_PORT` environment variable. -- HMR may not work in some environments such as Windows. ## Testing -- Test codes are located in [`/packages/backend/test`](/packages/backend/test). - -### Run test -Create a config file. +You can run non-backend tests by executing following commands: +```sh +pnpm --filter frontend test +pnpm --filter misskey-js test ``` + +Backend tests require manual preparation of servers. See the next section for more on this. + +### Backend +There are three types of test codes for the backend: +- Unit tests: [`/packages/backend/test/unit`](/packages/backend/test/unit) +- Single-server E2E tests: [`/packages/backend/test/e2e`](/packages/backend/test/e2e) +- Multiple-server E2E tests: [`/packages/backend/test-federation`](/packages/backend/test-federation) + +#### Running Unit Tests or Single-server E2E Tests +1. Create a config file: +```sh cp .github/misskey/test.yml .config/ ``` -Prepare DB/Redis for testing. -``` + +2. Start DB and Redis servers for testing: +```sh docker compose -f packages/backend/test/compose.yml up ``` -Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`. +Instead, you can prepare an empty (data can be erased) DB and edit `.config/test.yml` appropriately. -Run all test. +3. Run all tests: +```sh +pnpm --filter backend test # unit tests +pnpm --filter backend test:e2e # single-server E2E tests ``` -pnpm test +If you want to run a specific test, run as a following command: +```sh +pnpm --filter backend test -- packages/backend/test/unit/activitypub.ts +pnpm --filter backend test:e2e -- packages/backend/test/e2e/nodeinfo.ts ``` -#### Run specify test -``` -pnpm jest -- foo.ts -``` - -### e2e tests -TODO +#### Running Multiple-server E2E Tests +See [`/packages/backend/test-federation/README.md`](/packages/backend/test-federation/README.md). ## Environment Variable @@ -237,7 +273,6 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド query?: Record; loginRequired?: boolean; hash?: string; - globalCacheKey?: string; children?: RouteDef[]; } ``` @@ -440,6 +475,11 @@ describe('test', () => { コード上でMisskeyのドメイン固有の概念には`Mi`をprefixすることで、他のドメインの同様の概念と区別できるほか、名前の衝突を防ぐ。 ただし、文脈上Misskeyのものを指すことが明らかであり、名前の衝突の恐れがない場合は、一時的なローカル変数に限って`Mi`を省略してもよい。 +### Misskey.jsの型生成 +```bash +pnpm build-misskey-js-with-types +``` + ### How to resolve conflictions occurred at pnpm-lock.yaml? Just execute `pnpm` to fix it. @@ -572,3 +612,24 @@ marginはそのコンポーネントを使う側が設定する ### indexというファイル名を使うな ESMではディレクトリインポートは廃止されているのと、ディレクトリインポートせずともファイル名が index だと何故か一部のライブラリ?でディレクトリインポートだと見做されてエラーになる + +## CSS Recipe + +### Lighten CSS vars + +``` css +color: hsl(from var(--MI_THEME-accent) h s calc(l + 10)); +``` + +### Darken CSS vars + +``` css +color: hsl(from var(--MI_THEME-accent) h s calc(l - 10)); +``` + +### Add alpha to CSS vars + +``` css +color: color(from var(--MI_THEME-accent) srgb r g b / 0.5); +``` + diff --git a/COPYING b/COPYING index 6a5f3ca1d5..7635bfc913 100644 --- a/COPYING +++ b/COPYING @@ -1,5 +1,5 @@ Unless otherwise stated this repository is -Copyright © 2014-2024 syuilo and contributors +Copyright © 2014-2025 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 e21b2a31fc..9d5596f1f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.4 -ARG NODE_VERSION=20.16.0-bullseye +ARG NODE_VERSION=22.11.0-bookworm # build assets & compile TypeScript @@ -14,8 +14,6 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ && apt-get install -yqq --no-install-recommends \ build-essential -RUN corepack enable - WORKDIR /misskey COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] @@ -31,6 +29,8 @@ COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bu ARG NODE_ENV=production +RUN node -e "console.log(JSON.parse(require('node:fs').readFileSync('./package.json')).packageManager)" | xargs npm install -g + RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output @@ -48,8 +48,6 @@ RUN apt-get update \ && apt-get install -yqq --no-install-recommends \ build-essential -RUN corepack enable - WORKDIR /misskey COPY --link ["pnpm-lock.yaml", "pnpm-workspace.yaml", "package.json", "./"] @@ -61,6 +59,8 @@ COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bu ARG NODE_ENV=production +RUN node -e "console.log(JSON.parse(require('node:fs').readFileSync('./package.json')).packageManager)" | xargs npm install -g + RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \ pnpm i --frozen-lockfile --aggregate-output @@ -73,7 +73,6 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ ffmpeg tini curl libjemalloc-dev libjemalloc2 \ && ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so \ - && corepack enable \ && groupadd -g "${GID}" misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ && find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ @@ -81,13 +80,13 @@ RUN apt-get update \ && apt-get clean \ && rm -rf /var/lib/apt/lists +# add package.json to add pnpm +COPY ./package.json ./package.json +RUN node -e "console.log(JSON.parse(require('node:fs').readFileSync('./package.json')).packageManager)" | xargs npm install -g + USER misskey WORKDIR /misskey -# add package.json to add pnpm -COPY --chown=misskey:misskey ./package.json ./package.json -RUN corepack install - COPY --chown=misskey:misskey --from=target-builder /misskey/node_modules ./node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/node_modules ./packages/backend/node_modules COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules diff --git a/SECURITY.md b/SECURITY.md index fc5dec5de4..19f5f2eea2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,3 +6,15 @@ This will allow us to assess the risk, and make a fix available before we add a bug report to the GitHub repository. Thanks for helping make Misskey safe for everyone. + +> [!note] +> CNA [requires](https://www.cve.org/ResourcesSupport/AllResources/CNARules#section_5-2_Description) that CVEs include a description in English for inclusion in the CVE Catalog. +> +> When creating a security advisory, all content must be written in English (it is acceptable to include a non-English description along with the English one). + +## When create a patch + +If you can also create a patch to fix the vulnerability, please create a PR on the private fork. + +> [!note] +> There is a GitHub bug that prevents merging if a PR not following the develop branch of upstream, so please keep follow the develop branch. diff --git a/assets/about/drive.png b/assets/about/drive.png deleted file mode 100644 index 16037aae39..0000000000 Binary files a/assets/about/drive.png and /dev/null differ diff --git a/assets/about/post.png b/assets/about/post.png deleted file mode 100644 index 3c55f66c56..0000000000 Binary files a/assets/about/post.png and /dev/null differ diff --git a/assets/about/reaction.png b/assets/about/reaction.png deleted file mode 100644 index e4e7e06bc0..0000000000 Binary files a/assets/about/reaction.png and /dev/null differ diff --git a/assets/about/ui.png b/assets/about/ui.png deleted file mode 100644 index 0601837f4c..0000000000 Binary files a/assets/about/ui.png and /dev/null differ diff --git a/assets/ss/explore.jpg b/assets/ss/explore.jpg deleted file mode 100644 index bf81d794c3..0000000000 Binary files a/assets/ss/explore.jpg and /dev/null differ diff --git a/assets/ss/user.jpg b/assets/ss/user.jpg deleted file mode 100644 index 3ec595c199..0000000000 Binary files a/assets/ss/user.jpg and /dev/null differ diff --git a/cypress/e2e/basic.cy.ts b/cypress/e2e/basic.cy.ts index d2525e0a7d..6471f96504 100644 --- a/cypress/e2e/basic.cy.ts +++ b/cypress/e2e/basic.cy.ts @@ -23,6 +23,7 @@ describe('Before setup instance', () => { cy.intercept('POST', '/api/admin/accounts/create').as('signup'); + cy.get('[data-cy-admin-initial-password] input').type('example_password_please_change_this_or_you_will_get_hacked'); cy.get('[data-cy-admin-username] input').type('admin'); cy.get('[data-cy-admin-password] input').type('admin1234'); cy.get('[data-cy-admin-ok]').click(); @@ -119,11 +120,16 @@ describe('After user signup', () => { it('signin', () => { cy.visitHome(); - cy.intercept('POST', '/api/signin').as('signin'); + cy.intercept('POST', '/api/signin-flow').as('signin'); cy.get('[data-cy-signin]').click(); - cy.get('[data-cy-signin-username] input').type('alice'); - // Enterキーでサインインできるかの確認も兼ねる + + cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); + // Enterキーで続行できるかの確認も兼ねる + cy.get('[data-cy-signin-username] input').type('alice{enter}'); + + cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 }); + // Enterキーで続行できるかの確認も兼ねる cy.get('[data-cy-signin-password] input').type('alice1234{enter}'); cy.wait('@signin'); @@ -138,8 +144,9 @@ describe('After user signup', () => { cy.visitHome(); cy.get('[data-cy-signin]').click(); - cy.get('[data-cy-signin-username] input').type('alice'); - cy.get('[data-cy-signin-password] input').type('alice1234{enter}'); + + cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); + cy.get('[data-cy-signin-username] input').type('alice{enter}'); // TODO: cypressにブラウザの言語指定できる機能が実装され次第英語のみテストするようにする cy.contains(/アカウントが凍結されています|This account has been suspended due to/gi); @@ -226,7 +233,7 @@ describe('After user setup', () => { cy.get('[data-cy-post-form-text]').type('Hello, Misskey!'); cy.get('[data-cy-open-post-form-submit]').click(); - cy.contains('Hello, Misskey!'); + cy.contains('Hello, Misskey!', { timeout: 15000 }); }); it('open note form with hotkey', () => { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 281f2e6ccd..197ff963ac 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -48,16 +48,19 @@ Cypress.Commands.add('registerUser', (username, password, isAdmin = false) => { cy.request('POST', route, { username: username, password: password, + ...(isAdmin ? { setupPassword: 'example_password_please_change_this_or_you_will_get_hacked' } : {}), }).its('body').as(username); }); Cypress.Commands.add('login', (username, password) => { cy.visitHome(); - cy.intercept('POST', '/api/signin').as('signin'); + cy.intercept('POST', '/api/signin-flow').as('signin'); cy.get('[data-cy-signin]').click(); - cy.get('[data-cy-signin-username] input').type(username); + cy.get('[data-cy-signin-page-input]').should('be.visible', { timeout: 1000 }); + cy.get('[data-cy-signin-username] input').type(`${username}{enter}`); + cy.get('[data-cy-signin-page-password]').should('be.visible', { timeout: 10000 }); cy.get('[data-cy-signin-password] input').type(`${password}{enter}`); cy.wait('@signin').as('signedIn'); diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/idea/MkAbuseReport.stories.impl.ts similarity index 88% rename from packages/frontend/src/components/MkAbuseReport.stories.impl.ts rename to idea/MkAbuseReport.stories.impl.ts index cf09c96fd4..717bceb23d 100644 --- a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts +++ b/idea/MkAbuseReport.stories.impl.ts @@ -7,8 +7,8 @@ import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; -import { abuseUserReport } from '../../.storybook/fakes.js'; -import { commonHandlers } from '../../.storybook/mocks.js'; +import { abuseUserReport } from '../packages/frontend/.storybook/fakes.js'; +import { commonHandlers } from '../packages/frontend/.storybook/mocks.js'; import MkAbuseReport from './MkAbuseReport.vue'; export const Default = { render(args) { diff --git a/idea/README.md b/idea/README.md new file mode 100644 index 0000000000..f64d16800a --- /dev/null +++ b/idea/README.md @@ -0,0 +1 @@ +使われなくなったけど消すのは勿体ない(将来使えるかもしれない)コードを入れておくとこ diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml index b6bfbfa682..a792a6804d 100644 --- a/locales/ar-SA.yml +++ b/locales/ar-SA.yml @@ -251,7 +251,6 @@ removeAreYouSure: "متأكد من أنك تريد حذف {x}؟" deleteAreYouSure: "متأكد من أنك تريد حذف {x}؟" resetAreYouSure: "هل تريد إعادة التعيين؟" saved: "حُفظ" -messaging: "المحادثة" upload: "ارفع" keepOriginalUploading: "ابق الصورة الأصلية" keepOriginalUploadingDescription: "يحفظ الصور المرفوعة على حالتها الأصلية، وان عطّل ستولد نسخة مخصصة من الصورة." @@ -264,7 +263,6 @@ uploadFromUrlMayTakeTime: "سيستغرق بعض الوقت لاتمام الر explore: "استكشاف" messageRead: "مقروءة" noMoreHistory: "لا يوجد المزيد من التاريخ" -startMessaging: "ابدأ محادثة" nUsersRead: "قرأه {n}" agreeTo: "اوافق على {0}" agree: "أقبل" @@ -343,7 +341,6 @@ enableLocalTimeline: "تفعيل الخيط المحلي" enableGlobalTimeline: "تفعيل الخيط الزمني الشامل" disablingTimelinesInfo: "سيتمكن المديرون والمشرفون من الوصول إلى كل الخيوط الزمنية حتى وإن لم تفعّل." registration: "إنشاء حساب" -enableRegistration: "تفعيل إنشاء الحسابات الجديدة" invite: "دعوة" driveCapacityPerLocalAccount: "حصة التخزين لكل مستخدم محلي" driveCapacityPerRemoteAccount: "حصة التخزين لكل مستخدم بعيد" @@ -437,8 +434,6 @@ retype: "أعد الكتابة" noteOf: "ملاحظات {user}" quoteAttached: "اِقتُبسَ" quoteQuestion: "أتريد تضمينها كاقتباس" -noMessagesYet: "ليس هناك رسائل بعد" -newMessageExists: "لقد تلقيت رسالة جديدة" onlyOneFileCanBeAttached: "يمكنك إرفاق ملف واحد بالرسالة" signinRequired: "رجاءً لِج" invitations: "دعوة" @@ -626,10 +621,7 @@ abuseReported: "أُرسل البلاغ، شكرًا لك" reporter: "المُبلّغ" reporteeOrigin: "أصل البلاغ" reporterOrigin: "أصل المُبلّغ" -forwardReport: "وجّه البلاغ إلى المثيل البعيد" -forwardReportIsAnonymous: "في المثيل البعيد سيظهر المبلّغ كحساب مجهول." send: "أرسل" -abuseMarkAsResolved: "علّم البلاغ كمحلول" openInNewTab: "افتح في لسان جديد" defaultNavigationBehaviour: "سلوك الملاحة الافتراضي" editTheseSettingsMayBreakAccount: "تعديل هذه الإعدادات قد يسبب عطبًا لحسابك" @@ -1016,6 +1008,14 @@ sourceCode: "الشفرة المصدرية" flip: "اقلب" lastNDays: "آخر {n} أيام" surrender: "ألغِ" +postForm: "أنشئ ملاحظة" +information: "عن" +_chat: + invitations: "دعوة" + noHistory: "السجل فارغ" + members: "الأعضاء" + home: "الرئيسي" + send: "أرسل" _delivery: stop: "مُعلّق" _initialAccountSetting: @@ -1255,7 +1255,6 @@ _theme: buttonBg: "خلفية الأزرار" buttonHoverBg: "خلفية الأزرار (عند التمرير فوقها)" inputBorder: "حواف حقل الإدخال" - listItemHoverBg: "خلفية عناصر القائمة (عند التمرير فوقها)" driveFolderBg: "خلفية مجلد قرص التخزين" messageBg: "خلفية المحادثة" _sfx: @@ -1316,6 +1315,7 @@ _permissions: "read:gallery": "اعرض المعرض" "write:gallery": "عدّل المعرض" "read:gallery-likes": "يعرض ما أعجبك من مشاركات المعرض" + "write:chat": "اكتب أو احذف رسائل محادثة" _auth: shareAccess: "أتريد التفويض لـ \"{name}\" بالوصول لحسابك؟" shareAccessAsk: "هل تخول لهذا التطبيق الوصول لحسابك؟" @@ -1465,9 +1465,6 @@ _pages: newPage: "أنشئ صفحة جديدة" editPage: "عدّل الصفحة" readPage: "نُشّط عرض المصدر" - created: "نجح إنشاء الصفحة" - updated: "نجح تعديل الصفحة" - deleted: "نجح حذف الصفحة" pageSetting: "إعدادات الصفحة" nameAlreadyExists: "رابط الصفحة موجود مسبقًا" invalidNameTitle: "رابط الصفحة ليس صالحًا" @@ -1533,6 +1530,7 @@ _notification: reaction: "التفاعل" receiveFollowRequest: "طلبات المتابعة" followRequestAccepted: "طلبات المتابعة المقبولة" + login: "لِج" app: "إشعارات التطبيقات المرتبطة" _actions: followBack: "تابعك بالمثل" @@ -1588,3 +1586,10 @@ _reversi: _offlineScreen: title: "غير متصل - يتعذر الاتصال بالخادم" header: "يتعذر الاتصال بالخادم" +_remoteLookupErrors: + _noSuchObject: + title: "غير موجود" +_search: + searchScopeAll: "الكل" + searchScopeLocal: "المحلي" + searchScopeUser: "مستخدم محدد" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml index 6fb51ea5d8..4a55c91006 100644 --- a/locales/bn-BD.yml +++ b/locales/bn-BD.yml @@ -252,7 +252,6 @@ removeAreYouSure: "আপনি কি \"{x}\" সরানোর ব্যা deleteAreYouSure: "আপনি কি \"{x}\" সরানোর ব্যাপারে নিশ্চিত?" resetAreYouSure: "রিসেট করার ব্যাপারে নিশ্চিত?" saved: "সংরক্ষিত হয়েছে" -messaging: "চ্যাট" upload: "আপলোড" keepOriginalUploading: "আসল ছবি রাখুন" keepOriginalUploadingDescription: "ছবিটি আপলোড করার সময় আসল সংস্করণটি রাখুন। অপশনটি বন্ধ থাকলে, আপলোডের সময় ওয়েব প্রকাশনার জন্য ছবি ব্রাউজারে তৈরি করা হবে।" @@ -265,7 +264,6 @@ uploadFromUrlMayTakeTime: "URL হতে আপলোড হতে কিছু explore: "ঘুরে দেখুন" messageRead: "পড়া" noMoreHistory: "আর কোন ইতিহাস নেই" -startMessaging: "চ্যাট শুরু করুন" nUsersRead: "{n} জন পড়েছেন" agreeTo: "{0} এর প্রতি আমি সম্মত" start: "শুরু করুন" @@ -339,7 +337,6 @@ enableLocalTimeline: "স্থানীয় টাইমলাইন চাল enableGlobalTimeline: "গ্লোবাল টাইমলাইন চালু করুন" disablingTimelinesInfo: "আপনি এই টাইমলাইনগুলি বন্ধ করলেও প্রশাসক এবং মডারেটররা এই টাইমলাইনগুলি ব্যাবহার করতে পারবে" registration: "নিবন্ধন" -enableRegistration: "নতুন ব্যাবহারকারী নিবন্ধন চালু করুন" invite: "আমন্ত্রণ" driveCapacityPerLocalAccount: "প্রত্যেক স্থানীয় ব্যাবহারকারীর জন্য ড্রাইভের জায়গা" driveCapacityPerRemoteAccount: "প্রত্যেক রিমোট ব্যাবহারকারীর জন্য ড্রাইভের জায়গা" @@ -428,8 +425,6 @@ retype: "পুনঃ প্রবেশ" noteOf: "{user} এর নোট" quoteAttached: "উদ্ধৃত" quoteQuestion: "উদ্ধৃতি হিসাবে সংযুক্ত করবেন?" -noMessagesYet: "কোন মেসেজ নেই" -newMessageExists: "নতুন মেসেজ পেয়েছেন" onlyOneFileCanBeAttached: "আপনি মেসেজের সাথে সর্বোচ্চ একটি ফাইল যুক্ত করতে পারবেন" signinRequired: "দয়া করে লগ ইন করুন" invitations: "আমন্ত্রণ" @@ -451,7 +446,6 @@ or: "অথবা" language: "ভাষা" uiLanguage: "UI এর ভাষা" aboutX: "{x} সম্পর্কে" -disableDrawer: "ড্রয়ার মেনু প্রদর্শন করবেন না" noHistory: "কোনো ইতিহাস নেই" signinHistory: "প্রবেশ করার ইতিহাস" doing: "প্রক্রিয়া করছে..." @@ -625,10 +619,7 @@ abuseReported: "আপনার অভিযোগটি দাখিল কর reporter: "অভিযোগকারী" reporteeOrigin: "অভিযোগটির উৎস" reporterOrigin: "অভিযোগকারীর উৎস" -forwardReport: "রিমোট ইন্সত্যান্সে অভিযোগটি পাঠান" -forwardReportIsAnonymous: "আপনার তথ্য রিমোট ইন্সত্যান্সে পাঠানো হবে না এবং একটি বেনামী সিস্টেম অ্যাকাউন্ট হিসাবে প্রদর্শিত হবে।" send: "পাঠান" -abuseMarkAsResolved: "অভিযোগটিকে সমাধাকৃত হিসাবে চিহ্নিত করুন" openInNewTab: "নতুন ট্যাবে খুলুন" openInSideView: "সাইড ভিউতে খুলুন" defaultNavigationBehaviour: "ডিফল্ট নেভিগেশন" @@ -857,6 +848,14 @@ replies: "জবাব" renotes: "রিনোট" sourceCode: "সোর্স কোড" flip: "উল্টান" +postForm: "নোট লিখুন" +information: "আপনার সম্পর্কে" +_chat: + invitations: "আমন্ত্রণ" + noHistory: "কোনো ইতিহাস নেই" + members: "সদস্যবৃন্দ" + home: "মূল পাতা" + send: "পাঠান" _delivery: stop: "স্থগিত করা হয়েছে" _type: @@ -1021,7 +1020,6 @@ _theme: buttonBg: "বাটনের পটভূমি" buttonHoverBg: "বাটনের পটভূমি (হভার)" inputBorder: "ইনপুট ফিল্ডের বর্ডার" - listItemHoverBg: "লিস্ট আইটেমের পটভূমি (হোভার)" driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি" wallpaperOverlay: "ওয়ালপেপার ওভারলে" badge: "ব্যাজ" @@ -1090,6 +1088,7 @@ _permissions: "write:gallery": "গ্যালারী সম্পাদনা করুন" "read:gallery-likes": "গ্যালারীর পছন্দগুলি দেখুন" "write:gallery-likes": "গ্যালারীর পছন্দগুলি সম্পাদনা করুন" + "write:chat": "চ্যাটগুলি সম্পাদনা করুন" _auth: shareAccess: "\"{name}\" কে অ্যাকাউন্টের অ্যাক্সেস দিবেন?" shareAccessAsk: "অ্যাপ্লিকেশনটিকে অ্যাকাউন্টের অ্যাক্সেস দিবেন?" @@ -1243,9 +1242,6 @@ _pages: newPage: "নতুন পৃষ্ঠা বানান" editPage: "পৃষ্ঠাটি সম্পাদনা করুন" readPage: "উৎস দেখছেন" - created: "পৃষ্ঠা তৈরি করা হয়েছে" - updated: "পৃষ্ঠা সম্পাদনা করা হয়েছে" - deleted: "পৃষ্ঠা মুছে ফেলা হয়েছে" pageSetting: "পৃষ্ঠার সেটিংস" nameAlreadyExists: "পৃষ্ঠার URLটি ইতিমধ্যেই ব্যাবহার করা হয়েছে" invalidNameTitle: "পৃষ্ঠার URL অবৈধ" @@ -1314,6 +1310,7 @@ _notification: pollEnded: "পোল শেষ" receiveFollowRequest: "প্রাপ্ত অনুসরণের অনুরোধসমূহ" followRequestAccepted: "গৃহীত অনুসরণের অনুরোধসমূহ" + login: "প্রবেশ করুন" app: "লিঙ্ক করা অ্যাপ থেকে বিজ্ঞপ্তি" _actions: followBack: "ফলো ব্যাক করেছে" @@ -1353,3 +1350,9 @@ _moderationLogTypes: resetPassword: "পাসওয়ার্ড রিসেট করুন" _reversi: total: "মোট" +_remoteLookupErrors: + _noSuchObject: + title: "পাওয়া যায়নি" +_search: + searchScopeAll: "সবগুলো" + searchScopeLocal: "স্থানীয়" diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml index 7bd9a1bb32..fde5b2aad9 100644 --- a/locales/ca-ES.yml +++ b/locales/ca-ES.yml @@ -2,70 +2,76 @@ _lang_: "Català" headlineMisskey: "Una xarxa connectada per notes" introMisskey: "Benvingut! Misskey és un servei de microblogging descentralitzat de codi obert.\nCrea \"notes\" per compartir els teus pensaments amb tots els que t'envolten. 📡\nAmb \"reaccions\", també pots expressar ràpidament els teus sentiments sobre les notes de tothom. 👍\nExplorem un món nou! 🚀" -poweredByMisskeyDescription: "{name} És un del serveis (anomenats instàncies de Misskey) que utilitzen la plataforma de codi obert Misskey." +poweredByMisskeyDescription: "{name} És un dels serveis (anomenats instàncies de Misskey) que utilitzen la plataforma de codi obert Misskey." monthAndDay: "{day}/{month}" search: "Cercar" +reset: "Reiniciar" notifications: "Notificacions" username: "Nom d'usuari" password: "Contrasenya" -forgotPassword: "Contrasenya oblidada" -fetchingAsApObject: "Cercant en el Fediverse..." +initialPasswordForSetup: "Contrasenya inicial per fer la primera configuració " +initialPasswordIsIncorrect: "La contrasenya no és correcta." +initialPasswordForSetupDescription: "Fes servir la contrasenya que has fet servir al fitxer de configuració, si tu mateix has instal·lat Misskey.\nSi fas servir una empresa d'allotjament de Misskey, fes servir la contrasenya que t'han donat.\nSi no has posat cap contrasenya deixar l'espai en blanc." +forgotPassword: "Restableix la contrasenya " +fetchingAsApObject: "Cercant al Fediverse..." ok: "OK" -gotIt: "Ho he entès!" +gotIt: "D'acord " cancel: "Cancel·lar" noThankYou: "No, gràcies" enterUsername: "Introdueix el teu nom d'usuari" -renotedBy: "Impulsat per {usuari}" +renotedBy: "Impulsat per {user}" noNotes: "Cap nota" noNotifications: "Cap notificació" -instance: "Servidor" +instance: "Instància " settings: "Preferències" -notificationSettings: "Paràmetres de notificacions" +notificationSettings: "Configurar les notificacions" basicSettings: "Configuració bàsica" -otherSettings: "Configuració avançada" -openInWindow: "Obrir en una nova finestra" +otherSettings: "Altres configuracions" +openInWindow: "Obrir en una finestra nova" profile: "Perfil" timeline: "Línia de temps" noAccountDescription: "Aquest usuari encara no ha escrit la seva biografia." login: "Iniciar sessió" -loggingIn: "Identificant-se" +loggingIn: "Iniciar la sessió " logout: "Tancar la sessió" signup: "Registrar-se" uploading: "Pujant..." save: "Desa" users: "Usuaris" addUser: "Afegir un usuari" -favorite: "Afegir a preferits" +favorite: "Afegeix als preferits" favorites: "Favorits" unfavorite: "Eliminar dels preferits" favorited: "Afegit als preferits." alreadyFavorited: "Ja s'ha afegit als preferits." -cantFavorite: "No s'ha pogut afegir als preferits." -pin: "Fixar al perfil" +cantFavorite: "No es pot afegir als preferits." +pin: "Fixa al perfil" unpin: "Para de fixar del perfil" -copyContent: "Copiar el contingut" -copyLink: "Copiar l'enllaç" +copyContent: "Copia el contingut" +copyLink: "Copia l'enllaç" +copyRemoteLink: "Copiar l'enllaç remot" copyLinkRenote: "Copiar l'enllaç de la renota" delete: "Elimina" -deleteAndEdit: "Elimina i edita" +deleteAndEdit: "Eliminar i editar" deleteAndEditConfirm: "Segur que vols eliminar aquesta publicació i editar-la? Perdràs totes les reaccions, impulsos i respostes." addToList: "Afegir a una llista" -addToAntenna: "Afegir a l'antena" +addToAntenna: "Afegir a una antena" sendMessage: "Enviar un missatge" copyRSS: "Copiar RSS" copyUsername: "Copiar nom d'usuari" copyUserId: "Copiar ID d'usuari" -copyNoteId: "Copiar ID de nota" -copyFileId: "Copiar ID d'arxiu" -copyFolderId: "Copiar ID de carpeta" -copyProfileUrl: "Copiar URL del perfil" +copyNoteId: "Copiar ID de la nota" +copyFileId: "Copiar ID de l'arxiu" +copyFolderId: "Copiar ID de la carpeta" +copyProfileUrl: "Copiar adreça URL del perfil" searchUser: "Cercar un usuari" -reply: "Respondre" +searchThisUsersNotes: "Cercar les publicacions de l'usuari" +reply: "Respostes" loadMore: "Carregar més" showMore: "Veure més" -showLess: "Mostra menys" +showLess: "Mostrar menys" youGotNewFollower: "t'ha seguit" -receiveFollowRequest: "Sol·licitud de seguiment rebuda" +receiveFollowRequest: "Has rebut una sol·licitud de seguiment" followRequestAccepted: "Sol·licitud de seguiment acceptada" mention: "Menció" mentions: "Mencions" @@ -74,75 +80,78 @@ importAndExport: "Importar / Exportar" import: "Importar" export: "Exporta" files: "Fitxers" -download: "Baixar" -driveFileDeleteConfirm: "Estàs segur que vols suprimir el fitxer \"{name}\"? Les notes associades a aquest fitxer adjunt també se suprimiran." -unfollowConfirm: "Estàs segur que vols deixar de seguir {name}?" -exportRequested: "Has sol·licitat una exportació. Això pot trigar una estona. S'afegirà a la teva unitat un cop completat." -importRequested: "Has sol·licitat una importació. Això pot trigar una estona." +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}?" +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" noLists: "No tens cap llista" note: "Nota" notes: "Notes" -following: "Seguint" +following: "Segueixes " followers: "Seguidors" followsYou: "Et segueix" createList: "Crear llista" manageLists: "Gestionar les llistes" error: "Error" somethingHappened: "S'ha produït un error" -retry: "Torna-ho a intentar" +retry: "Torna-ho a provar" pageLoadError: "S'ha produït un error en carregar la pàgina" -pageLoadErrorDescription: "Això normalment es deu a errors de xarxa o a la memòria cau del navegador. Prova d'esborrar la memòria cau i torna-ho a provar després d'esperar una estona." +pageLoadErrorDescription: "Això normalment és a causa d'errors a la xarxa o a la memòria cau del navegador. Prova d'esborrar la memòria cau i torna-ho a provar després d'esperar un temps." serverIsDead: "Aquest servidor no respon. Espera una estona i torna-ho a provar." youShouldUpgradeClient: "Per veure aquesta pàgina, actualitzeu-la per actualitzar el vostre client." enterListName: "Introdueix un nom per a la llista" privacy: "Privadesa" makeFollowManuallyApprove: "Les sol·licituds de seguiment requereixen aprovació" defaultNoteVisibility: "Visibilitat per defecte" -follow: "Seguint" -followRequest: "Enviar la sol·licitud de seguiment" -followRequests: "Sol·licituds de seguiment" +follow: "Segueix" +followRequest: "Enviar sol·licitud de seguiment" +followRequests: "Peticions de seguiment" unfollow: "Deixar de seguir" followRequestPending: "Sol·licituds de seguiment pendents" enterEmoji: "Introduir un emoji" -renote: "Impulsa" +renote: "Impulsar" unrenote: "Anul·la l'impuls" renoted: "S'ha impulsat" +renotedToX: "Impulsat per {name}." cantRenote: "No es pot impulsar aquesta publicació" -cantReRenote: "No es pot impulsar l'impuls." +cantReRenote: "No es pot impulsar un impuls." quote: "Cita" -inChannelRenote: "Renotar només al Canal" -inChannelQuote: "Citar només al Canal" +inChannelRenote: "Impulsar només a un canal" +inChannelQuote: "Citar només a un canal" +renoteToChannel: "Impulsar a un canal" +renoteToOtherChannel: "Impulsar a un altre canal" pinnedNote: "Nota fixada" pinned: "Fixar al perfil" you: "Tu" clickToShow: "Fes clic per mostrar" -sensitive: "NSFW" +sensitive: "Sensible" add: "Afegir" -reaction: "Reaccions" +reaction: "Reacció " reactions: "Reaccions" -emojiPicker: "Selecció d'emojis" -pinnedEmojisForReactionSettingDescription: "Selecciona l'emoji amb el qual reaccionar" -pinnedEmojisSettingDescription: "Selecciona l'emoji amb el qual reaccionar" -emojiPickerDisplay: "Visualitza el selector d'emojis" +emojiPicker: "Selector d'emojis" +pinnedEmojisForReactionSettingDescription: "Selecciona l'emoji amb qui vols reaccionar" +pinnedEmojisSettingDescription: "Selecciona quins emojis vols deixar fixats i es mostrin en obrir el selector d'emojis" +emojiPickerDisplay: "Mostrar el selector d'emojis" overwriteFromPinnedEmojisForReaction: "Reemplaça els emojis de la reacció" -overwriteFromPinnedEmojis: "Sobreescriu des dels emojis fixats" +overwriteFromPinnedEmojis: "Sobreescriu els emojis fixats al panel de reaccions" reactionSettingDescription2: "Arrossega per reordenar, fes clic per suprimir, prem \"+\" per afegir." rememberNoteVisibility: "Recorda la configuració de visibilitat de les notes" attachCancel: "Eliminar el fitxer adjunt" deleteFile: "Esborrar l'arxiu " -markAsSensitive: "Marcar com a NSFW" +markAsSensitive: "Marcar com a sensible" unmarkAsSensitive: "Deixar de marcar com a sensible" enterFileName: "Defineix nom del fitxer" mute: "Silencia" unmute: "Deixa de silenciar" -renoteMute: "Silenciar Renotes" -renoteUnmute: "Treure el silenci de les renotes" +renoteMute: "Silenciar impulsos" +renoteUnmute: "Treure el silenci dels impulsos" block: "Bloqueja" unblock: "Desbloqueja" suspend: "Suspèn" unsuspend: "Deixa de suspendre" -blockConfirm: "Vols bloquejar?" +blockConfirm: "Vols bloquejar-lo?" unblockConfirm: "Vols desbloquejar-lo?" suspendConfirm: "Estàs segur que vols suspendre aquest compte?" unsuspendConfirm: "Estàs segur que vols treure la suspensió d'aquest compte?" @@ -151,6 +160,7 @@ editList: "Editar llista" selectChannel: "Selecciona un canal" selectAntenna: "Tria una antena" editAntenna: "Modificar antena" +createAntenna: "Crea una antena" selectWidget: "Triar un giny" editWidgets: "Editar ginys" editWidgetsExit: "Fet" @@ -167,31 +177,36 @@ youCanCleanRemoteFilesCache: "Pots netejar la memòria cau fent clic al botó de cacheRemoteSensitiveFiles: "Posar a la memòria cau arxius remots sensibles" cacheRemoteSensitiveFilesDescription: "Quan aquesta opció és desactiva, els arxius remots sensibles es carregant directament del servidor d'origen sense que es guardin a la memòria cau." flagAsBot: "Marca aquest compte com a bot" -flagAsBotDescription: "Marca aquest compte com a bot" +flagAsBotDescription: "Activa aquesta opció si el compte el controla un programa. Si s'activa, actuarà com un senyal per altres desenvolupadors per prevenir cadenes d'interacció sense fi i ajustar els paràmetres interns de Misskey pe tractar el compte com un bot." flagAsCat: "Marca aquest compte com a gat" flagAsCatDescription: "Activeu aquesta opció per marcar aquest compte com a gat." flagShowTimelineReplies: "Mostra les respostes a la línia de temps" -flagShowTimelineRepliesDescription: "Mostra les respostes a la línia de temps" +flagShowTimelineRepliesDescription: "Mostra les respostes dels usuaris a les notes d'altres usuaris a la línia de temps." autoAcceptFollowed: "Aprova automàticament les sol·licituds de seguiment dels usuaris que segueixes" addAccount: "Afegeix un compte" reloadAccountsList: "Recarregar la llista de contactes" loginFailed: "S'ha produït un error al accedir." showOnRemote: "Navega més en el perfil original" +continueOnRemote: "Veure perfil original" +chooseServerOnMisskeyHub: "Escull un servidor des del Hub de Misskey" +specifyServerHost: "Especifica un servidor directament" +inputHostName: "Introdueix el domini" general: "General" wallpaper: "Fons de Pantalla" setWallpaper: "Defineix el fons de pantalla" removeWallpaper: "Elimina el fons de pantalla" searchWith: "Cerca: {q}" youHaveNoLists: "No tens cap llista" -followConfirm: "Estàs segur que vols deixar de seguir {name}?" +followConfirm: "Segur que vols seguir a {name}?" proxyAccount: "Compte de proxy" proxyAccountDescription: "Un compte proxy és un compte que actua com a seguidor remot per als usuaris en determinades condicions. Per exemple, quan un usuari afegeix un usuari remot a la llista, l'activitat de l'usuari remot no es lliurarà al servidor si cap usuari local segueix aquest usuari, de manera que el compte proxy el seguirà." host: "Amfitrió" +selectSelf: "Escollir manualment" selectUser: "Selecciona usuari/a" recipient: "Destinatari" annotation: "Comentaris" federation: "Federació" -instances: "Servidors" +instances: "Instàncies " registeredAt: "Registrat a" latestRequestReceivedAt: "Última petició rebuda" latestStatus: "Últim estat" @@ -200,21 +215,22 @@ charts: "Gràfics" perHour: "Per hora" perDay: "Per dia" stopActivityDelivery: "Deixa d'enviar activitats" -blockThisInstance: "Deixa d'enviar activitats" +blockThisInstance: "Bloca aquesta instància " silenceThisInstance: "Silencia aquesta instància " +mediaSilenceThisInstance: "Silenciar els arxius d'aquesta instància " operations: "Accions" software: "Programari" version: "Versió" metadata: "Metadades" withNFiles: "{n} fitxer(s)" monitor: "Monitor" -jobQueue: "Cua de tasques" +jobQueue: "Cua de feines" cpuAndMemory: "CPU i memòria" network: "Xarxa" disk: "Disc" instanceInfo: "Informació del fitxer d'instal·lació" statistics: "Estadístiques" -clearQueue: "Esborrar la cua" +clearQueue: "Esborra la cua de feina" clearQueueConfirmTitle: "Esteu segur que voleu esborrar la cua?" clearQueueConfirmText: "Les notes no lliurades que quedin a la cua no es federaran. Normalment aquesta operació no és necessària." clearCachedFiles: "Esborra la memòria cau" @@ -223,6 +239,10 @@ blockedInstances: "Instàncies bloquejades" blockedInstancesDescription: "Llista els enllaços d'amfitrió de les instàncies que vols bloquejar separades per un salt de pàgina. Les instàncies llistades no podran comunicar-se amb aquesta instància." silencedInstances: "Instàncies silenciades" silencedInstancesDescription: "Llista els enllaços d'amfitrió de les instàncies que vols silenciar. Tots els comptes de les instàncies llistades s'establiran com silenciades i només podran fer sol·licitacions de seguiment, i no podran mencionar als comptes locals si no els segueixen. Això no afectarà les instàncies bloquejades." +mediaSilencedInstances: "Instàncies amb els arxius silenciats" +mediaSilencedInstancesDescription: "Llista els noms dels servidors que vulguis silenciar els arxius, un servidor per línia. Tots els comptes que pertanyin als servidors llistats seran tractats com sensibles i no podran fer servir emojis personalitzats. Això no tindrà efecte sobre els servidors blocats." +federationAllowedHosts: "Llista de servidors federats" +federationAllowedHostsDescription: "Llista dels servidors amb els quals es federa." muteAndBlock: "Silencia i bloca" mutedUsers: "Usuaris silenciats" blockedUsers: "Usuaris bloquejats" @@ -236,11 +256,11 @@ processing: "S'està processant..." preview: "Vista prèvia" default: "Per defecte" defaultValueIs: "Per defecte: {value}" -noCustomEmojis: "Cap emoji personalitzat" +noCustomEmojis: "No hi ha emojis personalitzats" noJobs: "No hi ha feines" federating: "Federant" blocked: "Bloquejat" -suspended: "Suspés" +suspended: "Anul·lar subscripció " all: "tot" subscribing: "Subscrit a" publishing: "S'està publicant" @@ -250,11 +270,11 @@ instanceFollowers: "Seguidors del servidor" instanceUsers: "Usuaris del servidor" changePassword: "Canvia la contrasenya" security: "Seguretat" -retypedNotMatch: "L'entrada no coincideix" +retypedNotMatch: "Les entrades no coincideix" currentPassword: "Contrasenya actual" newPassword: "Contrasenya nova" -newPasswordRetype: "Contrasenya nou (repeteix-la)" -attachFile: "Adjunta fitxers" +newPasswordRetype: "Contrasenya nova (repeteix-la)" +attachFile: "Afegeix un arxiu" more: "Més" featured: "Destacat" usernameOrUserId: "Nom o ID d'usuari" @@ -264,25 +284,24 @@ announcements: "Anuncis" imageUrl: "URL de la imatge" remove: "Eliminar" removed: "Eliminat" -removeAreYouSure: "Segur que voleu retirar «{x}»?" -deleteAreYouSure: "Segur que voleu retirar «{x}»?" -resetAreYouSure: "Segur que voleu restablir-ho?" -areYouSure: "Està segur?" +removeAreYouSure: "Segur que vols esborrar «{x}»?" +deleteAreYouSure: "Segur que vols esborrar «{x}»?" +resetAreYouSure: "Segur que vols restablir-ho?" +areYouSure: "Estàs segur?" saved: "S'ha desat" -messaging: "Xat" upload: "Puja" keepOriginalUploading: "Guarda la imatge original" -keepOriginalUploadingDescription: "Guarda la imatge pujada com hi és. Si està apagat, una versió per a la visualització a la xarxa serà generada quan sigui pujada." -fromDrive: "Des de la unitat" +keepOriginalUploadingDescription: "Guarda la imatge pujada sense modificar. Si està desactivat, es generarà una versió per visualitzar a la web en pujar la imatge." +fromDrive: "Des del Disc" fromUrl: "Des d'un enllaç" uploadFromUrl: "Carrega des d'un enllaç" uploadFromUrlDescription: "Enllaç del fitxer que vols carregar" uploadFromUrlRequested: "Càrrega sol·licitada" -uploadFromUrlMayTakeTime: "La càrrega des de l'enllaç pot prendre un temps" +uploadFromUrlMayTakeTime: "La càrrega des de l'enllaç pot trigar un temps" explore: "Explora" messageRead: "Vist" -noMoreHistory: "No hi resta més per veure" -startMessaging: "Començar a xatejar" +noMoreHistory: "No hi ha res més per veure" +startChat: "Comença a xatejar " nUsersRead: "Vist per {n}" agreeTo: "Accepto que {0}" agree: "Hi estic d'acord" @@ -294,7 +313,7 @@ home: "Inici" remoteUserCaution: "Ja que aquest usuari resideix a una instància remota, la informació mostrada es podria trobar incompleta." activity: "Activitat" images: "Imatges" -image: "Imatges" +image: "Imatge" birthday: "Aniversari" yearsOld: "{age} anys" registeredDate: "Data de registre" @@ -307,12 +326,13 @@ dark: "Fosc" lightThemes: "Temes clars" darkThemes: "Temes foscos" syncDeviceDarkMode: "Sincronitza el mode fosc amb la configuració del dispositiu" -drive: "Unitat" +drive: "Disc" fileName: "Nom del Fitxer" -selectFile: "Selecciona fitxers" +selectFile: "Selecciona un fitxer" selectFiles: "Selecciona fitxers" selectFolder: "Selecció de carpeta" -selectFolders: "Selecció de carpeta" +selectFolders: "Selecció de carpetes" +fileNotSelected: "Cap fitxer seleccionat" renameFile: "Canvia el nom del fitxer" folderName: "Nom de la carpeta" createFolder: "Crea una carpeta" @@ -320,11 +340,12 @@ renameFolder: "Canvia el nom de la carpeta" deleteFolder: "Elimina la carpeta" folder: "Carpeta " addFile: "Afegeix un fitxer" -emptyDrive: "La teva unitat és buida" +showFile: "Mostrar fitxer" +emptyDrive: "El teu Disc és buit" emptyFolder: "La carpeta està buida" unableToDelete: "No es pot eliminar" inputNewFileName: "Introduïu el nom de fitxer nou" -inputNewDescription: "Inserta una nova llegenda" +inputNewDescription: "Escriu el peu de foto." inputNewFolderName: "Introduïu el nom de la carpeta nova" circularReferenceFolder: "La carpeta destinatària és una subcarpeta de la carpeta a la qual la desitges moure" hasChildFilesOrFolders: "No és possible esborrar aquesta carpeta ja que no és buida" @@ -339,9 +360,9 @@ reload: "Actualitza" doNothing: "Ignora" reloadConfirm: "Vols recarregar?" watch: "Veure" -unwatch: "Deixar de veure" +unwatch: "Deixa de veure" accept: "Acceptar" -reject: "Denegar" +reject: "Denega" normal: "Normal" instanceName: "Nom del servidor" instanceDescription: "Descripció del servidor" @@ -362,7 +383,6 @@ enableLocalTimeline: "Activa la línia de temps local" enableGlobalTimeline: "Activa la línia de temps global" disablingTimelinesInfo: "Fins i tot si aquestes línies de temps són desactivades, els administradors i els moderadors poden continuar visualitzant per conveniència." registration: "Registre" -enableRegistration: "Permet els registres d'usuaris" invite: "Convida" driveCapacityPerLocalAccount: "Capacitat del disc per usuaris locals" driveCapacityPerRemoteAccount: "Capacitat del disc per usuaris remots" @@ -373,20 +393,20 @@ basicInfo: "Informació bàsica" pinnedUsers: "Usuaris fixats" pinnedUsersDescription: "Llista d'usuaris, separats per salts de línia, que seran fixats a la pestanya \"Explorar\"." pinnedPages: "Pàgines fixades" -pinnedPagesDescription: "Escriu els camins de les pàgines que vols fixar a la pàgina d'inici d'aquesta instància. Separades per salts de línia." +pinnedPagesDescription: "Escriu les adreces de les pàgines que vols fixar a la pàgina d'inici d'aquesta instància. Separades per salts de línia." pinnedClipId: "ID del retall fixat" pinnedNotes: "Nota fixada" hcaptcha: "hCaptcha" -enableHcaptcha: "Activar hCaptcha" +enableHcaptcha: "Activa hCaptcha" hcaptchaSiteKey: "Clau del lloc" hcaptchaSecretKey: "Clau secreta" mcaptcha: "mCaptcha" -enableMcaptcha: "Activar mCaptcha" +enableMcaptcha: "Activa mCaptcha" mcaptchaSiteKey: "Clau del lloc" mcaptchaSecretKey: "Clau secreta" mcaptchaInstanceUrl: "Adreça URL del servidor mCaptcha" recaptcha: "reCAPTCHA" -enableRecaptcha: "Activar reCAPTCHA" +enableRecaptcha: "Activa reCAPTCHA" recaptchaSiteKey: "Clau del lloc" recaptchaSecretKey: "Clau secreta" turnstile: "Turnstile" @@ -428,13 +448,14 @@ aboutMisskey: "Quant a Misskey" administrator: "Administrador/a" token: "Codi de verificació" 2fa: "Autenticació de doble factor" -setupOf2fa: "Configurar l'autenticació de doble factor" +setupOf2fa: "Configura l'autenticació de doble factor" totp: "Aplicació d'autenticació" totpDescription: "Escriu una contrasenya d'un sol us fent servir l'aplicació d'autenticació" moderator: "Moderador/a" moderation: "Moderació" moderationNote: "Nota de moderació " -addModerationNote: "Afegir una nota de moderació " +moderationNoteDescription: "Pots escriure notes que es compartiran entre els moderadors." +addModerationNote: "Afegeix una nota de moderació " moderationLogs: "Registre de moderació " nUsersMentioned: "{n} usuaris mencionats" securityKeyAndPasskey: "Clau de seguretat / Clau de pas" @@ -450,13 +471,13 @@ reduceUiAnimation: "Redueix les animacions de la interfície" share: "Comparteix" notFound: "No s'ha trobat" notFoundDescription: "No es troba cap pàgina que correspongui a aquesta adreça" -uploadFolder: "Carpeta per defecte per pujades" +uploadFolder: "Carpeta per defecte on desar els arxius pujats" markAsReadAllNotifications: "Marca totes les notificacions com a llegides" markAsReadAllUnreadNotes: "Marca-ho tot com a llegit" markAsReadAllTalkMessages: "Marcar tots els missatges com llegits" help: "Ajuda" inputMessageHere: "Escriu aquí el teu missatge " -close: "Tancar" +close: "Tanca" invites: "Convida" members: "Membres" transfer: "Transferir" @@ -468,10 +489,10 @@ retype: "Torneu a introduir-la" noteOf: "Publicació de: {user}" quoteAttached: "Frase adjunta" quoteQuestion: "Vols annexar-la com a cita?" -noMessagesYet: "Encara no hi ha missatges" -newMessageExists: "Has rebut un nou missatge" +attachAsFileQuestion: "El text copiat és massa llarg. Vols adjuntar-lo com un fitxer de text?" onlyOneFileCanBeAttached: "Només pots adjuntar un fitxer a un missatge" signinRequired: "Si us plau, Registra't o inicia la sessió abans de continuar" +signinOrContinueOnRemote: "Per continuar necessites moure el teu servidor o registrar-te / iniciar sessió en aquest servidor." invitations: "Convida" invitationCode: "Codi d'invitació" checking: "Comprovació en curs..." @@ -485,7 +506,7 @@ normalPassword: "Bona contrasenya" strongPassword: "Contrasenya segura" passwordMatched: "Correcte!" passwordNotMatched: "No coincideix" -signinWith: "Inicia sessió amb amb {x}" +signinWith: "Inicia sessió amb {x}" signinFailed: "Autenticació sense èxit. Intenta-ho un altre cop utilitzant la contrasenya i el nom correctes." or: "O" language: "Idioma" @@ -493,7 +514,10 @@ uiLanguage: "Idioma de l'interfície" aboutX: "Respecte a {x}" emojiStyle: "Estil d'emoji" native: "Nadiu" -disableDrawer: "No mostrar els menús en calaixos" +menuStyle: "Estil de menú" +style: "Estil" +drawer: "Calaix" +popup: "Emergent" showNoteActionsOnlyHover: "Només mostra accions de la nota en passar amb el cursor" showReactionsCount: "Mostra el nombre de reaccions a les publicacions" noHistory: "No hi ha un registre previ" @@ -512,7 +536,7 @@ mediaListWithOneImageAppearance: "Altura de la llista de fitxers amb una única limitTo: "Limita a {x}" noFollowRequests: "No tens sol·licituds de seguiment" openImageInNewTab: "Obre imatges a una nova pestanya" -dashboard: "Panell de control" +dashboard: "Taulell de control" local: "Local" remote: "Remot" total: "Total" @@ -543,12 +567,12 @@ objectStorageUseSSLDesc: "Desactiva'l si no tens pensat fer servir HTTPS per les objectStorageUseProxy: "Connectar-se mitjançant un Proxy" objectStorageUseProxyDesc: "Desactiva'l si no faràs servir un Proxy per les connexions de l'API" objectStorageSetPublicRead: "Configurar les pujades com públiques " -s3ForcePathStyleDesc: "Si s3ForcePathStyle es troba activat el nom del dipòsit s'ha d'incloure a l'adreça URL en comtes del nom del host. Potser que necessitis activar-ho quan facis servir, per exemple, Minio a un servidor propi." +s3ForcePathStyleDesc: "Si s3ForcePathStyle es troba activat el nom del cubell s'haurà d'especificar com a part de l'adreça URL en comptes del nom del servidor. Podria ser que necessitis activar aquesta opció quan facis servir serveis com ara l'allotjament a un servidor propi." serverLogs: "Registres del servidor" deleteAll: "Elimina-ho tot" showFixedPostForm: "Mostrar el formulari per escriure a l'inici de la línia de temps" showFixedPostFormInChannel: "Mostrar el formulari d'escriptura al principi de la línia de temps (Canals)" -withRepliesByDefaultForNewlyFollowed: "Inclou les respostes d'usuaris nous seguits a la línia de temps per defecte." +withRepliesByDefaultForNewlyFollowed: "Inclou les respostes d'usuaris nous que segueixes a la línia de temps per defecte." newNoteRecived: "Hi ha publicacions noves" sounds: "Sons" sound: "So" @@ -561,11 +585,12 @@ masterVolume: "Volum principal" notUseSound: "Sense so" useSoundOnlyWhenActive: "Reproduir sons només quan Misskey estigui actiu" details: "Detalls" +renoteDetails: "Més informació sobre l'impuls " chooseEmoji: "Tria un emoji" unableToProcess: "L'operació no pot ser completada " recentUsed: "Utilitzat recentment" install: "Instal·lació " -uninstall: "Desinstal·lar " +uninstall: "Desinstal·la" installedApps: "Aplicacions autoritzades " nothing: "No hi ha res per veure aquí " installedDate: "Data d'instal·lació" @@ -576,17 +601,19 @@ ascendingOrder: "Ascendent" descendingOrder: "Descendent" scratchpad: "Bloc de proves" scratchpadDescription: "El bloc de proves proporciona un entorn experimental per AiScript. Pot escriure i verificar els resultats que interactuen amb Misskey." +uiInspector: "Inspector de la interfície" +uiInspectorDescription: "Podeu visualitzar una llista d'elements UI presents en la memòria. Els components de la interfície d'usuari són generats per les funcions Ui:C:." output: "Sortida" script: "Script" disablePagesScript: "Desactivar AiScript a les pàgines " updateRemoteUser: "Actualitzar la informació de l'usuari remot" -unsetUserAvatar: "Desactivar l'avatar " +unsetUserAvatar: "Desactiva l'avatar " unsetUserAvatarConfirm: "Segur que vols desactivar l'avatar?" -unsetUserBanner: "Desactivar el bàner " +unsetUserBanner: "Desactiva el bàner " unsetUserBannerConfirm: "Segur que vols desactivar el bàner?" -deleteAllFiles: "Esborrar tots els arxius" +deleteAllFiles: "Esborra tots els arxius" deleteAllFilesConfirm: "Segur que vols esborrar tots els arxius?" -removeAllFollowing: "Deixar de seguir tots els usuaris seguits" +removeAllFollowing: "Deixa de seguir tots els usuaris que segueixes" removeAllFollowingDescription: "El fet d'executar això, et farà deixar de seguir a tots els usuaris de {host}. Si us plau, executa això si l'amfitrió, per exemple, ja no existeix." userSuspended: "Aquest usuari ha sigut suspès" userSilenced: "Aquest usuari està sent silenciat" @@ -616,8 +643,8 @@ disablePlayer: "Tanca el reproductor de vídeo" expandTweet: "Expandir post" themeEditor: "Editor de temes" description: "Descripció" -describeFile: "Afegir subtitulació" -enterFileDescription: "Afegeix un títol" +describeFile: "Afegeix una descripció " +enterFileDescription: "Escriu un peu de foto" author: "Autor" leaveConfirm: "Hi ha canvis sense guardar. Els vols descartar?" manage: "Administració" @@ -656,14 +683,19 @@ smtpSecure: "Fes servir SSL/TLS per connexions SMTP" smtpSecureInfo: "Desactiva això quan facis servir connexions STARTTLS" testEmail: "Prova l'enviament de correu " wordMute: "Silenciar paraules " +wordMuteDescription: "Minimitza les notes que contenen la paraula o frase especificada. Les notes minimitzades poden visualitzar-se fent clic sobre elles." hardWordMute: "Silenciar paraules fortes" +showMutedWord: "Mostrar paraules silenciades" +hardWordMuteDescription: "Oculta les notes que contenen la paraula o frase especificada. A diferència de Silenciar paraula, la nota quedarà completament oculta a la vista." regexpError: "Error de l'expressió regular " regexpErrorDescription: "S'ha produït un error a l'expressió regular a la línia {line} de les paraules silenciades {tab}:" instanceMute: "Silenciar servidor" userSaysSomething: "{name} n'ha dit alguna cosa" +userSaysSomethingAbout: "{name} està parlant sobre \"{word}\"" makeActive: "Activar" display: "Veure" copy: "Copiar" +copiedToClipboard: "Copiat al porta papers" metrics: "Mètriques" overview: "Visió General" logs: "Registres" @@ -692,10 +724,7 @@ abuseReported: "La teva denúncia s'ha enviat. Moltes gràcies." reporter: "Denunciant " reporteeOrigin: "Origen de la denúncia " reporterOrigin: "Origen del denunciant" -forwardReport: "Transferir la denúncia a una instància remota" -forwardReportIsAnonymous: "En lloc del teu compte, es farà servir un compte anònim com a denunciant al servidor remot." send: "Envia" -abuseMarkAsResolved: "Marca la denúncia com a resolta" openInNewTab: "Obre a una pestanya nova" openInSideView: "Obre a una vista lateral" defaultNavigationBehaviour: "Navegació per defecte" @@ -704,7 +733,7 @@ instanceTicker: "Informació de notes de la instància " waitingFor: "Esperant {x}" random: "Aleatori " system: "Sistema" -switchUi: "Canviar interfície d'usuari " +switchUi: "Canviar la interfície" desktop: "Escriptori" clip: "Retalls" createNew: "Crear" @@ -722,7 +751,7 @@ repliesCount: "Nombre de respostes" renotesCount: "Impulsos fets" repliedCount: "Nombre de respostes rebudes" renotedCount: "Impulsos rebuts" -followingCount: "Nombre de comptes seguits" +followingCount: "Nombre de comptes que segueixes" followersCount: "Nombre de seguidors" sentReactionsCount: "Nombre de reaccions enviades" receivedReactionsCount: "Nombre de reaccions rebudes" @@ -754,7 +783,7 @@ thisIsExperimentalFeature: "Aquesta és una característica experimental. La sev developer: "Programador" makeExplorable: "Fes que el compte sigui visible a la secció \"Explorar\"" makeExplorableDescription: "Si desactives aquesta opció, el teu compte no sortirà a la secció \"Explorar\"" -showGapBetweenNotesInTimeline: "Mostra una separació entre els articles a la línia de temps" +showGapBetweenNotesInTimeline: "Notes separades a la línia de temps" duplicate: "Duplicat" left: "Esquerra" center: "Centre" @@ -832,6 +861,7 @@ administration: "Administració" accounts: "Comptes" switch: "Canvia" noMaintainerInformationWarning: "La informació de l'administrador no s'ha configurat" +noInquiryUrlWarning: "No s'ha desat l'URL de consulta." noBotProtectionWarning: "La protecció contra bots no s'ha configurat." configure: "Configurar" postToGallery: "Crear una nova publicació a la galeria" @@ -884,7 +914,7 @@ off: "Desactivar" emailRequiredForSignup: "Demanar correu electrònic per registrar-se " unread: "Sense llegir" filter: "Filtrar" -controlPanel: "Panel de control" +controlPanel: "Taulell de control" manageAccounts: "Gestionar comptes" makeReactionsPublic: "Reaccions públiques " makeReactionsPublicDescription: "Això fa que totes les teves reaccions siguin visibles públicament " @@ -896,13 +926,14 @@ followersVisibility: "Visibilitat dels seguidors" continueThread: "Veure la continuació del fil" deleteAccountConfirm: "Això eliminarà el teu compte irreversiblement. Procedir?" incorrectPassword: "Contrasenya incorrecta." +incorrectTotp: "La contrasenya no és correcta, o ha caducat." voteConfirm: "Confirma el teu vot \"{choice}\"" hide: "Amagar" useDrawerReactionPickerForMobile: "Mostrar el selector de reaccions com un calaix al mòbil " welcomeBackWithName: "Benvingut de nou, {name}" clickToFinishEmailVerification: "Si us plau, fes clic a [{ok}] per completar la verificació per correu electrònic " overridedDeviceKind: "Tipus de dispositiu" -smartphone: "Telèfon intel·ligent" +smartphone: "Mòbil " tablet: "Tauleta" auto: "Automàtic " themeColor: "Color del tema" @@ -920,6 +951,9 @@ oneHour: "1 hora" oneDay: "Un dia" oneWeek: "Una setmana" oneMonth: "Un mes" +threeMonths: "3 mesos" +oneYear: "1 any" +threeDays: "3 dies" reflectMayTakeTime: "Això pot trigar una estona a tenir efecte" failedToFetchAccountInformation: "No es pot obtenir la informació del compte" rateLimitExceeded: "S'ha arribat al màxim de peticions" @@ -980,7 +1014,7 @@ sendPushNotificationReadMessageCaption: "Això pot fer que el teu dispositiu con windowMaximize: "Maximitzar " windowMinimize: "Minimitzar" windowRestore: "Restaurar" -caption: "Llegenda" +caption: "Peu de foto" loggedInAsBot: "Identificat com a bot" tools: "Eines" cannotLoad: "No es pot carregar" @@ -1021,6 +1055,7 @@ thisPostMayBeAnnoyingHome: "Publicar a la línia de temps d'Inici" thisPostMayBeAnnoyingCancel: "Cancel·lar " thisPostMayBeAnnoyingIgnore: "Publicar de totes maneres" collapseRenotes: "Col·lapsar les renotes que ja has vist" +collapseRenotesDescription: "Col·lapse les notes a les quals ja has reaccionat o que ja has renotat" internalServerError: "Error intern del servidor" internalServerErrorDescription: "El servidor ha fallat de manera inexplicable." copyErrorInfo: "Copiar la informació de l'error " @@ -1059,6 +1094,7 @@ retryAllQueuesConfirmTitle: "Tornar a intentar-ho tot?" retryAllQueuesConfirmText: "Això farà que la càrrega del servidor augmenti temporalment." enableChartsForRemoteUser: "Generar gràfiques d'usuaris remots" enableChartsForFederatedInstances: "Generar gràfiques d'instàncies remotes" +enableStatsForFederatedInstances: "Activa les estadístiques de les instàncies remotes federades" showClipButtonInNoteFooter: "Afegir \"Retall\" al menú d'acció de la nota" reactionsDisplaySize: "Mida de les reaccions" limitWidthOfReaction: "Limitar l'amplada màxima de la reacció i mostrar-les en una mida reduïda " @@ -1076,7 +1112,7 @@ forceShowAds: "Mostra els anuncis sempre " addMemo: "Afegir recordatori" editMemo: "Editar recordatori" reactionsList: "Reaccions" -renotesList: "Impulsos" +renotesList: "Llistat d'impulsos " notificationDisplay: "Notificacions" leftTop: "Dalt a l'esquerra " rightTop: "Dalt a la dreta " @@ -1094,16 +1130,21 @@ preservedUsernames: "Noms d'usuaris reservats" preservedUsernamesDescription: "Llistat de noms d'usuaris que no es poden fer servir separats per salts de linia. Aquests noms d'usuaris no estaran disponibles quan es creï un compte d'usuari normal, però els administradors els poden fer servir per crear comptes manualment. Per altre banda els comptes ja creats amb aquests noms d'usuari no es veure'n afectats." createNoteFromTheFile: "Compon una nota des d'aquest fitxer" archive: "Arxiu" +archived: "Arxivat" +unarchive: "Desarxivar" channelArchiveConfirmTitle: "Vols arxivar {name}?" channelArchiveConfirmDescription: "Un Canal arxivat no apareixerà a la llista de canals o als resultats de cerca. Tampoc es poden afegir noves entrades." thisChannelArchived: "Aquest Canal ha sigut arxivat." displayOfNote: "Mostrar notes" initialAccountSetting: "Configuració del perfil" -youFollowing: "Seguit" +youFollowing: "Segueixes " preventAiLearning: "Descartar l'ús d'aprenentatge automàtic (IA Generativa)" preventAiLearningDescription: "Demanar els indexadors no fer servir els texts, imatges, etc. en cap conjunt de dades per alimentar l'aprenentatge automàtic (IA Predictiva/ Generativa). Això s'aconsegueix afegint la etiqueta \"noai\" com a resposta HTML al contingut corresponent. Prevenir aquest ús totalment pot ser que no sigui aconseguit, ja que molts indexadors poden obviar aquesta etiqueta." options: "Opcions" specifyUser: "Especificar usuari" +lookupConfirm: "Vols fer una cerca?" +openTagPageConfirm: "Vols obrir una pàgina d'etiquetes?" +specifyHost: "Especifica un servidor" failedToPreviewUrl: "Vista prèvia no disponible" update: "Actualitzar" rolesThatCanBeUsedThisEmojiAsReaction: "Rols que poden fer servir aquest emoji com a reacció " @@ -1146,8 +1187,8 @@ currentAnnouncements: "Informes actuals" pastAnnouncements: "Informes passats" youHaveUnreadAnnouncements: "Tens informes per llegir." useSecurityKey: "Segueix les instruccions del teu navegador O dispositiu per fer servir el teu passkey." -replies: "Respondre" -renotes: "Impulsa" +replies: "Respostes" +renotes: "Impulsos" loadReplies: "Mostrar les respostes" loadConversation: "Mostrar la conversació " pinnedList: "Llista fixada" @@ -1162,7 +1203,7 @@ showRenotes: "Mostrar impulsos" edited: "Editat" notificationRecieveConfig: "Paràmetres de notificacions" mutualFollow: "Seguidor mutu" -followingOrFollower: "Seguit o seguidor" +followingOrFollower: "Seguint o seguidor" fileAttachedOnly: "Només notes amb adjunts" showRepliesToOthersInTimeline: "Mostrar les respostes a altres a la línia de temps" hideRepliesToOthersInTimeline: "Amagar les respostes a altres a la línia de temps" @@ -1172,7 +1213,10 @@ confirmShowRepliesAll: "Aquesta opció no té marxa enrere. Vols mostrar les tev confirmHideRepliesAll: "Aquesta opció no té marxa enrere. Vols ocultar les teves respostes a tots els usuaris que segueixes a la línia de temps?" externalServices: "Serveis externs" sourceCode: "Codi font" +sourceCodeIsNotYetProvided: "El codi font encara no es troba disponible. Contacta amb l'administrador per solucionar aquest problema." repositoryUrl: "URL del repositori" +repositoryUrlDescription: "Si estàs fent servir Misskey tal com és (sense cap canvi al codi font), introdueix https://github.com/misskey-dev/misskey" +repositoryUrlOrTarballRequired: "Si no ofereixes cap repositori, publica un fitxer tarball. Dona una ullada a .config/example.yml per a més informació." feedback: "Opinió" feedbackUrl: "URL per a opinar" impressum: "Impressum" @@ -1211,6 +1255,7 @@ showReplay: "Veure reproducció" replay: "Reproduir" replaying: "Reproduint" endReplay: "Tanca la redifusió" +copyReplayData: "Copia les dades de la resposta" ranking: "Classificació" lastNDays: "Últims {n} dies" backToTitle: "Torna al títol" @@ -1224,12 +1269,203 @@ gameRetry: "Torna a provar" notUsePleaseLeaveBlank: "Si no voleu usar-ho, deixeu-ho en blanc" useTotp: "Usa una contrasenya d'un sol ús" useBackupCode: "Usa un codi de recuperació" +launchApp: "Inicia l'aplicació " +useNativeUIForVideoAudioPlayer: "Fes servir la UI del navegador quan reprodueixis vídeo i àudio " +keepOriginalFilename: "Desa el nom del fitxer original" +keepOriginalFilenameDescription: "Si desactives aquesta opció els noms dels fitxers se substituiran per una cadena aleatòria quan carreguis nous fitxers de forma automàtica." +noDescription: "No hi ha una descripció " +alwaysConfirmFollow: "Confirma sempre els seguiments" +inquiry: "Contacte" +tryAgain: "Intenta-ho més tard." +confirmWhenRevealingSensitiveMedia: "Confirmació quan revelis contingut sensible " +sensitiveMediaRevealConfirm: "Aquest contingut potser sensible. Segur que ho vols revelar?" +createdLists: "Llistes creades " +createdAntennas: "Antenes creades" +fromX: "De {x}" +genEmbedCode: "Obtenir el codi per incrustar" +noteOfThisUser: "Notes d'aquest usuari" +clipNoteLimitExceeded: "No es poden afegir més notes a aquest clip." +performance: "Rendiment" +modified: "Modificat" +discard: "Descarta" +thereAreNChanges: "Hi ha(n) {n} canvi(s)" +signinWithPasskey: "Inicia sessió amb Passkey" +unknownWebAuthnKey: "Passkey desconeguda" +passkeyVerificationFailed: "La verificació a fallat" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificació de la passkey a estat correcta, però s'ha deshabilitat l'inici de sessió sense contrasenya." +messageToFollower: "Missatge als meus seguidors" +target: "Assumpte " +testCaptchaWarning: "És una característica dissenyada per a la prova de CAPTCHA. No l'utilitzes en l'entorn real." +prohibitedWordsForNameOfUser: "Noms prohibits per escollir noms d'usuari " +prohibitedWordsForNameOfUserDescription: "Si qualsevol d'aquestes paraules es troben a un nom d'usuari la creació de l'usuari no es durà a terme. Als moderadors no els afecta aquesta restricció." +yourNameContainsProhibitedWords: "El nom conté paraules prohibides " +yourNameContainsProhibitedWordsDescription: "Si de veritat vols fer servir aquest nom posat en contacte amb l'administrador." +thisContentsAreMarkedAsSigninRequiredByAuthor: "L'autor requereix l'inici de sessió per poder veure" +lockdown: "Bloquejat" +pleaseSelectAccount: "Seleccionar un compte" +availableRoles: "Roles disponibles " +acknowledgeNotesAndEnable: "Activa'l després de comprendre els possibles perills." +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." +confirmOnReact: "Confirmar en reaccionar" +reactAreYouSure: "Vols reaccionar amb \"{emoji}\"?" +markAsSensitiveConfirm: "Vols marcar aquest contingut com a sensible?" +unmarkAsSensitiveConfirm: "Vols deixar de marcar com a sensible aquest contingut?" +preferences: "Preferències " +accessibility: "Accessibilitat " +preferencesProfile: "Perfil de configuració " +copyPreferenceId: "Copiar l'ID de la configuració " +resetToDefaultValue: "Restaura al valor per defecte " +overrideByAccount: "Anul·lar per compte" +untitled: "Sense títol " +noName: "No hi ha un nom disponible " +skip: "Ometre " +restore: "Restaurar " +syncBetweenDevices: "Sincronització entre dispositius" +preferenceSyncConflictTitle: "Els valors de la configuració ja existeixen al dispositiu" +preferenceSyncConflictText: "Un element de la configuració amb sincronització activada desa els seus valors al servidor, però s'ha trobat un valor a la configuració desat al servidor per aquest element de la configuració. Quin valor us sobreescriure?" +preferenceSyncConflictChoiceServer: "Valors de configuració del servidor" +preferenceSyncConflictChoiceDevice: "Punts d'ajustos del dispositiu " +preferenceSyncConflictChoiceCancel: "Cancel·lar l'activació de la sincronització " +paste: "Pegar" +emojiPalette: "Calaix d'emojis" +postForm: "Formulari de publicació" +textCount: "Nombre de caràcters " +information: "Informació" +chat: "Xat" +migrateOldSettings: "Migració de la configuració antiga " +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 " +_chat: + noMessagesYet: "Encara no tens missatges " + newMessage: "Missatge nou" + individualChat: "Xat individual " + individualChat_description: "Pots mantenir converses individuals amb usuaris concrets." + roomChat: "Sala de xat" + roomChat_description: "Pots xatejar amb diverses persones.\nTambé pots xatejar amb usuaris que no poden fer xats privats, si ells accepten." + createRoom: "Crear una sala" + inviteUserToChat: "Invita usuaris per començar a xatejar" + yourRooms: "Sales creades" + joiningRooms: "Sales a les quals participes" + invitations: "Convida" + noInvitations: "No tens cap invitació " + history: "Historial de converses " + noHistory: "No hi ha un registre previ" + noRooms: "No hi ha habitacions" + inviteUser: "Invitar usuaris" + sentInvitations: "Enviar invitacions" + join: "Afegir-se " + ignore: "Ignorar " + leave: "Marxar" + members: "Membres" + searchMessages: "Buscar missatges " + home: "Inici" + send: "Envia" + newline: "Línia nova " + muteThisRoom: "Silenciar aquesta sala" + deleteRoom: "Esborrar la sala" + chatNotAvailableForThisAccountOrServer: "El xat no està disponible per aquest servidor o aquest compte." + chatNotAvailableInOtherAccount: "La funció de xat es troba desactivada al compte de l'altre usuari." + cannotChatWithTheUser: "No pots xatejar amb aquest usuari" + cannotChatWithTheUser_description: "El xat està desactivat o l'altra part encara no l'ha obert." + chatWithThisUser: "Xateja amb aquest usuari" + thisUserAllowsChatOnlyFromFollowers: "Aquest usuari només accepta xats d'usuaris que el segueixen." + thisUserAllowsChatOnlyFromFollowing: "Aquest usuari només accepta xats d'usuaris que segueix." + thisUserAllowsChatOnlyFromMutualFollowing: "Aquest usuari només accepta xats d'usuaris que segueixes i et segueixen." + thisUserNotAllowedChatAnyone: "Aquest usuari no accepta xats de ningú." + chatAllowedUsers: "Usuaris que poden xatejar" + chatAllowedUsers_note: "Pots xatejar amb qualsevol usuari a qui hagis enviat un missatge de xat, independentment d'aquesta configuració." + _chatAllowedUsers: + everyone: "Tothom" + followers: "Només els teus seguidors" + following: "Només usuaris als que segueixes" + mutual: "Només seguidors mutus" + none: "Ningú " +_emojiPalette: + palettes: "Calaixos d'emojis" + enableSyncBetweenDevicesForPalettes: "Activa la sincronització dels calaixos d'emojis entre dispositius" + paletteForMain: "Calaix d'emojis principal" + paletteForReaction: "Calaix d'emojis per reaccions" +_settings: + driveBanner: "Pots gestionar i configurar el Disc, comprovar el seu ús i establir una configuració per a la càrrega d'arxius." + pluginBanner: "Els complements poden fer-se servir per ampliar les funcionalitats del client. Els complements poden instal·lar-se, configurar-se individualment i gestionar-se." + notificationsBanner: "Pots configurar el tipus i l'abast de les notificacions que es rebran del servidor, també les notificacions emergents." + api: "API" + webhook: "Webhook" + serviceConnection: "Relació entre serveis" + serviceConnectionBanner: "Pots configurar i gestionar tokens d'accés i webhooks per integrar serveis i aplicacions externes." + accountData: "Dades del compte" + accountDataBanner: "Exportació/Importació i gestió d'arxius amb dades del compte." + muteAndBlockBanner: "Pots configurar i gestionar els continguts que desitges amagar i restringir les accions de determinats usuaris." + accessibilityBanner: "Els clients poden personalitzar-se i configurar-se per un ús òptim en funció de la seva visió i comportament." + privacyBanner: "Pots establir la configuració de privacitat del compte, com el grau de visibilitat del teu contingut, la facilitat per trobar-ho i si es pot aprovar els seguidors." + securityBanner: "Configura les opcions relacionades amb la seguretat del teu compte com ara contrasenyes, mètodes per iniciar sessió, aplicacions d'autentificació i claus d'accés." + preferencesBanner: "Pots configurar el comportament general del client segons les teves preferències." + appearanceBanner: "Pots configurar les preferències relacionades amb la visualització i l'aspecte del client segons el teu parer." + soundsBanner: "Configuració dels sons que reproduirà el client." + timelineAndNote: "Línia de temps i nota" + makeEveryTextElementsSelectable: "Fes que tots els elements del text siguin seleccionables" + makeEveryTextElementsSelectable_description: "L'activació pot reduir la usabilitat en determinades ocasions." + useStickyIcons: "Utilitza icones fixes" + showNavbarSubButtons: "Mostrar sub botons a la barra de navegació " + ifOn: "Quan s'encén " + ifOff: "Quan s'apaga " + _chat: + showSenderName: "Mostrar el nom del remitent" + sendOnEnter: "Introdueix per enviar" +_preferencesProfile: + profileName: "Nom del perfil" + profileNameDescription: "Estableix un nom que identifiqui aquest dispositiu." + profileNameDescription2: "Per exemple: \"PC Principal\", \"Smartphone\", etc" +_preferencesBackup: + autoBackup: "Còpia de seguretat automàtica " + restoreFromBackup: "Restaurar des d'una còpia de seguretat" + noBackupsFoundTitle: "No s'ha trobat cap còpia de seguretat" + noBackupsFoundDescription: "No s'han trobat còpies de seguretat creades automàticament, però si has desat, manualment, un arxiu de còpia de seguretat, pots importar-lo i carregar-lo." + selectBackupToRestore: "Seleccionar la còpia de seguretat que vols restaurar" + 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" +_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ó." + requireSigninToViewContentsDescription2: "També es desactivaran les vistes prèvies d'URLS (OGP), la incrustació a pàgines web i la visualització des de servidors que no admetin la citació de notes." + requireSigninToViewContentsDescription3: "Aquestes restriccions pot ser que no s'apliquin als continguts federats en servidors remots." + makeNotesFollowersOnlyBefore: "Permetre que les notes antigues només es mostrin als seguidors." + makeNotesFollowersOnlyBeforeDescription: "Mentre aquesta funció estigui activada, les notes que hagin passat la data i hora fixada o hagi passat els temps establert seran visibles només per als teus seguidors. Quan es desactivi, també es restableix l'estat públic de la nota." + makeNotesHiddenBefore: "Fes que les notes antigues siguin privades" + makeNotesHiddenBeforeDescription: "Mentres aquesta funció estigui activada les notes que hagin superat una data i hora fixada o hagi passat el temps establert només seran visibles per a tu. Si la desactives es restablirà també l'estat públic de les notes." + mayNotEffectForFederatedNotes: "Això pot ser que no afecti les notes federades." + mayNotEffectSomeSituations: "Aquestes restriccions són simplificades. Pot ser que no s'apliquin en determinades situacions, com quan es modera o visualitza un servidor remot." + notesHavePassedSpecifiedPeriod: "Notes publicades durant un període de temps especificat." + notesOlderThanSpecifiedDateAndTime: "Notes més antigues de la data i temps especificat " +_abuseUserReport: + forward: "Reenviar " + forwardDescription: "Reenvia l'informe a una altra instància com un compte del sistema anònima." + resolve: "Solució " + accept: "Acceptar " + reject: "Rebutjar" + resolveTutorial: "Si l'informe és legítim selecciona \"Acceptar\" per resoldre'l positivament. Però si l'informe no és legítim selecciona \"Rebutjar\" per resoldre'l negativament." _delivery: - stop: "Suspés" + status: "Estat d'entrega " + stop: "Anul·lar subscripció " + resume: "Torna a enviar" _type: none: "S'està publicant" + manuallySuspended: "Suspendre manualment" + goneSuspended: "Servidor suspès perquè el servidor s'ha esborrat" + autoSuspendedForNotResponding: "Servidor suspès perquè el servidor no respon" _bubbleGame: howToPlay: "Com es juga" + hold: "Mantenir" + _score: + score: "Puntuació " + scoreYen: "Diners guanyats" + highScore: "Millor puntuació " + maxChain: "Nombre màxim de combos" + yen: "{yen}Ien" + estimatedQty: "{qty}peces" + scoreSweets: "{onigiriQtyWithUnit}ongiris" _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." @@ -1344,6 +1580,12 @@ _serverSettings: fanoutTimelineDescription: "Quan es troba activat millora bastant el rendiment quan es recuperen les línies de temps i redueix la carrega de la base de dades. Com a contrapunt, l'ús de memòria de Redis es veurà incrementada. Considera d'estabilitat aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes de inestabilitat." fanoutTimelineDbFallback: "Carregar de la base de dades" fanoutTimelineDbFallbackDescription: "Quan s'activa, la línia de temps fa servir la base de dades per consultes adicionals si la línia de temps no es troba a la memòria cau. Si és desactiva la càrrega del servidor és veure reduïda, però també és reduirà el nombre de línies de temps que és poden obtenir." + 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." + 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ó." + openRegistration: "Registres oberts" + openRegistrationWarning: "Obrir els registres és arriscat. Es recomana obrir-los només si el servidor és monitorat constantment i per respondre immediatament davant qualsevol problema." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Si no es detecta activitat per part del moderador durant un període de temps, aquesta opció es desactiva automàticament per evitar el correu brossa." _accountMigration: moveFrom: "Migrar un altre compte a aquest" moveFromSub: "Crear un àlies per un altre compte" @@ -1357,7 +1599,7 @@ _accountMigration: startMigration: "Migrar" migrationConfirm: "Vols migrar aquest compte a {account}? Una vegada comenci la migració no es podrà parar O fer marxa enrere i no podràs tornar a fer servir aquest compte mai més." movedAndCannotBeUndone: "Aquest compte ha migrat.\nLes migracions no es poden desfer." - postMigrationNote: "Aquest compte deixarà de seguir tots els comptes que segueix 24 hores després de germinar la migració.\nEl nombre de seguidors i seguits passarà a ser de zero. Per evitar que els teus seguidors no puguin veure les publicacions marcades com a només seguidors continuaren seguint aquest compte." + postMigrationNote: "Aquest compte deixarà de seguir tots els comptes que segueix 24 hores després de terminar la migració.\nEl nombre de seguidors i seguits passarà a ser de zero. Per evitar que els teus seguidors no puguin veure les publicacions marcades com a només seguidors continuaren seguint aquest compte." movedTo: "Nou compte:" _achievements: earnedAt: "Desbloquejat el" @@ -1651,6 +1893,7 @@ _role: gtlAvailable: "Pot veure la línia de temps global" ltlAvailable: "Pot veure la línia de temps local" canPublicNote: "Pot enviar notes públiques" + mentionMax: "Nombre màxim de mencions a una nota" canInvite: "Pot crear invitacions a la instància " inviteLimit: "Límit d'invitacions " inviteLimitCycle: "Temps de refresc de les invitacions" @@ -1659,6 +1902,7 @@ _role: canManageAvatarDecorations: "Gestiona les decoracions dels avatars " driveCapacity: "Capacitat del disc" 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" antennaMax: "Nombre màxim d'antenes" wordMuteMax: "Nombre màxim de caràcters permesos a les paraules silenciades" @@ -1673,9 +1917,21 @@ _role: canSearchNotes: "Pot cercar notes" canUseTranslator: "Pot fer servir el traductor" avatarDecorationLimit: "Nombre màxim de decoracions que es poden aplicar els avatars" + canImportAntennas: "Autoritza la importació d'antenes " + canImportBlocking: "Autoritza la importació de bloquejats" + canImportFollowing: "Autoritza la importació de seguidors" + canImportMuting: "Autoritza la importació de silenciats" + canImportUserLists: "Autoritza la importació de llistes d'usuaris " + canChat: "Pot xatejar" _condition: + roleAssignedTo: "Assignat a rols manuals" isLocal: "Usuari local" isRemote: "Usuari remot" + isCat: "Usuaris gats" + isBot: "Usuaris bots" + isSuspended: "Usuari suspès" + isLocked: "Comptes privats" + isExplorable: "Fes que el compte aparegui a les cerques" createdLessThan: "Han passat menys de X a passat des de la creació del compte" createdMoreThan: "Han passat més de X des de la creació del compte" followersLessThanOrEq: "Té menys de X seguidors" @@ -1703,7 +1959,7 @@ _emailUnavailable: smtp: "Aquest servidor de correu electrònic no respon" banned: "No pots registrar-te amb aquesta adreça de correu electrònic " _ffVisibility: - public: "Publicar" + public: "Públic " followers: "Visible només per a seguidors " private: "Privat" _signup: @@ -1737,7 +1993,7 @@ _gallery: unlike: "Ja no m'agrada" _email: _follow: - title: "t'ha seguit" + title: "Tens un nou seguidor" _receiveFollowRequest: title: "Has rebut una sol·licitud de seguiment" _plugin: @@ -1745,6 +2001,7 @@ _plugin: installWarn: "Si us plau, no instal·lis afegits que no siguin de confiança." manage: "Gestionar els afegits" viewSource: "Veure l'origen " + viewLog: "Mostra el registre" _preferencesBackups: list: "Llista de còpies de seguretat" saveNew: "Fer una còpia de seguretat nova" @@ -1770,10 +2027,12 @@ _registry: domain: "Domini" createKey: "Crear una clau" _aboutMisskey: - about: "Misskey és un programa de codi obert desenvolupar per syuilo des de 2014" + about: "Misskey és un programa de codi obert desenvolupat des del 2014 per syuilo" contributors: "Col·laboradors principals" allContributors: "Tots els col·laboradors " source: "Codi font" + original: "Original" + thisIsModifiedVersion: "En {name} fa servir una versió modificada de Misskey." translation: "Tradueix Misskey" donate: "Fes un donatiu a Misskey" morePatrons: "També agraïm el suport d'altres col·laboradors que no surten en aquesta llista. Gràcies! 🥰" @@ -1827,6 +2086,7 @@ _theme: installed: "{name} Instal·lat " installedThemes: "Temes instal·lats " builtinThemes: "Temes integrats" + instanceTheme: "Tema de la instància " alreadyInstalled: "Aquest tema ja es troba instal·lat " invalid: "El format d'aquest tema no és correcte" make: "Crear un tema" @@ -1866,7 +2126,7 @@ _theme: hashtag: "Etiqueta" mention: "Menció" mentionMe: "Mencions (jo)" - renote: "Renotar" + renote: "Impulsar" modalBg: "Fons del modal" divider: "Divisor" scrollbarHandle: "Maneta de la barra de desplaçament" @@ -1881,7 +2141,6 @@ _theme: buttonBg: "Fons botó " buttonHoverBg: "Fons botó (en passar-hi per sobre)" inputBorder: "Contorn del cap d'introducció " - listItemHoverBg: "Fons dels elements d'una llista" driveFolderBg: "Fons de la carpeta Disc" wallpaperOverlay: "Superposició del fons de pantalla " badge: "Insígnia " @@ -1894,6 +2153,7 @@ _sfx: noteMy: "Nota (per mi)" notification: "Notificacions" reaction: "Quan se selecciona una reacció " + chatMessage: "Missatges del xat" _soundSettings: driveFile: "Fer servir un fitxer d'àudio del disc" driveFileWarn: "Seleccionar un fitxer d'àudio del disc" @@ -1901,6 +2161,7 @@ _soundSettings: driveFileTypeWarnDescription: "Seleccionar un fitxer d'àudio " driveFileDurationWarn: "L'àudio és massa llarg" driveFileDurationWarnDescription: "Els àudios molt llargs pot interrompre l'ús de Misskey. Vols continuar?" + driveFileError: "El so no es pot carregar. Canvia la configuració" _ago: future: "Futur " justNow: "Ara mateix" @@ -1953,6 +2214,7 @@ _2fa: backupCodesDescription: "Si l'aplicació d'autenticació no es pot utilitzar, es pot accedir al compte utilitzant els següents codis de còpia de seguretat. Assegura't de mantenir aquests codis en un lloc segur. Cada codi es pot utilitzar només una vegada." backupCodeUsedWarning: "Es va utilitzar un codi de còpia de seguretat. Si l'aplicació de certificació està disponible, reconfigura l'aplicació d'autenticació tan aviat com sigui possible." backupCodesExhaustedWarning: "Es van utilitzar tots els codis de còpia de seguretat. Si no es pot utilitzar l'aplicació d'autenticació, ja no es pot accedir al compte. Torna a registrar l'aplicació d'autenticació." + moreDetailedGuideHere: "Aquí tens una guia al detall" _permissions: "read:account": "Veure la informació del compte." "write:account": "Editar la informació del compte." @@ -2026,22 +2288,78 @@ _permissions: "read:admin:emoji": "Veure emojis" "write:admin:queue": "Gestionar la cua de feines" "read:admin:queue": "Veure la cua de feines" + "write:admin:promo": "Gestiona les notes promocionals" + "write:admin:drive": "Gestiona el disc de l'usuari" + "read:admin:drive": "Veure la informació del disc de l'usuari" + "read:admin:stream": "Fes servir l'API sobre Websocket per l'administració" + "write:admin:ad": "Gestiona la publicitat" + "read:admin:ad": "Veure anuncis" + "write:invite-codes": "Crear codis d'invitació" + "read:invite-codes": "Obtenir codis d'invitació" + "write:clip-favorite": "Gestionar els clips favorits" + "read:clip-favorite": "Veure clips favorits" + "read:federation": "Veure dades de federació" + "write:report-abuse": "Informar d'un abús" + "write:chat": "Crear o esborrar missatges de xat" + "read:chat": "Explorar xats" +_auth: + shareAccessTitle: "Concedeix permisos a l'aplicació" + shareAccess: "Vols que {name} pugui accedir al vostre compte?" + shareAccessAsk: "Segur que vols que aquesta aplicació pugui accedir al vostre compte?" + permission: "{name} demana els següents permisos" + permissionAsk: "Aquesta aplicació demana els següents permisos" + pleaseGoBack: "Si us plau, torna a l'aplicació" + callback: "Tornant a l'aplicació" + accepted: "Accés garantit" + denied: "Accés denegat" + 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" _antennaSources: all: "Totes les publicacions" homeTimeline: "Publicacions dels usuaris seguits" users: "Publicacions d'usuaris específics" userList: "Publicacions d'una llista d'usuaris" + userBlacklist: "Totes les notes excepte les d'un o alguns usuaris especificats" +_weekday: + sunday: "Diumenge" + monday: "Dilluns" + tuesday: "Dimarts" + wednesday: "Dimecres" + thursday: "Dijous" + friday: "Divendres" + saturday: "Dissabte" _widgets: profile: "Perfil" instanceInfo: "Informació del fitxer d'instal·lació" + memo: "Notes adhesives" notifications: "Notificacions" timeline: "Línia de temps" + calendar: "Calendari" + trends: "Tendència" + clock: "Rellotge" + rss: "Lector RSS" + rssTicker: "RSS ticker" activity: "Activitat" + photos: "Fotografies" + digitalClock: "Rellotge digital" + unixClock: "Rellotge UNIX" federation: "Federació" + instanceCloud: "Núvol d'instàncies" + postForm: "Formulari de publicació" + slideshow: "Presentació" button: "Botó " - jobQueue: "Cua de tasques" + onlineUsers: "Usuaris actius" + jobQueue: "Cua de feines" + serverMetric: "Mètriques del servidor" + aiscript: "Consola AiScript" + aiscriptApp: "Aplicació AiScript" + aichan: "Ai" + userList: "Llistat d'usuaris" _userList: chooseList: "Tria una llista" + clicker: "Clicker" + birthdayFollowings: "Usuaris que fan l'aniversari avui" _cw: hide: "Amagar" show: "Carregar més" @@ -2105,27 +2423,76 @@ _profile: changeBanner: "Canviar el bàner " verifiedLinkDescription: "Escrivint una adreça URL que enllaci a aquest perfil, una icona de propietat verificada es mostrarà al costat del camp." avatarDecorationMax: "Pot afegir un màxim de {max} decoracions." + followedMessage: "Missatge als nous seguidors" + followedMessageDescription: "Es pot configurar un missatge curt que es mostra a l'altra persona quan comença a seguir-te." + followedMessageDescriptionForLockedAccount: "Si comencen a seguir-te es mostra un missatge de quan es permet aquesta sol·licitud. " _exportOrImport: allNotes: "Totes les publicacions" + favoritedNotes: "Notes preferides" clips: "Retalls" - followingList: "Seguint" + followingList: "Seguint " muteList: "Silencia" blockingList: "Bloqueja" userLists: "Llistes" + excludeMutingUsers: "Exclou usuaris silenciats" + excludeInactiveUsers: "Exclou usuaris inactius" + withReplies: "Inclou a la línia de temps les respostes d'usuaris importats" _charts: federation: "Federació" + apRequest: "Peticions" + usersIncDec: "Diferència entre el nombre d'usuaris" + usersTotal: "Nombre total d'usuaris" + activeUsers: "Usuaris actius" + notesIncDec: "Diferència entre el nombre de notes" + localNotesIncDec: "Diferencia en el nombre de notes locals" + remoteNotesIncDec: "Diferencia en el nombre de notes remotes" + notesTotal: "Nombre total de notes" + filesIncDec: "Diferencia en el nombre de fitxers" + filesTotal: "Nombre total de fitxers" + storageUsageIncDec: "Diferencia en l'emmagatzematge usat" + storageUsageTotal: "Emmagatzematge usat" +_instanceCharts: + requests: "Peticions" + users: "Diferència entre el nombre d'usuaris" + usersTotal: "Usuaris totals acumulats" + notes: "Diferència entre el nombre de notes" + notesTotal: "Notes totals acumulades" + ff: "Diferència en nombre d'usuaris seguits / seguidors" + ffTotal: "Nombre total acumulat d'usuaris seguits / seguidors" + cacheSize: "Diferència a la mida de la memòria cau" + cacheSizeTotal: "Total acumulat de la mida de la memòria cau" + files: "Diferència al nombre d'arxius" + filesTotal: "Nombre acumulatiu de fitxers" _timelines: home: "Inici" local: "Local" social: "Social" global: "Global" _play: + new: "Crear un guió" + edit: "Editar guió" + created: "Guió creat" + updated: "Guió editat" + deleted: "Guió esborrat" + pageSetting: "Configuració del guió" + editThisPage: "Edita aquest guió" viewSource: "Veure l'origen " + my: "Els meus guions" + liked: "Guions que m'han agradat" featured: "Popular" title: "Títol " script: "Script" summary: "Descripció" + visibilityDescription: "" _pages: + newPage: "pa" + editPage: "Editar la pàgina" + readPage: "Veure el codi font d'aquesta pàgina" + pageSetting: "Configuració de la pàgina" + nameAlreadyExists: "L'adreça URL de la pàgina ja existeix" + invalidNameTitle: "L'adreça URL de la pàgina no és vàlida" + invalidNameText: "Assegurat que el títol de la pàgina no és buit" + editThisPage: "Editar la pàgina" viewSource: "Veure l'origen " viewPage: "Veure les teves pàgines " like: "M'agrada " @@ -2148,6 +2515,7 @@ _pages: eyeCatchingImageSet: "Escull una miniatura" eyeCatchingImageRemove: "Esborrar la miniatura" chooseBlock: "Afegeix un bloc" + enterSectionTitle: "Escriu el títol de la secció" selectType: "Seleccionar tipus" contentBlocks: "Contingut" inputBlocks: "Entrada " @@ -2158,6 +2526,8 @@ _pages: section: "Secció " image: "Imatges" button: "Botó " + dynamic: "Blocs dinàmics" + dynamicDescription: "Aquest bloc és antic. Ara en endavant fes servir {play}" note: "Incorporar una Nota" _note: id: "ID de la publicació" @@ -2180,6 +2550,7 @@ _notification: newNote: "Nota nova" unreadAntennaNote: "Antena {name}" roleAssigned: "Rol assignat " + chatRoomInvitationReceived: "T'han invitat a una sala de xat" emptyPushNotificationMessage: "Les notificacions han sigut actualitzades" achievementEarned: "Aconseguiment desblocat" testNotification: "Notificació de prova" @@ -2187,29 +2558,60 @@ _notification: sendTestNotification: "Enviar notificació de prova" notificationWillBeDisplayedLikeThis: "Les notificacions és veure'n així " reactedBySomeUsers: "Han reaccionat {n} usuaris" + likedBySomeUsers: "A {n} usuaris els hi agrada la teva nota" renotedBySomeUsers: "L'han impulsat {n} usuaris" + followedBySomeUsers: "Et segueixen {n} usuaris" + flushNotification: "Netejar notificacions" + exportOfXCompleted: "Completada l'exportació de {x}" + login: "Algú ha iniciat sessió " + createToken: "Token d'accés generat" + createTokenDescription: "Si no saps què és, esborra el token des de {text}." _types: all: "Tots" - follow: "Seguint" + note: "Notes noves" + follow: "Segueix-me" mention: "Menció" - renote: "Renotar" + reply: "Respostes" + renote: "Impulsos" quote: "Citar" reaction: "Reaccions" + pollEnded: "Enquesta terminada" + receiveFollowRequest: "Rebuda una petició de seguiment" + followRequestAccepted: "Petició de seguiment acceptada" + roleAssigned: "Rol donat" + chatRoomInvitationReceived: "Invitat a la sala de xat" + achievementEarned: "Assoliment desbloquejat" + exportCompleted: "Exportació completada" + login: "Iniciar sessió" + createToken: "Creació de tokens d'accés " + test: "Prova la notificació" + app: "Notificacions d'aplicacions" _actions: - followBack: "t'ha seguit també" + followBack: "També et segueix" reply: "Respondre" - renote: "Renotar" + renote: "Impulsar" _deck: + alwaysShowMainColumn: "Mostrar sempre la columna principal" columnAlign: "Alinea les columnes" addColumn: "Afig una columna" + newNoteNotificationSettings: "Configuració de notificacions per a notes noves" + configureColumn: "Configuració de columnes" swapLeft: "Mou a l’esquerra" swapRight: "Mou a la dreta" swapUp: "Mou cap amunt" swapDown: "Mou cap avall" + stackLeft: "Pila a la columna esquerra" popRight: "Col·loca a la dreta" profile: "Perfil" newProfile: "Perfil nou" deleteProfile: "Elimina el perfil" + introduction: "Crea la interfície perfecta posant les columnes allà on vulguis!" + introduction2: "Fes clic al botó + de la dreta per afegir noves columnes sempre que vulguis." + widgetsIntroduction: "Selecciona \"Editar ginys\" a la columna del menú i afegeix un." + useSimpleUiForNonRootPages: "Usa una interfície senzilla per a les pàgines navegades" + 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" _columns: main: "Principal" widgets: "Ginys" @@ -2220,21 +2622,84 @@ _deck: channel: "Canals" mentions: "Mencions" direct: "Publicacions directes" + roleTimeline: "Línia de temps dels rols" +_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}" +_disabledTimeline: + title: "Línia de tems desactivada" + description: "No pots fer servir aquesta línia de temps amb els teus rols actuals." +_drivecleaner: + orderBySizeDesc: "Mida del fitxer descendent" + orderByCreatedAtAsc: "Data ascendent" _webhookSettings: + createWebhook: "Crear un Webhook" + modifyWebhook: "Modificar un Webhook" name: "Nom" + secret: "Secret" + trigger: "Activador" active: "Activat" + _events: + follow: "Quan se segueix a un usuari" + followed: "Quan et segueixen" + note: "Quan es publica una nota" + reply: "Quan es rep una resposta" + renote: "Quan es renoti" + reaction: "Quan es rep una reacció " + mention: "Quan et mencionen" + _systemEvents: + abuseReport: "Quan reps un nou informe de moderació " + abuseReportResolved: "Quan resols un informe de moderació " + userCreated: "Quan es crea un usuari" + inactiveModeratorsWarning: "Quan el compte d'un moderador no té activitat durant un temps" + inactiveModeratorsInvitationOnlyChanged: "Quan el compte d'un moderador no té activitat durant un temps, i el servidor es canvia a registre per invitacions" + deleteConfirm: "Segur que vols esborrar el webhook?" + testRemarks: "Si feu clic al botó a la dreta de l'interruptor, podeu enviar un webhook de prova amb dades dummy." _abuseReport: _notificationRecipient: + createRecipient: "Afegeix un destinatari a l'informe de moderació " + modifyRecipient: "Editar un destinatari en l'informe de moderació " + recipientType: "Tipus de notificació " _recipientType: mail: "Correu electrònic" + webhook: "Webhook" + _captions: + mail: "Enviar un correu electrònic a tots els moderadors quan es rep un informe de moderació " + webhook: "Enviar una notificació al SystemWebhook quan es rebi o es resolgui un informe de moderació " + keywords: "Paraules clau" + notifiedUser: "Usuaris que s'han de notificar " + notifiedWebhook: "Webhook que s'ha de fer servir" + deleteConfirm: "Segur que vols esborrar el destinatari de l'informe de moderació?" _moderationLogTypes: + createRole: "Rol creat" + deleteRole: "Rol esborrat" + updateRole: "Rol actualitzat" + assignRole: "Assignat al rol" + unassignRole: "Esborrat del rol" suspend: "Suspèn" + unsuspend: "Suspensió treta" + addCustomEmoji: "Afegit emoji personalitzat" + updateCustomEmoji: "Actualitzat emoji personalitzat" + deleteCustomEmoji: "Esborrat emoji personalitzat" + updateServerSettings: "Configuració del servidor actualitzada" + updateUserNote: "Nota de moderació actualitzada" + deleteDriveFile: "Fitxer esborrat" + deleteNote: "Nota esborrada" + createGlobalAnnouncement: "Anunci global creat" + createUserAnnouncement: "Anunci individual creat" + updateGlobalAnnouncement: "Anunci global actualitzat" + updateUserAnnouncement: "Anunci individual actualitzat " + deleteGlobalAnnouncement: "Anunci global esborrat" + deleteUserAnnouncement: "Anunci individual esborrat " resetPassword: "Restableix la contrasenya" suspendRemoteInstance: "Servidor remot suspès " unsuspendRemoteInstance: "S'ha tret la suspensió del servidor remot" + updateRemoteInstanceNote: "Nota de moderació de la instància remota actualitzada" markSensitiveDriveFile: "Fitxer marcat com a sensible" unmarkSensitiveDriveFile: "S'ha tret la marca de sensible del fitxer" resolveAbuseReport: "Informe resolt" + forwardAbuseReport: "Informe reenviat" + updateAbuseReportNote: "Nota de moderació d'un informe actualitzat" createInvitation: "Crear codi d'invitació " createAd: "Anunci creat" deleteAd: "Anunci esborrat" @@ -2244,6 +2709,18 @@ _moderationLogTypes: deleteAvatarDecoration: "S'ha esborrat la decoració de l'avatar " unsetUserAvatar: "Esborrar l'avatar d'aquest usuari" unsetUserBanner: "Esborrar el bàner d'aquest usuari" + createSystemWebhook: "Crear un SystemWebhook" + updateSystemWebhook: "Actualitzar SystemWebhook" + deleteSystemWebhook: "Esborrar SystemWebhook" + createAbuseReportNotificationRecipient: "Crear un destinatari per l'informe de moderació " + updateAbuseReportNotificationRecipient: "Actualitzar destinatari per l'informe de moderació " + deleteAbuseReportNotificationRecipient: "Esborrar destinatari de l'informe de moderació " + deleteAccount: "Esborrar el compte " + deletePage: "Esborrar la pàgina" + deleteFlash: "Esborrar el guió" + deleteGalleryPost: "Esborrar la publicació de la galeria" + deleteChatRoom: "Esborra la sala de xat" + updateProxyAccountDescription: "Actualitzar descripció del compte proxy" _fileViewer: title: "Detall del fitxer" type: "Tipus de fitxer" @@ -2257,10 +2734,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Assegura't que qui distribueix aquest recurs és fiable abans d'instal·lar-ho." _plugin: title: "Vols instal·lar aquest afegit?" - metaTitle: "Informació de l'afegit " _theme: title: "Vols instal·lar aquest tema?" - metaTitle: "Informació del tema" _meta: base: "Paleta de colors base" _vendorInfo: @@ -2270,5 +2745,245 @@ _externalResourceInstaller: _errors: _invalidParams: title: "Paràmetres no vàlids " + description: "No hi ha suficient informació per carregar les dades del lloc extern. Confirma l'URL que hi ha escrita." + _resourceTypeNotSupported: + title: "El recurs extern no està suportat." + description: "Aquesta mena de recurs no està suportat. Contacta amb l'administrador." + _failedToFetch: + title: "Ha fallat l'obtenció de dades" + fetchErrorDescription: "Ha aparegut un error comunicant-se amb el lloc extern. Si després d'intentar-ho un altre cop no es resol, contacta amb l'administrador." + parseErrorDescription: "Ha aparegut un error processant les dades carregades del lloc extern. Contacta amb l'administrador." + _hashUnmatched: + title: "Ha fallat la verificació de les dades" + description: "Ha aparegut un error verificant les dades obtingudes. Com a mesura de seguretat la instal·lació no pot continuar. Contacta amb l'administrador." + _pluginParseFailed: + title: "Error d'AiScript" + description: "Les dades sol·licitades s'han obtingut correctament, però hem trobat un error durant el processament d'AiScript. Contacta amb l'autor de l'afegit. Detalls de l'error es pot veure a la consola JavaScript." + _pluginInstallFailed: + title: "La instal·lació de l'afegit a fallat" + description: "Ha aparegut un error durant la instal·lació de l'afegit. Intenta-ho una altra vegada. El detall de l'error es pot veure a la consola JavaScript." + _themeParseFailed: + title: "Ha fallat el processament del tema" + description: "Les dades sol·licitades s'han obtingut correctament, però hem trobat un error durant el processament del tema. Contacta amb l'autor de l'afegit. Detalls de l'error es pot veure a la consola JavaScript." + _themeInstallFailed: + title: "La instal·lació del tema a fallat" + description: "Ha aparegut un error durant la instal·lació del tema. Intenta-ho una altra vegada. El detall de l'error es pot veure a la consola JavaScript." +_dataSaver: + _media: + title: "Carregant multimèdia " + description: "Desactiva la càrrega automàtica d'imatges i vídeos. Les imatges i els vídeos amagats es carregaran quan es faci clic a sobre." + _avatar: + title: "Avatars animats" + description: "Detenir l'animació dels avatars animats. Les imatges animades solen tenir un pes més gran que les imatges normals, reduint el tràfic disponible." + _urlPreview: + title: "Miniatures vista prèvia de l'URL" + description: "Les imatges en miniatura que serveixen com a vista prèvia de les URLs no es tornaran a carregar." + _code: + title: "Ressaltat del codi " + description: "Quan s'utilitza codi MFM, no es llegeix fins que es copiï. En els punts destacats del codi s'han de llegir els fitxers definits per a cada llengua que resulti alt, però no es poden llegir automàticament, per la qual cosa es poden reduir les quantitats de comunicació." +_hemisphere: + N: "Hemisferi Nord " + S: "Hemisferi Sud" + caption: "El fan servir alguns clients per determinar l'estació de l'any." _reversi: + reversi: "Reversi" + gameSettings: "Opcions del joc" + chooseBoard: "Escull un taulell" + blackOrWhite: "Negres/Blanques" + blackIs: "{name} juga amb negres " + rules: "Regles" + thisGameIsStartedSoon: "El joc començarà en breu" + waitingForOther: "Esperant la tirada de l'oponent " + waitingForMe: "Esperant el teu torn" + waitingBoth: "Prepara't " + ready: "Preparat " + cancelReady: " No preparat " + opponentTurn: "Torn de l'oponent " + myTurn: "El teu torn" + turnOf: "Li toca a {name}" + pastTurnOf: "Torn de {name}" + surrender: "Rendeix-te" + surrendered: "T'has rendit" + timeout: "Temps esgotat" + drawn: "Empat" + won: "{name} ha guanyat" + black: "Negres" + white: "Blanques" total: "Total" + turnCount: "Torn {count}" + myGames: "Jugades" + allGames: "Totes les jugades" + ended: "Acabat" + playing: "Jugant" + isLlotheo: "Qui tingui menys pedres guanya (Llotheo)" + loopedMap: "Mapa de recursiu" + canPutEverywhere: "Les fitxes es poden posar a qualsevol lloc" + timeLimitForEachTurn: "Temps límit per jugada" + freeMatch: "Partida lliure" + lookingForPlayer: "Buscant contrincant..." + gameCanceled: "La partida s'ha cancel·lat " + shareToTlTheGameWhenStart: "Compartir la partida a la línia de temps quan comenci" + iStartedAGame: "La partida ha començat! #MisskeyReversi" + opponentHasSettingsChanged: "L'oponent h canviat la seva configuració " + allowIrregularRules: "Regles irregulars (totalment lliure)" + disallowIrregularRules: "Sense regles irregulars" + showBoardLabels: "Mostrar el número de línia i columna al tauler de joc" + useAvatarAsStone: "Fer servir els avatars dels usuaris com a fitxes" +_offlineScreen: + title: "Fora de línia - No es pot connectar amb el servidor" + header: "Impossible connectar amb el servidor" +_urlPreviewSetting: + title: "Configuració per a la previsualització de l'URL" + enable: "Activa la previsualització de l'URL" + timeout: "Temps màxim per carregar la previsualització de l'URL (ms)" + timeoutDescription: "Si l'obtenció de la previsualització triga més que el temps establert, no es generarà la vista prèvia." + maximumContentLength: "Longitud màxima del contingut (bytes)" + maximumContentLengthDescription: "Si la màxima longitud és més gran que aquest valor, la previsualització no es generarà." + requireContentLength: "Generar la previsualització només si es pot obtenir la longitud màxima " + requireContentLengthDescription: "Si l'altre servidor no proporciona la longitud màxima, la previsualització no es generarà." + userAgent: "User-Agent" + userAgentDescription: "Estableix l'User-Agent que és farà servir per a la recuperació de la vista prèvia. Si és deixa en blanc es farà servir l'User-Agent per defecte." + summaryProxy: "Proxy endpoints per generar vistes prèvies" + summaryProxyDescription: "La vista prèvia es genera fent servir Summaly proxy, no la genera el mateix Misskey." + summaryProxyDescription2: "Els següents paràmetres són passats al proxy com cadenes de consulta. Si el proxy no els admet, s'ignoren els valors configurats." +_mediaControls: + pip: "Imatge sobre impressionada " + playbackRate: "Velocitat de reproducció " + loop: "Reproducció en bucle" +_contextMenu: + title: "Menú contextual" + app: "Aplicació " + appWithShift: "Aplicació amb la tecla shift" + native: "Interfície del navegador" +_gridComponent: + _error: + requiredValue: "Aquest camp és obligatori" + columnTypeNotSupport: "La validació d'expressions regulars només s'admet per columnes de tipus text." + patternNotMatch: "Aquest valor no coincideix amb {pattern}" + notUnique: "Aquest valor ha de ser únic " +_roleSelectDialog: + notSelected: "No seleccionat" +_customEmojisManager: + _gridCommon: + copySelectionRows: "Copiar línies seleccionades " + copySelectionRanges: "Copiar selecció " + deleteSelectionRows: "Esborrar línies seleccionades" + deleteSelectionRanges: "Esborrar files de la selecció " + searchSettings: "Configuració del cercador" + searchSettingCaption: "Defineix criteris de cerca detallats." + searchLimit: "Nombre de pantalles" + sortOrder: "Ordenar" + registrationLogs: "Registres d'inscripcions " + registrationLogsCaption: "Quan s'actualitzin o s'esborrin emojis es mostrarà un registre. Desapareixeran quan s'actualitzin, s'esborrin, visitis una nova pàgina o la recarreguis." + alertEmojisRegisterFailedDescription: "No s'ha pogut actualitzar o esborrar l'emoji. Si us plau, dona una ullada al registre per més detalls." + _logs: + showSuccessLogSwitch: "Mostrar el registre d'èxit " + failureLogNothing: "No hi ha registres de fallades." + logNothing: "No hi ha registres." + _remote: + selectionRowDetail: "Detall de la línia seleccionada" + importSelectionRows: "Importar les files seleccionades" + importSelectionRangesRows: "Importar les files de la selecció " + importEmojisButton: "Importar els Emojis marcats" + confirmImportEmojisTitle: "Importar Emojis" + confirmImportEmojisDescription: "Importar {count} Emojis d'una adreça remota. Tingues cura de les llicències dels Emojis. Vols importar-los?" + _local: + tabTitleList: "Llistar els Emojis registrats" + tabTitleRegister: "Registre d'Emojis" + _list: + emojisNothing: "No hi ha Emojis registrats" + markAsDeleteTargetRows: "Files seleccionades que s'han d'esborrar " + markAsDeleteTargetRanges: "Selecció de files per la seva eliminació " + alertUpdateEmojisNothingDescription: "No hi ha Emojis actualitzats." + alertDeleteEmojisNothingDescription: "No hi ha Emoji per esborrar." + confirmMovePage: "Vols canviar de pàgina?" + confirmChangeView: "Vols canviar la pantalla?" + confirmUpdateEmojisDescription: "Actualitzar {count} Emojis. Vols executar-ho?" + confirmDeleteEmojisDescription: "Esborrar {count} Emojis marcats. Vols continuar?" + confirmResetDescription: "Es restabliran tots els canvis fets fins ara." + confirmMovePageDesciption: "S'han fet canvis als Emojis d'aquesta pàgina. Si continues navegant sense guardar els canvis, es perdran tots els canvis fets en aquesta pàgina." + dialogSelectRoleTitle: "Buscar Emojis per rol" + _register: + uploadSettingTitle: "Actualitza la configuració " + uploadSettingDescription: "En aquesta pantalla pots configurar el que s'ha de fer quan es puja un Emoji." + directoryToCategoryLabel: "Escriu el nom del directori al camp de \"categoria\"" + directoryToCategoryCaption: "Quan arrossegues un directori, escriu el nom del directori al camp categoria." + emojiInputAreaCaption: "Selecciona els Emojis que vols registrar gent servir un dels mètodes." + emojiInputAreaList1: "Arrossega i deixar anar fitxers o directoris dintre del quadrat." + emojiInputAreaList2: "Clica l'enllaç per seleccionar un fitxer des del teu ordinador." + emojiInputAreaList3: "Clica aquest enllaç per seleccionar del Disc" + confirmRegisterEmojisDescription: "Registrar els Emojis de la llista com a nous Emojis personalitzats. Vols continuar? (Per evitar una sobrecàrrega només {count} Emojis es poden registrar d'una sola vegada)" + confirmClearEmojisDescription: "Descartar els canvis i esborrar els Emojis de la llista. Vols continuar?" + confirmUploadEmojisDescription: "Pujar els {count} fitxers que has arrossegat al disc. Vols continuar?" +_embedCodeGen: + title: "Personalitza el codi per incrustar" + header: "Mostrar la capçalera" + autoload: "Carregar automàticament (no recomanat)" + maxHeight: "Alçada màxima" + maxHeightDescription: "0 anul·la la configuració màxima. Per evitar que continuï creixent verticalment, especifiqui qualsevol valor." + maxHeightWarn: "El límit màxim d'alçada és nul (0). Si això no és un canvi previst, estableix el màxim d'alçada a un cert valor." + previewIsNotActual: "La visualització és diferent de la que es mostra quan s'implanta." + rounded: "Angle recte" + border: "Afegeix un marc al contenidor" + applyToPreview: "Aplica a la vista prèvia" + generateCode: "Crea el codi per incrustar" + codeGenerated: "Codi generat" + codeGeneratedDescription: "Si us plau, enganxeu el codi generat al lloc web." +_selfXssPrevention: + warning: "Advertència " + title: "\"Enganxa qualsevol cosa en aquesta finestra\" És tot un engany." + description1: "Si posa alguna cosa al seu compte, un usuari malintencionat podria segrestar-la o robar-li les dades." + description2: "Si no entens que estàs fent %cpara ara mateix i tanca la finestra." + description3: "Per obtenir més informació. {link}" +_followRequest: + recieved: "Sol·licituds rebudes" + sent: "Sol·licituds enviades" +_remoteLookupErrors: + _federationNotAllowed: + title: "No es pot establir connexió amb aquest servidor" + description: "És possible que s'hagi desactivat la comunicació amb aquest servidor o que hagi estat bloquejat.\nPosa't en contacte amb l'administrador del servidor." + _uriInvalid: + title: "L'adreça és incorrecte" + description: "Hi ha un problema amb l'adreça introduïda; comprova que no hagis escrit caràcters que no es puguin fer servir." + _requestFailed: + title: "La sol·licitud a fallat" + description: "La comunicació amb aquest servidor a fallat. És possible que l'altre servidor no funcioni. Comprova també que no has posat una adreça no vàlida o inexistent." + _responseInvalid: + title: "La resposta no és correcta " + description: "Hem pogut comunicar-nos amb aquest servidor, però les dades rebudes no són correctes." + _noSuchObject: + title: "No s'ha trobat" + description: "No es pot trobar el recurs sol·licitat, si us plau comprova l'adreça una altra vegada." +_captcha: + verify: "Passar pel CAPTCHA" + testSiteKeyMessage: "Pots comprovar una vista prèvia introduïnt valors de prova per la clau del lloc i la clau secreta. Si vols més informació consulteu la següent pàgina." + _error: + _requestFailed: + title: "Ha fallat la sol·licitud del CAPTCHA" + text: "Si us plau, torna a intentar-ho d'aquí una estona o comprova els ajustos de nou." + _verificationFailed: + title: "Ha fallat la validació CAPTCHA" + text: "Comprova que els ajustos són els correctes." + _unknown: + title: "Error CAPTCHA" + text: "S'ha produït un error inesperat." +_bootErrors: + title: "Hi ha hagut en error en carregar" + serverError: "Si el problema persisteix després d'esperar una mica i recarregar, posa't en contacte amb l'administrador del servidor amb el següent codi d'error." + solution: "Per intentar resoldre el problema pots fer el següent." + solution1: "Actualitza el navegador i el sistema operatiu a l'última versió " + solution2: "Desactiva els adblockers" + solution3: "Esborra la memòria cau del navegador" + solution4: "(Navegador Tor) configura dom.webaudio.enabled a true" + otherOption: "Altres opcions" + otherOption1: "Esborrar la configuració i la memòria cau del client" + otherOption2: "Iniciar client senzill" + otherOption3: "Iniciar l'eina de reparació " +_search: + searchScopeAll: "Tot" + searchScopeLocal: "Local" + searchScopeServer: "Instància " + searchScopeUser: "Especificar usuari" + pleaseEnterServerHost: "Introdueix l'adreça de la instància " + pleaseSelectUser: "Selecciona un usuari" + serverHostPlaceholder: "Ex: misskey.example.com" diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml index 7db7424762..c5c8c0f860 100644 --- a/locales/cs-CZ.yml +++ b/locales/cs-CZ.yml @@ -5,9 +5,13 @@ introMisskey: "Vítejte! Misskey je otevřený a decentralizovaný microblogový poweredByMisskeyDescription: "{name} je jeden ze serverů využívající open source platformu Misskey (nazývaná \"Misskey instance\")." monthAndDay: "{day}. {month}." search: "Vyhledávání" +reset: "Obnovit" notifications: "Oznámení" username: "Uživatelské jméno" password: "Heslo" +initialPasswordForSetup: "Počáteční heslo pro nastavení" +initialPasswordIsIncorrect: "Počáteční heslo pro nastavení je nesprávné" +initialPasswordForSetupDescription: "Použijte heslo, které jste nastavili v konfiguračním souboru, pokud jste Misskey instalovali ručně.\nPokud užíváte Misskey hostovací službu, použijte poskytnuté heslo.\nPokud jste heslo nenastavovali, zanechte prázdné." forgotPassword: "Zapomenuté heslo" fetchingAsApObject: "Načítám data z Fediversu..." ok: "Potvrdit" @@ -45,6 +49,8 @@ pin: "Připnout" unpin: "Odepnout" copyContent: "Zkopírovat obsah" copyLink: "Kopírovat odkaz" +copyRemoteLink: "Zkoprírovat vzdálený odkaz" +copyLinkRenote: "Zkopírovat odkaz renotu" delete: "Smazat" deleteAndEdit: "Smazat a upravit" deleteAndEditConfirm: "Jste si jistí že chcete smazat tuto poznámku a editovat ji? Ztratíte tím všechny reakce, sdílení a odpovědi na ni." @@ -168,6 +174,9 @@ addAccount: "Přidat účet" reloadAccountsList: "Obnovit list účtů" loginFailed: "Přihlášení se nezdařilo." showOnRemote: "Více na původním profilu" +continueOnRemote: "Pokračujte na původní profil" +chooseServerOnMisskeyHub: "Vyberete si server z Misskey Hubu" +inputHostName: "Zadejte doménu" general: "Obecně" wallpaper: "Obrázek na pozadí" setWallpaper: "Nastavení obrázku na pozadí" @@ -192,6 +201,7 @@ perHour: "za hodinu" perDay: "za den" stopActivityDelivery: "Přestat zasílat aktivitu" blockThisInstance: "Blokovat tuto instanci" +silenceThisInstance: "Utišit tuto instanci" operations: "Operace" software: "Software" version: "Verze" @@ -256,7 +266,6 @@ removeAreYouSure: "Jste si jistí že chcete smazat \"{x}\"?" deleteAreYouSure: "Jste si jistí že chcete smazat \"{x}\"?" resetAreYouSure: "Opravdu resetovat?" saved: "Uloženo" -messaging: "Zprávy" upload: "Nahrát soubory" keepOriginalUploading: "Ponechat originální obrázek" keepOriginalUploadingDescription: "Uloží původní nahraný obrázek jak je. Pokud je to vypnuté, vygeneruje se zobrazení verze na webu při nahrátí." @@ -269,7 +278,6 @@ uploadFromUrlMayTakeTime: "Může trvat nějakou dobu, dokud nebude dokončeno n explore: "Objevovat" messageRead: "Přečtené" noMoreHistory: "To je vše" -startMessaging: "Zahájit chat" nUsersRead: "přečteno {n} uživateli" agreeTo: "Souhlasím s {0}" agree: "Souhlasím" @@ -348,7 +356,6 @@ enableLocalTimeline: "Povolit lokální čas" enableGlobalTimeline: "Povolit globální čas" disablingTimelinesInfo: "Administrátoři a Moderátoři budou mít stálý přístup ke všem časovým osám i přes to že nejsou zapnuté." registration: "Registrace" -enableRegistration: "Povolit registraci novým uživatelům" invite: "Pozvat" driveCapacityPerLocalAccount: "Kapacita disku na lokálního uživatele" driveCapacityPerRemoteAccount: "Kapacita disku na vzdáleného uživatele" @@ -366,8 +373,11 @@ hcaptcha: "hCaptcha" enableHcaptcha: "Aktivovat hCaptchu" hcaptchaSiteKey: "Klíč stránky" hcaptchaSecretKey: "Tajný Klíč (Secret Key)" +mcaptcha: "mCaptcha" +enableMcaptcha: "Aktivovat mCaptchu" mcaptchaSiteKey: "Klíč stránky" mcaptchaSecretKey: "Tajný Klíč (Secret Key)" +mcaptchaInstanceUrl: "URL mCaptcha serveru" recaptcha: "reCAPTCHA" enableRecaptcha: "Zapnout ReCAPTCHu" recaptchaSiteKey: "Klíč stránky" @@ -446,8 +456,6 @@ retype: "Zadejte znovu" noteOf: "{user} poznámky" quoteAttached: "Citace" quoteQuestion: "Přiložit jako citaci?" -noMessagesYet: "Zatím tu nejsou žádné zprávy" -newMessageExists: "Máte novou zprávu" onlyOneFileCanBeAttached: "Ke zprávě můžete přiložit jenom jeden soubor" signinRequired: "Přihlašte se, prosím" invitations: "Pozvat" @@ -471,7 +479,8 @@ uiLanguage: "Jazyk uživatelského rozhraní" aboutX: "O {x}" emojiStyle: "Styl emoji" native: "Výchozí" -disableDrawer: "Nepoužívat šuplíkové menu" +style: "Vzhled" +popup: "Vyskakovací okno" showNoteActionsOnlyHover: "Zobrazit akce poznámky jenom při naběhnutí myši" noHistory: "Žádná historie" signinHistory: "Historie přihlášení" @@ -534,6 +543,7 @@ showInPage: "Zobrazit na stránce" popout: "Pop-out" volume: "Hlasitost" masterVolume: "Celková hlasitost" +notUseSound: "Zakázat zvuk" details: "Detaily" chooseEmoji: "Vybrat emotikon" unableToProcess: "Operace nebyla dokončena." @@ -658,10 +668,7 @@ abuseReported: "Nahlášení bylo odesláno. Děkujeme převelice." reporter: "Nahlásil" reporteeOrigin: "Původ nahlášení" reporterOrigin: "Původ nahlasovače" -forwardReport: "Přeposlat nahlášení do vzdálené instance" -forwardReportIsAnonymous: "Místo vašeho účtu se ve vzdálené instanci zobrazí anonymní systémový účet jako nahlašovač." send: "Odeslat" -abuseMarkAsResolved: "Označit nahlášení jako vyřešené" openInNewTab: "Otevřít v nové kartě" openInSideView: "Otevřít v bočním panelu" defaultNavigationBehaviour: "Výchozí chování navigace" @@ -1099,6 +1106,14 @@ sourceCode: "Zdrojový kód" flip: "Otočit" lastNDays: "Posledních {n} dnů" surrender: "Zrušit" +postForm: "Formulář pro odeslání" +information: "Informace" +_chat: + invitations: "Pozvat" + noHistory: "Žádná historie" + members: "Členové" + home: "Domů" + send: "Odeslat" _delivery: stop: "Suspendováno" _type: @@ -1633,7 +1648,6 @@ _theme: buttonBg: "Pozadí tlačítka" buttonHoverBg: "Pozadí tlačítka (Hover)" inputBorder: "Ohraničení vstupního pole" - listItemHoverBg: "Pozadí položky seznamu (Hover)" driveFolderBg: "Pozadí složky disku" wallpaperOverlay: "Překrytí tapety" badge: "Odznak" @@ -1715,6 +1729,7 @@ _permissions: "write:gallery": "Upravit galerii" "read:gallery-likes": "Zobrazit seznam to se mi líbí příspěvků v galerii" "write:gallery-likes": "Upravit seznam to se mi líbí příspěvků v galerii" + "write:chat": "Sestavit nebo mazat zprávy chatu" _auth: shareAccessTitle: "Udělovat oprávnění k aplikacím" shareAccess: "Chcete autorizovat \"{name}\" pro přístup k tomuto účtu?" @@ -1889,9 +1904,6 @@ _pages: newPage: "Vytvořit novou stránku" editPage: "Upravit stránku" readPage: "Prohlížení zdroje této stránky" - created: "Stránka byla úspěšně vytvořena" - updated: "Stránka byla úspěšně aktualizována" - deleted: "Stránka byla úspěšně smazána" pageSetting: "Nastavení stránky" nameAlreadyExists: "Zadaná adresa URL stránky již existuje" invalidNameTitle: "Zadaná adresa URL stránky je neplatná" @@ -1963,6 +1975,7 @@ _notification: receiveFollowRequest: "Obdržené žádosti o sledování" followRequestAccepted: "Přijaté žádosti o sledování" achievementEarned: "Úspěch odemčen" + login: "Přihlásit se" app: "Oznámení z propojených aplikací" _actions: followBack: "vás začal sledovat zpět" @@ -2029,3 +2042,10 @@ _moderationLogTypes: createInvitation: "Vygenerovat pozvánku" _reversi: total: "Celkem" +_remoteLookupErrors: + _noSuchObject: + title: "Nenalezeno" +_search: + searchScopeAll: "Vše" + searchScopeLocal: "Místní" + searchScopeUser: "Upřesnit uživatele" diff --git a/locales/de-DE.yml b/locales/de-DE.yml index 8e44a3bbd4..ee0a97098c 100644 --- a/locales/de-DE.yml +++ b/locales/de-DE.yml @@ -5,9 +5,13 @@ introMisskey: "Willkommen! Misskey ist eine dezentralisierte Open-Source Microbl poweredByMisskeyDescription: "{name} ist einer der durch die Open-Source-Plattform Misskey betriebenen Dienste." monthAndDay: "{day}.{month}." search: "Suchen" +reset: "Zurücksetzen" notifications: "Benachrichtigungen" username: "Benutzername" password: "Passwort" +initialPasswordForSetup: "Initiales Passwort für die Einrichtung" +initialPasswordIsIncorrect: "Das initiale Passwort für die Einrichtung ist falsch" +initialPasswordForSetupDescription: "Verwende das in der Konfigurationsdatei angegebene Passwort, wenn du Misskey selbst installiert hast.\nWenn du einen Misskey-Hostingdienst o.ä. nutzt, verwende das dort angegebene Kennwort.\nWenn du kein Passwort festgelegt hast, lasse es leer, um fortzufahren." forgotPassword: "Passwort vergessen" fetchingAsApObject: "Wird aus dem Fediverse angefragt …" ok: "OK" @@ -45,6 +49,7 @@ pin: "An dein Profil anheften" unpin: "Von deinem Profil lösen" copyContent: "Inhalt kopieren" copyLink: "Link kopieren" +copyRemoteLink: "Remote-Link kopieren" copyLinkRenote: "Renote-Link kopieren" delete: "Löschen" deleteAndEdit: "Löschen und Bearbeiten" @@ -60,6 +65,7 @@ copyFileId: "Datei-ID kopieren" copyFolderId: "Ordner-ID kopieren" copyProfileUrl: "Profil-URL kopieren" searchUser: "Nach einem Benutzer suchen" +searchThisUsersNotes: "Notizen dieses Benutzers suchen" reply: "Antworten" loadMore: "Mehr laden" showMore: "Mehr anzeigen" @@ -108,11 +114,14 @@ enterEmoji: "Gib ein Emoji ein" renote: "Renote" unrenote: "Renote zurücknehmen" renoted: "Renote getätigt." +renotedToX: "Renoted zu {name}." cantRenote: "Renote dieses Beitrags nicht möglich." cantReRenote: "Renote einer Renote nicht möglich." quote: "Zitieren" inChannelRenote: "Kanal-interner Renote" inChannelQuote: "Kanal-internes Zitat" +renoteToChannel: "Renote zu Kanal" +renoteToOtherChannel: "Renote zu anderem Kanal" pinnedNote: "Angeheftete Notiz" pinned: "Angeheftet" you: "Du" @@ -124,12 +133,13 @@ reactions: "Reaktionen" emojiPicker: "Emoji auswählen" pinnedEmojisForReactionSettingDescription: "Lege Emojis fest, die angepinnt werden sollen, um sie beim Reagieren als Erstes anzuzeigen." pinnedEmojisSettingDescription: "Lege Emojis fest, die angepinnt werden sollen, um sie in der Emoji-Auswahl als Erstes anzuzeigen" +emojiPickerDisplay: "Anzeige der Emoji-Auswahl" overwriteFromPinnedEmojisForReaction: "Überschreiben mit den Reaktions-Einstellungen" overwriteFromPinnedEmojis: "Überschreiben mit den allgemeinen Einstellungen" reactionSettingDescription2: "Ziehe um Anzuordnen, klicke um zu löschen, drücke „+“ um hinzuzufügen" rememberNoteVisibility: "Notizsichtbarkeit merken" attachCancel: "Anhang entfernen" -deleteFile: "Datei gelöscht" +deleteFile: "Datei löschen" markAsSensitive: "Als sensibel markieren" unmarkAsSensitive: "Als nicht sensibel markieren" enterFileName: "Dateinamen eingeben" @@ -150,6 +160,7 @@ editList: "Liste bearbeiten" selectChannel: "Kanal auswählen" selectAntenna: "Antenne auswählen" editAntenna: "Antenne bearbeiten" +createAntenna: "Erstelle eine Antenne" selectWidget: "Widget auswählen" editWidgets: "Widgets bearbeiten" editWidgetsExit: "Fertig" @@ -176,6 +187,10 @@ addAccount: "Benutzerkonto hinzufügen" reloadAccountsList: "Benutzerkontoliste aktualisieren" loginFailed: "Anmeldung fehlgeschlagen" showOnRemote: "Auf Ursprungsinstanz ansehen" +continueOnRemote: "Weiter auf Remote-Server" +chooseServerOnMisskeyHub: "Wähle einen Server aus dem Misskey Hub" +specifyServerHost: "Server-Host auswählen" +inputHostName: "Gib die Domain an" general: "Allgemein" wallpaper: "Hintergrund" setWallpaper: "Hintergrund festlegen" @@ -186,6 +201,7 @@ followConfirm: "Möchtest du {name} wirklich folgen?" proxyAccount: "Proxy-Benutzerkonto" proxyAccountDescription: "Ein Proxy-Konto ist ein Benutzerkonto, das unter bestimmten Bedingungen als Follower für Benutzer fremder Instanzen fungiert. Wenn zum Beispiel ein Benutzer einen Benutzer einer fremden Instanz zu einer Liste hinzufügt, werden die Aktivitäten des entfernten Benutzers nicht an die Instanz übermittelt, wenn kein lokaler Benutzer diesem Benutzer folgt; stattdessen folgt das Proxy-Konto." host: "Hostname" +selectSelf: "Mich auswählen" selectUser: "Benutzer auswählen" recipient: "Empfänger" annotation: "Anmerkung" @@ -201,6 +217,7 @@ perDay: "Pro Tag" stopActivityDelivery: "Senden von Aktivitäten einstellen" blockThisInstance: "Diese Instanz blockieren" silenceThisInstance: "Instanz stummschalten" +mediaSilenceThisInstance: "Medien dieses Servers stummschalten" operations: "Aktionen" software: "Software" version: "Version" @@ -222,6 +239,10 @@ blockedInstances: "Blockierte Instanzen" blockedInstancesDescription: "Gib die Hostnamen der Instanzen, welche blockiert werden sollen, durch Zeilenumbrüche getrennt an. Blockierte Instanzen können mit dieser instanz nicht mehr kommunizieren." silencedInstances: "Stummgeschaltete Instanzen" silencedInstancesDescription: "Gib die Hostnamen der Instanzen, welche stummgeschaltet werden sollen, durch Zeilenumbrüche getrennt an. Alle Konten dieser Instanzen werden als stummgeschaltet behandelt, können nur noch Follow-Anfragen stellen und wenn nicht gefolgt keine lokalen Konten erwähnen. Blockierte Instanzen sind davon nicht betroffen." +mediaSilencedInstances: "Medien-stummgeschaltete Server" +mediaSilencedInstancesDescription: "Gib pro Zeile die Hostnamen der Server ein, dessen Medien du stummschalten möchtest. Alle Benutzerkonten der aufgeführten Server werden als sensibel behandelt und können keine benutzerdefinierten Emojis verwenden. Gesperrte Server sind davon nicht betroffen." +federationAllowedHosts: "Föderierte Instanzen" +federationAllowedHostsDescription: "Trage die Hostnamen ein mit den du eine Föderation eingehen möchtest. Trenne mit Zeilenumbruch." muteAndBlock: "Stummschaltungen und Blockierungen" mutedUsers: "Stummgeschaltete Benutzer" blockedUsers: "Blockierte Benutzer" @@ -268,7 +289,6 @@ deleteAreYouSure: "Möchtest du „{x}“ wirklich löschen?" resetAreYouSure: "Wirklich zurücksetzen?" areYouSure: "Bist du sicher?" saved: "Erfolgreich gespeichert" -messaging: "Chat" upload: "Hochladen" keepOriginalUploading: "Originalbild speichern" keepOriginalUploadingDescription: "Speichert das Originalbild so, wie es ist. Ist dies deaktiviert, wird eine Version zum Anzeigen im Internet generiert." @@ -281,7 +301,7 @@ uploadFromUrlMayTakeTime: "Es kann eine Weile dauern, bis das Hochladen abgeschl explore: "Erkunden" messageRead: "Gelesen" noMoreHistory: "Kein weiterer Verlauf vorhanden" -startMessaging: "Neuen Chat erstellen" +startChat: "Chat starten" nUsersRead: "Von {n} Benutzern gelesen" agreeTo: "Ich stimme {0} zu" agree: "Zustimmen" @@ -312,6 +332,7 @@ selectFile: "Datei auswählen" selectFiles: "Dateien auswählen" selectFolder: "Ordner auswählen" selectFolders: "Ordner auswählen" +fileNotSelected: "Keine Datei ausgewählt" renameFile: "Datei umbenennen" folderName: "Ordnername" createFolder: "Ordner erstellen" @@ -319,6 +340,7 @@ renameFolder: "Ordner umbenennen" deleteFolder: "Ordner löschen" folder: "Ordner" addFile: "Datei hinzufügen" +showFile: "Datei anzeigen" emptyDrive: "Deine Drive ist leer" emptyFolder: "Dieser Ordner ist leer" unableToDelete: "Nicht löschbar" @@ -361,7 +383,6 @@ enableLocalTimeline: "Lokale Chronik aktivieren" enableGlobalTimeline: "Globale Chronik aktivieren" disablingTimelinesInfo: "Administratoren und Moderatoren haben immer Zugriff auf alle Chroniken, auch wenn diese deaktiviert sind." registration: "Registrieren" -enableRegistration: "Registrierung neuer Benutzer erlauben" invite: "Einladen" driveCapacityPerLocalAccount: "Drive-Kapazität pro lokalem Benutzerkonto" driveCapacityPerRemoteAccount: "Drive-Kapazität pro Benutzer fremder Instanzen" @@ -399,6 +420,7 @@ name: "Name" antennaSource: "Antennenquelle" antennaKeywords: "Zu beobachtende Schlüsselwörter" antennaExcludeKeywords: "Zu ignorierende Schlüsselwörter" +antennaExcludeBots: "Bot-Accounts ausschließen" antennaKeywordsDescription: "Zum Nutzen einer \"UND\"-Verknüpfung Einträge mit Leerzeichen trennen, zum Nutzen einer \"ODER\"-Verknüpfung Einträge mit einem Zeilenumbruch trennen" notifyAntenna: "Über neue Notizen benachrichtigen" withFileAntenna: "Nur Notizen mit Dateien" @@ -432,6 +454,7 @@ totpDescription: "Logge dich via Authentifizierungs-App mit Einmalpasswort ein" moderator: "Moderator" moderation: "Moderation" moderationNote: "Moderationsnotiz" +moderationNoteDescription: "Trage hier Notizen ein. Diese sind nur für die Moderatoren sichtbar." addModerationNote: "Moderationsnotiz hinzufügen" moderationLogs: "Moderationsprotokolle" nUsersMentioned: "Von {n} Benutzern erwähnt" @@ -466,10 +489,10 @@ retype: "Erneut eingeben" noteOf: "Notiz von {user}" quoteAttached: "Zitat" quoteQuestion: "Als Zitat anhängen?" -noMessagesYet: "Noch keine Nachrichten vorhanden" -newMessageExists: "Du hast eine neue Nachricht" +attachAsFileQuestion: "Der Text in der Zwischenablage ist lang. Möchtest du ihn als Textdatei anhängen?" onlyOneFileCanBeAttached: "Es kann pro Nachricht nur eine Datei angehängt werden" signinRequired: "Bitte registriere oder melde dich an, um fortzufahren" +signinOrContinueOnRemote: "Um fortzufahren, gehe zu deiner Instanz oder registriere bzw. melde dich an dieser Instanz an. " invitations: "Einladungen" invitationCode: "Einladungscode" checking: "Wird überprüft …" @@ -491,8 +514,12 @@ uiLanguage: "Sprache der Benutzeroberfläche" aboutX: "Über {x}" emojiStyle: "Emoji-Stil" native: "Nativ" -disableDrawer: "Keine ausfahrbaren Menüs verwenden" +menuStyle: "Menü Stil" +style: "Stil" +drawer: "App-Übersicht" +popup: "Pop-up" showNoteActionsOnlyHover: "Notizmenü nur bei Mouseover anzeigen" +showReactionsCount: "Zeige die Anzahl der Reaktionen auf Notizen an" noHistory: "Kein Verlauf gefunden" signinHistory: "Anmeldungsverlauf" enableAdvancedMfm: "Erweitertes MFM aktivieren" @@ -558,6 +585,7 @@ masterVolume: "Gesamtlautstärke" notUseSound: "Gebe kein Ton aus" useSoundOnlyWhenActive: "Gebe nur Ton aus, wenn Misskey aktiv ist" details: "Details" +renoteDetails: "Renote Details" chooseEmoji: "Emoji auswählen" unableToProcess: "Der Vorgang konnte nicht abgeschlossen werden" recentUsed: "Vor kurzem verwendet" @@ -573,6 +601,8 @@ ascendingOrder: "Aufsteigende Reihenfolge" descendingOrder: "Absteigende Reihenfolge" scratchpad: "Testumgebung" scratchpadDescription: "Die Testumgebung bietet einen Bereich für AiScript-Experimente. Dort kannst du AiScript schreiben, ausführen sowie dessen Auswirkungen auf Misskey überprüfen." +uiInspector: "UI-Inspektor" +uiInspectorDescription: "Die Liste der UI-Komponenten-Server können im Zwischenspeicher angesehen werden. Die UI-Komponente wird von der Funktion Ui:C: generiert." output: "Ausgabe" script: "Skript" disablePagesScript: "AiScript auf Seiten deaktivieren" @@ -653,13 +683,19 @@ smtpSecure: "Für SMTP-Verbindungen implizit SSL/TLS verwenden" smtpSecureInfo: "Schalte dies aus, falls du STARTTLS verwendest." testEmail: "Emailversand testen" wordMute: "Wortstummschaltung" +wordMuteDescription: "Minimiert Notizen, die das angegebene Wort oder den angegebenen Ausdruck enthalten. Minimierte Notizen können angezeigt werden, indem du auf sie klickst." +hardWordMute: "Harte Wortstummschaltung" +showMutedWord: "Stummgeschaltete Wörter anzeigen" +hardWordMuteDescription: "Blendet Notizen aus, die das angegebene Wort oder die angegebene Phrase enthalten. Im Gegensatz zur Wortstummschaltung wird die Notiz vollständig ausgeblendet." regexpError: "Fehler in einem regulären Ausdruck" regexpErrorDescription: "Im regulären Ausdruck deiner in Zeile {line} von {tab}en Wortstummschaltungen ist ein Fehler aufgetreten:" instanceMute: "Instanzstummschaltungen" userSaysSomething: "{name} hat etwas gesagt" +userSaysSomethingAbout: "{name} sagt etwas über '{word}'" makeActive: "Aktivieren" display: "Anzeigeart" copy: "Kopieren" +copiedToClipboard: "In die Zwischenablage kopiert" metrics: "Metriken" overview: "Übersicht" logs: "Protokolle" @@ -674,6 +710,7 @@ useGlobalSettingDesc: "Ist diese Option aktiviert, werden die Benachrichtigungse other: "Anderes" regenerateLoginToken: "Anmeldetoken regenerieren" regenerateLoginTokenDescription: "Den zur Anmeldung intern verwendeten Token regenerieren. Normalerweise wird dies nicht benötigt. Bei Regeneration werden alle Geräte ausgeloggt." +theKeywordWhenSearchingForCustomEmoji: "Das ist das Schlagwort beim Suchen von benutzerdefinierten Emojis." setMultipleBySeparatingWithSpace: "Trenne Elemente durch ein Leerzeichen um mehrere Einstellungen zu kofigurieren." fileIdOrUrl: "Datei-ID oder URL" behavior: "Verhalten" @@ -687,10 +724,7 @@ abuseReported: "Deine Meldung wurde versendet. Vielen Dank." reporter: "Melder" reporteeOrigin: "Herkunft des Gemeldeten" reporterOrigin: "Herkunft des Meldenden" -forwardReport: "Meldung an fremde Instanz weiterleiten" -forwardReportIsAnonymous: "Anstatt deines Benutzerkontos wird bei der fremden Instanz ein anonymes Systemkonto als Melder angezeigt." send: "Senden" -abuseMarkAsResolved: "Meldung als gelöst markieren" openInNewTab: "In neuem Tab öffnen" openInSideView: "In Seitenansicht öffnen" defaultNavigationBehaviour: "Standardnavigationsverhalten" @@ -827,6 +861,7 @@ administration: "Verwaltung" accounts: "Benutzerkonten" switch: "Wechseln" noMaintainerInformationWarning: "Betreiberinformationen sind nicht konfiguriert." +noInquiryUrlWarning: "Keine gültige Kontakt-URL." noBotProtectionWarning: "Schutz vor Bots ist nicht konfiguriert." configure: "Konfigurieren" postToGallery: "Neuen Galeriebeitrag erstellen" @@ -886,9 +921,12 @@ makeReactionsPublicDescription: "Jeder wird die Liste deiner gesendeten Reaktion classic: "Classic" muteThread: "Thread stummschalten" unmuteThread: "Threadstummschaltung aufheben" +followingVisibility: "Sichtbarkeit der Gefolgten" +followersVisibility: "Sichtbarkeit der Folgenden" continueThread: "Weiteren Threadverlauf anzeigen" deleteAccountConfirm: "Dein Benutzerkonto wird unwiderruflich gelöscht. Trotzdem fortfahren?" incorrectPassword: "Falsches Passwort." +incorrectTotp: "Das Einmalpasswort ist falsch oder abgelaufen." voteConfirm: "Wirklich für „{choice}“ abstimmen?" hide: "Inhalt verbergen" useDrawerReactionPickerForMobile: "Auf mobilen Geräten ausfahrbare Reaktionsauswahl anzeigen" @@ -913,6 +951,9 @@ oneHour: "Eine Stunde" oneDay: "Einen Tag" oneWeek: "Eine Woche" oneMonth: "1 Monat" +threeMonths: "3 Monate" +oneYear: "1 Jahr" +threeDays: "3 Tage" reflectMayTakeTime: "Es kann etwas dauern, bis sich dies widerspiegelt." failedToFetchAccountInformation: "Benutzerkontoinformationen konnten nicht abgefragt werden" rateLimitExceeded: "Versuchsanzahl überschritten" @@ -986,6 +1027,7 @@ neverShow: "Nicht wieder anzeigen" remindMeLater: "Vielleicht später" didYouLikeMisskey: "Gefällt dir Misskey?" pleaseDonate: "Misskey ist die kostenlose Software, die von {host} verwendet wird. Wir würden uns über Spenden freuen, damit dessen Entwicklung weitergeführt werden kann!" +correspondingSourceIsAvailable: "Der entsprechende Quellcode ist verfügbar unter {anchor}" roles: "Rollen" role: "Rolle" noRole: "Rolle nicht gefunden" @@ -1013,6 +1055,7 @@ thisPostMayBeAnnoyingHome: "Zur Startseite schicken" thisPostMayBeAnnoyingCancel: "Abbrechen" thisPostMayBeAnnoyingIgnore: "Trotzdem schicken" collapseRenotes: "Bereits gesehene Renotes verkürzt anzeigen" +collapseRenotesDescription: "Klappe Notizen ein, auf die du bereits reagiert oder die du renoted hast." internalServerError: "Serverinterner Fehler" internalServerErrorDescription: "Im Server ist ein unerwarteter Fehler aufgetreten." copyErrorInfo: "Fehlerdetails kopieren" @@ -1036,6 +1079,8 @@ resetPasswordConfirm: "Wirklich Passwort zurücksetzen?" sensitiveWords: "Sensible Wörter" sensitiveWordsDescription: "Die Notizsichtbarkeit aller Notizen, die diese Wörter enthalten, wird automatisch auf \"Startseite\" gesetzt. Durch Zeilenumbrüche können mehrere konfiguriert werden." sensitiveWordsDescription2: "Durch die Verwendung von Leerzeichen können AND-Verknüpfungen angegeben werden und durch das Umgeben von Schrägstrichen können reguläre Ausdrücke verwendet werden." +prohibitedWords: "Verbotene Wörter" +prohibitedWordsDescription: "Aktiviert eine Fehlermeldung, wenn versucht wird, eine Notiz zu veröffentlichen, die das/die eingestellte(n) Wort(e) enthält. Mehrere Begriffe können durch Zeilenumbrüche getrennt festgelegt werden." prohibitedWordsDescription2: "Durch die Verwendung von Leerzeichen können AND-Verknüpfungen angegeben werden und durch das Umgeben von Schrägstrichen können reguläre Ausdrücke verwendet werden." hiddenTags: "Ausgeblendete Hashtags" hiddenTagsDescription: "Die hier eingestellten Tags werden nicht mehr in den Trends angezeigt. Mit der Umschalttaste können mehrere ausgewählt werden." @@ -1049,12 +1094,15 @@ retryAllQueuesConfirmTitle: "Wirklich erneut versuchen?" retryAllQueuesConfirmText: "Dies wird zu einer temporären Erhöhung der Serverlast führen." enableChartsForRemoteUser: "Diagramme für Nutzer fremder Instanzen erstellen" enableChartsForFederatedInstances: "Diagramme für fremde Instanzen erstellen" +enableStatsForFederatedInstances: "Abruf von Informationen über förderierte Server" showClipButtonInNoteFooter: "\"Clip\" zum Notizmenu hinzufügen" reactionsDisplaySize: "Reaktionsanzeigegröße" limitWidthOfReaction: "Begrenze die Breite der Reaktion und zeige sie verkleinert an" noteIdOrUrl: "Notiz-ID oder URL" video: "Video" videos: "Videos" +audio: "Audio" +audioFiles: "Audio" dataSaver: "Datensparmodus" accountMigration: "Kontomigration" accountMoved: "Dieser Benutzer ist zu einem neuen Konto migriert:" @@ -1082,6 +1130,8 @@ preservedUsernames: "Reservierte Benutzernamen" preservedUsernamesDescription: "Gib zu reservierende Benutzernamen durch Zeilenumbrüche getrennt an. Diese werden für die Registrierung gesperrt, können aber von Administratoren zur manuellen Erstellung von Konten verwendet werden. Existierende Konten, die diese Namen bereits verwenden, werden nicht beeinträchtigt." createNoteFromTheFile: "Notiz für diese Datei schreiben" archive: "Archivieren" +archived: "Archiviert" +unarchive: "Dearchivieren" channelArchiveConfirmTitle: "{name} wirklich archivieren?" channelArchiveConfirmDescription: "Ein archivierter Kanal taucht nicht mehr in der Kanalliste oder in Suchergebnissen auf. Zudem können ihm keine Beiträge mehr hinzugefügt werden." thisChannelArchived: "Dieser Kanal wurde archiviert." @@ -1092,6 +1142,9 @@ preventAiLearning: "Verwendung in machinellem Lernen (Generative bzw. Prediktive preventAiLearningDescription: "Fordert Crawler auf, gepostetes Text- oder Bildmaterial usw. nicht in Datensätzen für maschinelles Lernen (Generative bzw. Prediktive AI/KI) zu verwenden. Dies wird durch das Hinzufügen einer \"noai\"-Flag in der HTML-Antwort des jeweiligen Inhalts erreicht. Da diese Flag jedoch ignoriert werden kann, ist eine vollständige Verhinderung hierdurch nicht möglich." options: "Optionen" specifyUser: "Spezifischer Benutzer" +lookupConfirm: "Bist du sicher, dass du das nachschlagen möchtest?" +openTagPageConfirm: "Hashtag Seite wirklich öffnen?" +specifyHost: "Host" failedToPreviewUrl: "Vorschau nicht anzeigbar" update: "Aktualisieren" rolesThatCanBeUsedThisEmojiAsReaction: "Rollen, die dieses Emoji als Reaktion verwenden können" @@ -1150,6 +1203,7 @@ showRenotes: "Renotes anzeigen" edited: "Bearbeitet" notificationRecieveConfig: "Benachrichtigungseinstellungen" mutualFollow: "Gegenseitig gefolgt" +followingOrFollower: "Follow oder Follower" fileAttachedOnly: "Nur Notizen mit Dateien" showRepliesToOthersInTimeline: "Antworten in Chronik anzeigen" hideRepliesToOthersInTimeline: "Antworten nicht in Chronik anzeigen" @@ -1159,6 +1213,12 @@ confirmShowRepliesAll: "Dies ist eine unwiderrufliche Aktion. Wirklich Antworten confirmHideRepliesAll: "Dies ist eine unwiderrufliche Aktion. Wirklich Antworten von allen momentan gefolgten Benutzern nicht in der Chronik anzeigen?" externalServices: "Externe Dienste" sourceCode: "Quellcode" +sourceCodeIsNotYetProvided: "Der Quellcode ist noch nicht verfügbar. Kontaktiere den Administrator, um das Problem zu lösen." +repositoryUrl: "Repository URL" +repositoryUrlDescription: "Solltest du Misskey so wie es ist verwenden (im unveränderten Quellcode), gebe Folgendes an:\nhttps://github.com/misskey-dev/misskey" +repositoryUrlOrTarballRequired: "Wenn du kein Repository veröffentlicht hast, musst du stattdessen einen Tarball bereitstellen. Siehe .config/example.yml für weitere Informationen." +feedback: "Feedback" +feedbackUrl: "Feedback-Website" impressum: "Impressum" impressumUrl: "Impressums-URL" impressumDescription: "In manchen Ländern, wie Deutschland und dessen Umgebung, ist die Angabe von Betreiberinformationen (ein Impressum) bei kommerziellem Betrieb zwingend." @@ -1168,6 +1228,7 @@ tosAndPrivacyPolicy: "Nutzungsbedingungen und Datenschutzerklärung" avatarDecorations: "Profilbilddekoration" attach: "Anbringen" detach: "Entfernen" +detachAll: "Alles Entfernen" angle: "Winkel" flip: "Umdrehen" showAvatarDecorations: "Profilbilddekoration anzeigen" @@ -1180,15 +1241,223 @@ signupPendingError: "Beim Überprüfen der Mailadresse ist etwas schiefgelaufen. cwNotationRequired: "Ist \"Inhaltswarnung verwenden\" aktiviert, muss eine Beschreibung gegeben werden." doReaction: "Reagieren" code: "Code" +reloadRequiredToApplySettings: "Eine Aktualisierung ist erforderlich, um die Einstellungen zu übernehmen." +remainingN: "Verbleibend: {n}" +overwriteContentConfirm: "Bist du sicher, dass du den aktuellen Inhalt überschreiben willst?" +seasonalScreenEffect: "Saisonaler Bildschirmeffekt" decorate: "Dekorieren" addMfmFunction: "MFM hinzufügen" +enableQuickAddMfmFunction: "Erweiterte MFM-Auswahl anzeigen" +bubbleGame: "Bubble Game" sfx: "Soundeffekte" -lastNDays: "Letzten {n} Tage" +soundWillBePlayed: "Es wird Ton wiedergegeben" +showReplay: "Wiederholung anzeigen" +replay: "Aufzeichnen" +replaying: "Aufzeichnung" +endReplay: "Aufzeichnung verlassen" +copyReplayData: "Aufzeichnung kopieren" +ranking: "Rangliste" +lastNDays: "Letzte {n} Tage" +backToTitle: "Zurück zum Startbildschirm" +hemisphere: "Hemisphäre" +withSensitive: "Zeige \"sensitive Inhalte\" an" +userSaysSomethingSensitive: "{name} sagt etwas mit sensiblem Inhalt." +enableHorizontalSwipe: "Wischen, um zwischen Tabs zu wechseln" +loading: "Laden" surrender: "Abbrechen" +gameRetry: "Erneut versuchen" +notUsePleaseLeaveBlank: "Leer lassen, wenn nicht verwendet" +useTotp: "Gib das Einmalpasswort ein" +useBackupCode: "Verwende die Backup-Codes" +launchApp: "Starte die App" +useNativeUIForVideoAudioPlayer: "Browser-Benutzeroberfläche für die Video- und Audiowiedergabe verwenden" +keepOriginalFilename: "Ursprünglichen Dateinamen beibehalten" +keepOriginalFilenameDescription: "Wenn diese Einstellung deaktiviert ist, wird der Dateiname beim Hochladen automatisch durch eine zufällige Zeichenfolge ersetzt." +noDescription: "Keine Beschreibung vorhanden" +alwaysConfirmFollow: "Folgen immer bestätigen" +inquiry: "Kontakt" +tryAgain: "Bitte später erneut versuchen" +confirmWhenRevealingSensitiveMedia: "Das Anzeigen von sensiblen Medien bestätigen" +sensitiveMediaRevealConfirm: "Es könnte sich um sensible Medien handeln. Möchtest du sie anzeigen?" +createdLists: "Erstellte Listen" +createdAntennas: "Erstellte Antennen" +fromX: "Von {x}" +genEmbedCode: "Einbettungscode generieren" +noteOfThisUser: "Notizen dieses Benutzers" +clipNoteLimitExceeded: "Zu diesem Clip können keine weiteren Notizen hinzugefügt werden." +performance: "Leistung" +modified: "Bearbeitet" +discard: "Verwerfen" +thereAreNChanges: "Es gibt {n} Änderung(en)" +signinWithPasskey: "Mit Passkey anmelden" +unknownWebAuthnKey: "Unbekannter Passkey" +passkeyVerificationFailed: "Die Passkey-Verifizierung ist fehlgeschlagen." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "Die Verifizierung des Passkeys war erfolgreich, aber die passwortlose Anmeldung ist deaktiviert." +messageToFollower: "Nachricht an die Follower" +target: "Speicherort" +testCaptchaWarning: "Diese Funktion ist für CAPTCHA-Testzwecke gedacht.\nNicht in einer Produktivumgebung verwenden." +prohibitedWordsForNameOfUser: "Verbotene Begriffe für Benutzernamen" +prohibitedWordsForNameOfUserDescription: "Wenn eine Zeichenfolge aus dieser Liste im Namen eines Benutzers enthalten ist, wird der Benutzername abgelehnt. Benutzer mit Moderatorenrechten sind von dieser Einschränkung nicht betroffen." +yourNameContainsProhibitedWords: "Dein Name enthält einen verbotenen Begriff" +yourNameContainsProhibitedWordsDescription: "Der Name enthält eine verbotene Zeichenfolge. Wende dich an deinen Serveradministrator, wenn du diesen Namen verwenden möchtest." +thisContentsAreMarkedAsSigninRequiredByAuthor: "Logge dich ein, um weitere Inhalte von diesem Nutzer zu sehen." +lockdown: "Sperren" +pleaseSelectAccount: "Bitte Konto auswählen" +availableRoles: "Verfügbare Rollen" +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." +confirmOnReact: "Reagieren bestätigen" +reactAreYouSure: "Willst du eine \"{emoji}\"-Reaktion hinzufügen?" +markAsSensitiveConfirm: "Möchtest du dieses Medium als sensibel kennzeichnen?" +unmarkAsSensitiveConfirm: "Möchtest du die Kennzeichnung dieses Mediums als sensibel aufheben?" +preferences: "Einstellungen" +preferencesProfile: "Einstellungsprofil" +copyPreferenceId: "Kopiere die Einstellungs-ID" +resetToDefaultValue: "Auf Standard zurücksetzen" +untitled: "Unbenannt" +noName: "Kein Name" +skip: "Überspringen" +restore: "Wiederherstellen" +syncBetweenDevices: "Zwischen Geräten synchronisieren" +preferenceSyncConflictTitle: "Der konfigurierte Wert ist auf dem Server bereits vorhanden." +preferenceSyncConflictText: "Die Einstellungen mit aktivierter Synchronisierung werden ihre Werte auf dem Server speichern. Es gibt jedoch bereits Werte auf dem Server. Welche Einstellungswerte sollen überschrieben werden?" +preferenceSyncConflictChoiceServer: "Konfigurierte Werte auf dem Server" +preferenceSyncConflictChoiceDevice: "Konfigurierte Werte auf dem Gerät" +preferenceSyncConflictChoiceCancel: "Einrichten der Synchronisierung abbrechen" +paste: "Einfügen" +emojiPalette: "Emoji-Palette" +postForm: "Notizfenster" +textCount: "Zeichenanzahl" +information: "Über" +chat: "Chat" +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" +_chat: + noMessagesYet: "Noch keine Nachrichten" + newMessage: "Neue Nachricht" + individualChat: "Privater Chat" + individualChat_description: "Führe einen privaten Chat mit einer anderen Person." + roomChat_description: "Ein Chat-Raum, an dem mehrere Personen teilnehmen können.\nDu kannst auch Personen einladen, die keine privaten Chats zulassen, wenn sie die Einladung annehmen." + createRoom: "Raum erstellen" + inviteUserToChat: "Lade Benutzer ein, um mit dem Chatten zu beginnen" + yourRooms: "Erstellte Räume" + invitations: "Einladen" + noInvitations: "Keine Einladungen" + history: "Verlauf" + noHistory: "Kein Verlauf gefunden" + noRooms: "Keine Räume gefunden" + inviteUser: "Benutzer einladen" + sentInvitations: "Verschickte Einladungen" + join: "Beitreten" + ignore: "Ignorieren" + leave: "Raum verlassen" + members: "Mitglieder" + searchMessages: "Nachrichten suchen" + home: "Startseite" + send: "Senden" + newline: "Neue Zeile" + muteThisRoom: "Raum stummschalten" + deleteRoom: "Raum löschen" + chatNotAvailableForThisAccountOrServer: "Der Chat ist auf diesem Server oder für dieses Konto nicht aktiviert." + chatNotAvailableInOtherAccount: "Die Chatfunktion wurde vom anderen Benutzer deaktiviert." + cannotChatWithTheUser: "Starten eines Chats mit diesem Benutzer nicht möglich" + cannotChatWithTheUser_description: "Der Chat ist entweder nicht verfügbar oder die andere Seite hat den Chat nicht aktiviert." + chatWithThisUser: "Mit dem Benutzer chatten" + thisUserAllowsChatOnlyFromFollowers: "Dieser Benutzer nimmt nur Chats von Followern an." + thisUserAllowsChatOnlyFromFollowing: "Dieser Benutzer nimmt nur Chats von Benutzern an, denen er folgt." + thisUserAllowsChatOnlyFromMutualFollowing: "Dieser Benutzer akzeptiert nur Chats von Benutzern, die sich gegenseitig folgen." + thisUserNotAllowedChatAnyone: "Dieser Benutzer nimmt keine Chats von anderen Benutzern an." + chatAllowedUsers: "Wem das Chatten erlaubt werden soll" + chatAllowedUsers_note: "Du kannst unabhängig von dieser Einstellung mit allen Personen chatten, denen du eine Chat-Nachricht gesendet hast." + _chatAllowedUsers: + everyone: "Jeder" + followers: "Nur deine Follower" + following: "Nur Benutzer, denen du folgst" + mutual: "Nur Benutzer, die sich gegenseitig folgen" + none: "Niemand" +_emojiPalette: + palettes: "Palette" + enableSyncBetweenDevicesForPalettes: "Synchronisierung der Paletten zwischen Geräten aktivieren" + paletteForMain: "Hauptpalette" + paletteForReaction: "Reaktions-Palette" +_settings: + driveBanner: "Du kannst den Drive verwalten und konfigurieren, die Auslastung überprüfen und Einstellungen für das Hochladen von Dateien vornehmen." + pluginBanner: "Du kannst die Funktionen des Clients mit Plugins erweitern. Plugins können installiert, individuell konfiguriert und verwaltet werden." + api: "API" + webhook: "Webhook" + serviceConnectionBanner: "Du kannst Zugriffstoken und Webhooks für die Integration mit externen Anwendungen und Diensten verwalten und konfigurieren." + accountData: "Kontodaten" + accountDataBanner: "Export/Import und Verwaltung von Kontodatenarchiven." + muteAndBlockBanner: "Du kannst Einstellungen konfigurieren und verwalten, um Inhalte auszublenden und Aktionen für bestimmte Benutzer zu beschränken." + accessibilityBanner: "Die Clients können personalisiert und für eine optimale Nutzung im Hinblick auf ihre Darstellung und ihr Verhalten eingerichtet werden." + privacyBanner: "Du kannst Einstellungen für die Privatsphäre deines Kontos vornehmen, z. B. inwieweit Inhalte veröffentlicht werden, wie leicht sie zu finden sind und ob Follower genehmigt werden müssen." + securityBanner: "Du kannst Einstellungen für die Kontosicherheit konfigurieren, z. B. Passwörter, Anmeldemethoden, Authentifizierungs-Apps und Passkeys." + appearanceBanner: "Du kannst das Erscheinungsbild und die Anzeigeeinstellungen für den Client nach deinen Wünschen konfigurieren." + soundsBanner: "Du kannst die Einstellungen für die Wiedergabe von Klängen im Client konfigurieren." + timelineAndNote: "Chroniken und Notizen" + makeEveryTextElementsSelectable: "Alle Textelemente auswählbar machen" + makeEveryTextElementsSelectable_description: "Die Aktivierung kann in manchen Situationen die Benutzerfreundlichkeit beeinträchtigen." + ifOn: "Wenn eingeschaltet" + ifOff: "Wenn ausgeschaltet" + _chat: + showSenderName: "Name des Absenders anzeigen" + sendOnEnter: "Eingabetaste sendet Nachricht" +_preferencesProfile: + profileName: "Profilname" + profileNameDescription: "Lege einen Namen fest, der dieses Gerät identifiziert." + profileNameDescription2: "Beispiel: \"Haupt-PC\", \"Smartphone\"" +_preferencesBackup: + autoBackup: "Automatische Sicherung" + restoreFromBackup: "Wiederherstellen aus der Sicherung" + noBackupsFoundTitle: "Keine Sicherungen gefunden" + noBackupsFoundDescription: "Es wurden keine automatisch erstellten Sicherungen gefunden, aber wenn du eine Sicherungsdatei manuell gespeichert hast, kannst du diese importieren und wiederherstellen." + selectBackupToRestore: "Wähle die wiederherzustellende Sicherung" + youNeedToNameYourProfileToEnableAutoBackup: "Um die automatische Sicherung zu aktivieren, müssen Profilnamen festgelegt werden." + autoPreferencesBackupIsNotEnabledForThisDevice: "Die automatische Sicherung der Einstellungen ist auf diesem Gerät nicht aktiviert." + backupFound: "Konfigurationssicherung gefunden." +_accountSettings: + requireSigninToViewContents: "Anmeldung erfordern, um Inhalte anzuzeigen" + requireSigninToViewContentsDescription1: "Erfordere eine Anmeldung, um alle Notizen und andere Inhalte anzuzeigen, die du erstellt hast. Dadurch wird verhindert, dass Crawler deine Informationen sammeln." + requireSigninToViewContentsDescription2: "Der Inhalt wird nicht in URL-Vorschauen (OGP), eingebettet in Webseiten oder auf Servern, die keine Zitate unterstützen, angezeigt." + requireSigninToViewContentsDescription3: "Diese Einschränkungen gelten möglicherweise nicht für föderierte Inhalte von anderen Servern." + makeNotesFollowersOnlyBefore: "Macht frühere Notizen nur für Follower sichtbar" + makeNotesFollowersOnlyBeforeDescription: "Solange diese Funktion aktiviert ist, sind Notizen, die nach dem eingestellten Datum und der eingestellten Zeit liegen oder die eingestellte Zeit abgelaufen ist, nur für Follower sichtbar. Bei Deaktivierung wird auch der öffentliche Status der Notiz wiederhergestellt." + makeNotesHiddenBefore: "Frühere Notizen privat machen" + makeNotesHiddenBeforeDescription: "" + mayNotEffectForFederatedNotes: "Dies hat möglicherweise keine Auswirkungen auf Notizen, die an andere Server föderiert werden." + mayNotEffectSomeSituations: "Diese Einschränkungen sind vereinfacht. Sie gelten möglicherweise nicht in allen Situationen, z. B. bei der Anzeige auf einem fremden Server oder während der Moderation." + notesOlderThanSpecifiedDateAndTime: "Notizen vor einem bestimmtem Datum und Uhrzeit" +_abuseUserReport: + forward: "Weiterleiten" + forwardDescription: "Leite die Meldung an einen entfernten Server als anonymes Systemkonto weiter." + resolve: "lösen" + accept: "Akzeptieren" + reject: "Ablehnen" + resolveTutorial: "Wenn der Inhalt der Meldung rechtmäßig ist, wähle „Akzeptieren“, um sie als gelöst zu markieren.\nWenn der Inhalt der Meldung unzulässig ist, wähle „Ablehnen“, um sie zu ignorieren." _delivery: + status: "Auslieferungsstatus" stop: "Gesperrt" _type: none: "Wird veröffentlicht" + manuallySuspended: "Manuell gesperrt" + goneSuspended: "Gesperrt wegen Löschung des Servers" + autoSuspendedForNotResponding: "Gesperrt, weil der Server nicht antwortet" +_bubbleGame: + howToPlay: "Wie man spielt" + hold: "Halten" + _score: + score: "Spielstand" + scoreYen: "Verdienter Geldbetrag" + highScore: "Höchstpunktzahl" + maxChain: "Maximale Anzahl an Verkettungen" + yen: "{yen} Yen" + estimatedQty: "{qty} Stück" + scoreSweets: "{onigiriQtyWithUnit} Onigiri" + _howToPlay: + section1: "Passe die Position an und lasse das Objekt in das Spielfeld fallen." + section2: "Wenn sich zwei Objekte der gleichen Art berühren, verwandeln sie sich in ein anderes Objekt und du bekommst Punkte." + section3: "Das Spiel ist vorbei, wenn die Objekte aus dem Spielfeld herausragen. Versuche eine hohe Punktzahl zu erreichen, indem du die Objekte miteinander verschmelzt, ohne dass das Spielfeld überläuft!" _announcement: forExistingUsers: "Nur für existierende Nutzer" forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt." @@ -1230,21 +1499,59 @@ _initialTutorial: title: "Was sind Notizen?" description: "Beiträge auf Misskey heißen \"Notizen\". Notizen werden chronologisch in der Chronik angeordnet und in Echtzeit aktualisiert." reply: "Klicke auf diesen Button, um auf eine Nachricht zu antworten. Es ist auch möglich, auf Antworten zu antworten und die Unterhaltung wie einen Thread fortzusetzen." + renote: "Du kannst diese Notiz in deiner eigenen Chronik teilen. Du kannst sie auch mit deinen Kommentaren zitieren." + reaction: "Du kannst der Notiz Reaktionen hinzufügen. Weitere Einzelheiten werden auf der nächsten Seite erläutert." + menu: "Du kannst Details zu Notizen anzeigen, Links kopieren und verschiedene andere Aktionen durchführen." _reaction: title: "Was sind Reaktionen?" + description: "Auf Notizen kann mit verschiedenen Emojis reagiert werden. Reaktionen ermöglichen es dir, Nuancen auszudrücken, die mit einem einfachen „Gefällt mir“ vielleicht nicht ausgedrückt werden können." + letsTryReacting: "Reaktionen können durch Klicken auf die Schaltfläche „+“ in der Notiz hinzugefügt werden. Versuche, auf diese Beispielnotiz zu reagieren!" reactToContinue: "Füge eine Reaktion hinzu, um fortzufahren." reactNotification: "Du erhältst Echtzeit-Benachrichtigungen, wenn jemand auf deine Notiz reagiert." + reactDone: "Du kannst eine Reaktion zurücknehmen, indem du auf den '-' Button drückst." + _timeline: + title: "So funktionieren die Chroniken" + description1: "Misskey stellt mehrere Chroniken bereit (einige können je nach den Richtlinien des Servers nicht verfügbar sein)." + home: "Du kannst Beiträge von den Konten sehen, denen du folgst." + local: "Du kannst Beiträge aller Benutzer auf diesem Server sehen." + social: "Notizen von der Startseite und der lokalen Chronik werden angezeigt." + global: "Du kannst Notizen von allen föderierten Servern sehen." + description2: "Du kannst jederzeit am oberen Rand des Bildschirms zwischen den jeweiligen Chroniken wechseln." + description3: "Darüber hinaus gibt es Listen-Chroniken und Kanal-Chroniken. Weitere Einzelheiten findest du unter {link}." _postNote: + description1: "Wenn du eine Notiz auf Misskey veröffentlichst, stehen dir verschiedene Optionen zur Verfügung. Die Oberfläche sieht folgendermaßen aus." _visibility: description: "Du kannst einschränken, wer deine Notiz sehen kann." public: "Deine Notiz wird für alle Nutzer sichtbar sein." + home: "Nur auf der Startseite sichtbar. Kann von Followern, Profilbesuchern und durch Renotes gesehen werden." + followers: "Nur für Follower sichtbar. Nur Follower können es sehen und niemand sonst, und es kann nicht von anderen gerenoted werden." + direct: "Die Notiz wird nur für den angegebenen Benutzer veröffentlicht und der Empfänger wird benachrichtigt. Kann anstelle von Direktnachrichten verwendet werden." doNotSendConfidencialOnDirect1: "Sei vorsichtig, wenn du sensible Informationen verschickst!" + doNotSendConfidencialOnDirect2: "Die Administratoren des Servers können den Inhalt der Notiz sehen. Sei vorsichtig mit sensiblen Informationen, wenn du Direktnachrichten an Benutzer auf nicht vertrauenswürdigen Servern sendest." + localOnly: "Wenn du eine Notiz mit dieser Einstellung veröffentlichst, wird sie nicht an andere Server weitergeleitet. Benutzer auf anderen Servern können diese Notizen nicht direkt sehen, unabhängig von den obigen Anzeigeeinstellungen." _cw: title: "Inhaltswarnung" + description: "Anstelle des Textes wird das angezeigt, was du im Abschnitt „Anmerkungen“ angibst. Drücke auf „Inhalt anzeigen“, um den vollständigen Text zu sehen." + _exampleNote: + cw: "Das wird dich bestimmt hungrig machen!" + note: "Ich hatte gerade einen Donut mit Schokoladenüberzug 🍩😋" + useCases: "Dient zur Kennzeichnung von Notizen, wie sie in den Serverrichtlinien vorgeschrieben sind, oder zur eigenen Festlegung von Spoiler-Beiträgen oder sensiblem Text." + _howToMakeAttachmentsSensitive: + title: "Wie markiert man Anhänge als sensibel?" + description: "Markiere Anhänge als sensibel, die aufgrund von den Serverregeln nicht sichtbar sein sollen." + tryThisFile: "Versuche, das angehängte Bild als sensibel zu markieren!" + _exampleNote: + note: "Ups, ich habe es vergeigt, den Natto-Deckel zu öffnen..." + method: "Um einen Anhang als sensibel zu kennzeichnen, klicke auf das Vorschaubild der Datei, um das Menü zu öffnen, und klicke auf „Als sensibel markieren“." + sensitiveSucceeded: "Wenn du Dateien anhängst, stelle bitte die Sensibilität entsprechend der Serverrichtlinien ein." + doItToContinue: "Markiere die angehängte Datei als sensibel, um fortzufahren." _done: title: "Du hast das Tutorial abgeschlossen! 🎉" + description: "Die hier beschriebenen Funktionen sind nur ein kleiner Teil dessen, was Misskey zu bieten hat; um mehr darüber zu erfahren, wie du Misskey benutzen kannst, besuche bitte {link}." _timelineDescription: + home: "In der Startseiten-Chronik kannst du Notizen von Konten sehen, denen du folgst." local: "In der lokalen Chronik siehst du Notizen von allen Benutzern auf diesem Server." + social: "Die soziale Chronik zeigt Notizen von der Startseite und der lokalen Chronik." global: "In der globalen Chronik siehst du Notizen von allen föderierten Servern." _serverRules: description: "Eine Reihe von Regeln, die vor der Registrierung angezeigt werden. Eine Zusammenfassung der Nutzungsbedingungen anzuzeigen ist empfohlen." @@ -1260,6 +1567,12 @@ _serverSettings: fanoutTimelineDescription: "Ist diese Option aktiviert, kann eine erhebliche Verbesserung im Abrufen von Chroniken und eine Reduzierung der Datenbankbelastung erzielt werden, im Gegenzug zu einer Steigerung in der Speichernutzung von Redis. Bei geringem Serverspeicher oder Serverinstabilität kann diese Option deaktiviert werden." fanoutTimelineDbFallback: "Auf die Datenbank zurückfallen" fanoutTimelineDbFallbackDescription: "Ist diese Option aktiviert, wird die Chronik auf zusätzliche Abfragen in der Datenbank zurückgreifen, wenn sich die Chronik nicht im Cache befindet. Eine Deaktivierung führt zu geringerer Serverlast, aber schränkt den Zeitraum der abrufbaren Chronik ein. " + reactionsBufferingDescription: "Wenn diese Option aktiviert ist, kann sie die Leistung beim Erstellen von Reaktionen erheblich verbessern und die Belastung der Datenbank verringern. Allerdings steigt die Speichernutzung von Redis." + inquiryUrl: "Kontakt-URL" + inquiryUrlDescription: "Gib eine URL für das Kontaktformular der Serverbetreiber oder eine Webseite an, die Kontaktinformationen enthält." + openRegistration: "Registrierung von Konten aktivieren" + openRegistrationWarning: "Das Aktivieren von Registrierungen ist riskant. Es wird empfohlen, sie nur dann zu aktivieren, wenn der Server ständig überwacht wird und im Falle eines Problems sofort reagiert werden kann." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Wenn über einen bestimmten Zeitraum keine Moderatorenaktivität festgestellt wird, wird diese Einstellung automatisch deaktiviert, um Spam zu verhindern." _accountMigration: moveFrom: "Von einem anderen Konto zu diesem migrieren" moveFromSub: "Alias für ein anderes Konto erstellen" @@ -1518,7 +1831,14 @@ _achievements: title: "Testüberfluss" description: "Betätige den Benachrichtigungstest mehrfach innerhalb einer extrem kurzen Zeitspanne" _tutorialCompleted: + title: "Misskey Grundkurs-Diplom" description: "Tutorial abgeschlossen" + _bubbleGameExplodingHead: + title: "🤯" + description: "Das größte Objekt im Bubble Game" + _bubbleGameDoubleExplodingHead: + title: "Doppel🤯" + description: "Zwei der größten Objekte im Bubble Game zur gleichen Zeit" _role: new: "Rolle erstellen" edit: "Rolle bearbeiten" @@ -1559,6 +1879,7 @@ _role: gtlAvailable: "Kann auf die globale Chronik zugreifen" ltlAvailable: "Kann auf die lokale Chronik zugreifen" canPublicNote: "Kann öffentliche Notizen erstellen" + mentionMax: "Maximale Anzahl von Erwähnungen in einer Notiz" canInvite: "Erstellung von Einladungscodes für diese Instanz" inviteLimit: "Maximalanzahl an Einladungen" inviteLimitCycle: "Zyklus des Einladungslimits" @@ -1567,6 +1888,7 @@ _role: canManageAvatarDecorations: "Profilbilddekorationen verwalten" driveCapacity: "Drive-Kapazität" alwaysMarkNsfw: "Dateien immer als NSFW markieren" + canUpdateBioMedia: "Kann ein Profil- oder ein Bannerbild bearbeiten" pinMax: "Maximale Anzahl an angehefteten Notizen" antennaMax: "Maximale Anzahl an Antennen" wordMuteMax: "Maximale Zeichenlänge für Wortstummschaltungen" @@ -1581,9 +1903,21 @@ _role: canSearchNotes: "Nutzung der Notizsuchfunktion" canUseTranslator: "Verwendung des Übersetzers" avatarDecorationLimit: "Maximale Anzahl an Profilbilddekorationen, die angebracht werden können" + canImportAntennas: "Importieren von Antennen erlauben" + canImportBlocking: "Importieren von Blockierungen zulassen" + canImportFollowing: "Importieren von Gefolgten zulassen" + canImportMuting: "Importieren von Stummgeschalteten zulassen" + canImportUserLists: "Importieren von Listen erlauben" + canChat: "Chatten erlauben" _condition: + roleAssignedTo: "Manuellen Rollen zugewiesen" isLocal: "Lokaler Benutzer" isRemote: "Benutzer fremder Instanz" + isCat: "Katzen-Benutzer" + isBot: "Bot-Benutzer" + isSuspended: "Gesperrter Benutzer" + isLocked: "Private Konten" + isExplorable: "Benutzer, die ihr Konto im \"Erkunden\"-Bereich sichtbar machen" createdLessThan: "Kontoerstellung liegt weniger als X zurück" createdMoreThan: "Kontoerstellung liegt mehr als X zurück" followersLessThanOrEq: "Hat X oder weniger Follower" @@ -1653,6 +1987,7 @@ _plugin: installWarn: "Installiere bitte nur vertrauenswürdige Plugins." manage: "Plugins verwalten" viewSource: "Quelltext anzeigen" + viewLog: "Protokoll anzeigen" _preferencesBackups: list: "Erstellte Backups" saveNew: "Neu erstellen" @@ -1682,6 +2017,8 @@ _aboutMisskey: contributors: "Hauptmitwirkende" allContributors: "Alle Mitwirkenden" source: "Quellcode" + original: "Original" + thisIsModifiedVersion: "{name} verwendet eine modifizierte Version des ursprünglichen Misskey." translation: "Misskey übersetzen" donate: "An Misskey spenden" morePatrons: "Wir schätzen ebenso die Unterstützung vieler anderer hier nicht gelisteter Personen sehr. Danke! 🥰" @@ -1711,6 +2048,7 @@ _channel: notesCount: "{n} Notizen" nameAndDescription: "Name und Beschreibung" nameOnly: "Nur Name" + allowRenoteToExternal: "Renotes und Zitierungen außerhalb des Kanals erlauben" _menuDisplay: sideFull: "Seitlich" sideIcon: "Seitlich (Icons)" @@ -1734,6 +2072,7 @@ _theme: installed: "{name} wurde installiert" installedThemes: "Installierte Farbschemata" builtinThemes: "Eingebaute Farbschemata" + instanceTheme: "Server-Thema" alreadyInstalled: "Dieses Farbschema ist bereits installiert" invalid: "Der Code dieses Farbschemas ist ungültig" make: "Farbschema erstellen" @@ -1788,7 +2127,6 @@ _theme: buttonBg: "Hintergrund von Schaltflächen" buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)" inputBorder: "Rahmen von Eingabefeldern" - listItemHoverBg: "Hintergrund von Listeneinträgen (Mouseover)" driveFolderBg: "Hintergrund von Drive-Ordnern" wallpaperOverlay: "Hintergrundbild-Overlay" badge: "Wappen" @@ -1800,6 +2138,16 @@ _sfx: note: "Notizen" noteMy: "Meine Notizen" notification: "Benachrichtigungen" + reaction: "Auswählen einer Reaktion" + chatMessage: "Chat-Nachrichten" +_soundSettings: + driveFile: "Audiodatei aus dem Drive verwenden" + driveFileWarn: "Wähle eine Audiodatei aus dem Drive" + driveFileTypeWarn: "Diese Datei wird nicht unterstützt" + driveFileTypeWarnDescription: "Bitte wähle eine Audiodatei" + driveFileDurationWarn: "Audio zu lang." + driveFileDurationWarnDescription: "Lange Töne kann die Verwendung von Misskey stören. Trotzdem fortfahren?" + driveFileError: "Audio konnte nicht geladen werden. Bitte ändere die Einstellung." _ago: future: "Zukunft" justNow: "Gerade eben" @@ -1811,6 +2159,14 @@ _ago: monthsAgo: "vor {n} Monat(en)" yearsAgo: "vor {n} Jahr(en)" invalid: "Ungültig" +_timeIn: + seconds: "In {n}s" + minutes: "In {n} Min." + hours: "In {n} Std." + days: "In {n} Tagen" + weeks: "In {n} Wochen" + months: "In {n} Monaten" + years: "In {n} Jahren" _time: second: "Sekunde(n)" minute: "Minute(n)" @@ -1844,6 +2200,7 @@ _2fa: backupCodesDescription: "Verwende diese Codes, falls du nicht mehr auf deine App zur Zweifaktorauthentifizierung zugreifen kannst. Jeder Code kann nur einmal verwendet werden. Bewahre sie an einem sicheren Ort auf." backupCodeUsedWarning: "Ein Backup-Code wurde verwendet. Falls du den Zugriff zu deiner Zweifaktorauthentifizierungsapp verloren hast, konfiguriere diese bitte möglichst bald erneut." backupCodesExhaustedWarning: "Alle Backup-Codes wurden verwendet. Falls du den Zugang zu deiner Zweifaktorauthentifizierungsapp verlierst, wirst du dich nicht mehr in dieses Konto einloggen können. Bitte konfiguriere diese App erneut." + moreDetailedGuideHere: "Hier ist eine ausführliche Anleitung" _permissions: "read:account": "Deine Benutzerkontoinformationen lesen" "write:account": "Deine Benutzerkontoinformationen bearbeiten" @@ -1881,6 +2238,52 @@ _permissions: "write:flash": "Deine Plays bearbeiten oder löschen" "read:flash-likes": "Liste der Plays, die mir gefallen, lesen" "write:flash-likes": "Liste der Plays, die mir gefallen, bearbeiten" + "read:admin:abuse-user-reports": "Meldungen von Benutzern ansehen" + "write:admin:delete-account": "Benutzerkonto löschen" + "write:admin:delete-all-files-of-a-user": "Alle Dateien eines Benutzers löschen" + "read:admin:index-stats": "Statistiken zu Datenbankindizes einsehen" + "read:admin:table-stats": "Statistiken zu Datenbanktabellen einsehen" + "read:admin:user-ips": "IP-Adressen von Benutzern anzeigen" + "read:admin:meta": "Metadaten der Instanz einsehen" + "write:admin:reset-password": "Benutzerpasswort zurücksetzen" + "write:admin:resolve-abuse-user-report": "Meldungen von Benutzern lösen" + "write:admin:send-email": "E-Mail versenden" + "read:admin:server-info": "Serverinformationen anzeigen" + "read:admin:show-moderation-log": "Moderationsprotokoll einsehen" + "read:admin:show-user": "Private Benutzerinformationen einsehen" + "write:admin:suspend-user": "Benutzer sperren" + "write:admin:unset-user-avatar": "Benutzer-Profilbild entfernen" + "write:admin:unset-user-banner": "Benutzer-Banner entfernen" + "write:admin:unsuspend-user": "Benutzer entsperren" + "write:admin:meta": "Metadaten der Instanz verwalten" + "write:admin:user-note": "Moderationsvermerke verwalten" + "write:admin:roles": "Rollen verwalten" + "read:admin:roles": "Rollen anzeigen" + "write:admin:relays": "Relays verwalten" + "read:admin:relays": "Relays anzeigen" + "write:admin:invite-codes": "Einladungscodes verwalten" + "read:admin:invite-codes": "Einladungscodes anzeigen" + "write:admin:announcements": "Ankündigungen verwalten" + "read:admin:announcements": "Ankündigungen einsehen" + "write:admin:avatar-decorations": "Kann Avatar-Dekorationen verwalten" + "read:admin:avatar-decorations": "Avatar-Dekorationen ansehen" + "write:admin:account": "Benutzerkonten verwalten" + "read:admin:account": "Benutzerkonten anzeigen" + "write:admin:emoji": "Emojis verwalten" + "read:admin:emoji": "Emojis anzeigen" + "write:admin:queue": "Job-Warteschlange verwalten" + "read:admin:queue": "Job-Warteschlange anzeigen" + "write:admin:drive": "Benutzer-Drive verwalten" + "read:admin:drive": "Benutzer-Drive ansehen" + "read:admin:stream": "Verwendung der Websocket-API für Administratoren" + "write:admin:ad": "Werbung verwalten" + "read:admin:ad": "Werbung ansehen" + "write:invite-codes": "Einladungscodes erstellen" + "read:invite-codes": "Einladungscodes anzeigen" + "read:federation": "Informationen zur Föderation einsehen" + "write:report-abuse": "Verstöße melden" + "write:chat": "Chats bedienen" + "read:chat": "Chats durchsuchen" _auth: shareAccessTitle: "Verteilung von App-Berechtigungen" shareAccess: "Möchtest du „{name}“ authorisieren, auf dieses Benutzerkonto zugreifen zu können?" @@ -1889,8 +2292,10 @@ _auth: permissionAsk: "Diese Anwendung fordert folgende Berechtigungen" pleaseGoBack: "Bitte kehre zur Anwendung zurück" callback: "Es wird zur Anwendung zurückgekehrt" + accepted: "Zugriff gewährt" denied: "Zugriff verweigert" pleaseLogin: "Bitte logge dich ein, um Apps zu authorisieren." + byClickingYouWillBeRedirectedToThisUrl: "Wenn der Zugang gewährt wird, wirst du automatisch zu folgender URL weitergeleitet" _antennaSources: all: "Alle Notizen" homeTimeline: "Notizen von Benutzern, denen gefolgt wird" @@ -1998,6 +2403,10 @@ _profile: changeAvatar: "Profilbild ändern" changeBanner: "Banner ändern" verifiedLinkDescription: "Gibst du hier eine URL ein, die einen Link zu deinem Profile enthält, wird neben diesem Feld ein Icon zur Besitzbestätigung angezeigt." + avatarDecorationMax: "Du kannst bis zu {max} Dekorationen hinzufügen." + followedMessage: "Nachricht, wenn dir jemand folgt" + followedMessageDescription: "Du kannst eine kurze Nachricht festlegen, die dem Empfänger angezeigt wird, wenn er dir folgt." + followedMessageDescriptionForLockedAccount: "Wenn Folgeanfragen deine Genehmigung brauchen, wird dies beim Genehmigen einer Anfrage angezeigt." _exportOrImport: allNotes: "Alle Notizen" favoritedNotes: "Als Favorit markierte Notizen" @@ -2055,13 +2464,11 @@ _play: title: "Titel" script: "Skript" summary: "Beschreibung" + visibilityDescription: "Wenn du die Sichtbarkeit auf Privat stellst, wird der Play nicht auf deinem Profil sichtbar sein, aber jeder, der die URL hat, kann ihn trotzdem aufrufen." _pages: newPage: "Seite erstellen" editPage: "Seite bearbeiten" readPage: "Quelltextansicht" - created: "Seite erfolgreich erstellt" - updated: "Seite erfolgreich aktualisiert" - deleted: "Seite erfolgreich gelöscht" pageSetting: "Seiteneinstellungen" nameAlreadyExists: "Die angegebene Seiten-URL existiert bereits" invalidNameTitle: "Die angegebene Seiten-URL ist ungültig" @@ -2089,6 +2496,7 @@ _pages: eyeCatchingImageSet: "Vorschaubild festlegen" eyeCatchingImageRemove: "Vorschaubild entfernen" chooseBlock: "Block hinzufügen" + enterSectionTitle: "Titel des Abschnitts eingeben" selectType: "Typ auswählen" contentBlocks: "Inhalt" inputBlocks: "Eingabe" @@ -2099,6 +2507,8 @@ _pages: section: "Abschnitt" image: "Bild" button: "Knopf" + dynamic: "Dynamische Bausteine" + dynamicDescription: "Dieser Baustein wurde abgeschafft. Bitte verwende von nun an {play}." note: "Eingebettete Notiz" _note: id: "Notiz-ID" @@ -2120,6 +2530,8 @@ _notification: pollEnded: "Umfrageergebnisse sind verfügbar" newNote: "Neue Notiz" unreadAntennaNote: "Antenne {name}" + roleAssigned: "Rolle zugewiesen" + chatRoomInvitationReceived: "Du wurdest in einen Chatraum eingeladen" emptyPushNotificationMessage: "Push-Benachrichtigungen wurden aktualisiert" achievementEarned: "Errungenschaft freigeschaltet" testNotification: "Testbenachrichtigung" @@ -2127,8 +2539,13 @@ _notification: sendTestNotification: "Testbenachrichtigung senden" notificationWillBeDisplayedLikeThis: "Benachrichtigungen sehen so aus" reactedBySomeUsers: "{n} Benutzer haben eine Reaktion geschickt" + likedBySomeUsers: "{n} Benutzer mochten deine Notiz" renotedBySomeUsers: "Renote von {n} Benutzern" followedBySomeUsers: "Von {n} Benutzern gefolgt" + flushNotification: "Benachrichtigungen löschen" + exportOfXCompleted: "Der Export von {x} ist abgeschlossen" + login: "Neue Anmeldung erfolgt" + createToken: "Ein Zugangstoken wurde erstellt" _types: all: "Alle" note: "Neue Notizen" @@ -2141,7 +2558,13 @@ _notification: pollEnded: "Ende von Umfragen" receiveFollowRequest: "Erhaltene Follow-Anfragen" followRequestAccepted: "Akzeptierte Follow-Anfragen" + roleAssigned: "Rolle zugewiesen" + chatRoomInvitationReceived: "Einladungen zum Chatraum" achievementEarned: "Errungenschaft freigeschaltet" + exportCompleted: "Der Export ist abgeschlossen" + login: "Anmeldung" + createToken: "Erstellung von Zugriffstokens" + test: "Test-Benachrichtigungen" app: "Benachrichtigungen von Apps" _actions: followBack: "folgt dir nun auch" @@ -2151,6 +2574,7 @@ _deck: alwaysShowMainColumn: "Hauptspalte immer zeigen" columnAlign: "Spaltenausrichtung" addColumn: "Spalte hinzufügen" + newNoteNotificationSettings: "Benachrichtigungseinstellungen für neue Notizen" configureColumn: "Spalteneinstellungen" swapLeft: "Mit linker Spalte tauschen" swapRight: "Mit rechter Spalte tauschen" @@ -2167,6 +2591,7 @@ _deck: useSimpleUiForNonRootPages: "Simple Benutzeroberfläche für navigierte Seiten verwenden" usedAsMinWidthWhenFlexible: "Ist \"Automatische Breitenanpassung\" aktiviert, wird hierfür die minimale Breite verwendet" flexible: "Automatische Breitenanpassung" + enableSyncBetweenDevicesForProfiles: "Aktivieren der Synchronisierung von Profilinformationen zwischen Geräten" _columns: main: "Hauptspalte" widgets: "Widgets" @@ -2189,8 +2614,10 @@ _drivecleaner: orderByCreatedAtAsc: "Aufsteigendes Erstelldatum" _webhookSettings: createWebhook: "Webhook erstellen" + modifyWebhook: "Webhook bearbeiten" name: "Name" secret: "Secret" + trigger: "Auslöser" active: "Aktiviert" _events: follow: "Wenn du jemandem folgst" @@ -2200,10 +2627,29 @@ _webhookSettings: renote: "Wenn du ein Renote erhältst" reaction: "Wenn du eine Reaktion erhältst" mention: "Wenn du erwähnt wirst" + _systemEvents: + abuseReport: "Wenn eine neue Meldung eingeht" + abuseReportResolved: "Wenn eine Meldung gelöst wird" + userCreated: "Beim Anlegen eines Benutzers" + inactiveModeratorsWarning: "Wenn Moderatoren für eine gewisse Zeit inaktiv sind" + inactiveModeratorsInvitationOnlyChanged: "Wenn ein Moderator über einen gewissen Zeitraum inaktiv war und der Server auf Einladungsbasis umgestellt wird" + deleteConfirm: "Bist du sicher, dass du den Webhook löschen willst?" + testRemarks: "Klicke auf die Schaltfläche rechts neben dem Schalter, um einen Test-Webhook mit Dummy-Daten zu senden." _abuseReport: _notificationRecipient: + createRecipient: "Meldungsempfänger hinzufügen" + modifyRecipient: "Bearbeite einen Empfänger für Meldungen" + recipientType: "Art der Benachrichtigung" _recipientType: mail: "Email" + webhook: "Webhook" + _captions: + mail: "Die Benachrichtigung wird bei Eingang einer Meldung an die E-Mail-Adressen der Moderatoren gesendet" + webhook: "Sendet eine Benachrichtigung an den System Webhook, wenn eine Meldung eingegangen ist oder gelöst wurde" + keywords: "Schlüsselwort" + notifiedUser: "Zu benachrichtigender Benutzer" + notifiedWebhook: "Zu verwendender Webhook" + deleteConfirm: "Bist du sicher, dass du den Empfänger der Benachrichtigung entfernen möchtest?" _moderationLogTypes: createRole: "Rolle erstellt" deleteRole: "Rolle gelöscht" @@ -2228,9 +2674,12 @@ _moderationLogTypes: resetPassword: "Passwort zurückgesetzt" suspendRemoteInstance: "Fremde Instanz gesperrt" unsuspendRemoteInstance: "Fremde Instanz entsperrt" + updateRemoteInstanceNote: "Aktualisierung der Moderationshinweise für fremde Server." markSensitiveDriveFile: "Datei als sensitiv markiert" unmarkSensitiveDriveFile: "Datei als nicht sensitiv markiert" resolveAbuseReport: "Meldung bearbeitet" + forwardAbuseReport: "Meldung weitergeleitet" + updateAbuseReportNote: "Moderationsnotiz einer Meldung aktualisiert" createInvitation: "Einladung erstellt" createAd: "Werbung erstellt" deleteAd: "Werbung gelöscht" @@ -2238,6 +2687,20 @@ _moderationLogTypes: createAvatarDecoration: "Profilbilddekoration erstellt" updateAvatarDecoration: "Profilbilddekoration aktualisiert" deleteAvatarDecoration: "Profilbilddekoration gelöscht" + unsetUserAvatar: "Profilbild zurückgesetzt" + unsetUserBanner: "Profilbanner zurückgesetzt" + createSystemWebhook: "System-Webhook erstellt" + updateSystemWebhook: "System-Webhook aktualisiert" + deleteSystemWebhook: "System-Webhook gelöscht" + createAbuseReportNotificationRecipient: "Empfänger für Meldungen erstellt" + updateAbuseReportNotificationRecipient: "Empfänger für Meldungen aktualisiert" + deleteAbuseReportNotificationRecipient: "Empfänger für Meldungen entfernt" + deleteAccount: "Benutzerkonto gelöscht" + deletePage: "Seite gelöscht" + deleteFlash: "Play gelöscht" + deleteGalleryPost: "Galeriebeitrag gelöscht" + deleteChatRoom: "Chatraum gelöscht" + updateProxyAccountDescription: "Beschreibung des Proxy-Benutzerkontos aktualisiert" _fileViewer: title: "Dateiinformationen" type: "Dateityp" @@ -2251,10 +2714,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Überprüfe vor Installation die Vertrauenswürdigkeit des Vertreibers." _plugin: title: "Möchtest du dieses Plugin installieren?" - metaTitle: "Plugininformation" _theme: title: "Möchten du dieses Farbschema installieren?" - metaTitle: "Farbschemainfo" _meta: base: "Farbschemavorlage" _vendorInfo: @@ -2287,9 +2748,208 @@ _externalResourceInstaller: _themeInstallFailed: title: "Das Farbschema konnte nicht installiert werden" description: "Während der Installation des Farbschemas ist ein Problem aufgetreten. Bitte versuche es erneut. Detaillierte Fehlerinformationen können über die Javascript-Konsole abgerufen werden." +_dataSaver: + _media: + title: "Laden von Medien verhindern" + description: "Verhindert, dass Bilder/Videos automatisch geladen werden. Ausgeblendete Bilder/Videos werden geladen, wenn du auf sie tippst." + _avatar: + title: "Animierte Profilbilder deaktivieren" + description: "Die Animation von Profilbildern wird angehalten. Da animierte Bilder eine größere Dateigröße haben können als normale Bilder, kann dies den Datenverkehr weiter reduzieren." + _urlPreview: + title: "URL-Vorschaubilder ausblenden" + description: "URL-Vorschaubilder werden nicht mehr geladen." + _code: + title: "Code-Hervorhebungen ausblenden" + description: "Wenn Code-Hervorhebungen in MFM usw. verwendet werden, werden sie erst geladen, wenn sie angetippt werden. Die Syntaxhervorhebung erfordert das Herunterladen der Definitionsdateien für jede Programmiersprache. Es ist daher zu erwarten, dass die Deaktivierung des automatischen Ladens dieser Dateien die Menge des Datenverkehrs reduziert." +_hemisphere: + N: "Nördliche Erdhalbkugel" + S: "Südliche Erdhalbkugel" + caption: "Wird in einigen Client-Einstellungen zur Bestimmung der Jahreszeit verwendet." _reversi: + reversi: "Reversi" + gameSettings: "Spieleinstellungen" + chooseBoard: "Spielbrett auswählen" blackOrWhite: "Schwarz/Weiß" + blackIs: "{name} spielt Schwarz" rules: "Regeln" + thisGameIsStartedSoon: "Das Spiel wird in Kürze beginnen" + waitingForOther: "Warte auf den Zug des Gegenspielers" + waitingForMe: "Warte auf deinen Zug" + waitingBoth: "Mach dich bereit" + ready: "Bereit" + cancelReady: "Nicht bereit" + opponentTurn: "Dein Gegner ist an der Reihe" + myTurn: "Du bist am Zug" + turnOf: "{name} ist am Zug" + pastTurnOf: "Zug von {name}" + surrender: "Aufgeben" + surrendered: "Aufgegeben" + timeout: "Zeit abgelaufen" + drawn: "Unentschieden" + won: "{name} hat gewonnen" black: "Schwarz" white: "Weiß" total: "Gesamt" + turnCount: " Zug {count}" + myGames: "Meine Runden" + allGames: "Alle Runden" + ended: "Beendet" + playing: "Partie läuft" + timeLimitForEachTurn: "Zeitlimit eines Zugs" + freeMatch: "Freies Spiel" + lookingForPlayer: "Gegner werden gesucht..." + gameCanceled: "Das Spiel wurde abgesagt." + shareToTlTheGameWhenStart: "Spiel in der Chronik teilen, wenn es gestartet wurde" + iStartedAGame: "Das Spiel hat begonnen! #MisskeyReversi" + opponentHasSettingsChanged: "Der Gegner hat seine Einstellungen geändert." + allowIrregularRules: "Irreguläre Regeln (völlig frei)" + disallowIrregularRules: "Keine irregulären Regeln" + showBoardLabels: "Anzeige der Zeilen- und Spaltennummern am Spielbrett" + useAvatarAsStone: "Steine in Benutzeravatare umwandeln" +_offlineScreen: + title: "Offline - keine Verbindung zum Server möglich" + header: "Verbindung zum Server nicht möglich" +_urlPreviewSetting: + title: "Einstellungen der URL-Vorschau" + enable: "URL-Vorschau aktivieren" + timeout: "Zeitüberschreitung beim Abrufen der Vorschau (ms)" + timeoutDescription: "Übersteigt die für die Vorschau benötigte Zeit diesen Wert, wird keine Vorschau generiert." + maximumContentLength: "Maximale Content-Length (Bytes)" + maximumContentLengthDescription: "Wenn die Content-Length diesen Wert überschreitet, wird keine Vorschau erzeugt." + requireContentLength: "Vorschau nur generieren, wenn Content-Length verfügbar ist" + requireContentLengthDescription: "Wenn der Server keine Content-Length zurückgibt, wird keine Vorschau erzeugt." + userAgent: "User-Agent" + userAgentDescription: "Legt den User-Agent fest, der beim Abrufen der Vorschau verwendet werden soll. Bleibt er leer, wird der Standard-User-Agent verwendet." + summaryProxy: "Proxy-Endpunkte, die Vorschaubilder erzeugen" + summaryProxyDescription: "Generierung von Vorschaubildern mit Summaly Proxy anstelle von Misskey selbst." + summaryProxyDescription2: "Die folgenden Parameter werden als Abfrage-Strings mit dem Proxy verknüpft. Wenn der Proxy sie nicht unterstützt, werden die Werte ignoriert." +_mediaControls: + pip: "Bild-in-Bild" + playbackRate: "Wiedergabegeschwindigkeit" + loop: "Endloswiedergabe" +_contextMenu: + title: "Kontextmenü" + app: "Anwendung" + appWithShift: "Anwendung per Umschalttaste" + native: "Natives Browsermenü" +_gridComponent: + _error: + requiredValue: "Dieser Wert ist ein Pflichtfeld" + columnTypeNotSupport: "Die Validierung regulärer Ausdrücke wird nur für Spalten vom Typ \"Text\" unterstützt." + patternNotMatch: "Dieser Wert stimmt nicht mit dem Schema in {pattern} überein" + notUnique: "Dieser Wert muss eindeutig sein" +_roleSelectDialog: + notSelected: "Nicht ausgewählt" +_customEmojisManager: + _gridCommon: + copySelectionRows: "Ausgewählte Zeilen kopieren" + copySelectionRanges: "Auswahl kopieren" + deleteSelectionRows: "Ausgewählte Zeilen löschen" + searchSettings: "Sucheinstellungen" + searchSettingCaption: "Detaillierte Suchkriterien festlegen." + searchLimit: "Anzahl der Ergebnisse" + sortOrder: "Sortierung" + registrationLogs: "Registrierungsprotokoll" + registrationLogsCaption: "Protokolle werden beim Aktualisieren oder Löschen von Emojis angezeigt. Sie verschwinden nach dem Aktualisieren oder Löschen, dem Wechsel zu einer neuen Seite oder dem Neuladen." + alertEmojisRegisterFailedDescription: "Emoji konnte nicht aktualisiert oder gelöscht werden. Bitte prüfe das Registrierungsprotokoll für Details." + _logs: + failureLogNothing: "Es gibt kein Fehlerprotokoll." + logNothing: "Keine Protokoll-Einträge." + _remote: + selectionRowDetail: "Details der ausgewählten Zeile" + importSelectionRangesRows: "Zeilen in der Auswahl importieren" + importEmojisButton: "Ausgewählte Emojis importieren" + confirmImportEmojisTitle: "Emojis importieren" + confirmImportEmojisDescription: "Importiere {count} Emoji(s), die von entfernten Server empfangen wurden. Bitte achte genau auf die Lizenz der Emojis. Bist du sicher, dass du fortfahren möchtest?" + _local: + tabTitleList: "Hinzugefügte Emojis" + tabTitleRegister: "Emojis hinzufügen" + _list: + emojisNothing: "Es wurden keine Emojis hinzugefügt." + alertUpdateEmojisNothingDescription: "Es wurden keine Emojis geändert." + alertDeleteEmojisNothingDescription: "Es gibt keine zu löschenden Emojis." + confirmUpdateEmojisDescription: "Aktualisiere {count} Emoji(s). Willst du fortfahren?" + confirmDeleteEmojisDescription: "Lösche {count} ausgewählte Emoji(s). Willst du fortfahren?" + confirmMovePageDesciption: "An den Emojis auf dieser Seite wurden Änderungen vorgenommen.\nWenn du die Seite verlässt, ohne zu speichern, werden alle auf dieser Seite vorgenommenen Änderungen verworfen." + _register: + uploadSettingDescription: "Hier kannst du das Verhalten beim Hochladen von Emojis konfigurieren." + directoryToCategoryLabel: "Gib den Namen des Verzeichnisses in das Feld „Kategorie“ ein" + directoryToCategoryCaption: "Wenn du ein Verzeichnis ziehst und ablegst, gib den Verzeichnisnamen in das Feld „Kategorie“ ein." + emojiInputAreaList1: "Ziehe Bilddateien oder Verzeichnisse per Drag-and-drop in diesen Rahmen" + emojiInputAreaList2: "Klicke auf diesen Link, um von deinem PC aus zu wählen" + emojiInputAreaList3: "Klicke auf diesen Link, um vom Drive aus zu wählen" + confirmRegisterEmojisDescription: "Füge die in der Liste aufgeführten Emojis als neue benutzerdefinierte Emojis hinzu. Bist du sicher? (Um eine Überlastung zu vermeiden, können nur {count} Emoji(s) in einem Vorgang hinzugefügt werden)" + confirmClearEmojisDescription: "Verwerfe die Bearbeitungen und lösche die Emojis aus der Liste. Bist du sicher, dass du fortfahren möchtest?" + confirmUploadEmojisDescription: "Lade die {count} abgelegte(n) Datei(en) in das Drive hoch. Bist du sicher, dass du fortfahren möchtest?" +_embedCodeGen: + title: "Einbettungscode anpassen" + header: "Kopfzeile anzeigen" + autoload: "Automatisch mehr laden (veraltet)" + maxHeight: "Maximale Höhe" + maxHeightDescription: "Der Wert 0 deaktiviert die Einstellung der maximalen Höhe. Gib einen Wert an, um zu verhindern, dass das Widget weiterhin vertikal vergrößert wird." + maxHeightWarn: "Die Begrenzung der maximalen Höhe ist deaktiviert (0). Wenn dies nicht beabsichtigt war, setze die maximale Höhe auf einen Wert fest." + previewIsNotActual: "Die Anzeige weicht von der tatsächlichen Einbettung ab, da sie den auf dem Vorschaufenster angezeigten Bereich überschreitet." + rounded: "Ecken abrunden" + border: "Dem äußeren Rand einen Rahmen hinzufügen" + applyToPreview: "Auf die Vorschau anwenden" + generateCode: "Einbettungscode generieren" + codeGenerated: "Der Code wurde generiert" + codeGeneratedDescription: "Füge den generierten Code in deine Website ein, um den Inhalt einzubetten." +_selfXssPrevention: + warning: "WARNUNG" + title: "„Füge in diesen Bereich etwas ein“ ist eine Betrugsmasche." + description1: "Wenn du hier etwas einfügst, könnte ein böswilliger Benutzer dein Konto übernehmen oder deine persönlichen Daten stehlen." + description2: "Wenn du das nicht genau verstehst, was du einfügst, %csolltest du die Eingabe abbrechen und das Fenster schließen." + description3: "Weitere Informationen findest du hier. {link}" +_followRequest: + recieved: "Anfrage erhalten" + sent: "Anfrage gesendet" +_remoteLookupErrors: + _federationNotAllowed: + title: "Kommunikation mit diesem Server nicht möglich" + description: "Möglicherweise wurde die Kommunikation mit diesem Server deaktiviert oder dieser Server ist blockiert.\nWende dich bitte an den Serveradministrator." + _uriInvalid: + title: "URI ist fehlerhaft" + description: "Es gibt ein Problem mit der von dir eingegebenen URI. Bitte prüfe, ob du Zeichen eingegeben hast, die in der URI nicht verwendet werden können." + _requestFailed: + title: "Anfrage fehlgeschlagen" + description: "Die Kommunikation mit diesem Server ist fehlgeschlagen. Der Server ist möglicherweise nicht erreichbar. Bitte vergewissere dich auch, dass du keine ungültige oder nicht existierende URI eingegeben hast." + _responseInvalid: + title: "Die Antwort ist ungültig" + description: "Die Kommunikation mit dem Server war erfolgreich, aber die erhaltenen Daten waren nicht korrekt. Wenn du Remote-Inhalte über einen Server eines Dritten abfragst, verwende bitte erneut eine URI, die vom Ursprungsserver abgerufen werden kann." + _noSuchObject: + title: "Nicht gefunden" + description: "Die angeforderte Ressource konnte nicht gefunden werden, bitte überprüfe die URI erneut." +_captcha: + verify: "Bitte beantworte das CAPTCHA" + testSiteKeyMessage: "Du kannst die Vorschau prüfen, indem du die Testwerte für den Site- und Secret-Key eingibst. Weitere Informationen findest du auf der folgenden Seite." + _error: + _requestFailed: + title: "CAPTCHA-Anfrage fehlgeschlagen." + text: "Bitte probiere es später noch einmal oder überprüfe die Einstellungen erneut." + _verificationFailed: + title: "CAPTCHA-Prüfung fehlgeschlagen" + text: "Bitte überprüfe nochmals, ob die Einstellungen korrekt sind." + _unknown: + title: "CAPTCHA-Fehler" + text: "Es ist ein unerwarteter Fehler aufgetreten." +_bootErrors: + title: "Laden fehlgeschlagen" + serverError: "Wenn das Problem nach kurzem Warten und erneutem Laden immer noch nicht behoben ist, wende dich bitte an den Serveradministrator und gib die folgende Fehler-ID an." + solution: "Folgendes könnte das Problem lösen." + solution1: "Aktualisiere deinen Browser und dein Betriebssystem auf die neueste Version" + solution2: "Deaktiviere den Werbeblocker" + solution3: "Leere den Browser-Cache" + solution4: "(Tor Browser) Setze dom.webaudio.enabled auf true" + otherOption: "Weitere Optionen" + otherOption1: "Client-Einstellungen und Cache löschen" + otherOption2: "Einfachen Client starten" + otherOption3: "Starte das Reparaturwerkzeug" +_search: + searchScopeAll: "Alle" + searchScopeLocal: "Lokal" + searchScopeServer: "Bestimmter Server" + searchScopeUser: "Spezifischer Benutzer" + pleaseEnterServerHost: "Gib den Server-Host ein" + pleaseSelectUser: "Benutzer auswählen" + serverHostPlaceholder: "Beispiel: misskey.example.com" diff --git a/locales/el-GR.yml b/locales/el-GR.yml index 5eca348e18..c8aff304d2 100644 --- a/locales/el-GR.yml +++ b/locales/el-GR.yml @@ -162,14 +162,12 @@ imageUrl: "URL εικόνας" remove: "Διαγραφή" removed: "Η διαγραφή ολοκληρώθηκε επιτυχώς" saved: "Αποθηκεύτηκε" -messaging: "Συνομιλία" upload: "Ανεβάστε" fromDrive: "Από τον Αποθηκευτικό Χώρο" fromUrl: "Από URL" uploadFromUrl: "Ανεβάστε από URL" explore: "Εξερευνήστε" messageRead: "Διαβάστηκε" -startMessaging: "Ξεκινήστε μία συνομιλία" nUsersRead: "διαβάστηκε από {n}" start: "Ας αρχίσουμε" home: "Κεντρικό" @@ -288,6 +286,11 @@ cannotUploadBecauseNoFreeSpace: "Το ανέβασμα απέτυχε λόγω icon: "Εικονίδιο" replies: "Απάντηση" renotes: "Κοινοποίηση σημειώματος" +postForm: "Φόρμα δημοσίευσης" +information: "Πληροφορίες" +_chat: + members: "Μέλη" + home: "Κεντρικό" _email: _follow: title: "Έχετε ένα νέο ακόλουθο" @@ -321,6 +324,7 @@ _permissions: "write:notifications": "Διαχειριστείτε τις ειδοποιήσεις σας" "read:pages": "Δείτε τις Σελίδες σας" "write:pages": "Επεξεργαστείτε ή διαγράψτε τις σελίδες σας" + "write:chat": "Γράψτε ή διαγράψτε μηνύματα συνομιλίας" _antennaSources: all: "Όλα τα σημειώματα" homeTimeline: "Σημειώματα από μέλη που ακολουθείτε" @@ -378,6 +382,7 @@ _notification: renote: "Κοινοποίηση σημειώματος" quote: "Παράθεση" reaction: "Αντιδράσεις" + login: "Σύνδεση" _actions: reply: "Απάντηση" renote: "Κοινοποίηση σημειώματος" @@ -396,3 +401,5 @@ _moderationLogTypes: suspend: "Αποβολή" _reversi: total: "Σύνολο" +_search: + searchScopeLocal: "Τοπικό" diff --git a/locales/en-US.yml b/locales/en-US.yml index c82ea3c9a2..089ed2383d 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -5,9 +5,13 @@ introMisskey: "Welcome! Misskey is an open source, decentralized microblogging s poweredByMisskeyDescription: "{name} is one of the services powered by the open source platform Misskey (referred to as a \"Misskey instance\")." monthAndDay: "{month}/{day}" search: "Search" +reset: "Reset" notifications: "Notifications" username: "Username" password: "Password" +initialPasswordForSetup: "Initial password for setup" +initialPasswordIsIncorrect: "Initial password for setup is incorrect" +initialPasswordForSetupDescription: "Use the password you entered in the configuration file if you installed Misskey yourself.\n If you are using a Misskey hosting service, use the password provided.\n If you have not set a password, leave it blank to continue." forgotPassword: "Forgot password" fetchingAsApObject: "Fetching from the Fediverse..." ok: "OK" @@ -45,6 +49,7 @@ pin: "Pin to profile" unpin: "Unpin from profile" copyContent: "Copy contents" copyLink: "Copy link" +copyRemoteLink: "Copy remote link" copyLinkRenote: "Copy renote link" delete: "Delete" deleteAndEdit: "Delete and edit" @@ -109,7 +114,7 @@ enterEmoji: "Enter an emoji" renote: "Renote" unrenote: "Remove renote" renoted: "Renoted." -renotedToX: "Renote to {name}." +renotedToX: "Renoted to {name}." cantRenote: "This post can't be renoted." cantReRenote: "A renote can't be renoted." quote: "Quote" @@ -127,7 +132,7 @@ reaction: "Reactions" reactions: "Reactions" emojiPicker: "Emoji picker" pinnedEmojisForReactionSettingDescription: "Set the emojis to be pinned and displayed when reacting." -pinnedEmojisSettingDescription: "Set the emojis to be pinned and displayed when viewing emoji picker." +pinnedEmojisSettingDescription: "Set the emojis to be pinned and displayed when viewing emoji picker" emojiPickerDisplay: "Emoji picker display" overwriteFromPinnedEmojisForReaction: "Override from reaction settings" overwriteFromPinnedEmojis: "Override from general settings" @@ -236,6 +241,8 @@ silencedInstances: "Silenced instances" silencedInstancesDescription: "List the host names of the servers that you want to silence, separated by a new line. All accounts belonging to the listed servers will be treated as silenced, and can only make follow requests, and cannot mention local accounts if not followed. This will not affect the blocked servers." mediaSilencedInstances: "Media-silenced servers" mediaSilencedInstancesDescription: "List the host names of the servers that you want to media-silence, separated by a new line. All accounts belonging to the listed servers will be treated as sensitive, and can't use custom emojis. This will not affect the blocked servers." +federationAllowedHosts: "Federation allowed servers" +federationAllowedHostsDescription: "Specify the hostnames of the servers you want to allow federation separated by line breaks." muteAndBlock: "Mutes and Blocks" mutedUsers: "Muted users" blockedUsers: "Blocked users" @@ -282,7 +289,6 @@ deleteAreYouSure: "Are you sure that you want to delete \"{x}\"?" resetAreYouSure: "Really reset?" areYouSure: "Are you sure?" saved: "Saved" -messaging: "Chat" upload: "Upload" keepOriginalUploading: "Keep original image" keepOriginalUploadingDescription: "Saves the originally uploaded image as-is. If turned off, a version to display on the web will be generated on upload." @@ -295,7 +301,7 @@ uploadFromUrlMayTakeTime: "It may take some time until the upload is complete." explore: "Explore" messageRead: "Read" noMoreHistory: "There is no further history" -startMessaging: "Start a new chat" +startChat: "Start chat" nUsersRead: "read by {n}" agreeTo: "I agree to {0}" agree: "Agree" @@ -326,7 +332,7 @@ selectFile: "Select a file" selectFiles: "Select files" selectFolder: "Select a folder" selectFolders: "Select folders" -fileNotSelected: "" +fileNotSelected: "No file selected" renameFile: "Rename file" folderName: "Folder name" createFolder: "Create a folder" @@ -334,6 +340,7 @@ renameFolder: "Rename this folder" deleteFolder: "Delete this folder" folder: "Folder" addFile: "Add a file" +showFile: "Show files" emptyDrive: "Your Drive is empty" emptyFolder: "This folder is empty" unableToDelete: "Unable to delete" @@ -376,7 +383,6 @@ enableLocalTimeline: "Enable local timeline" enableGlobalTimeline: "Enable global timeline" disablingTimelinesInfo: "Adminstrators and Moderators will always have access to all timelines, even if they are not enabled." registration: "Register" -enableRegistration: "Enable new user registration" invite: "Invite" driveCapacityPerLocalAccount: "Drive capacity per local user" driveCapacityPerRemoteAccount: "Drive capacity per remote user" @@ -448,6 +454,7 @@ totpDescription: "Use an authenticator app to enter one-time passwords" moderator: "Moderator" moderation: "Moderation" moderationNote: "Moderation note" +moderationNoteDescription: "You can fill in notes that will be shared only among moderators." addModerationNote: "Add moderation note" moderationLogs: "Moderation logs" nUsersMentioned: "Mentioned by {n} users" @@ -483,8 +490,6 @@ noteOf: "Note by {user}" quoteAttached: "Quote" quoteQuestion: "Append as quote?" attachAsFileQuestion: "The text in clipboard is long. Would you want to attach it as text file?" -noMessagesYet: "No messages yet" -newMessageExists: "There are new messages" onlyOneFileCanBeAttached: "You can only attach one file to a message" signinRequired: "Please register or sign in before continuing" signinOrContinueOnRemote: "To continue, you need to move your server or sign up / log in to this server." @@ -509,7 +514,10 @@ uiLanguage: "User interface language" aboutX: "About {x}" emojiStyle: "Emoji style" native: "Native" -disableDrawer: "Don't use drawer-style menus" +menuStyle: "Menu style" +style: "Style" +drawer: "Drawer" +popup: "Pop up" showNoteActionsOnlyHover: "Only show note actions on hover" showReactionsCount: "See the number of reactions in notes" noHistory: "No history available" @@ -575,8 +583,9 @@ popout: "Pop-out" volume: "Volume" masterVolume: "Master volume" notUseSound: "Disable sound" -useSoundOnlyWhenActive: "Output sounds only if Misskey is active." +useSoundOnlyWhenActive: "Output sounds only if Misskey is active" details: "Details" +renoteDetails: "Renote details" chooseEmoji: "Select an emoji" unableToProcess: "The operation could not be completed" recentUsed: "Recently used" @@ -592,6 +601,8 @@ ascendingOrder: "Ascending" descendingOrder: "Descending" scratchpad: "Scratchpad" scratchpadDescription: "The Scratchpad provides an environment for AiScript experiments. You can write, execute, and check the results of it interacting with Misskey in it." +uiInspector: "UI inspector" +uiInspectorDescription: "You can see the UI component server list on memory. UI component will be generated by Ui:C: function." output: "Output" script: "Script" disablePagesScript: "Disable AiScript on Pages" @@ -672,14 +683,19 @@ smtpSecure: "Use implicit SSL/TLS for SMTP connections" smtpSecureInfo: "Turn this off when using STARTTLS" testEmail: "Test email delivery" wordMute: "Word mute" +wordMuteDescription: "Minimize notes that contain the specified word or phrase. Minimized notes can be displayed by clicking on them." hardWordMute: "Hard word mute" +showMutedWord: "Show muted words" +hardWordMuteDescription: "Hide notes that contain the specified word or phrase. Unlike word mute, the note will be completely hidden from view." regexpError: "Regular Expression error" regexpErrorDescription: "An error occurred in the regular expression on line {line} of your {tab} word mutes:" instanceMute: "Instance Mutes" userSaysSomething: "{name} said something" +userSaysSomethingAbout: "{name} said something about \"{word}\"" makeActive: "Activate" display: "Display" copy: "Copy" +copiedToClipboard: "Copied to clipboard" metrics: "Metrics" overview: "Overview" logs: "Logs" @@ -708,10 +724,7 @@ abuseReported: "Your report has been sent. Thank you very much." reporter: "Reporter" reporteeOrigin: "Reportee Origin" reporterOrigin: "Reporter Origin" -forwardReport: "Forward report to remote instance" -forwardReportIsAnonymous: "Instead of your account, an anonymous system account will be displayed as reporter at the remote instance." send: "Send" -abuseMarkAsResolved: "Mark report as resolved" openInNewTab: "Open in new tab" openInSideView: "Open in side view" defaultNavigationBehaviour: "Default navigation behavior" @@ -913,6 +926,7 @@ followersVisibility: "Visibility of followers" continueThread: "View thread continuation" deleteAccountConfirm: "This will irreversibly delete your account. Proceed?" incorrectPassword: "Incorrect password." +incorrectTotp: "The one-time password is incorrect or has expired." voteConfirm: "Confirm your vote for \"{choice}\"?" hide: "Hide" useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile" @@ -937,6 +951,9 @@ oneHour: "One hour" oneDay: "One day" oneWeek: "One week" oneMonth: "One month" +threeMonths: "3 months" +oneYear: "1 year" +threeDays: "3 days" reflectMayTakeTime: "It may take some time for this to be reflected." failedToFetchAccountInformation: "Could not fetch account information" rateLimitExceeded: "Rate limit exceeded" @@ -1077,9 +1094,10 @@ retryAllQueuesConfirmTitle: "Really retry all?" retryAllQueuesConfirmText: "This will temporarily increase the server load." enableChartsForRemoteUser: "Generate remote user data charts" enableChartsForFederatedInstances: "Generate remote instance data charts" +enableStatsForFederatedInstances: "Receive remote server stats" showClipButtonInNoteFooter: "Add \"Clip\" to note action menu" reactionsDisplaySize: "Reaction display size" -limitWidthOfReaction: "Limits the maximum width of reactions and display them in reduced size." +limitWidthOfReaction: "Limit the maximum width of reactions and display them in reduced size." noteIdOrUrl: "Note ID or URL" video: "Video" videos: "Videos" @@ -1126,7 +1144,7 @@ options: "Options" specifyUser: "Specific user" lookupConfirm: "Do you want to look up?" openTagPageConfirm: "Do you want to open a hashtag page?" -specifyHost: "Specify a host" +specifyHost: "Specific host" failedToPreviewUrl: "Could not preview" update: "Update" rolesThatCanBeUsedThisEmojiAsReaction: "Roles that can use this emoji as reaction" @@ -1241,7 +1259,7 @@ copyReplayData: "Copy replay data" ranking: "Ranking" lastNDays: "Last {n} days" backToTitle: "Go back to title" -hemisphere: "Where are you located" +hemisphere: "Where you live" withSensitive: "Include notes with sensitive files" userSaysSomethingSensitive: "Post by {name} contains sensitive content" enableHorizontalSwipe: "Swipe to switch tabs" @@ -1263,6 +1281,171 @@ confirmWhenRevealingSensitiveMedia: "Confirm when revealing sensitive media" sensitiveMediaRevealConfirm: "This might be a sensitive media. Are you sure to reveal?" createdLists: "Created lists" createdAntennas: "Created antennas" +fromX: "From {x}" +genEmbedCode: "Generate embed code" +noteOfThisUser: "Notes by this user" +clipNoteLimitExceeded: "No more notes can be added to this clip." +performance: "Performance" +modified: "Modified" +discard: "Discard" +thereAreNChanges: "There are {n} change(s)" +signinWithPasskey: "Sign in with Passkey" +unknownWebAuthnKey: "Unknown Passkey" +passkeyVerificationFailed: "Passkey verification has failed." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "Passkey verification has succeeded but password-less login is disabled." +messageToFollower: "Message to followers" +target: "Target" +testCaptchaWarning: "This function is intended for CAPTCHA testing purposes.\nDo not use in a production environment." +prohibitedWordsForNameOfUser: "Prohibited words for user names" +prohibitedWordsForNameOfUserDescription: "If any of the strings in this list are included in the user's name, the name will be denied. Users with moderator privileges are not affected by this restriction." +yourNameContainsProhibitedWords: "Your name contains prohibited words" +yourNameContainsProhibitedWordsDescription: "If you wish to use this name, please contact your server administrator." +thisContentsAreMarkedAsSigninRequiredByAuthor: "Set by the author to require login to view" +lockdown: "Lockdown" +pleaseSelectAccount: "Select an account" +availableRoles: "Available roles" +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." +confirmOnReact: "Confirm when reacting" +reactAreYouSure: "Would you like to add a \"{emoji}\" reaction?" +markAsSensitiveConfirm: "Do you want to set this media as sensitive?" +unmarkAsSensitiveConfirm: "Do you want to remove the sensitive designation for this media?" +preferences: "Preferences" +accessibility: "Accessibility" +preferencesProfile: "Preferences profile" +copyPreferenceId: "Copy the preference ID" +resetToDefaultValue: "Revert to default" +overrideByAccount: "Override by the account" +untitled: "Untitled" +noName: "No name" +skip: "Skip" +restore: "Restore" +syncBetweenDevices: "Sync between devices" +preferenceSyncConflictTitle: "The configured value exists on the server." +preferenceSyncConflictText: "The sync enabled settings will save their values to the server. However, there are existing values on the server. Which set of values would you like to overwrite?" +preferenceSyncConflictChoiceServer: "Configured value on server" +preferenceSyncConflictChoiceDevice: "Configured value on device" +preferenceSyncConflictChoiceCancel: "Cancel enabling sync" +paste: "Paste" +emojiPalette: "Emoji palette" +postForm: "Posting form" +textCount: "Character count" +information: "About" +chat: "Chat" +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" +_chat: + noMessagesYet: "No messages yet" + newMessage: "New message" + individualChat: "Private Chat" + individualChat_description: "Have a private chat with another person." + roomChat: "Room Chat" + roomChat_description: "A chat room which can have multiple people.\nYou can also invite people who don't allow private chats if they accept the invite." + createRoom: "Create Room" + inviteUserToChat: "Invite users to start chatting" + yourRooms: "Created rooms" + joiningRooms: "Joined rooms" + invitations: "Invite" + noInvitations: "No invitations" + history: "History" + noHistory: "No history available" + noRooms: "No rooms found" + inviteUser: "Invite Users" + sentInvitations: "Sent Invites" + join: "Join" + ignore: "Ignore" + leave: "Leave room" + members: "Members" + searchMessages: "Search messages" + home: "Home" + send: "Send" + newline: "New line" + muteThisRoom: "Mute room" + deleteRoom: "Delete room" + chatNotAvailableForThisAccountOrServer: "Chat is not enabled on this server or for this account." + chatNotAvailableInOtherAccount: "The chat function is disabled for the other user." + cannotChatWithTheUser: "Cannot start a chat with this user" + cannotChatWithTheUser_description: "Chat is either unavailable or the other party has not enabled chat." + chatWithThisUser: "Chat with user" + thisUserAllowsChatOnlyFromFollowers: "This user accepts chats from followers only." + thisUserAllowsChatOnlyFromFollowing: "This user accepts chats only from users they follow." + thisUserAllowsChatOnlyFromMutualFollowing: "This user only accepts chats from users who are mutual followers." + thisUserNotAllowedChatAnyone: "This user is not accepting chats from anyone." + chatAllowedUsers: "Who to allow chatting with" + chatAllowedUsers_note: "You can chat with anyone to whom you have sent a chat message regardless of this setting." + _chatAllowedUsers: + everyone: "Everyone" + followers: "Only your followers" + following: "Only users you are following" + mutual: "Mutual followers only" + none: "Nobody" +_emojiPalette: + palettes: "Palette" + enableSyncBetweenDevicesForPalettes: "Enable palette sync between devices" + paletteForMain: "Main palette" + paletteForReaction: "Reaction palette" +_settings: + driveBanner: "You can manage and configure the drive, check usage, and configure file upload settings." + pluginBanner: "You can extend client features with plugins. You can install plugins, configure and manage individually." + notificationsBanner: "You can configure the types and range of notifications from the server and push notifications." + api: "API" + webhook: "Webhook" + serviceConnection: "Service integration" + serviceConnectionBanner: "Manage and configure access tokens and Webhooks to integrate with external apps or services." + accountData: "Account data" + accountDataBanner: "Export and import to manage account data." + muteAndBlockBanner: "You can configure and manage settings to hide content and restrict actions from specific users." + accessibilityBanner: "You can personalize the client's visuals and behavior, and configure settings to optimize usage." + privacyBanner: "You can configure settings related to account privacy, such as content visibility, discoverability, and follow approval." + securityBanner: "You can configure settings related to account security, such as password, login methods, authentication apps, and Passkeys." + preferencesBanner: "You can configure the overall behavior of the client according to your preferences." + appearanceBanner: "You can configure the appearance and display settings for the client according to your preferences." + soundsBanner: "You can configure the sound settings for playback in the client." + timelineAndNote: "Timeline and note" + makeEveryTextElementsSelectable: "Make all text elements selectable" + makeEveryTextElementsSelectable_description: "Enabling this may reduce usability in some situations." + useStickyIcons: "Make icons follow while scrolling" + showNavbarSubButtons: "Show sub-buttons on the navigation bar" + ifOn: "When turned on" + ifOff: "When turned off" + _chat: + showSenderName: "Show sender's name" + sendOnEnter: "Press Enter to send" +_preferencesProfile: + profileName: "Profile name" + profileNameDescription: "Set a name that identifies this device." + profileNameDescription2: "Example: \"Main PC\", \"Smartphone\"" +_preferencesBackup: + autoBackup: "Auto backup" + restoreFromBackup: "Restore from backup" + noBackupsFoundTitle: "No backups found" + noBackupsFoundDescription: "No auto-created backups were found, but if you have manually saved a backup file, you can import and restore it." + selectBackupToRestore: "Select a backup to restore" + 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" +_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." + requireSigninToViewContentsDescription2: "Content will not be displayed in URL previews (OGP), embedded in web pages, or on servers that don't support note quotes." + requireSigninToViewContentsDescription3: "These restrictions may not apply to federated content from other remote servers." + makeNotesFollowersOnlyBefore: "Make past notes to be displayed only to followers" + makeNotesFollowersOnlyBeforeDescription: "While this feature is enabled, only followers can see notes past the set date and time or have been visible for a set time. When it is deactivated, the note publication status will also be restored." + makeNotesHiddenBefore: "Make past notes private" + makeNotesHiddenBeforeDescription: "While this feature is enabled, notes that are past the set date and time or have been visible only to you. When it is deactivated, the note publication status will also be restored." + mayNotEffectForFederatedNotes: "Notes federated to a remote server may not be affected." + mayNotEffectSomeSituations: "These restrictions are simplified. They may not apply in some situations, such as when viewing on a remote server or during moderation." + notesHavePassedSpecifiedPeriod: "Note that the specified time has passed" + notesOlderThanSpecifiedDateAndTime: "Notes before the specified date and time" +_abuseUserReport: + forward: "Forward" + forwardDescription: "Forward the report to a remote server as an anonymous system account." + resolve: "Resolve" + accept: "Accept" + reject: "Reject" + resolveTutorial: "If the report's content is legitimate, select \"Accept\" to mark it as resolved.\nIf the report's content is illegitimate, select \"Reject\" to ignore it." _delivery: status: "Delivery status" stop: "Suspended" @@ -1397,8 +1580,12 @@ _serverSettings: fanoutTimelineDescription: "Greatly increases performance of timeline retrieval and reduces load on the database when enabled. In exchange, memory usage of Redis will increase. Consider disabling this in case of low server memory or server instability." fanoutTimelineDbFallback: "Fallback to database" fanoutTimelineDbFallbackDescription: "When enabled, the timeline will fall back to the database for additional queries if the timeline is not cached. Disabling it further reduces the server load by eliminating the fallback process, but limits the range of timelines that can be retrieved." + reactionsBufferingDescription: "When enabled, performance during reaction creation will be greatly improved, reducing the load on the database. However, Redis memory usage will increase." inquiryUrl: "Inquiry URL" inquiryUrlDescription: "Specify a URL for the inquiry form to the server maintainer or a web page for the contact information." + openRegistration: "Make the account creation open" + openRegistrationWarning: "Opening registration carries risks. It is recommended to only enable it if you have a system in place to continuously monitor the server and respond immediately in case of any issues." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "If no moderator activity is detected for a while, this setting will be automatically turned off to prevent spam." _accountMigration: moveFrom: "Migrate another account to this one" moveFromSub: "Create alias to another account" @@ -1715,7 +1902,7 @@ _role: canManageAvatarDecorations: "Manage avatar decorations" driveCapacity: "Drive capacity" alwaysMarkNsfw: "Always mark files as NSFW" - canUpdateBioMedia: "Allow to edit an icon or a banner image" + canUpdateBioMedia: "Can edit an icon or a banner image" pinMax: "Maximum number of pinned notes" antennaMax: "Maximum number of antennas" wordMuteMax: "Maximum number of characters allowed in word mutes" @@ -1730,6 +1917,12 @@ _role: canSearchNotes: "Usage of note search" canUseTranslator: "Translator usage" avatarDecorationLimit: "Maximum number of avatar decorations that can be applied" + canImportAntennas: "Allow importing antennas" + canImportBlocking: "Allow importing blocking" + canImportFollowing: "Allow importing following" + canImportMuting: "Allow importing muting" + canImportUserLists: "Allow importing lists" + canChat: "Allow Chat" _condition: roleAssignedTo: "Assigned to manual roles" isLocal: "Local user" @@ -1893,6 +2086,7 @@ _theme: installed: "{name} has been installed" installedThemes: "Installed themes" builtinThemes: "Built-in themes" + instanceTheme: "Server theme" alreadyInstalled: "This theme is already installed" invalid: "The format of this theme is invalid" make: "Make a theme" @@ -1947,7 +2141,6 @@ _theme: buttonBg: "Button background" buttonHoverBg: "Button background (Hover)" inputBorder: "Input field border" - listItemHoverBg: "List item background (Hover)" driveFolderBg: "Drive folder background" wallpaperOverlay: "Wallpaper overlay" badge: "Badge" @@ -1960,6 +2153,7 @@ _sfx: noteMy: "Own note" notification: "Notifications" reaction: "On choosing a reaction" + chatMessage: "Chat Messages" _soundSettings: driveFile: "Use an audio file in Drive." driveFileWarn: "Select an audio file from Drive." @@ -2106,6 +2300,8 @@ _permissions: "read:clip-favorite": "View favorited clips" "read:federation": "Get federation data" "write:report-abuse": "Report violation" + "write:chat": "Compose or delete chat messages" + "read:chat": "Browse Chat" _auth: shareAccessTitle: "Granting application permissions" shareAccess: "Would you like to authorize \"{name}\" to access this account?" @@ -2114,8 +2310,11 @@ _auth: permissionAsk: "This application requests the following permissions" pleaseGoBack: "Please go back to the application" callback: "Returning to the application" + accepted: "Access granted" denied: "Access denied" + 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" _antennaSources: all: "All notes" homeTimeline: "Notes from followed users" @@ -2160,7 +2359,7 @@ _widgets: _userList: chooseList: "Select a list" clicker: "Clicker" - birthdayFollowings: "Users who celebrate their birthday today" + birthdayFollowings: "Today's Birthdays" _cw: hide: "Hide" show: "Show content" @@ -2224,6 +2423,9 @@ _profile: changeBanner: "Change banner" verifiedLinkDescription: "By entering an URL that contains a link to your profile here, an ownership verification icon can be displayed next to the field." avatarDecorationMax: "You can add up to {max} decorations." + followedMessage: "Message when you are followed" + followedMessageDescription: "You can set a short message to be displayed to the recipient when they follow you." + followedMessageDescriptionForLockedAccount: "If you have set up that follow requests require approval, this will be displayed when you grant a follow request." _exportOrImport: allNotes: "All notes" favoritedNotes: "Favorite notes" @@ -2286,9 +2488,6 @@ _pages: newPage: "Create a new Page" editPage: "Edit this Page" readPage: "Viewing this Page's source" - created: "Page successfully created" - updated: "Page successfully edited" - deleted: "Page successfully deleted" pageSetting: "Page settings" nameAlreadyExists: "The specified Page URL already exists" invalidNameTitle: "The specified Page URL is invalid" @@ -2351,6 +2550,7 @@ _notification: newNote: "New note" unreadAntennaNote: "Antenna {name}" roleAssigned: "Role given" + chatRoomInvitationReceived: "You have been invited to a chat room" emptyPushNotificationMessage: "Push notifications have been updated" achievementEarned: "Achievement unlocked" testNotification: "Test notification" @@ -2362,6 +2562,10 @@ _notification: renotedBySomeUsers: "Renote from {n} users" followedBySomeUsers: "Followed by {n} users" flushNotification: "Clear notifications" + exportOfXCompleted: "Export of {x} has been completed" + login: "Someone logged in" + createToken: "An access token has been created" + createTokenDescription: "If you have no idea, delete the access token through \"{text}\"." _types: all: "All" note: "New notes" @@ -2375,7 +2579,12 @@ _notification: receiveFollowRequest: "Received follow requests" followRequestAccepted: "Accepted follow requests" roleAssigned: "Role given" + chatRoomInvitationReceived: "Invited to chat room" achievementEarned: "Achievement unlocked" + exportCompleted: "The export has been completed" + login: "Sign In" + createToken: "Create access token" + test: "Notification test" app: "Notifications from linked apps" _actions: followBack: "followed you back" @@ -2402,6 +2611,7 @@ _deck: useSimpleUiForNonRootPages: "Use simple UI for navigated pages" 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" _columns: main: "Main" widgets: "Widgets" @@ -2438,21 +2648,24 @@ _webhookSettings: reaction: "When receiving a reaction" mention: "When being mentioned" _systemEvents: - abuseReport: "When received a new abuse report" - abuseReportResolved: "When resolved abuse reports" + abuseReport: "When received a new report" + abuseReportResolved: "When resolved report" userCreated: "When user is created" + inactiveModeratorsWarning: "When moderators have been inactive for a while" + inactiveModeratorsInvitationOnlyChanged: "When a moderator has been inactive for a while, and the server is changed to invitation-only" deleteConfirm: "Are you sure you want to delete the Webhook?" + testRemarks: "Click the button to the right of the switch to send a test Webhook with dummy data." _abuseReport: _notificationRecipient: - createRecipient: "Add a recipient for abuse reports" - modifyRecipient: "Edit a recipient for abuse reports" + createRecipient: "Add recipient for reports" + modifyRecipient: "Edit a recipient for reports" recipientType: "Notification type" _recipientType: mail: "Email" webhook: "Webhook" _captions: - mail: "Send the email to moderators' email addresses when you receive abuse." - webhook: "Send a notification to SystemWebhook when you receive or resolve abuse." + mail: "Send the email to moderators' email addresses when you receive reports." + webhook: "Send a notification to System Webhook when you receive or resolve reports." keywords: "Keywords" notifiedUser: "Users to notify" notifiedWebhook: "Webhook to use" @@ -2485,6 +2698,8 @@ _moderationLogTypes: markSensitiveDriveFile: "File marked as sensitive" unmarkSensitiveDriveFile: "File unmarked as sensitive" resolveAbuseReport: "Report resolved" + forwardAbuseReport: "Report forwarded" + updateAbuseReportNote: "Moderation note of a report updated" createInvitation: "Invite generated" createAd: "Ad created" deleteAd: "Ad deleted" @@ -2492,18 +2707,20 @@ _moderationLogTypes: createAvatarDecoration: "Avatar decoration created" updateAvatarDecoration: "Avatar decoration updated" deleteAvatarDecoration: "Avatar decoration deleted" - unsetUserAvatar: "Unset this user's avatar" - unsetUserBanner: "Unset this user's banner" - createSystemWebhook: "Create SystemWebhook" - updateSystemWebhook: "Update SystemWebhook" - deleteSystemWebhook: "Delete SystemWebhook" - createAbuseReportNotificationRecipient: "Create a recipient for abuse reports" - updateAbuseReportNotificationRecipient: "Update recipients for abuse reports" - deleteAbuseReportNotificationRecipient: "Delete a recipient for abuse reports" - deleteAccount: "Delete the account" - deletePage: "Delete the page" - deleteFlash: "Delete Play" - deleteGalleryPost: "Delete the gallery post" + unsetUserAvatar: "User avatar unset" + unsetUserBanner: "User banner unset" + createSystemWebhook: "System Webhook created" + updateSystemWebhook: "System Webhook updated" + deleteSystemWebhook: "System Webhook deleted" + createAbuseReportNotificationRecipient: "Recipient for reports created" + updateAbuseReportNotificationRecipient: "Recipient for reports updated" + deleteAbuseReportNotificationRecipient: "Recipient for reports deleted" + deleteAccount: "Account deleted" + deletePage: "Page deleted" + deleteFlash: "Play deleted" + deleteGalleryPost: "Gallery post deleted" + deleteChatRoom: "Deleted Chat Room" + updateProxyAccountDescription: "Update the description of the proxy account" _fileViewer: title: "File details" type: "File type" @@ -2517,10 +2734,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Make sure the distributor of this resource is trustworthy before installation." _plugin: title: "Do you want to install this plugin?" - metaTitle: "Plugin information" _theme: title: "Do you want to install this theme?" - metaTitle: "Theme information" _meta: base: "Base color scheme" _vendorInfo: @@ -2559,7 +2774,7 @@ _dataSaver: description: "Prevents images/videos from being loaded automatically. Hidden images/videos will be loaded when tapped." _avatar: title: "Avatar image" - description: "Stop avatar image animation. Animated images can be larger in file size than normal images, potentially leading to further reductions in data traffic." + description: "Stop avatar image animation. Animated images can be larger in file size than normal images, potentially leading to further reductions in data traffic." _urlPreview: title: "URL preview thumbnails" description: "URL preview thumbnail images will no longer be loaded." @@ -2640,3 +2855,135 @@ _contextMenu: app: "Application" appWithShift: "Application with shift key" native: "Native" +_gridComponent: + _error: + requiredValue: "This value is required" + columnTypeNotSupport: "Validation with regular expression is supported only for type:text columns." + patternNotMatch: "This value doesn't match the pattern in {pattern}" + notUnique: "This value must be unique" +_roleSelectDialog: + notSelected: "Not selected" +_customEmojisManager: + _gridCommon: + copySelectionRows: "Copy selected rows" + copySelectionRanges: "Copy selection" + deleteSelectionRows: "Delete selected rows" + deleteSelectionRanges: "Delete rows in the selection" + searchSettings: "Search settings" + searchSettingCaption: "Set detailed search criteria." + searchLimit: "" + sortOrder: "Sort order" + registrationLogs: "Registration log" + registrationLogsCaption: "Logs will be displayed when updating or deleting Emojis. They will disappear after updating or deleting them, moving to a new page, or reloading." + alertEmojisRegisterFailedDescription: "Failed to update or delete Emojis. Please check the registration log for details." + _logs: + showSuccessLogSwitch: "Show success log" + failureLogNothing: "There is no failure log." + logNothing: "There is no log." + _remote: + selectionRowDetail: "Selected row's detail" + importSelectionRows: "Import selected rows" + importSelectionRangesRows: "Import rows in the selection" + importEmojisButton: "Import checked Emojis" + confirmImportEmojisTitle: "Import Emojis" + confirmImportEmojisDescription: "Import {count} Emoji(s) received from the remote server. Please pay close attention to the license of the Emoji. Are you sure to continue?" + _local: + tabTitleList: "Registered emojis" + tabTitleRegister: "Emoji registration" + _list: + emojisNothing: "There are no registered Emojis." + markAsDeleteTargetRows: "Mark selected rows as a target to delete" + markAsDeleteTargetRanges: "Mark rows in the selection as a target to delete" + alertUpdateEmojisNothingDescription: "There are no updated Emojis." + alertDeleteEmojisNothingDescription: "There are no Emojis to be deleted." + confirmMovePage: "" + confirmChangeView: "" + confirmUpdateEmojisDescription: "Update {count} Emoji(s). Are you sure to continue?" + confirmDeleteEmojisDescription: "Delete checked {count} Emoji(s). Are you sure to continue?" + confirmResetDescription: "" + confirmMovePageDesciption: "Changes have been made to the Emojis on this page.\nIf you leave the page without saving, all changes made on this page will be discarded." + dialogSelectRoleTitle: "Search by roll set in Emojis" + _register: + uploadSettingTitle: "Upload settings" + uploadSettingDescription: "On this screen, you can configure the behavior when uploading Emojis." + directoryToCategoryLabel: "Enter the directory name in the \"category\" field" + directoryToCategoryCaption: "When you drag and drop a directory, enter the directory name in the \"category\" field." + emojiInputAreaCaption: "Select the Emojis you wish to register using one of the methods." + emojiInputAreaList1: "Drag and drop image files or a directory into this frame" + emojiInputAreaList2: "Click this link to select from your computer" + emojiInputAreaList3: "Click this link to select from the drive" + confirmRegisterEmojisDescription: "Register the Emojis from the list as new custom Emojis. Are you sure to continue? (To avoid overload, only {count} Emoji(s) can be registered in a single operation)" + confirmClearEmojisDescription: "Discard the edits and clear the Emojis from the list. Are you sure to continue?" + confirmUploadEmojisDescription: "Upload the dragged and dropped {count} file(s) to the drive. Are you sure to continue?" +_embedCodeGen: + title: "Customize embed code" + header: "Show header" + autoload: "Automatically load more (deprecated)" + maxHeight: "Max height" + maxHeightDescription: "Setting it to 0 disables the max height setting. Specify some value to prevent the widget from continuing to expand vertically." + maxHeightWarn: "The max height limit is disabled (0). If this was not intended, set the max height to some value." + previewIsNotActual: "The display differs from the actual embedding because it exceeds the range displayed on the preview screen." + rounded: "Make it rounded" + border: "Add a border to the outer frame" + applyToPreview: "Apply to the preview" + generateCode: "Generate embed code" + codeGenerated: "The code has been generated" + codeGeneratedDescription: "Paste the generated code into your website to embed the content." +_selfXssPrevention: + warning: "WARNING" + title: "\"Paste something on this screen\" is all a scam." + description1: "If you paste something here, a malicious user could hijack your account or steal your personal information." + description2: "If you do not understand exactly what you are trying to paste, %cstop working right now and close this window." + description3: "For more information, please refer to this. {link}" +_followRequest: + recieved: "Received request" + sent: "Sent request" +_remoteLookupErrors: + _federationNotAllowed: + title: "Unable to communicate with this server" + description: "Communication with this server may have been disabled or this server may be blocked.\nPlease contact the server administrator." + _uriInvalid: + title: "URI is invalid" + description: "There is a problem with the URI you entered. Please check if you entered characters that cannot be used in the URI." + _requestFailed: + title: "Request failed" + description: "Communication with this server failed. The server may be down. Also, please make sure that you have not entered an invalid or nonexistent URI." + _responseInvalid: + title: "Response is invalid" + description: "It could communicate with this server, but the data obtained was incorrect." + _noSuchObject: + title: "Not found" + description: "The requested resource was not found, please recheck the URI." +_captcha: + verify: "Please verify the CAPTCHA" + testSiteKeyMessage: "You can check the preview by entering the test values for the site and secret keys.\nPlease see the following page for details." + _error: + _requestFailed: + title: "Failed to request CAPTCHA" + text: "Please run it after a while or check the settings again." + _verificationFailed: + title: "Failed to validate CAPTCHA" + text: "Please check again if the settings are correct." + _unknown: + title: "CAPTCHA error" + text: "An unexpected error occurred." +_bootErrors: + title: "Failed to load" + serverError: "If the problem persists after waiting a moment and reloading, please contact the server administrator with the following Error ID." + solution: "The following may solve the problem." + solution1: "Update your browser and OS to the latest version" + solution2: "Disable ad blocker" + solution3: "Clear the browser cache" + solution4: "Set the dom.webaudio.enabled to true for Tor Browser" + otherOption: "Other options" + otherOption1: "Delete client settings and cache" + otherOption2: "Start the simple client" + otherOption3: "Launch the repair tool" +_search: + searchScopeAll: "All" + searchScopeLocal: "Local" + searchScopeServer: "Specific server" + searchScopeUser: "Specific user" + pleaseEnterServerHost: "Enter the server host" + pleaseSelectUser: "Select user" + serverHostPlaceholder: "Example: misskey.example.com" diff --git a/locales/es-ES.yml b/locales/es-ES.yml index 2621965d1b..b7f3a65a96 100644 --- a/locales/es-ES.yml +++ b/locales/es-ES.yml @@ -5,9 +5,13 @@ 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" notifications: "Notificaciones" username: "Nombre de usuario" password: "Contraseña" +initialPasswordForSetup: "Contraseña para iniciar la inicialización" +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" fetchingAsApObject: "Buscando en el fediverso" ok: "OK" @@ -45,6 +49,7 @@ pin: "Fijar al perfil" unpin: "Desfijar" copyContent: "Copiar contenido" copyLink: "Copiar enlace" +copyRemoteLink: "Copiar enlace remoto" copyLinkRenote: "Copiar enlace de renota" delete: "Borrar" deleteAndEdit: "Borrar y editar" @@ -109,11 +114,14 @@ enterEmoji: "Ingresar emojis" renote: "Renotar" unrenote: "Quitar renota" renoted: "Renotado" +renotedToX: "{name} usuarios han renotado。" cantRenote: "No se puede renotar este post" cantReRenote: "No se puede renotar una renota" quote: "Citar" inChannelRenote: "Renota sólo del canal" inChannelQuote: "Cita sólo del canal" +renoteToChannel: "Renotar a otro canal" +renoteToOtherChannel: "Renotar a otro canal" pinnedNote: "Nota fijada" pinned: "Fijar al perfil" you: "Tú" @@ -152,6 +160,7 @@ editList: "Editar lista" selectChannel: "Seleccionar canal" selectAntenna: "Seleccionar antena" editAntenna: "Editar antena" +createAntenna: "Crear una antena" selectWidget: "Seleccionar widget" editWidgets: "Editar widgets" editWidgetsExit: "Terminar edición" @@ -178,6 +187,10 @@ addAccount: "Agregar Cuenta" reloadAccountsList: "Recargar lista de cuentas" loginFailed: "Error al iniciar sesión." showOnRemote: "Ver en una instancia remota" +continueOnRemote: "Ver en una instancia remota" +chooseServerOnMisskeyHub: "Elegir un servidor en Misskey Hub" +specifyServerHost: "Especifica una instancia directamente" +inputHostName: "Introduzca el dominio" general: "General" wallpaper: "Fondo de pantalla" setWallpaper: "Establecer fondo de pantalla" @@ -188,6 +201,7 @@ followConfirm: "¿Desea seguir a {name}?" proxyAccount: "Cuenta proxy" proxyAccountDescription: "Una cuenta proxy es una cuenta que actúa como un seguidor remoto de un usuario bajo ciertas condiciones. Por ejemplo, cuando un usuario añade un usuario remoto a una lista, si ningún usuario local sigue al usuario agregado a la lista, la instancia no puede obtener su actividad. Así que la cuenta proxy sigue al usuario añadido a la lista" host: "Host" +selectSelf: "Elígete a ti mismo" selectUser: "Elegir usuario" recipient: "Recipiente" annotation: "Anotación" @@ -203,6 +217,7 @@ perDay: "por día" stopActivityDelivery: "Dejar de enviar actividades" blockThisInstance: "Bloquear instancia" silenceThisInstance: "Silenciar esta instancia" +mediaSilenceThisInstance: "Silencia la Multimedia(Imágenes,videos...) para este servidor" operations: "Operaciones" software: "Software" version: "Versión" @@ -224,6 +239,10 @@ blockedInstances: "Instancias bloqueadas" blockedInstancesDescription: "Seleccione los hosts de las instancias que desea bloquear, separadas por una linea nueva. Las instancias bloqueadas no podrán comunicarse con esta instancia." silencedInstances: "Instancias silenciadas" silencedInstancesDescription: "Listar los hostname de las instancias que quieres silenciar. Todas las cuentas de las instancias listadas serán tratadas como silenciadas, solo podrán hacer peticiones de seguimiento, y no podrán mencionar cuentas locales si no las siguen. Esto no afecta a las instancias bloqueadas." +mediaSilencedInstances: "Servidores silenciados (Multimedia)" +mediaSilencedInstancesDescription: "Listar las instancias que quieres silenciar. Todas las cuentas de las instancias listadas serán tratadas como silenciadas, solo podrán hacer peticiones de seguimiento, y no podrán mencionar cuentas locales si no las siguen. Esto no afecta a las instancias bloqueadas." +federationAllowedHosts: "Servidores federados" +federationAllowedHostsDescription: "Establezca los nombres de los servidores que pueden federarse, separados por una nueva línea." muteAndBlock: "Silenciar y bloquear" mutedUsers: "Usuarios silenciados" blockedUsers: "Usuarios bloqueados" @@ -270,7 +289,6 @@ deleteAreYouSure: "¿Desea borrar \"{x}\"?" resetAreYouSure: "¿Desea reestablecer?" areYouSure: "¿Estás conforme?" saved: "Guardado" -messaging: "Chat" upload: "Subir" keepOriginalUploading: "Mantener la imagen original" keepOriginalUploadingDescription: "Mantener la versión original al cargar imágenes. Si está desactivado, el navegador generará imágenes para la publicación web en el momento de recargar la página" @@ -283,7 +301,6 @@ uploadFromUrlMayTakeTime: "Subir el fichero puede tardar un tiempo." explore: "Explorar" messageRead: "Ya leído" noMoreHistory: "El historial se ha acabado" -startMessaging: "Iniciar chat" nUsersRead: "Leído por {n} personas" agreeTo: "De acuerdo con {0}" agree: "De acuerdo." @@ -314,6 +331,7 @@ selectFile: "Elegir archivo" selectFiles: "Elegir archivos" selectFolder: "Seleccione una carpeta" selectFolders: "Seleccione carpetas" +fileNotSelected: "Archivo no seleccionado." renameFile: "Renombrar archivo" folderName: "Nombre de la carpeta" createFolder: "Crear carpeta" @@ -321,6 +339,7 @@ renameFolder: "Renombrar carpeta" deleteFolder: "Borrar carpeta" folder: "Carpeta" addFile: "Agregar archivo" +showFile: "Examinar archivos" emptyDrive: "El drive está vacío" emptyFolder: "La carpeta está vacía" unableToDelete: "No se puede borrar" @@ -363,7 +382,6 @@ enableLocalTimeline: "Habilitar linea de tiempo local" enableGlobalTimeline: "Habilitar linea de tiempo global" disablingTimelinesInfo: "Aunque se desactiven estas lineas de tiempo, por conveniencia el administrador y los moderadores pueden seguir usándolos" registration: "Registro" -enableRegistration: "Permitir nuevos registros" invite: "Invitar" driveCapacityPerLocalAccount: "Capacidad del drive por usuario local" driveCapacityPerRemoteAccount: "Capacidad del drive por usuario remoto" @@ -435,6 +453,7 @@ totpDescription: "Ingresa una contaseña de un sólo uso usando la aplicación a moderator: "Moderador" moderation: "Moderación" moderationNote: "Nota de moderación" +moderationNoteDescription: "Puedes rellenar notas que solo se comparten entre moderadores." addModerationNote: "Añadir nota de moderación" moderationLogs: "Log de moderación" nUsersMentioned: "{n} usuarios mencionados" @@ -469,10 +488,10 @@ retype: "Ingrese de nuevo" noteOf: "Notas de {user}" quoteAttached: "Cita añadida" quoteQuestion: "¿Quiere añadir una cita?" -noMessagesYet: "Aún no hay chat" -newMessageExists: "Tienes un mensaje nuevo" +attachAsFileQuestion: "El texto del portapapeles es demasiado grande ¿Desea adjuntarlo como archivo de texto?" onlyOneFileCanBeAttached: "Solo se puede añadir un archivo al mensaje" signinRequired: "Iniciar sesión" +signinOrContinueOnRemote: "Para continuar, tendrá que ir a su servidor o registrarse e iniciar sesión en este servidor" invitations: "Invitar" invitationCode: "Código de invitación" checking: "Comprobando" @@ -494,7 +513,10 @@ uiLanguage: "Idioma de visualización de la interfaz" aboutX: "Acerca de {x}" emojiStyle: "Estilo de emoji" native: "Nativo" -disableDrawer: "No mostrar los menús en cajones" +menuStyle: "Diseño del menú" +style: "Diseño" +drawer: "Cajón de Aplicaciones" +popup: "Ventana emergente" showNoteActionsOnlyHover: "Mostrar acciones de la nota sólo al pasar el cursor" showReactionsCount: "Mostrar el número de reacciones en las notas" noHistory: "No hay datos en el historial" @@ -562,6 +584,7 @@ masterVolume: "Volumen principal" notUseSound: "Sin sonido" useSoundOnlyWhenActive: "Sonar solo cuando Misskey esté activo" details: "Detalles" +renoteDetails: "Detalles(Renota)" chooseEmoji: "Elije un emoji" unableToProcess: "La operación no se puede llevar a cabo" recentUsed: "Usado recientemente" @@ -577,6 +600,8 @@ ascendingOrder: "Ascendente" descendingOrder: "Descendente" scratchpad: "Scratch pad" scratchpadDescription: "Scratchpad proporciona un entorno experimental para AiScript. Puede escribir, ejecutar y verificar los resultados que interactúan con Misskey." +uiInspector: "Inspector de UI" +uiInspectorDescription: "Puedes visualizar una lista de elementos UI presentes en la memoria. Los componentes de la interfaz de usuario son generados por las funciones UI:C:" output: "Salida" script: "Script" disablePagesScript: "Deshabilitar AiScript en Páginas" @@ -657,11 +682,15 @@ smtpSecure: "Usar SSL/TLS implícito en la conexión SMTP" smtpSecureInfo: "Apagar cuando se use STARTTLS" testEmail: "Prueba de envío" wordMute: "Silenciar palabras" +wordMuteDescription: "Minimiza las notas que contienen la palabra o frase especificada. Las notas minimizadas pueden visualizarse haciendo clic sobre ellas." hardWordMute: "Filtro de palabra fuerte" +showMutedWord: "Mostrar palabras silenciadas." +hardWordMuteDescription: "Oculta las notas que contienen la palabra o frase especificada. A diferencia de Silenciar palabra, la nota quedará completamente oculta a la vista." regexpError: "Error de la expresión regular" regexpErrorDescription: "Ocurrió un error en la expresión regular en la linea {line} de las palabras muteadas {tab}" instanceMute: "Instancias silenciadas" userSaysSomething: "{name} dijo algo" +userSaysSomethingAbout: "{name} dijo algo sobre {word}" makeActive: "Activar" display: "Apariencia" copy: "Copiar" @@ -693,10 +722,7 @@ abuseReported: "Se ha enviado el reporte. Muchas gracias." reporter: "Reportador" reporteeOrigin: "Reportar a" reporterOrigin: "Origen del reporte" -forwardReport: "Transferir un informe a una instancia remota" -forwardReportIsAnonymous: "No puede ver su información de la instancia remota y aparecerá como una cuenta anónima del sistema" send: "Enviar" -abuseMarkAsResolved: "Marcar reporte como resuelto" openInNewTab: "Abrir en una Nueva Pestaña" openInSideView: "Abrir en una vista al costado" defaultNavigationBehaviour: "Navegación por defecto" @@ -833,6 +859,7 @@ administration: "Administrar" accounts: "Cuentas" switch: "Cambiar" noMaintainerInformationWarning: "No se ha establecido la información del administrador" +noInquiryUrlWarning: "No se ha guardado la URL de consulta." noBotProtectionWarning: "La protección contra los bots no está configurada" configure: "Configurar" postToGallery: "Crear una nueva publicación en la galería" @@ -897,6 +924,7 @@ followersVisibility: "Visibilidad de seguidores" continueThread: "Ver la continuación del hilo" deleteAccountConfirm: "La cuenta será borrada. ¿Está seguro?" incorrectPassword: "La contraseña es incorrecta" +incorrectTotp: "La contraseña de un solo uso es incorrecta o ha caducado." voteConfirm: "¿Confirma su voto a {choice}?" hide: "Ocultar" useDrawerReactionPickerForMobile: "Mostrar panel de reacciones en móviles" @@ -921,6 +949,9 @@ oneHour: "1 hora" oneDay: "1 día" oneWeek: "1 semana" oneMonth: "1 mes" +threeMonths: "Tres meses" +oneYear: "Un año" +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" @@ -1022,6 +1053,7 @@ thisPostMayBeAnnoyingHome: "Publicar en línea de tiempo 'Inicio'" thisPostMayBeAnnoyingCancel: "detener" thisPostMayBeAnnoyingIgnore: "Publicar de todos modos" collapseRenotes: "Colapsar renotas que ya hayas visto" +collapseRenotesDescription: "Contrae notas a las que ya has reaccionado o renotado " internalServerError: "Error interno del servidor" internalServerErrorDescription: "El servidor tuvo un error inesperado." copyErrorInfo: "Copiar detalles del error" @@ -1060,6 +1092,7 @@ retryAllQueuesConfirmTitle: "Desea ¿reintentar inmediatamente todas las colas?" retryAllQueuesConfirmText: "La carga del servidor está incrementándose temporalmente " enableChartsForRemoteUser: "Generar gráficas de usuarios remotos." enableChartsForFederatedInstances: "Generar gráficos de servidores remotos" +enableStatsForFederatedInstances: "Activar las estadísticas de las instancias remotas federadas" showClipButtonInNoteFooter: "Añadir \"Clip\" al menú de notas" reactionsDisplaySize: "Tamaño de las reacciones" limitWidthOfReaction: "Limitar ancho de las reacciones" @@ -1095,6 +1128,8 @@ preservedUsernames: "Nombre de usuario reservado" preservedUsernamesDescription: "La lista de nombres de usuario para reservar tienen que separarse con saltos de línea.\nEstos estarán indisponibles durante la creación de cuentas, pero pueden ser usados para que los administradores puedan crear esas cuentas manualmente. Las cuentas existentes con esos nombres de usuario no se verán afectadas." createNoteFromTheFile: "Componer una nota desde éste archivo" archive: "Archivo" +archived: "Archivado" +unarchive: "Desarchivar" channelArchiveConfirmTitle: "¿Seguro de archivar {name}?" channelArchiveConfirmDescription: "Un canal archivado no aparecerá en la lista de canales ni en los resultados. Las nuevas publicaciones tampoco serán añadidas." thisChannelArchived: "El canal ha sido archivado." @@ -1105,6 +1140,9 @@ preventAiLearning: "Rechazar el uso en el Aprendizaje de Máquinas. (IA Generati 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" specifyUser: "Especificar usuario" +lookupConfirm: "¿Quiere informarse?" +openTagPageConfirm: "¿Quieres abrir la página de etiquetas?" +specifyHost: "Especificar Host" failedToPreviewUrl: "No se pudo generar la vista previa" update: "Actualizar" rolesThatCanBeUsedThisEmojiAsReaction: "Roles que pueden usar este emoji como reacción" @@ -1232,8 +1270,47 @@ useBackupCode: "Usar códigos de respaldo" launchApp: "Ejecutar la app" useNativeUIForVideoAudioPlayer: "Usar la interfaz del navegador cuando se reproduce audio y vídeo" keepOriginalFilename: "Mantener el nombre original del archivo" +keepOriginalFilenameDescription: "Si desactivas esta opción, los nombres de los archivos serán remplazados por una cadena de caracteres aleatoria cuando subas los archivos." noDescription: "No hay descripción" alwaysConfirmFollow: "Confirmar siempre cuando se sigue a alguien" +inquiry: "Contacto" +tryAgain: "Por favor , inténtalo de nuevo" +confirmWhenRevealingSensitiveMedia: "Confirmación cuando se revele contenido sensible" +sensitiveMediaRevealConfirm: "Esto puede contener contenido sensible. ¿Estás seguro/a de querer mostrarlo?" +createdLists: "Listas creadas" +createdAntennas: "Antenas creadas" +fromX: "De {x}" +genEmbedCode: "Obtener el código para incrustar" +noteOfThisUser: "Notas de este usuario" +clipNoteLimitExceeded: "No se pueden añadir más notas a este clip." +performance: "Rendimiento" +modified: "Modificado" +discard: "Descartar" +thereAreNChanges: "Hay {n} cambio(s)" +signinWithPasskey: "Iniciar sesión con clave de acceso" +unknownWebAuthnKey: "Esto no se ha registrado llave maestra." +passkeyVerificationFailed: "La verificación de la clave de acceso ha fallado." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificación de la clave de acceso ha sido satisfactoria pero se ha deshabilitado el inicio de sesión sin contraseña." +messageToFollower: "Mensaje a seguidores" +target: "Para" +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" +postForm: "Formulario" +information: "Información" +_chat: + invitations: "Invitar" + noHistory: "No hay datos en el historial" + members: "Miembros" + home: "Inicio" + send: "Enviar" +_settings: + webhook: "Webhook" +_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." +_abuseUserReport: + accept: "Acepte" + reject: "repudio" _delivery: stop: "Suspendido" _type: @@ -1909,7 +1986,6 @@ _theme: buttonBg: "Fondo de botón" buttonHoverBg: "Fondo de botón (hover)" inputBorder: "Borde de los campos de entrada" - listItemHoverBg: "Fondo de elemento de listas (hover)" driveFolderBg: "Fondo de capeta del drive" wallpaperOverlay: "Transparencia del fondo de pantalla" badge: "Medalla" @@ -2067,6 +2143,7 @@ _permissions: "read:clip-favorite": "Ver los clips que me gustan" "read:federation": "Ver instancias federadas" "write:report-abuse": "Crear reportes de usuario" + "write:chat": "Administrar chat" _auth: shareAccessTitle: "Permisos de la aplicación" shareAccess: "¿Desea permitir el acceso a la cuenta \"{name}\"?" @@ -2247,9 +2324,6 @@ _pages: newPage: "Crear página" editPage: "Editar página" readPage: "Viendo la fuente" - created: "La página fue creada" - updated: "La página fue actualizada" - deleted: "La página borrada" pageSetting: "Configurar página" nameAlreadyExists: "La URL de la página especificada ya existe" invalidNameTitle: "URL inválida" @@ -2334,6 +2408,8 @@ _notification: followRequestAccepted: "El seguimiento fue aceptado" roleAssigned: "Rol asignado" achievementEarned: "Logro desbloqueado" + login: "Iniciar sesión" + test: "Pruebas de nofiticaciones" app: "Notificaciones desde aplicaciones" _actions: followBack: "Te sigue de vuelta" @@ -2392,10 +2468,14 @@ _webhookSettings: renote: "Cuando reciba un \"re-note\"" reaction: "Cuando se recibe una reacción" mention: "Cuando hay una mención" + _systemEvents: + userCreated: "Cuando se crea el usuario." _abuseReport: _notificationRecipient: _recipientType: mail: "Correo" + webhook: "Webhook" + keywords: "Palabras Clave" _moderationLogTypes: createRole: "Rol creado" deleteRole: "Rol eliminado" @@ -2445,10 +2525,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Asegúrate de que el distribuidor de este recurso es de confianza antes de proceder a la instalación." _plugin: title: "¿Quieres instalar este plugin?" - metaTitle: "Información del plugin" _theme: title: "¿Quieres instalar este tema?" - metaTitle: "Información del tema" _meta: base: "Esquema de color base" _vendorInfo: @@ -2499,6 +2577,7 @@ _hemisphere: S: "Hemisferio sur" _reversi: reversi: "Reversi" + rules: "Reglas" won: "{name} ha ganado" total: "Total" _urlPreviewSetting: @@ -2509,3 +2588,13 @@ _mediaControls: pip: "Picture in Picture" playbackRate: "Velocidad de reproducción" loop: "Reproducción en bucle" +_followRequest: + recieved: "Petición de seguimiento recibida" + sent: "Petición de seguimiento enviada" +_remoteLookupErrors: + _noSuchObject: + title: "No se encuentra" +_search: + searchScopeAll: "Todo" + searchScopeLocal: "Local" + searchScopeUser: "Especificar usuario" diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml index c1e2555d0d..aed6b5c570 100644 --- a/locales/fr-FR.yml +++ b/locales/fr-FR.yml @@ -8,6 +8,9 @@ search: "Rechercher" 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" +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 …" ok: "OK" @@ -60,6 +63,7 @@ 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" reply: "Répondre" loadMore: "Afficher plus …" showMore: "Voir plus" @@ -108,6 +112,7 @@ enterEmoji: "Insérer un émoji" renote: "Renoter" unrenote: "Annuler la Renote" renoted: "Renoté !" +renotedToX: "Renoté en {name}" cantRenote: "Ce message ne peut pas être renoté." cantReRenote: "Impossible de renoter une Renote." quote: "Citer" @@ -151,6 +156,7 @@ editList: "Modifier la liste" selectChannel: "Sélectionner un canal" selectAntenna: "Sélectionner une antenne" editAntenna: "Modifier l'antenne" +createAntenna: "Créer une antenne" selectWidget: "Sélectionner un widget" editWidgets: "Modifier les widgets" editWidgetsExit: "Valider les modifications" @@ -177,6 +183,7 @@ addAccount: "Ajouter un compte" reloadAccountsList: "Rafraichir la liste des comptes" loginFailed: "Échec de la connexion" showOnRemote: "Voir sur l’instance distante" +continueOnRemote: "Continuer sur l'instance distante" general: "Général" wallpaper: "Fond d’écran" setWallpaper: "Définir le fond d’écran" @@ -187,6 +194,7 @@ followConfirm: "Êtes-vous sûr·e de vouloir suivre {name} ?" proxyAccount: "Compte proxy" proxyAccountDescription: "Un compte proxy se comporte, dans certaines conditions, comme un·e abonné·e distant·e pour les utilisateurs d'autres instances. Par exemple, quand un·e utilisateur·rice ajoute un·e utilisateur·rice distant·e à une liste, ses notes ne seront pas visibles sur l'instance si personne ne suit cet·te utilisateur·rice. Le compte proxy va donc suivre cet·te utilisateur·rice pour que ses notes soient acheminées." host: "Serveur distant" +selectSelf: "Sélectionner manuellement" selectUser: "Sélectionner un·e utilisateur·rice" recipient: "Destinataire" annotation: "Commentaires" @@ -269,7 +277,6 @@ deleteAreYouSure: "Êtes-vous sûr·e de vouloir supprimer « {x} » ?" resetAreYouSure: "Voulez-vous réinitialiser ?" areYouSure: "Êtes-vous sûr·e ?" saved: "Enregistré" -messaging: "Discuter" upload: "Téléverser" keepOriginalUploading: "Garder l’image d’origine" keepOriginalUploadingDescription: "Conserve la version originale lors du téléchargement d'images. S'il est désactivé, le navigateur génère l'image pour la publication web lors du téléchargement." @@ -282,7 +289,6 @@ uploadFromUrlMayTakeTime: "Le téléversement de votre fichier peut prendre un c explore: "Découvrir" messageRead: "Lu" noMoreHistory: "Il n’y a plus d’historique" -startMessaging: "Commencer à discuter" nUsersRead: "Lu par {n} personnes" agreeTo: "J’accepte {0}" agree: "Accepter" @@ -320,6 +326,7 @@ renameFolder: "Renommer le dossier" deleteFolder: "Supprimer le dossier" folder: "Dossier" addFile: "Ajouter un fichier" +showFile: "Voir les fichiers" emptyDrive: "Le Disque est vide" emptyFolder: "Le dossier est vide" unableToDelete: "Suppression impossible" @@ -362,7 +369,6 @@ enableLocalTimeline: "Activer le fil local" enableGlobalTimeline: "Activer le fil global" disablingTimelinesInfo: "Même si vous désactivez ces fils, les administrateur·rice·s et les modérateur·rice·s pourront toujours y accéder." registration: "S’inscrire" -enableRegistration: "Autoriser les nouvelles inscriptions" invite: "Inviter" driveCapacityPerLocalAccount: "Capacité de stockage du Disque par utilisateur local" driveCapacityPerRemoteAccount: "Capacité de stockage du Disque par utilisateur distant" @@ -430,10 +436,11 @@ token: "Jeton" 2fa: "Authentification à deux facteurs" setupOf2fa: "Configuration de l’authentification à deux facteurs" totp: "Application d'authentification" -totpDescription: "Entrez un mot de passe à usage unique à l'aide d'une application d'authentification" +totpDescription: "Entrer un mot de passe à usage unique à l'aide d'une application d'authentification" moderator: "Modérateur·rice·s" moderation: "Modérations" moderationNote: "Note de modération" +moderationNoteDescription: "Vous pouvez remplir des notes qui seront partagés seulement entre modérateurs." addModerationNote: "Ajouter une note de modération" moderationLogs: "Journal de modération" nUsersMentioned: "{n} utilisateur·rice·s mentionné·e·s" @@ -468,8 +475,6 @@ retype: "Confirmation" noteOf: "Notes de {user}" quoteAttached: "Avec citation" quoteQuestion: "Souhaitez-vous ajouter une citation ?" -noMessagesYet: "Pas encore de discussion" -newMessageExists: "Vous avez un nouveau message" onlyOneFileCanBeAttached: "Vous ne pouvez joindre qu’un seul fichier au message" signinRequired: "Veuillez vous connecter" invitations: "Invitations" @@ -493,7 +498,10 @@ uiLanguage: "Langue d’affichage de l’interface" aboutX: "À propos de {x}" emojiStyle: "Style des émojis" native: "Natif" -disableDrawer: "Les menus ne s'affichent pas dans le tiroir" +menuStyle: "Style du menu" +style: "Style" +drawer: "Sélecteur" +popup: "Pop-up" showNoteActionsOnlyHover: "Afficher les actions de note uniquement au survol" showReactionsCount: "Afficher le nombre de réactions des notes" noHistory: "Pas d'historique" @@ -576,6 +584,7 @@ ascendingOrder: "Ascendant" descendingOrder: "Descendant" scratchpad: "ScratchPad" scratchpadDescription: "ScratchPad fournit un environnement expérimental pour AiScript. Vous pouvez vérifier la rédaction de votre code, sa bonne exécution et le résultat de son interaction avec Misskey." +uiInspector: "Inspecteur UI" output: "Sortie" script: "Script" disablePagesScript: "Désactiver AiScript sur les Pages" @@ -619,7 +628,7 @@ description: "Description" describeFile: "Ajouter une description d'image" enterFileDescription: "Saisissez une description" author: "Auteur·rice" -leaveConfirm: "Vous avez des modifications non-sauvegardées. Voulez-vous les ignorer ?" +leaveConfirm: "Vous avez des modifications non sauvegardées. Voulez-vous les ignorer ?" manage: "Gestion" plugins: "Extensions" preferencesBackups: "Sauvegarder les paramètres" @@ -692,10 +701,7 @@ abuseReported: "Le rapport est envoyé. Merci." reporter: "Signalé par" reporteeOrigin: "Origine du signalement" reporterOrigin: "Signalé par" -forwardReport: "Transférer le signalement à l’instance distante" -forwardReportIsAnonymous: "L'instance distante ne sera pas en mesure de voir vos informations et apparaîtra comme un compte anonyme du système." send: "Envoyer" -abuseMarkAsResolved: "Marquer le signalement comme résolu" openInNewTab: "Ouvrir dans un nouvel onglet" openInSideView: "Ouvrir en vue latérale" defaultNavigationBehaviour: "Navigation par défaut" @@ -832,6 +838,7 @@ administration: "Gestion" accounts: "Comptes" switch: "Remplacer" noMaintainerInformationWarning: "Informations administrateur non configurées." +noInquiryUrlWarning: "L'URL demandé n'est pas définie" noBotProtectionWarning: "La protection contre les bots n'est pas configurée." configure: "Configurer" postToGallery: "Publier dans la galerie" @@ -896,6 +903,7 @@ followersVisibility: "Visibilité des abonnés" continueThread: "Afficher la suite du fil" deleteAccountConfirm: "Votre compte sera supprimé. Êtes vous certain ?" incorrectPassword: "Le mot de passe est incorrect." +incorrectTotp: "Le mot de passe à usage unique est incorrect ou a expiré." voteConfirm: "Confirmez-vous votre vote pour « {choice} » ?" hide: "Masquer" useDrawerReactionPickerForMobile: "Afficher le sélecteur de réactions en tant que panneau sur mobile" @@ -920,6 +928,9 @@ oneHour: "1 heure" oneDay: "1 jour" oneWeek: "1 semaine" oneMonth: "Un mois" +threeMonths: "3 mois" +oneYear: "1 an" +threeDays: "3 jours" reflectMayTakeTime: "Cela peut prendre un certain temps avant que cela ne se termine." failedToFetchAccountInformation: "Impossible de récupérer les informations du compte." rateLimitExceeded: "Limite de taux dépassée" @@ -927,7 +938,7 @@ cropImage: "Recadrer l'image" cropImageAsk: "Voulez-vous recadrer cette image ?" cropYes: "Rogner" cropNo: "Utiliser en l'état" -file: "Fichiers" +file: "Fichier" recentNHours: "Dernières {n} heures" recentNDays: "Derniers {n} jours" noEmailServerWarning: "Serveur de courrier non configuré." @@ -1059,6 +1070,7 @@ retryAllQueuesConfirmTitle: "Vraiment réessayer ?" retryAllQueuesConfirmText: "Cela peut augmenter temporairement la charge du serveur." enableChartsForRemoteUser: "Générer les graphiques pour les utilisateurs distants" enableChartsForFederatedInstances: "Générer les graphiques pour les instances distantes" +enableStatsForFederatedInstances: "Recevoir les statistiques des instances distantes" showClipButtonInNoteFooter: "Ajouter « Clip » au menu d'action de la note" reactionsDisplaySize: "Taille de l'affichage des réactions" limitWidthOfReaction: "Limiter la largeur maximale des réactions et les afficher en taille réduite" @@ -1106,6 +1118,8 @@ preventAiLearning: "Refuser l'usage dans l'apprentissage automatique d'IA géné preventAiLearningDescription: "Demander aux robots d'indexation de ne pas utiliser le contenu publié, tel que les notes et les images, dans l'apprentissage automatique d'IA générative. Cela est réalisé en incluant le drapeau « noai » dans la réponse HTML. Une prévention complète n'est toutefois pas possible, car il est au robot d'indexation de respecter cette demande." options: "Options" specifyUser: "Spécifier l'utilisateur·rice" +openTagPageConfirm: "Ouvrir une page d'hashtags ?" +specifyHost: "Spécifier un serveur distant" failedToPreviewUrl: "Aperçu d'URL échoué" update: "Mettre à jour" rolesThatCanBeUsedThisEmojiAsReaction: "Rôles qui peuvent utiliser cet émoji comme réaction" @@ -1226,13 +1240,63 @@ enableHorizontalSwipe: "Glisser pour changer d'onglet" loading: "Chargement en cours" surrender: "Annuler" gameRetry: "Réessayer" +notUsePleaseLeaveBlank: "Laisser vide si non utilisé" +useTotp: "Entrer un mot de passe à usage unique" +useBackupCode: "Utiliser le codes de secours" launchApp: "Lancer l'app" +useNativeUIForVideoAudioPlayer: "Lire les vidéos et audios en utilisant l'UI du navigateur" +keepOriginalFilename: "Garder le nom original du fichier" +keepOriginalFilenameDescription: "Si vous désactivez ce paramètre, les noms de fichiers seront automatiquement remplacés par des noms aléatoires lorsque vous téléchargerez des fichiers." +noDescription: "Il n'y a pas de description" +alwaysConfirmFollow: "Confirmer lors d'un abonnement" inquiry: "Contact" +tryAgain: "Veuillez réessayer plus tard" +confirmWhenRevealingSensitiveMedia: "Confirmer pour révéler du contenu sensible" +sensitiveMediaRevealConfirm: "Ceci pourrait être du contenu sensible. Voulez-vous l'afficher ?" +createdLists: "Listes créées" +createdAntennas: "Antennes créées" +fromX: "De {x}" +genEmbedCode: "Générer le code d'intégration" +noteOfThisUser: "Notes de cet·te utilisateur·rice" +clipNoteLimitExceeded: "Aucune note supplémentaire ne peut être ajoutée à ce clip." +performance: "Performance" +modified: "Modifié" +discard: "Annuler" +thereAreNChanges: "Il y a {n} modification(s)" +signinWithPasskey: "Se connecter avec une clé d'accès" +unknownWebAuthnKey: "Clé d'accès inconnue." +passkeyVerificationFailed: "La vérification de la clé d'accès a échoué." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "La vérification de la clé d'accès a réussi, mais la connexion sans mot de passe est désactivée." +messageToFollower: "Message aux abonné·es" +target: "Destinataire" +prohibitedWordsForNameOfUser: "Mots interdits pour les noms d'utilisateur·rices" +lockdown: "Verrouiller" +pleaseSelectAccount: "Sélectionner un compte" +availableRoles: "Rôles disponibles" +postForm: "Formulaire de publication" +information: "Informations" +_chat: + invitations: "Inviter" + noHistory: "Pas d'historique" + members: "Membres" + home: "Principal" + send: "Envoyer" +_abuseUserReport: + forward: "Transférer" + forwardDescription: "Transférer le signalement vers une instance distante en tant qu'anonyme." + resolve: "Résoudre" + accept: "Accepter" + reject: "Rejeter" + resolveTutorial: "Si le signalement est légitime dans son contenu, sélectionnez « Accepter » pour marquer le cas comme résolu par l'affirmative.\nSi le contenu du rapport n'est pas légitime, sélectionnez « Rejeter » pour marquer le cas comme résolu par la négative." _delivery: status: "Statut de la diffusion" stop: "Suspendu·e" + resume: "Reprendre" _type: none: "Publié" + manuallySuspended: "Suspendre manuellement" + goneSuspended: "L'instance est suspendue en raison de la suppression de ce dernier" + autoSuspendedForNotResponding: "L'instance est suspendue car elle ne répond pas" _bubbleGame: howToPlay: "Comment jouer" hold: "Réserver" @@ -1243,6 +1307,7 @@ _bubbleGame: maxChain: "Nombre maximum de chaînes" yen: "{yen} yens" estimatedQty: "{qty} pièces" + scoreSweets: "{onigiriQtyWithUnit} Onigiri(s)" _announcement: forExistingUsers: "Pour les utilisateurs existants seulement" needConfirmationToRead: "Exiger la confirmation de la lecture" @@ -1262,6 +1327,7 @@ _initialAccountSetting: profileSetting: "Paramètres du profil" privacySetting: "Paramètres de confidentialité" initialAccountSettingCompleted: "Configuration du profil terminée avec succès !" + haveFun: "Profitez de {name} !" youCanContinueTutorial: "Vous pouvez procéder au tutoriel sur l'utilisation de {name}(Misskey) ou vous arrêter ici et commencer à l'utiliser immédiatement." startTutorial: "Démarrer le tutoriel" skipAreYouSure: "Désirez-vous ignorer la configuration du profil ?" @@ -1355,18 +1421,60 @@ _achievements: flavor: "Passez un bon moment avec Misskey !" _notes10: title: "Quelques notes" + description: "Poster 10 notes" _notes100: title: "Beaucoup de notes" + description: "Poster 100 notes" + _notes500: + title: "Couvert de notes" + description: "Poster 500 notes" + _notes1000: + title: "Une montagne de notes" + description: "Poster 1000 notes" + _notes5000: + title: "Débordement de notes" + description: "Poster 5 000 notes" + _notes10000: + title: "Super note" + description: "Poster 10 000 notes" + _notes20000: + title: "Encore... plus... de... notes..." + description: "Poster 20 000 notes" + _notes30000: + title: "Notes notes notes !" + description: "Poster 30 000 notes" + _notes40000: + title: "Usine de notes" + description: "Poster 40 000 notes" + _notes50000: + title: "Planète des notes" + description: "Poster 50 000 notes" + _notes60000: + title: "Quasar de note" + description: "Poster 50 000 notes" + _notes70000: + title: "Trou noir de notes" + description: "Poster 70 000 notes" + _notes80000: + title: "Galaxie de notes" + description: "Poster 80 000 notes" + _notes90000: + title: "Univers de notes" + description: "Poster 90 000 notes" _notes100000: title: "ALL YOUR NOTE ARE BELONG TO US" + description: "Poster 100 000 notes" + flavor: "Avez-vous tant de choses à dire ?" _login3: - title: "Débutant Ⅰ" + title: "Débutant I" description: "Se connecter pour un total de 3 jours" + flavor: "Dès maintenant, appelez-moi Misskeynaute" _login7: - title: "Débutant Ⅱ" + title: "Débutant II" description: "Se connecter pour un total de 7 jours" + flavor: "On s'habitue ?" _login15: - title: "Débutant Ⅲ" + title: "Débutant III" description: "Se connecter pour un total de 15 jours" _login30: title: "Misskeynaute I" @@ -1390,6 +1498,7 @@ _achievements: _login500: title: "Expert I" description: "Se connecter pour un total de 500 jours" + flavor: "Non, mes amis, j'aime les notes" _login600: title: "Expert II" description: "Se connecter pour un total de 600 jours" @@ -1397,11 +1506,18 @@ _achievements: title: "Expert III" description: "Se connecter pour un total de 700 jours" _login800: + title: "Maître des notes I" description: "Se connecter pour un total de 800 jours" _login900: + title: "Maître des notes II" description: "Se connecter pour un total de 900 jours" _login1000: + title: "Maître des notes III" + description: "Se connecter pour un total de 1 000 jours" flavor: "Merci d'utiliser Misskey !" + _noteClipped1: + title: "Je... dois... clip..." + description: "Ajouter sa première note aux clips" _profileFilled: title: "Bien préparé" description: "Configuration de votre profil" @@ -1460,21 +1576,31 @@ _achievements: _driveFolderCircularReference: title: "Référence circulaire" _setNameToSyuilo: + title: "Complexe de dieu" description: "Vous avez spécifié « syuilo » comme nom" _passedSinceAccountCreated1: title: "Premier anniversaire" + description: "Un an est passé depuis la création du compte" _passedSinceAccountCreated2: title: "Second anniversaire" + description: "Deux ans sont passés depuis la création du compte" _passedSinceAccountCreated3: title: "3ème anniversaire" + description: "Trois ans sont passés depuis la création du compte" _loggedInOnBirthday: title: "Joyeux Anniversaire !" description: "Vous vous êtes connecté à la date de votre anniversaire" _loggedInOnNewYearsDay: title: "Bonne année !" + description: "Vous vous êtes connecté le premier jour de l'année" + flavor: "Merci pour le soutient continue sur cette instance." _cookieClicked: + title: "Jeu de clic sur des cookies" + description: "Cliqué sur un cookie" flavor: "Attendez une minute, vous êtes sur le mauvais site web ?" _brainDiver: + title: "Brain Diver" + description: "Poster le lien sur Brain Diver" flavor: "Misskey-Misskey La-Tu-Ma" _smashTestNotificationButton: title: "Débordement de tests" @@ -1482,6 +1608,11 @@ _achievements: _tutorialCompleted: title: "Diplôme de la course élémentaire de Misskey" description: "Terminer le tutoriel" + _bubbleGameExplodingHead: + title: "🤯" + description: "Le plus gros objet du jeu de bulles" + _bubbleGameDoubleExplodingHead: + title: "Double🤯" _role: new: "Nouveau rôle" edit: "Modifier le rôle" @@ -1512,9 +1643,11 @@ _role: canManageCustomEmojis: "Gestion des émojis personnalisés" canManageAvatarDecorations: "Gestion des décorations d'avatar" driveCapacity: "Capacité de stockage du Disque" + antennaMax: "Nombre maximum d'antennes" wordMuteMax: "Nombre maximal de caractères dans le filtre de mots" canUseTranslator: "Usage de la fonctionnalité de traduction" avatarDecorationLimit: "Nombre maximal de décorations d'avatar" + canImportAntennas: "Autoriser l'importation d'antennes" _sensitiveMediaDetection: description: "L'apprentissage automatique peut être utilisé pour détecter automatiquement les médias sensibles à modérer. La sollicitation des serveurs augmente légèrement." sensitivity: "Sensibilité de la détection" @@ -1705,7 +1838,6 @@ _theme: buttonBg: "Arrière-plan du bouton" buttonHoverBg: "Arrière-plan du bouton (survolé)" inputBorder: "Cadre de la zone de texte" - listItemHoverBg: "Arrière-plan d'item de liste (survolé)" driveFolderBg: "Arrière-plan du dossier de disque" wallpaperOverlay: "Superposition de fond d'écran" badge: "Badge" @@ -1798,6 +1930,30 @@ _permissions: "write:gallery": "Éditer la galerie" "read:gallery-likes": "Voir les mentions « J'aime » dans la galerie" "write:gallery-likes": "Gérer les mentions « J'aime » dans la galerie" + "read:flash": "Voir le Play" + "write:flash": "Modifier le Play" + "read:flash-likes": "Lire vos mentions j'aime des Play" + "write:flash-likes": "Modifier vos mentions j'aime des Play" + "read:admin:abuse-user-reports": "Voir les utilisateurs signalés" + "write:admin:delete-account": "Supprimer le compte d'utilisateur" + "write:admin:delete-all-files-of-a-user": "Supprimer tous les fichiers d'un utilisateur" + "read:admin:index-stats": "Voir les statistiques sur les index de base de données" + "read:admin:table-stats": "Voir les statistiques sur les index de base de données" + "read:admin:user-ips": "Voir l'adresse IP de l'utilisateur" + "read:admin:meta": "Voir les métadonnées de l'instance" + "write:admin:reset-password": "Réinitialiser le mot de passe de l'utilisateur" + "write:admin:resolve-abuse-user-report": "Résoudre le signalement d'un utilisateur" + "write:admin:send-email": "Envoyer un mail" + "read:admin:server-info": "Voir les informations de l'instance" + "read:admin:show-moderation-log": "Voir les logs de modération" + "read:admin:show-user": "Voir les informations privées de l'utilisateur" + "write:admin:suspend-user": "Suspendre l'utilisateur" + "write:admin:unset-user-avatar": "Retirer l'avatar de l'utilisateur" + "write:admin:unset-user-banner": "Retirer la bannière de l'utilisateur" + "write:admin:unsuspend-user": "Lever la suspension d'un utilisateur" + "write:admin:meta": "Gérer les métadonnées de l'instance" + "write:admin:roles": "Gérer les rôles" + "write:chat": "Gérer les discussions" _auth: shareAccess: "Autoriser \"{name}\" à accéder à votre compte ?" shareAccessAsk: "Voulez-vous vraiment autoriser cette application à accéder à votre compte?" @@ -1949,7 +2105,16 @@ _timelines: social: "Social" global: "Global" _play: + new: "Créer un Play" + edit: "Modifier un Play" + created: "Play créé" + updated: "Play édité" + deleted: "Play supprimé" + pageSetting: "Configuration du Play" + editThisPage: "Modifier ce Play" viewSource: "Afficher la source" + my: "Mes Play" + liked: "Play aimés" featured: "Populaire" title: "Titre" script: "Script" @@ -1958,9 +2123,6 @@ _pages: newPage: "Créer une page" editPage: "Modifier une page" readPage: "Affichage de la source en cours" - created: "La page a été créée !" - updated: "La page a été mise à jour !" - deleted: "La page a été supprimée" pageSetting: "Paramètres de la Page" nameAlreadyExists: "L'URL de page spécifiée existe déjà" invalidNameTitle: "L'URL de page spécifiée n’est pas valide" @@ -2023,10 +2185,13 @@ _notification: achievementEarned: "Accomplissement déverrouillé" testNotification: "Tester la notification" reactedBySomeUsers: "{n} utilisateur·rice·s ont réagi" + likedBySomeUsers: "{n} utilisateurs ont aimé votre note" renotedBySomeUsers: "{n} utilisateur·rice·s ont renoté" followedBySomeUsers: "{n} utilisateur·rice·s se sont abonné·e·s à vous" + login: "Quelqu'un s'est connecté" _types: all: "Toutes" + note: "Nouvelles notes" follow: "Nouvel·le abonné·e" mention: "Mentions" reply: "Réponses" @@ -2038,6 +2203,7 @@ _notification: followRequestAccepted: "Demande d'abonnement acceptée" roleAssigned: "Rôle reçu" achievementEarned: "Déverrouillage d'accomplissement" + login: "Se connecter" app: "Notifications provenant des apps" _actions: followBack: "Suivre" @@ -2075,11 +2241,14 @@ _drivecleaner: orderByCreatedAtAsc: "Date d'ajout ascendante" _webhookSettings: name: "Nom" + secret: "Secret" + trigger: "Activateur" active: "Activé" _abuseReport: _notificationRecipient: _recipientType: mail: "E-mail " + keywords: "Mots clés " _moderationLogTypes: createRole: "Rôle créé" deleteRole: "Rôle supprimé" @@ -2116,6 +2285,7 @@ _moderationLogTypes: deleteAvatarDecoration: "Décoration d'avatar supprimée" unsetUserAvatar: "Supprimer l'avatar de l'utilisateur·rice" unsetUserBanner: "Supprimer la bannière de l'utilisateur·rice" + deleteFlash: "Supprimer le Play" _fileViewer: title: "Détails du fichier" type: "Type du fichier" @@ -2129,10 +2299,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Veuillez confirmer que le distributeur est fiable avant l'installation." _plugin: title: "Voulez-vous installer cette extension ?" - metaTitle: "Informations sur l'extension" _theme: title: "Voulez-vous installer ce thème ?" - metaTitle: "Informations sur le thème" _meta: base: "Palette de couleurs de base" _vendorInfo: @@ -2179,5 +2347,27 @@ _dataSaver: title: "Mise en évidence du code" description: "Si la notation de mise en évidence du code est utilisée, par exemple dans la MFM, elle ne sera pas chargée tant qu'elle n'aura pas été tapée. La mise en évidence du code nécessite le chargement du fichier de définition de chaque langue à mettre en évidence, mais comme ces fichiers ne sont plus chargés automatiquement, on peut s'attendre à une réduction du trafic de données." _reversi: + reversi: "Reversi" + blackIs: "{name} joue les noirs" + rules: "Règles" waitingBoth: "Préparez-vous" + myTurn: "C’est votre tour" + turnOf: "C'est le tour de {name}" + pastTurnOf: "Tour de {name}" + surrender: "Se rendre" + surrendered: "Par abandon" total: "Total" + playing: "En cours" + lookingForPlayer: "Recherche d'adversaire" +_mediaControls: + playbackRate: "Vitesse de lecture" +_embedCodeGen: + title: "Personnaliser le code d'intégration" + generateCode: "Générer le code d'intégration" +_remoteLookupErrors: + _noSuchObject: + title: "Non trouvé" +_search: + searchScopeAll: "Tous" + searchScopeLocal: "Local" + searchScopeUser: "Spécifier l'utilisateur·rice" diff --git a/locales/hu-HU.yml b/locales/hu-HU.yml index 023a91494d..d0fdc027e9 100644 --- a/locales/hu-HU.yml +++ b/locales/hu-HU.yml @@ -1,5 +1,5 @@ --- -_lang_: "Japán" +_lang_: "Magyar" monthAndDay: "{month}.{day}." search: "Keresés" notifications: "Értesítések" @@ -96,6 +96,7 @@ _notification: renote: "Renote" quote: "Idézet" reaction: "Reakciók" + login: "Bejelentkezés" _actions: renote: "Renote" _deck: diff --git a/locales/id-ID.yml b/locales/id-ID.yml index 24f7482fca..944d416ac1 100644 --- a/locales/id-ID.yml +++ b/locales/id-ID.yml @@ -60,6 +60,7 @@ copyFileId: "Salin Berkas" copyFolderId: "Salin Folder" copyProfileUrl: "Salin Alamat Web Profil" searchUser: "Cari pengguna" +searchThisUsersNotes: "Mencari catatan pengguna" reply: "Balas" loadMore: "Selebihnya" showMore: "Selebihnya" @@ -154,6 +155,7 @@ editList: "Sunting daftar" selectChannel: "Pilih kanal" selectAntenna: "Pilih Antena" editAntenna: "Sunting antena" +createAntenna: "Membuat antena." selectWidget: "Pilih gawit" editWidgets: "Sunting gawit" editWidgetsExit: "Selesai" @@ -194,6 +196,7 @@ followConfirm: "Apakah kamu yakin ingin mengikuti {name}?" proxyAccount: "Akun proksi" proxyAccountDescription: "Akun proksi merupakan sebuah akun yang bertindak sebagai pengikut instansi luar untuk pengguna dalam kondisi tertentu. Sebagai contoh, ketika pengguna menambahkan seorang pengguna instansi luar ke dalam daftar, aktivitas dari pengguna instansi luar tidak akan disampaikan ke instansi apabila tidak ada pengguna lokal yang mengikuti pengguna tersebut, dengan begitu akun proksilah yang akan mengikutinya." host: "Host" +selectSelf: "Pilih diri sendiri" selectUser: "Pilih pengguna" recipient: "Penerima" annotation: "Keterangan konten" @@ -230,6 +233,7 @@ 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." +federationAllowedHosts: "Server yang membolehkan federasi" muteAndBlock: "Bisukan / Blokir" mutedUsers: "Pengguna yang dibisukan" blockedUsers: "Pengguna yang diblokir" @@ -276,7 +280,6 @@ deleteAreYouSure: "Apakah kamu yakin ingin menghapus \"{x}\"?" resetAreYouSure: "Yakin mau atur ulang?" areYouSure: "Apakah kamu yakin?" saved: "Telah disimpan" -messaging: "Pesan" upload: "Unggah" keepOriginalUploading: "Simpan gambar asli" keepOriginalUploadingDescription: "Simpan gambar yang diunggah sebagaimana gambar aslinya. Bila dimatikan, versi tampilan web akan dihasilkan pada saat diunggah." @@ -289,7 +292,6 @@ uploadFromUrlMayTakeTime: "Membutuhkan beberapa waktu hingga pengunggahan selesa explore: "Jelajahi" messageRead: "Telah dibaca" noMoreHistory: "Tidak ada sejarah lagi" -startMessaging: "Mulai mengirim pesan" nUsersRead: "Dibaca oleh {n}" agreeTo: "Saya setuju kepada {0}" agree: "Setuju" @@ -328,6 +330,7 @@ renameFolder: "Ubah nama folder" deleteFolder: "Hapus folder" folder: "Folder" addFile: "Tambahkan berkas" +showFile: "Tampilkan berkas" emptyDrive: "Drive kosong" emptyFolder: "Folder kosong" unableToDelete: "Tidak dapat menghapus" @@ -370,7 +373,6 @@ enableLocalTimeline: "Nyalakan lini masa lokal" enableGlobalTimeline: "Nyalakan lini masa global" disablingTimelinesInfo: "Admin dan Moderator akan selalu memiliki akses ke semua lini masa meskipun lini masa tersebut tidak diaktifkan." registration: "Pendaftaran" -enableRegistration: "Nyalakan pendaftaran pengguna baru" invite: "Undang" driveCapacityPerLocalAccount: "Kapasitas drive per pengguna lokal" driveCapacityPerRemoteAccount: "Kapasitas drive per pengguna remote" @@ -477,8 +479,6 @@ noteOf: "Catatan milik {user}" quoteAttached: "Dikutip" quoteQuestion: "Apakah kamu ingin menambahkan kutipan?" attachAsFileQuestion: "Teks dalam papan klip terlalu panjang. Apakah kamu ingin melampirkannya sebagai berkas teks?" -noMessagesYet: "Tidak ada pesan" -newMessageExists: "Kamu mendapatkan pesan baru" onlyOneFileCanBeAttached: "Kamu hanya dapat melampirkan satu berkas ke dalam pesan" signinRequired: "Silahkan login" invitations: "Undangan" @@ -502,7 +502,8 @@ uiLanguage: "Bahasa antarmuka pengguna" aboutX: "Tentang {x}" emojiStyle: "Gaya emoji" native: "Native" -disableDrawer: "Jangan gunakan menu bergaya laci" +menuStyle: "Gaya menu" +style: "Gaya" showNoteActionsOnlyHover: "Hanya tampilkan aksi catatan saat ditunjuk" showReactionsCount: "Lihat jumlah reaksi dalam catatan" noHistory: "Tidak ada riwayat" @@ -701,10 +702,7 @@ abuseReported: "Laporan kamu telah dikirimkan. Terima kasih." reporter: "Pelapor" reporteeOrigin: "Yang dilaporkan" reporterOrigin: "Pelapor" -forwardReport: "Teruskan laporan ke instansi luar" -forwardReportIsAnonymous: "Untuk melindungi privasi akun kamu, akun anonim dari sistem akan digunakan sebagai pelapor pada instansi luar." send: "Kirim" -abuseMarkAsResolved: "Tandai laporan sebagai selesai" openInNewTab: "Buka di tab baru" openInSideView: "Buka di tampilan samping" defaultNavigationBehaviour: "Navigasi bawaan" @@ -929,6 +927,9 @@ oneHour: "1 Jam" oneDay: "1 Hari" oneWeek: "1 Bulan" oneMonth: "satu bulan" +threeMonths: "3 bulan" +oneYear: "1 tahun" +threeDays: "3 hari" reflectMayTakeTime: "Mungkin perlu beberapa saat untuk dicerminkan." failedToFetchAccountInformation: "Gagal untuk mendapatkan informasi akun" rateLimitExceeded: "Batas sudah terlampaui" @@ -1103,6 +1104,7 @@ preservedUsernames: "Nama pengguna tercadangkan" preservedUsernamesDescription: "Daftar nama pengguna yang dicadangkan dipisah dengan baris baru. Nama pengguna berikut akan tidak dapat dipakai pada pembuatan akun normal, namun dapat digunakan oleh admin untuk membuat akun baru. Akun yang sudah ada dengan menggunakan nama pengguna ini tidak akan terpengaruh." createNoteFromTheFile: "Buat catatan dari berkas ini" archive: "Arsipkan" +archived: "Diarsipkan" 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." @@ -1113,6 +1115,7 @@ preventAiLearning: "Tolak penggunaan Pembelajaran Mesin (AI Generatif)" preventAiLearningDescription: "Minta perayap web untuk tidak menggunakan materi teks atau gambar yang telah diposting ke dalam set data Pembelajaran Mesin (Prediktif / Generatif). Hal ini dicapai dengan menambahkan flag HTML-Response \"noai\" ke masing-masing konten. Pencegahan penuh mungkin tidak dapat dicapai dengan flag ini, karena juga dapat diabaikan begitu saja." options: "Opsi peran" specifyUser: "Pengguna spesifik" +openTagPageConfirm: "Apakah ingin membuka laman tagar?" failedToPreviewUrl: "Tidak dapat dipratinjau" update: "Perbarui" rolesThatCanBeUsedThisEmojiAsReaction: "Peran yang dapat menggunakan emoji ini sebagai reaksi" @@ -1245,6 +1248,28 @@ noDescription: "Tidak ada deskripsi" alwaysConfirmFollow: "Selalu konfirmasi ketika mengikuti" inquiry: "Hubungi kami" tryAgain: "Silahkan coba lagi." +createdLists: "Senarai yang dibuat" +createdAntennas: "Antena yang dibuat" +fromX: "Dari {x}" +noteOfThisUser: "Catatan oleh pengguna ini" +clipNoteLimitExceeded: "Klip ini tak bisa ditambahi lagi catatan." +performance: "Kinerja" +modified: "Diubah" +thereAreNChanges: "Ada {n} perubahan" +prohibitedWordsForNameOfUser: "Kata yang dilarang untuk nama pengguna" +postForm: "Buat catatan" +information: "Informasi" +_chat: + invitations: "Undang" + noHistory: "Tidak ada riwayat" + members: "Anggota" + home: "Beranda" + send: "Kirim" +_settings: + webhook: "Webhook" +_abuseUserReport: + accept: "Setuju" + reject: "Tolak" _delivery: status: "Status pengiriman" stop: "Ditangguhkan" @@ -1709,6 +1734,8 @@ _role: canSearchNotes: "Penggunaan pencarian catatan" canUseTranslator: "Penggunaan penerjemah" avatarDecorationLimit: "Jumlah maksimum dekorasi avatar yang dapat diterapkan" + canImportAntennas: "Izinkan mengimpor antena" + canImportUserLists: "Izinkan mengimpor senarai" _condition: roleAssignedTo: "Ditugaskan ke peran manual" isLocal: "Pengguna lokal" @@ -1926,7 +1953,6 @@ _theme: buttonBg: "Latar belakang tombol" buttonHoverBg: "Latar belakang tombol (Mengambang)" inputBorder: "Batas bidang masukan" - listItemHoverBg: "Latar belakang daftar item (Mengambang)" driveFolderBg: "Latar belakang folder drive" wallpaperOverlay: "Lapisan wallpaper" badge: "Lencana" @@ -1946,6 +1972,7 @@ _soundSettings: driveFileTypeWarnDescription: "Pilih berkas audio" driveFileDurationWarn: "Audio ini terlalu panjang" driveFileDurationWarnDescription: "Audio panjang dapat mengganggu penggunaan Misskey. Masih ingin melanjutkan?" + driveFileError: "Tak bisa memuat audio. Mohon ubah pengaturan" _ago: future: "Masa depan" justNow: "Baru saja" @@ -2084,6 +2111,7 @@ _permissions: "read:clip-favorite": "Lihat klip yang difavoritkan" "read:federation": "Mendapatkan data federasi" "write:report-abuse": "Melaporkan pelanggaran" + "write:chat": "Buat atau hapus obrolan" _auth: shareAccessTitle: "Mendapatkan ijin akses aplikasi" shareAccess: "Apakah kamu ingin mengijinkan \"{name}\" untuk mengakses akun ini?" @@ -2264,9 +2292,6 @@ _pages: newPage: "Buat halaman baru" editPage: "Sunting halaman" readPage: "Lihat sumber kode aktif" - created: "Halaman berhasil dibuat" - updated: "Halaman berhasil diperbaharui!" - deleted: "Halaman telah dihapus" pageSetting: "Pengaturan Halaman" nameAlreadyExists: "URL Halaman yang ditentukan sudah ada" invalidNameTitle: "URL Halaman yang ditentukan tidak valid" @@ -2353,6 +2378,7 @@ _notification: followRequestAccepted: "Permintaan mengikuti disetujui" roleAssigned: "Peran Diberikan" achievementEarned: "Pencapaian didapatkan" + login: "Masuk" app: "Notifikasi dari aplikasi tertaut" _actions: followBack: "Ikuti Kembali" @@ -2417,6 +2443,8 @@ _abuseReport: _notificationRecipient: _recipientType: mail: "Surel" + webhook: "Webhook" + keywords: "Kata kunci" _moderationLogTypes: createRole: "Peran telah dibuat" deleteRole: "Peran telah dihapus" @@ -2454,6 +2482,7 @@ _moderationLogTypes: deleteAvatarDecoration: "Hapus dekorasi avatar" unsetUserAvatar: "Hapus avatar pengguna" unsetUserBanner: "Hapus banner pengguna" + deleteAccount: "Akun dihapus" _fileViewer: title: "Rincian berkas" type: "Jenis berkas" @@ -2467,10 +2496,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Pastikan sumber dari sumber daya ini terpercaya sebelum melakukan pemasangan." _plugin: title: "Apakah kamu ingin memasang plugin ini?" - metaTitle: "Informasi plugin" _theme: title: "Apakah kamu ingin memasang tema ini?" - metaTitle: "Informasi tema" _meta: base: "Skema warna dasar" _vendorInfo: @@ -2585,3 +2612,10 @@ _mediaControls: pip: "Gambar dalam Gambar" playbackRate: "Kecepatan Pemutaran" loop: "Ulangi Pemutaran" +_remoteLookupErrors: + _noSuchObject: + title: "Tidak dapat ditemukan" +_search: + searchScopeAll: "Semua" + searchScopeLocal: "Lokal" + searchScopeUser: "Pengguna spesifik" diff --git a/locales/index.d.ts b/locales/index.d.ts index f234262195..3645639305 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -36,6 +36,10 @@ export interface Locale extends ILocale { * 検索 */ "search": string; + /** + * リセット + */ + "reset": string; /** * 通知 */ @@ -48,6 +52,20 @@ export interface Locale extends ILocale { * パスワード */ "password": string; + /** + * 初期設定開始用パスワード + */ + "initialPasswordForSetup": string; + /** + * 初期設定開始用のパスワードが違います。 + */ + "initialPasswordIsIncorrect": string; + /** + * Misskeyを自分でインストールした場合は、設定ファイルに入力したパスワードを使用してください。 + * Misskeyのホスティングサービスなどを使用している場合は、提供されたパスワードを使用してください。 + * パスワードを設定していない場合は、空欄にしたまま続行してください。 + */ + "initialPasswordForSetupDescription": string; /** * パスワードを忘れた */ @@ -196,6 +214,10 @@ export interface Locale extends ILocale { * リンクをコピー */ "copyLink": string; + /** + * リモートのリンクをコピー + */ + "copyRemoteLink": string; /** * リノートのリンクをコピー */ @@ -960,6 +982,14 @@ export interface Locale extends ILocale { * メディアサイレンスしたいサーバーのホストを改行で区切って設定します。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。ブロックしたインスタンスには影響しません。 */ "mediaSilencedInstancesDescription": string; + /** + * 連合を許可するサーバー + */ + "federationAllowedHosts": string; + /** + * 連合を許可するサーバーのホストを改行で区切って設定します。 + */ + "federationAllowedHostsDescription": string; /** * ミュートとブロック */ @@ -1144,10 +1174,6 @@ export interface Locale extends ILocale { * 保存しました */ "saved": string; - /** - * チャット - */ - "messaging": string; /** * アップロード */ @@ -1197,9 +1223,9 @@ export interface Locale extends ILocale { */ "noMoreHistory": string; /** - * チャットを開始 + * チャットを始める */ - "startMessaging": string; + "startChat": string; /** * {n}人が読みました */ @@ -1352,6 +1378,10 @@ export interface Locale extends ILocale { * ファイルを追加 */ "addFile": string; + /** + * ファイルを表示 + */ + "showFile": string; /** * ドライブは空です */ @@ -1520,10 +1550,6 @@ export interface Locale extends ILocale { * 登録 */ "registration": string; - /** - * 誰でも新規登録できるようにする - */ - "enableRegistration": string; /** * 招待 */ @@ -1808,6 +1834,10 @@ export interface Locale extends ILocale { * モデレーションノート */ "moderationNote": string; + /** + * モデレーター間でだけ共有されるメモを記入することができます。 + */ + "moderationNoteDescription": string; /** * モデレーションノートを追加する */ @@ -1948,14 +1978,6 @@ export interface Locale extends ILocale { * クリップボードのテキストが長いです。テキストファイルとして添付しますか? */ "attachAsFileQuestion": string; - /** - * まだチャットはありません - */ - "noMessagesYet": string; - /** - * 新しいメッセージがあります - */ - "newMessageExists": string; /** * メッセージに添付できるファイルはひとつです */ @@ -2053,9 +2075,21 @@ export interface Locale extends ILocale { */ "native": string; /** - * メニューをドロワーで表示しない + * メニューのスタイル */ - "disableDrawer": string; + "menuStyle": string; + /** + * スタイル + */ + "style": string; + /** + * ドロワー + */ + "drawer": string; + /** + * ポップアップ + */ + "popup": string; /** * ノートのアクションをホバー時のみ表示する */ @@ -2324,6 +2358,10 @@ export interface Locale extends ILocale { * 詳細 */ "details": string; + /** + * リノートの詳細 + */ + "renoteDetails": string; /** * 絵文字を選択 */ @@ -2712,10 +2750,22 @@ export interface Locale extends ILocale { * ワードミュート */ "wordMute": string; + /** + * 指定した語句を含むノートを最小化します。最小化されたノートをクリックすることで表示することができます。 + */ + "wordMuteDescription": string; /** * ハードワードミュート */ "hardWordMute": string; + /** + * ミュートされたワードを表示 + */ + "showMutedWord": string; + /** + * 指定した語句を含むノートを隠します。ワードミュートとは異なり、ノートは完全に表示されなくなります。 + */ + "hardWordMuteDescription": string; /** * 正規表現エラー */ @@ -2732,6 +2782,10 @@ export interface Locale extends ILocale { * {name}が何かを言いました */ "userSaysSomething": ParameterizedString<"name">; + /** + * {name}が「{word}」について何かを言いました + */ + "userSaysSomethingAbout": ParameterizedString<"name" | "word">; /** * アクティブにする */ @@ -2744,6 +2798,10 @@ export interface Locale extends ILocale { * コピー */ "copy": string; + /** + * クリップボードにコピーされました + */ + "copiedToClipboard": string; /** * メトリクス */ @@ -2856,22 +2914,10 @@ export interface Locale extends ILocale { * 通報元 */ "reporterOrigin": string; - /** - * リモートサーバーに通報を転送する - */ - "forwardReport": string; - /** - * リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。 - */ - "forwardReportIsAnonymous": string; /** * 送信 */ "send": string; - /** - * 対応済みにする - */ - "abuseMarkAsResolved": string; /** * 新しいタブで開く */ @@ -3676,6 +3722,10 @@ export interface Locale extends ILocale { * パスワードが間違っています。 */ "incorrectPassword": string; + /** + * ワンタイムパスワードが間違っているか、期限切れになっています。 + */ + "incorrectTotp": string; /** * 「{choice}」に投票しますか? */ @@ -3772,6 +3822,18 @@ export interface Locale extends ILocale { * 1ヶ月 */ "oneMonth": string; + /** + * 3ヶ月 + */ + "threeMonths": string; + /** + * 1年 + */ + "oneYear": string; + /** + * 3日 + */ + "threeDays": string; /** * 反映されるまで時間がかかる場合があります。 */ @@ -4125,7 +4187,7 @@ export interface Locale extends ILocale { */ "invalidParamError": string; /** - * リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる等の可能性もあります。 + * リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる・許可されていない文字を入力している等の可能性もあります。 */ "invalidParamErrorDescription": string; /** @@ -4332,6 +4394,10 @@ export interface Locale extends ILocale { * リモートサーバーのチャートを生成 */ "enableChartsForFederatedInstances": string; + /** + * リモートサーバーの情報を取得 + */ + "enableStatsForFederatedInstances": string; /** * ノートのアクションにクリップを追加 */ @@ -4897,7 +4963,7 @@ export interface Locale extends ILocale { */ "disableStreamingTimeline": string; /** - * 通知をグルーピングして表示する + * 通知をグルーピング */ "useGroupedNotifications": string; /** @@ -5092,6 +5158,646 @@ export interface Locale extends ILocale { * これ以上このクリップにノートを追加できません。 */ "clipNoteLimitExceeded": string; + /** + * パフォーマンス + */ + "performance": string; + /** + * 変更あり + */ + "modified": string; + /** + * 破棄 + */ + "discard": string; + /** + * {n}件の変更があります + */ + "thereAreNChanges": ParameterizedString<"n">; + /** + * パスキーでログイン + */ + "signinWithPasskey": string; + /** + * 登録されていないパスキーです。 + */ + "unknownWebAuthnKey": string; + /** + * パスキーの検証に失敗しました。 + */ + "passkeyVerificationFailed": string; + /** + * パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。 + */ + "passkeyVerificationSucceededButPasswordlessLoginDisabled": string; + /** + * フォロワーへのメッセージ + */ + "messageToFollower": string; + /** + * 対象 + */ + "target": string; + /** + * CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。 + */ + "testCaptchaWarning": string; + /** + * 禁止ワード(ユーザーの名前) + */ + "prohibitedWordsForNameOfUser": string; + /** + * このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。 + */ + "prohibitedWordsForNameOfUserDescription": string; + /** + * 変更しようとした名前に禁止された文字列が含まれています + */ + "yourNameContainsProhibitedWords": string; + /** + * 名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。 + */ + "yourNameContainsProhibitedWordsDescription": string; + /** + * 投稿者により、表示にはログインが必要と設定されています + */ + "thisContentsAreMarkedAsSigninRequiredByAuthor": string; + /** + * ロックダウン + */ + "lockdown": string; + /** + * アカウントを選択してください + */ + "pleaseSelectAccount": string; + /** + * 利用可能なロール + */ + "availableRoles": string; + /** + * 注意事項を理解した上でオンにします。 + */ + "acknowledgeNotesAndEnable": string; + /** + * このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。 + */ + "federationSpecified": string; + /** + * このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。 + */ + "federationDisabled": string; + /** + * リアクションする際に確認する + */ + "confirmOnReact": string; + /** + * " {emoji} " をリアクションしますか? + */ + "reactAreYouSure": ParameterizedString<"emoji">; + /** + * このメディアをセンシティブとして設定しますか? + */ + "markAsSensitiveConfirm": string; + /** + * このメディアのセンシティブ指定を解除しますか? + */ + "unmarkAsSensitiveConfirm": string; + /** + * 環境設定 + */ + "preferences": string; + /** + * アクセシビリティ + */ + "accessibility": string; + /** + * 設定のプロファイル + */ + "preferencesProfile": string; + /** + * 設定IDをコピー + */ + "copyPreferenceId": string; + /** + * 初期値に戻す + */ + "resetToDefaultValue": string; + /** + * アカウントで上書き + */ + "overrideByAccount": string; + /** + * 無題 + */ + "untitled": string; + /** + * 名前はありません + */ + "noName": string; + /** + * スキップ + */ + "skip": string; + /** + * 復元 + */ + "restore": string; + /** + * デバイス間で同期 + */ + "syncBetweenDevices": string; + /** + * サーバーに設定値が存在します + */ + "preferenceSyncConflictTitle": string; + /** + * 同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか? + */ + "preferenceSyncConflictText": string; + /** + * サーバーの設定値 + */ + "preferenceSyncConflictChoiceServer": string; + /** + * デバイスの設定値 + */ + "preferenceSyncConflictChoiceDevice": string; + /** + * 同期の有効化をキャンセル + */ + "preferenceSyncConflictChoiceCancel": string; + /** + * ペースト + */ + "paste": string; + /** + * 絵文字パレット + */ + "emojiPalette": string; + /** + * 投稿フォーム + */ + "postForm": string; + /** + * 文字数 + */ + "textCount": string; + /** + * 情報 + */ + "information": string; + /** + * チャット + */ + "chat": string; + /** + * 旧設定情報を移行 + */ + "migrateOldSettings": string; + /** + * 通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。 + */ + "migrateOldSettings_description": string; + /** + * 圧縮 + */ + "compress": string; + /** + * 右 + */ + "right": string; + /** + * 下 + */ + "bottom": string; + /** + * 上 + */ + "top": string; + "_chat": { + /** + * まだメッセージはありません + */ + "noMessagesYet": string; + /** + * 新しいメッセージ + */ + "newMessage": string; + /** + * 個人チャット + */ + "individualChat": string; + /** + * 特定ユーザーとの一対一のチャットができます。 + */ + "individualChat_description": string; + /** + * ルームチャット + */ + "roomChat": string; + /** + * 複数人でのチャットができます。 + * また、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。 + */ + "roomChat_description": string; + /** + * ルームを作成 + */ + "createRoom": string; + /** + * ユーザーを招待してチャットを始めましょう + */ + "inviteUserToChat": string; + /** + * 作成したルーム + */ + "yourRooms": string; + /** + * 参加中のルーム + */ + "joiningRooms": string; + /** + * 招待 + */ + "invitations": string; + /** + * 招待はありません + */ + "noInvitations": string; + /** + * 履歴 + */ + "history": string; + /** + * 履歴はありません + */ + "noHistory": string; + /** + * ルームはありません + */ + "noRooms": string; + /** + * ユーザーを招待 + */ + "inviteUser": string; + /** + * 送信した招待 + */ + "sentInvitations": string; + /** + * 参加 + */ + "join": string; + /** + * 無視 + */ + "ignore": string; + /** + * ルームから退出 + */ + "leave": string; + /** + * メンバー + */ + "members": string; + /** + * メッセージを検索 + */ + "searchMessages": string; + /** + * ホーム + */ + "home": string; + /** + * 送信 + */ + "send": string; + /** + * 改行 + */ + "newline": string; + /** + * このルームをミュート + */ + "muteThisRoom": string; + /** + * ルームを削除 + */ + "deleteRoom": string; + /** + * このサーバー、またはこのアカウントでチャットは有効化されていません。 + */ + "chatNotAvailableForThisAccountOrServer": string; + /** + * 相手のアカウントでチャット機能が使えない状態になっています。 + */ + "chatNotAvailableInOtherAccount": string; + /** + * このユーザーとのチャットを開始できません + */ + "cannotChatWithTheUser": string; + /** + * チャットが使えない状態になっているか、相手がチャットを開放していません。 + */ + "cannotChatWithTheUser_description": string; + /** + * チャットする + */ + "chatWithThisUser": string; + /** + * このユーザーはフォロワーからのみチャットを受け付けています。 + */ + "thisUserAllowsChatOnlyFromFollowers": string; + /** + * このユーザーはフォローしているユーザーからのみチャットを受け付けています。 + */ + "thisUserAllowsChatOnlyFromFollowing": string; + /** + * このユーザーは相互フォローのユーザーからのみチャットを受け付けています。 + */ + "thisUserAllowsChatOnlyFromMutualFollowing": string; + /** + * このユーザーは誰からもチャットを受け付けていません。 + */ + "thisUserNotAllowedChatAnyone": string; + /** + * チャットを許可する相手 + */ + "chatAllowedUsers": string; + /** + * 自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。 + */ + "chatAllowedUsers_note": string; + "_chatAllowedUsers": { + /** + * 誰でも + */ + "everyone": string; + /** + * 自分のフォロワーのみ + */ + "followers": string; + /** + * 自分がフォローしているユーザーのみ + */ + "following": string; + /** + * 相互フォローのユーザーのみ + */ + "mutual": string; + /** + * 誰も許可しない + */ + "none": string; + }; + }; + "_emojiPalette": { + /** + * パレット + */ + "palettes": string; + /** + * パレットのデバイス間同期を有効にする + */ + "enableSyncBetweenDevicesForPalettes": string; + /** + * メインで使用するパレット + */ + "paletteForMain": string; + /** + * リアクションで使用するパレット + */ + "paletteForReaction": string; + }; + "_settings": { + /** + * ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。 + */ + "driveBanner": string; + /** + * プラグインを利用するとクライアントの機能を拡張することができます。プラグインのインストール、個別の設定と管理が行えます。 + */ + "pluginBanner": string; + /** + * サーバーからの受信する通知の種類と範囲や、プッシュ通知の設定が行えます。 + */ + "notificationsBanner": string; + /** + * API + */ + "api": string; + /** + * Webhook + */ + "webhook": string; + /** + * サービス連携 + */ + "serviceConnection": string; + /** + * 外部のアプリ・サービスと連携するためのアクセストークンやWebhookの管理と設定が行えます。 + */ + "serviceConnectionBanner": string; + /** + * アカウントのデータ + */ + "accountData": string; + /** + * アカウントデータのアーカイブをエクスポート/インポートして管理できます。 + */ + "accountDataBanner": string; + /** + * 非表示にするコンテンツの設定や、特定のユーザーからのアクションを制限する設定と管理を行えます。 + */ + "muteAndBlockBanner": string; + /** + * クライアントの視覚や動作に関するパーソナライズを行い、より最適に使用できるように設定できます。 + */ + "accessibilityBanner": string; + /** + * コンテンツの公開範囲、見つけやすさ、フォローの承認制などアカウントのプライバシーに関する設定を行えます。 + */ + "privacyBanner": string; + /** + * パスワード、ログイン方法、認証アプリ、パスキーなどアカウントのセキュリティに関する設定を行えます。 + */ + "securityBanner": string; + /** + * 好みに応じた、クライアントの全体的な動作の設定が行えます。 + */ + "preferencesBanner": string; + /** + * 好みに応じた、クライアントの見た目・表示方法に関する設定が行えます。 + */ + "appearanceBanner": string; + /** + * クライアントで再生するサウンドの設定が行えます。 + */ + "soundsBanner": string; + /** + * タイムラインとノート + */ + "timelineAndNote": string; + /** + * 全てのテキスト要素を選択可能にする + */ + "makeEveryTextElementsSelectable": string; + /** + * 有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。 + */ + "makeEveryTextElementsSelectable_description": string; + /** + * アイコンをスクロールに追従させる + */ + "useStickyIcons": string; + /** + * ナビゲーションバーに副ボタンを表示 + */ + "showNavbarSubButtons": string; + /** + * オンのとき + */ + "ifOn": string; + /** + * オフのとき + */ + "ifOff": string; + "_chat": { + /** + * 送信者の名前を表示 + */ + "showSenderName": string; + /** + * Enterで送信 + */ + "sendOnEnter": string; + }; + }; + "_preferencesProfile": { + /** + * プロファイル名 + */ + "profileName": string; + /** + * このデバイスを識別する名前を設定してください。 + */ + "profileNameDescription": string; + /** + * 例: 「メインPC」、「スマホ」など + */ + "profileNameDescription2": string; + }; + "_preferencesBackup": { + /** + * 自動バックアップ + */ + "autoBackup": string; + /** + * バックアップから復元 + */ + "restoreFromBackup": string; + /** + * バックアップが見つかりませんでした + */ + "noBackupsFoundTitle": string; + /** + * 自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。 + */ + "noBackupsFoundDescription": string; + /** + * 復元するバックアップを選択してください + */ + "selectBackupToRestore": string; + /** + * 自動バックアップを有効にするにはプロファイル名の設定が必要です。 + */ + "youNeedToNameYourProfileToEnableAutoBackup": string; + /** + * このデバイスで設定の自動バックアップは有効になっていません。 + */ + "autoPreferencesBackupIsNotEnabledForThisDevice": string; + /** + * 設定のバックアップが見つかりました + */ + "backupFound": string; + }; + "_accountSettings": { + /** + * コンテンツの表示にログインを必須にする + */ + "requireSigninToViewContents": string; + /** + * あなたが作成した全てのノートなどのコンテンツを表示するのにログインを必須にします。クローラーに情報が収集されるのを防ぐ効果が期待できます。 + */ + "requireSigninToViewContentsDescription1": string; + /** + * URLプレビュー(OGP)、Webページへの埋め込み、ノートの引用に対応していないサーバーからの表示も不可になります。 + */ + "requireSigninToViewContentsDescription2": string; + /** + * リモートサーバーに連合されたコンテンツでは、これらの制限が適用されない場合があります。 + */ + "requireSigninToViewContentsDescription3": string; + /** + * 過去のノートをフォロワーのみ表示可能にする + */ + "makeNotesFollowersOnlyBefore": string; + /** + * この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートがフォロワーのみ表示可能になります。無効に戻すと、ノートの公開状態も元に戻ります。 + */ + "makeNotesFollowersOnlyBeforeDescription": string; + /** + * 過去のノートを非公開化する + */ + "makeNotesHiddenBefore": string; + /** + * この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートが自分のみ表示可能(非公開化)になります。無効に戻すと、ノートの公開状態も元に戻ります。 + */ + "makeNotesHiddenBeforeDescription": string; + /** + * リモートサーバーに連合されたノートには効果が及ばない場合があります。 + */ + "mayNotEffectForFederatedNotes": string; + /** + * これらの制限は簡易的なものです。リモートサーバーでの閲覧やモデレーション時など、一部のシチュエーションでは適用されない場合があります。 + */ + "mayNotEffectSomeSituations": string; + /** + * 指定した時間を経過しているノート + */ + "notesHavePassedSpecifiedPeriod": string; + /** + * 指定した日時より前のノート + */ + "notesOlderThanSpecifiedDateAndTime": string; + }; + "_abuseUserReport": { + /** + * 転送 + */ + "forward": string; + /** + * 匿名のシステムアカウントとして、リモートサーバーに通報を転送します。 + */ + "forwardDescription": string; + /** + * 解決 + */ + "resolve": string; + /** + * 是認 + */ + "accept": string; + /** + * 否認 + */ + "reject": string; + /** + * 内容が正当である通報に対応した場合は「是認」を選択し、肯定的にケースが解決されたことをマークします。 + * 内容が正当でない通報の場合は「否認」を選択し、否定的にケースが解決されたことをマークします。 + */ + "resolveTutorial": string; + }; "_delivery": { /** * 配信状態 @@ -5595,6 +6301,18 @@ export interface Locale extends ILocale { * サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。 */ "inquiryUrlDescription": string; + /** + * アカウントの作成をオープンにする + */ + "openRegistration": string; + /** + * 登録を開放することはリスクが伴います。サーバーを常に監視し、トラブルが発生した際にすぐに対応できる体制がある場合のみオンにすることを推奨します。 + */ + "openRegistrationWarning": string; + /** + * 一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。 + */ + "thisSettingWillAutomaticallyOffWhenModeratorsInactive": string; }; "_accountMigration": { /** @@ -6786,6 +7504,10 @@ export interface Locale extends ILocale { * リストのインポートを許可 */ "canImportUserLists": string; + /** + * チャットを許可 + */ + "canChat": string; }; "_condition": { /** @@ -7391,6 +8113,10 @@ export interface Locale extends ILocale { * 標準のテーマ */ "builtinThemes": string; + /** + * サーバーのテーマ + */ + "instanceTheme": string; /** * そのテーマは既にインストールされています */ @@ -7604,10 +8330,6 @@ export interface Locale extends ILocale { * 入力ボックスの縁取り */ "inputBorder": string; - /** - * リスト項目の背景 (ホバー) - */ - "listItemHoverBg": string; /** * ドライブフォルダーの背景 */ @@ -7655,6 +8377,10 @@ export interface Locale extends ILocale { * リアクション選択時 */ "reaction": string; + /** + * チャットのメッセージ + */ + "chatMessage": string; }; "_soundSettings": { /** @@ -8227,6 +8953,14 @@ export interface Locale extends ILocale { * 違反を報告する */ "write:report-abuse": string; + /** + * チャットを操作する + */ + "write:chat": string; + /** + * チャットを閲覧する + */ + "read:chat": string; }; "_auth": { /** @@ -8257,14 +8991,26 @@ export interface Locale extends ILocale { * アプリケーションに戻っています */ "callback": string; + /** + * アクセスを許可しました + */ + "accepted": string; /** * アクセスを拒否しました */ "denied": string; + /** + * 以下のユーザーとして操作しています + */ + "scopeUser": string; /** * アプリケーションにアクセス許可を与えるには、ログインが必要です。 */ "pleaseLogin": string; + /** + * アクセスを許可すると、自動で以下のURLに遷移します + */ + "byClickingYouWillBeRedirectedToThisUrl": string; }; "_antennaSources": { /** @@ -8677,6 +9423,18 @@ export interface Locale extends ILocale { * 最大{max}つまでデコレーションを付けられます。 */ "avatarDecorationMax": ParameterizedString<"max">; + /** + * フォローされた時のメッセージ + */ + "followedMessage": string; + /** + * フォローされた時に相手に表示する短いメッセージを設定できます。 + */ + "followedMessageDescription": string; + /** + * フォローを承認制にしている場合、フォローリクエストを許可した時に表示されます。 + */ + "followedMessageDescriptionForLockedAccount": string; }; "_exportOrImport": { /** @@ -8913,18 +9671,6 @@ export interface Locale extends ILocale { * ソースを表示中 */ "readPage": string; - /** - * ページを作成しました - */ - "created": string; - /** - * ページを更新しました - */ - "updated": string; - /** - * ページを削除しました - */ - "deleted": string; /** * ページ設定 */ @@ -9134,7 +9880,7 @@ export interface Locale extends ILocale { */ "youGotQuote": ParameterizedString<"name">; /** - * {name}がRenoteしました + * {name}がリノートしました */ "youRenoted": ParameterizedString<"name">; /** @@ -9165,6 +9911,10 @@ export interface Locale extends ILocale { * ロールが付与されました */ "roleAssigned": string; + /** + * チャットルームへ招待されました + */ + "chatRoomInvitationReceived": string; /** * プッシュ通知の更新をしました */ @@ -9209,6 +9959,22 @@ export interface Locale extends ILocale { * 通知の履歴をリセットする */ "flushNotification": string; + /** + * {x}のエクスポートが完了しました + */ + "exportOfXCompleted": ParameterizedString<"x">; + /** + * ログインがありました + */ + "login": string; + /** + * アクセストークンが作成されました + */ + "createToken": string; + /** + * 心当たりがない場合は「{text}」を通じてアクセストークンを削除してください。 + */ + "createTokenDescription": ParameterizedString<"text">; "_types": { /** * すべて @@ -9231,7 +9997,7 @@ export interface Locale extends ILocale { */ "reply": string; /** - * Renote + * リノート */ "renote": string; /** @@ -9258,10 +10024,30 @@ export interface Locale extends ILocale { * ロールが付与された */ "roleAssigned": string; + /** + * チャットルームへ招待された + */ + "chatRoomInvitationReceived": string; /** * 実績の獲得 */ "achievementEarned": string; + /** + * エクスポートが完了した + */ + "exportCompleted": string; + /** + * ログイン + */ + "login": string; + /** + * アクセストークンの作成 + */ + "createToken": string; + /** + * 通知のテスト + */ + "test": string; /** * 連携アプリからの通知 */ @@ -9277,7 +10063,7 @@ export interface Locale extends ILocale { */ "reply": string; /** - * Renote + * リノート */ "renote": string; }; @@ -9291,6 +10077,18 @@ export interface Locale extends ILocale { * カラムの寄せ */ "columnAlign": string; + /** + * カラム間のマージン + */ + "columnGap": string; + /** + * デッキメニューの位置 + */ + "deckMenuPosition": string; + /** + * ナビゲーションバーの位置 + */ + "navbarPosition": string; /** * カラムを追加 */ @@ -9363,6 +10161,10 @@ export interface Locale extends ILocale { * 幅を自動調整 */ "flexible": string; + /** + * プロファイル情報のデバイス間同期を有効にする + */ + "enableSyncBetweenDevicesForProfiles": string; "_columns": { /** * メイン @@ -9504,6 +10306,14 @@ export interface Locale extends ILocale { * ユーザーが作成されたとき */ "userCreated": string; + /** + * モデレーターが一定期間非アクティブになったとき + */ + "inactiveModeratorsWarning": string; + /** + * モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき + */ + "inactiveModeratorsInvitationOnlyChanged": string; }; /** * Webhookを削除しますか? @@ -9675,6 +10485,14 @@ export interface Locale extends ILocale { * 通報を解決 */ "resolveAbuseReport": string; + /** + * 通報を転送 + */ + "forwardAbuseReport": string; + /** + * 通報のモデレーションノート更新 + */ + "updateAbuseReportNote": string; /** * 招待コードを作成 */ @@ -9751,6 +10569,14 @@ export interface Locale extends ILocale { * ギャラリーの投稿を削除 */ "deleteGalleryPost": string; + /** + * チャットルームを削除 + */ + "deleteChatRoom": string; + /** + * プロキシアカウントの説明を更新 + */ + "updateProxyAccountDescription": string; }; "_fileViewer": { /** @@ -9796,20 +10622,12 @@ export interface Locale extends ILocale { * このプラグインをインストールしますか? */ "title": string; - /** - * プラグイン情報 - */ - "metaTitle": string; }; "_theme": { /** * このテーマをインストールしますか? */ "title": string; - /** - * テーマ情報 - */ - "metaTitle": string; }; "_meta": { /** @@ -10244,6 +11062,227 @@ export interface Locale extends ILocale { */ "native": string; }; + "_gridComponent": { + "_error": { + /** + * この値は必須項目です + */ + "requiredValue": string; + /** + * 正規表現によるバリデーションはtype:textのカラムのみサポートします。 + */ + "columnTypeNotSupport": string; + /** + * この値は{pattern}のパターンに一致しません + */ + "patternNotMatch": ParameterizedString<"pattern">; + /** + * この値は一意である必要があります + */ + "notUnique": string; + }; + }; + "_roleSelectDialog": { + /** + * 選択されていません + */ + "notSelected": string; + }; + "_customEmojisManager": { + "_gridCommon": { + /** + * 選択行をコピー + */ + "copySelectionRows": string; + /** + * 選択範囲をコピー + */ + "copySelectionRanges": string; + /** + * 選択行を削除 + */ + "deleteSelectionRows": string; + /** + * 選択範囲の値をクリア + */ + "deleteSelectionRanges": string; + /** + * 検索設定 + */ + "searchSettings": string; + /** + * 検索条件を詳細に設定します。 + */ + "searchSettingCaption": string; + /** + * 表示件数 + */ + "searchLimit": string; + /** + * 並び順 + */ + "sortOrder": string; + /** + * 登録ログ + */ + "registrationLogs": string; + /** + * 絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。 + */ + "registrationLogsCaption": string; + /** + * 絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。 + */ + "alertEmojisRegisterFailedDescription": string; + }; + "_logs": { + /** + * 成功ログを表示 + */ + "showSuccessLogSwitch": string; + /** + * 失敗ログはありません。 + */ + "failureLogNothing": string; + /** + * ログはありません。 + */ + "logNothing": string; + }; + "_remote": { + /** + * 選択行の詳細 + */ + "selectionRowDetail": string; + /** + * 選択行をインポート + */ + "importSelectionRows": string; + /** + * 選択範囲の行をインポート + */ + "importSelectionRangesRows": string; + /** + * チェックされた絵文字をインポート + */ + "importEmojisButton": string; + /** + * 絵文字のインポート + */ + "confirmImportEmojisTitle": string; + /** + * リモートから受信した{count}個の絵文字のインポートを行います。絵文字のライセンスに十分な注意を払ってください。実行しますか? + */ + "confirmImportEmojisDescription": ParameterizedString<"count">; + }; + "_local": { + /** + * 登録済み絵文字一覧 + */ + "tabTitleList": string; + /** + * 絵文字の登録 + */ + "tabTitleRegister": string; + "_list": { + /** + * 登録された絵文字はありません。 + */ + "emojisNothing": string; + /** + * 選択行を削除対象にする + */ + "markAsDeleteTargetRows": string; + /** + * 選択範囲の行を削除対象にする + */ + "markAsDeleteTargetRanges": string; + /** + * 変更された絵文字はありません。 + */ + "alertUpdateEmojisNothingDescription": string; + /** + * 削除対象の絵文字はありません。 + */ + "alertDeleteEmojisNothingDescription": string; + /** + * ページを移動しますか? + */ + "confirmMovePage": string; + /** + * 表示を変更しますか? + */ + "confirmChangeView": string; + /** + * {count}個の絵文字を更新します。実行しますか? + */ + "confirmUpdateEmojisDescription": ParameterizedString<"count">; + /** + * チェックがつけられた{count}個の絵文字を削除します。実行しますか? + */ + "confirmDeleteEmojisDescription": ParameterizedString<"count">; + /** + * 今までに加えた変更がすべてリセットされます。 + */ + "confirmResetDescription": string; + /** + * このページの絵文字に変更が加えられています。 + * 保存せずにこのままページを移動すると、このページで加えた変更はすべて破棄されます。 + */ + "confirmMovePageDesciption": string; + /** + * 絵文字に設定されたロールで検索 + */ + "dialogSelectRoleTitle": string; + }; + "_register": { + /** + * アップロード設定 + */ + "uploadSettingTitle": string; + /** + * この画面で絵文字アップロードを行う際の動作を設定できます。 + */ + "uploadSettingDescription": string; + /** + * ディレクトリ名を"category"に入力する + */ + "directoryToCategoryLabel": string; + /** + * ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を"category"に入力します。 + */ + "directoryToCategoryCaption": string; + /** + * いずれかの方法で登録する絵文字を選択してください。 + */ + "emojiInputAreaCaption": string; + /** + * この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ + */ + "emojiInputAreaList1": string; + /** + * このリンクをクリックしてPCから選択する + */ + "emojiInputAreaList2": string; + /** + * このリンクをクリックしてドライブから選択する + */ + "emojiInputAreaList3": string; + /** + * リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです) + */ + "confirmRegisterEmojisDescription": ParameterizedString<"count">; + /** + * 編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか? + */ + "confirmClearEmojisDescription": string; + /** + * ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか? + */ + "confirmUploadEmojisDescription": ParameterizedString<"count">; + }; + }; + }; "_embedCodeGen": { /** * 埋め込みコードをカスタマイズ @@ -10298,6 +11337,210 @@ export interface Locale extends ILocale { */ "codeGeneratedDescription": string; }; + "_selfXssPrevention": { + /** + * 警告 + */ + "warning": string; + /** + * 「この画面に何か貼り付けろ」はすべて詐欺です。 + */ + "title": string; + /** + * ここに何かを貼り付けると、悪意のあるユーザーにアカウントを乗っ取られたり、個人情報を盗まれたりする可能性があります。 + */ + "description1": string; + /** + * 貼り付けようとしているものが何なのかを正確に理解していない場合は、%c今すぐ作業を中止してこのウィンドウを閉じてください。 + */ + "description2": string; + /** + * 詳しくはこちらをご確認ください。 {link} + */ + "description3": ParameterizedString<"link">; + }; + "_followRequest": { + /** + * 受け取った申請 + */ + "recieved": string; + /** + * 送った申請 + */ + "sent": string; + }; + "_remoteLookupErrors": { + "_federationNotAllowed": { + /** + * このサーバーとは通信できません + */ + "title": string; + /** + * このサーバーとの通信が無効化されているか、このサーバーをブロックしている・ブロックされている可能性があります。 + * サーバー管理者にお問い合わせください。 + */ + "description": string; + }; + "_uriInvalid": { + /** + * URIが不正です + */ + "title": string; + /** + * 入力されたURIに問題があります。URIに使用できない文字を入力していないか確認してください。 + */ + "description": string; + }; + "_requestFailed": { + /** + * リクエストに失敗しました + */ + "title": string; + /** + * このサーバーとの通信に失敗しました。相手サーバーがダウンしている可能性があります。また、不正なURIや存在しないURIを入力していないか確認してください。 + */ + "description": string; + }; + "_responseInvalid": { + /** + * レスポンスが不正です + */ + "title": string; + /** + * このサーバーと通信することはできましたが、得られたデータが不正なものでした。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。 + */ + "description": string; + }; + "_noSuchObject": { + /** + * 見つかりません + */ + "title": string; + /** + * 要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。 + */ + "description": string; + }; + }; + "_captcha": { + /** + * CAPTCHAを通過してください + */ + "verify": string; + /** + * サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できます。 + * 詳細は下記ページをご確認ください。 + */ + "testSiteKeyMessage": string; + "_error": { + "_requestFailed": { + /** + * CAPTCHAのリクエストに失敗しました + */ + "title": string; + /** + * しばらく後に実行するか、設定をもう一度ご確認ください。 + */ + "text": string; + }; + "_verificationFailed": { + /** + * CAPTCHAの検証に失敗しました + */ + "title": string; + /** + * 設定が正しいかどうかもう一度確認ください。 + */ + "text": string; + }; + "_unknown": { + /** + * CAPTCHAエラー + */ + "title": string; + /** + * 想定外のエラーが発生しました。 + */ + "text": string; + }; + }; + }; + "_bootErrors": { + /** + * 読み込みに失敗しました + */ + "title": string; + /** + * 少し待ってからリロードしてもまだ問題が解決されない場合、以下のError IDを添えてサーバー管理者に連絡してください。 + */ + "serverError": string; + /** + * 以下を行うと解決する可能性があります。 + */ + "solution": string; + /** + * ブラウザおよびOSを最新バージョンに更新する + */ + "solution1": string; + /** + * アドブロッカーを無効にする + */ + "solution2": string; + /** + * ブラウザのキャッシュをクリアする + */ + "solution3": string; + /** + * (Tor Browser) dom.webaudio.enabledをtrueに設定する + */ + "solution4": string; + /** + * その他のオプション + */ + "otherOption": string; + /** + * クライアント設定とキャッシュを削除 + */ + "otherOption1": string; + /** + * 簡易クライアントを起動 + */ + "otherOption2": string; + /** + * 修復ツールを起動 + */ + "otherOption3": string; + }; + "_search": { + /** + * 全て + */ + "searchScopeAll": string; + /** + * ローカル + */ + "searchScopeLocal": string; + /** + * サーバー指定 + */ + "searchScopeServer": string; + /** + * ユーザー指定 + */ + "searchScopeUser": string; + /** + * サーバーのホストを入力してください + */ + "pleaseEnterServerHost": string; + /** + * ユーザーを選択してください + */ + "pleaseSelectUser": string; + /** + * 例: misskey.example.com + */ + "serverHostPlaceholder": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/index.js b/locales/index.js index c2738884eb..091d216dee 100644 --- a/locales/index.js +++ b/locales/index.js @@ -15,6 +15,7 @@ const merge = (...args) => args.reduce((a, c) => ({ const languages = [ 'ar-SA', + 'ca-ES', 'cs-CZ', 'da-DK', 'de-DE', diff --git a/locales/it-IT.yml b/locales/it-IT.yml index 2b4b1e425e..3ec8414ded 100644 --- a/locales/it-IT.yml +++ b/locales/it-IT.yml @@ -1,13 +1,17 @@ --- _lang_: "Italiano" -headlineMisskey: "Rete collegata tramite note" +headlineMisskey: "Rete collegata tramite Note" introMisskey: "Eccoci! Misskey è un servizio di microblogging decentralizzato, libero e aperto. \n\n📡 Puoi pubblicare «Note» per condividere ciò che sta succedendo o per dire a tutti qualcosa su di te. \n\n👍 Puoi reagire inviando emoji rapidi alle «Note» provenienti da altri profili nel Fediverso.\n\n🚀 Esplora un nuovo mondo insieme a noi!" poweredByMisskeyDescription: "{name} è uno dei servizi (chiamati istanze) che utilizzano la piattaforma open source Misskey." monthAndDay: "{day}/{month}" search: "Cerca" +reset: "Ripristinare" notifications: "Notifiche" username: "Nome utente" password: "Password" +initialPasswordForSetup: "Password iniziale, per avviare le impostazioni" +initialPasswordIsIncorrect: "Password iniziale, sbagliata." +initialPasswordForSetupDescription: "Se hai installato Misskey di persona, usa la password che hai indicato nel file di configurazione.\nSe stai utilizzando un servizio di hosting Misskey, usa la password fornita dal gestore.\nSe non hai una password preimpostata, lascia il campo vuoto e continua." forgotPassword: "Hai dimenticato la password?" fetchingAsApObject: "Recuperando dal Fediverso..." ok: "OK" @@ -45,6 +49,7 @@ pin: "Fissa sul profilo" unpin: "Non fissare sul profilo" copyContent: "Copia il contenuto" copyLink: "Copia il link" +copyRemoteLink: "Copia link remoto" copyLinkRenote: "Copia collegamento alla Rinota" delete: "Elimina" deleteAndEdit: "Elimina e modifica" @@ -53,18 +58,19 @@ addToList: "Aggiungi alla lista" addToAntenna: "Aggiungi all'antenna" sendMessage: "Invia messaggio" copyRSS: "Copia RSS" -copyUsername: "Copia nome utente" +copyUsername: "Copia indirizzo del profilo" copyUserId: "Copia ID del profilo" copyNoteId: "Copia ID della Nota" copyFileId: "Copia ID del file" copyFolderId: "Copia ID della cartella" copyProfileUrl: "Copia URL del profilo" searchUser: "Cerca profilo" +searchThisUsersNotes: "Cerca le sue Note" reply: "Rispondi" loadMore: "Mostra di più" showMore: "Espandi" showLess: "Comprimi" -youGotNewFollower: "Adesso ti segue" +youGotNewFollower: "Hai un nuovo Follower" receiveFollowRequest: "Hai ricevuto una richiesta di follow" followRequestAccepted: "Ha accettato la tua richiesta di follow" mention: "Menzioni" @@ -76,14 +82,14 @@ export: "Esporta" files: "Allegati" download: "Scarica" driveFileDeleteConfirm: "Vuoi davvero eliminare il file \"{name}\", e le Note a cui è stato allegato?" -unfollowConfirm: "Vuoi davvero smettere di seguire {name}?" +unfollowConfirm: "Vuoi davvero togliere il Following a {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" noLists: "Nessuna lista" note: "Nota" notes: "Note" -following: "Follow" +following: "Following" followers: "Follower" followsYou: "Follower" createList: "Aggiungi una nuova lista" @@ -101,8 +107,8 @@ makeFollowManuallyApprove: "Approva i follower manualmente" defaultNoteVisibility: "Privacy predefinita delle note" follow: "Segui" followRequest: "Richiesta di follow" -followRequests: "Richieste di follow" -unfollow: "Smetti di seguire" +followRequests: "Relazioni" +unfollow: "Togli Following" followRequestPending: "Richiesta in approvazione" enterEmoji: "Inserisci emoji" renote: "Rinota" @@ -120,7 +126,7 @@ pinnedNote: "Nota in primo piano" pinned: "Fissa sul profilo" you: "Tu" clickToShow: "Contenuto occultato, cliccare solo se si intende vedere" -sensitive: "Allegato esplicito" +sensitive: "Esplicito" add: "Aggiungi" reaction: "Reazioni" reactions: "Reazioni" @@ -154,6 +160,7 @@ editList: "Modifica Lista" selectChannel: "Seleziona canale" selectAntenna: "Scegli un'antenna" editAntenna: "Modifica Antenna" +createAntenna: "Crea Antenna" selectWidget: "Seleziona il riquadro" editWidgets: "Modifica i riquadri" editWidgetsExit: "Conferma le modifiche" @@ -190,10 +197,11 @@ setWallpaper: "Imposta sfondo" removeWallpaper: "Elimina lo sfondo" searchWith: "Cerca: {q}" youHaveNoLists: "Non hai ancora creato nessuna lista" -followConfirm: "Vuoi seguire {name}?" +followConfirm: "Confermi il Following a {name}?" proxyAccount: "Profilo proxy" proxyAccountDescription: "Un profilo proxy funziona come follower per i profili remoti, sotto certe condizioni. Ad esempio, quando un profilo locale ne inserisce uno remoto in una lista (senza seguirlo), se nessun altro segue quel profilo remoto, le attività non possono essere distribuite. Dunque, il profilo proxy le seguirà per tutti." host: "Host" +selectSelf: "Segli me" selectUser: "Seleziona profilo" recipient: "Destinatario" annotation: "Annotazione preventiva" @@ -209,6 +217,7 @@ perDay: "giornaliero" stopActivityDelivery: "Interrompi la distribuzione di attività" blockThisInstance: "Bloccare l'istanza" silenceThisInstance: "Silenziare l'istanza" +mediaSilenceThisInstance: "Silenzia i media dell'istanza" operations: "Operazioni" software: "Software" version: "Versione" @@ -219,7 +228,7 @@ jobQueue: "Coda di lavoro" cpuAndMemory: "CPU e Memoria" network: "Rete" disk: "Disco" -instanceInfo: "Informazioni sull'istanza" +instanceInfo: "Informazioni sul server" statistics: "Statistiche" clearQueue: "Svuota coda" clearQueueConfirmTitle: "Vuoi davvero svuotare la coda?" @@ -230,6 +239,10 @@ blockedInstances: "Istanze bloccate" blockedInstancesDescription: "Elenca le istanze che vuoi bloccare, una per riga. Esse non potranno più interagire con la tua istanza." silencedInstances: "Istanze silenziate" silencedInstancesDescription: "Elenca i nomi host delle istanze che vuoi silenziare. Tutti i profili nelle istanze silenziate vengono trattati come tali. Possono solo inviare richieste di follow e menzionare soltanto i profili locali che seguono. Le istanze bloccate non sono interessate." +mediaSilencedInstances: "Istanze coi media silenziati" +mediaSilencedInstancesDescription: "Elenca i nomi host delle istanze di cui vuoi silenziare i media, uno per riga. Tutti gli allegati dei profili nelle istanze silenziate per via degli allegati espliciti, verranno impostati come tali, le emoji personalizzate non saranno disponibili. Le istanze bloccate sono escluse." +federationAllowedHosts: "Server a cui consentire la federazione" +federationAllowedHostsDescription: "Indica gli host dei server a cui è consentita la federazione, uno per ogni linea." muteAndBlock: "Silenziare e bloccare" mutedUsers: "Profili silenziati" blockedUsers: "Profili bloccati" @@ -252,7 +265,7 @@ all: "Tutte" subscribing: "Iscrizione" publishing: "Pubblicazione" notResponding: "Nessuna risposta" -instanceFollowing: "Seguiti dall'istanza" +instanceFollowing: "Istanza Following" instanceFollowers: "Follower dell'istanza" instanceUsers: "Profili nell'istanza" changePassword: "Aggiorna Password" @@ -276,7 +289,6 @@ deleteAreYouSure: "Vuoi davvero eliminare \"{x}\"?" resetAreYouSure: "Ripristinare?" areYouSure: "Confermi?" saved: "Salvato" -messaging: "Messaggi" upload: "Carica" keepOriginalUploading: "Conservare l'immagine originale." keepOriginalUploadingDescription: "Conserva la versione originale quando si caricano le immagini. Se è disattivato, il browser genera l'immagine per la pubblicazione sul Web durante il caricamento." @@ -289,7 +301,7 @@ uploadFromUrlMayTakeTime: "Il caricamento del file può richiedere tempo." explore: "Esplora" messageRead: "Visualizzato" noMoreHistory: "Non c'è più cronologia da visualizzare" -startMessaging: "Nuovo messaggio" +startChat: "Inizia a chattare" nUsersRead: "Letto da {n} persone" agreeTo: "Sono d'accordo con {0}" agree: "Accetto" @@ -328,6 +340,7 @@ renameFolder: "Rinomina cartella" deleteFolder: "Elimina cartella" folder: "Cartella" addFile: "Allega" +showFile: "Visualizza file" emptyDrive: "Il Drive è vuoto" emptyFolder: "La cartella è vuota" unableToDelete: "Eliminazione impossibile" @@ -370,7 +383,6 @@ enableLocalTimeline: "Abilita la timeline locale" enableGlobalTimeline: "Abilita la timeline federata" disablingTimelinesInfo: "Anche disabilitandole, gli Amministratori e i Moderatori potranno comunque accedervi." registration: "Iscriviti" -enableRegistration: "Consenti a chiunque di registrarsi" invite: "Invita" driveCapacityPerLocalAccount: "Capienza del Drive per profilo locale" driveCapacityPerRemoteAccount: "Capienza del Drive per profilo remoto" @@ -429,10 +441,10 @@ recentlyRegisteredUsers: "Profili iscritti di recente" recentlyDiscoveredUsers: "Profili scoperti di recente" exploreUsersCount: "Ci sono {count} profili" exploreFediverse: "Esplora il Fediverso" -popularTags: "Tag di tendenza" +popularTags: "Hashtag popolari" userList: "Liste" about: "Informazioni" -aboutMisskey: "Informazioni di Misskey" +aboutMisskey: "A proposito di Misskey" administrator: "Amministratore" token: "Token" 2fa: "Autenticazione a due fattori" @@ -442,6 +454,7 @@ totpDescription: "Puoi autenticarti inserendo un codice OTP tramite la tua App d moderator: "Moderatore" moderation: "moderazione" moderationNote: "Promemoria di moderazione" +moderationNoteDescription: "Puoi scrivere promemoria condivisi solo tra moderatori." addModerationNote: "Aggiungi promemoria di moderazione" moderationLogs: "Cronologia di moderazione" nUsersMentioned: "{n} profili ne parlano" @@ -449,7 +462,7 @@ securityKeyAndPasskey: "Chiave di sicurezza e accesso" securityKey: "Chiave di sicurezza" lastUsed: "Ultima attività" lastUsedAt: "Uso più recente: {t}" -unregister: "Annulla l'iscrizione" +unregister: "Rimuovi autenticazione a due fattori (2FA/MFA)" passwordLessLogin: "Accedi senza password" passwordLessLoginDescription: "Accedi senza password, usando la chiave di sicurezza" resetPassword: "Ripristina la password" @@ -477,8 +490,6 @@ noteOf: "Note di {user}" quoteAttached: "Citazione allegata" quoteQuestion: "Vuoi aggiungere una citazione?" attachAsFileQuestion: "Il testo copiato eccede le dimensioni, vuoi allegarlo?" -noMessagesYet: "Ancora nessuna chat" -newMessageExists: "Hai ricevuto un nuovo messaggio" 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" @@ -503,7 +514,10 @@ uiLanguage: "Lingua di visualizzazione dell'interfaccia" aboutX: "Informazioni su {x}" emojiStyle: "Stile emoji" native: "Nativo" -disableDrawer: "Non mostrare il menù sul drawer" +menuStyle: "Stile menu" +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" noHistory: "Nessuna cronologia" @@ -520,7 +534,7 @@ regenerate: "Generare di nuovo" fontSize: "Dimensione carattere" mediaListWithOneImageAppearance: "Altezza dell'elenco media con una sola immagine " limitTo: "Limita a {x}" -noFollowRequests: "Non hai alcuna richiesta di follow" +noFollowRequests: "Non ci sono richieste di relazione" openImageInNewTab: "Apri le immagini in un nuovo tab" dashboard: "Pannello di controllo" local: "Locale" @@ -536,8 +550,8 @@ promote: "Pubblicizza" numberOfDays: "Numero di giorni" hideThisNote: "Nasconda la nota" showFeaturedNotesInTimeline: "Mostrare le note di tendenza nella tua timeline" -objectStorage: "Stoccaggio oggetti" -useObjectStorage: "Utilizza stoccaggio oggetti" +objectStorage: "Storage S3" +useObjectStorage: "Utilizza lo storage S3 in cloud" objectStorageBaseUrl: "Base URL" objectStorageBaseUrlDesc: "URL di riferimento. In caso di utilizzo di proxy o CDN l'URL è 'https://.s3.amazonaws.com' per S3, 'https://storage.googleapis.com/' per GCS eccetera. " objectStorageBucket: "Bucket" @@ -559,7 +573,7 @@ deleteAll: "Cancella cronologia" showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" showFixedPostFormInChannel: "Per i canali, mostra il modulo di pubblicazione in cima alla timeline" withRepliesByDefaultForNewlyFollowed: "Quando segui nuovi profili, includi le risposte in TL come impostazione predefinita" -newNoteRecived: "Nuove note da leggere" +newNoteRecived: "Nuove Note da leggere" sounds: "Impostazioni suoni" sound: "Suono" listen: "Ascolta" @@ -571,6 +585,7 @@ masterVolume: "Volume principale" notUseSound: "Non emettere suoni" useSoundOnlyWhenActive: "Emetti suoni solo quando Misskey è in attività" details: "Dettagli" +renoteDetails: "Dettagli della Rinota" chooseEmoji: "Scegli emoji" unableToProcess: "Impossibile compiere l'operazione" recentUsed: "Usato di recente" @@ -586,7 +601,9 @@ ascendingOrder: "Aumenta" 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." -output: "Uscita" +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:." +output: "Output" script: "Script" disablePagesScript: "Disabilita AiScript nelle pagine" updateRemoteUser: "Aggiorna dati dal profilo remoto" @@ -597,7 +614,7 @@ unsetUserBannerConfirm: "Vuoi davvero rimuovere l'intestazione dal profilo?" deleteAllFiles: "Elimina tutti i file" deleteAllFilesConfirm: "Vuoi davvero eliminare tutti i file?" removeAllFollowing: "Annulla tutti i follow" -removeAllFollowingDescription: "Cancella tutti i follows del server {host}. Per favore, esegui se, ad esempio, l'istanza non esiste più." +removeAllFollowingDescription: "Togli il Following a tutti i profili su {host}. Utile, ad esempio, quando l'istanza non esiste più." userSuspended: "L'utente è in sospensione" userSilenced: "Profilo silenziato" yourAccountSuspendedTitle: "Questo profilo è sospeso" @@ -666,14 +683,19 @@ 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" +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." 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 parlato" +userSaysSomething: "{name} ha detto qualcosa" +userSaysSomethingAbout: "{name} ha Notato a riguardo di \"{word}\"" makeActive: "Attiva" display: "Visualizza" copy: "Copia" +copiedToClipboard: "Copiato negli appunti" metrics: "Statistiche" overview: "Anteprima" logs: "Log" @@ -682,10 +704,10 @@ database: "Base dati" channel: "Canale" create: "Crea" notificationSetting: "Impostazioni notifiche" -notificationSettingDesc: "Seleziona il tipo di notifiche da visualizzare." +notificationSettingDesc: "Scegli quali notifiche mostrare." useGlobalSetting: "Usa impostazioni generali" useGlobalSettingDesc: "Quando attiva, verranno utilizzate le impostazioni notifiche del profilo. Altrimenti si possono segliere impostazioni personalizzate." -other: "Ulteriori" +other: "Eccetera" regenerateLoginToken: "Genera di nuovo un token di connessione" regenerateLoginTokenDescription: "Genera un nuovo token di autenticazione. Solitamente questa operazione non è necessaria: quando si genera un nuovo token, tutti i dispositivi vanno disconnessi." theKeywordWhenSearchingForCustomEmoji: "Questa sarà la parola chiave durante la ricerca di emoji personalizzate" @@ -702,10 +724,7 @@ abuseReported: "La segnalazione è stata inviata. Grazie." reporter: "il corrispondente" reporteeOrigin: "Segnalazione a" reporterOrigin: "Segnalazione da" -forwardReport: "Inoltro di un report a un'istanza remota." -forwardReportIsAnonymous: "L'istanza remota non vedrà le tue informazioni, apparirai come profilo di sistema, anonimo." send: "Inviare" -abuseMarkAsResolved: "Risolvi segnalazione" openInNewTab: "Apri in una nuova scheda" openInSideView: "Apri in vista laterale" defaultNavigationBehaviour: "Navigazione preimpostata" @@ -732,7 +751,7 @@ 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 profili seguiti" +followingCount: "Numero di Following" followersCount: "Numero di profili che ti seguono" sentReactionsCount: "Numero di reazioni inviate" receivedReactionsCount: "Numero di reazioni ricevute" @@ -745,7 +764,7 @@ 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." lockedAccountInfo: "A meno che non imposti la visibilità delle tue note su \"Solo ai follower\", le tue note sono visibili da tutti, anche se hai configurato l'account per confermare manualmente le richieste di follow." -alwaysMarkSensitive: "Segnare gli allegati come espliciti come opzione predefinita" +alwaysMarkSensitive: "Segnare automaticamente come espliciti gli allegati" loadRawImages: "Visualizza le intere immagini allegate invece delle miniature." disableShowingAnimatedImages: "Disabilita le immagini animate" highlightSensitiveMedia: "Evidenzia i media espliciti" @@ -827,7 +846,7 @@ onlineStatus: "Stato di connessione" hideOnlineStatus: "Modalità invisibile" hideOnlineStatusDescription: "Attivando questa opzione potresti ridurre l'usabilità di alcune funzioni, come la ricerca." online: "Online" -active: "Attività" +active: "Attivo" offline: "Offline" notRecommended: "Sconsigliato" botProtection: "Protezione contro i bot" @@ -872,7 +891,7 @@ searchResult: "Risultati della Ricerca" hashtags: "Hashtag" troubleshooting: "Risoluzione problemi" useBlurEffect: "Utilizza effetto sfocatura" -learnMore: "Più dettagli" +learnMore: "Per saperne di più" misskeyUpdated: "Misskey è stato aggiornato!" whatIsNew: "Informazioni sull'aggiornamento" translate: "Traduci" @@ -880,19 +899,19 @@ 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." aiChanMode: "Modalità Ai" -devMode: "Modalità sviluppatori" +devMode: "Modalità sviluppo" keepCw: "Mostra i contenuti espliciti" pubSub: "Publish/Subscribe del profilo" lastCommunication: "La comunicazione più recente" resolved: "Risolto" unresolved: "Non risolto" -breakFollow: "Impedire di seguirmi" -breakFollowConfirm: "Vuoi davvero che questo profilo smetta di seguirti?" +breakFollow: "Rimuovi Follower" +breakFollowConfirm: "Vuoi davvero togliere questo Follower?" itsOn: "Abilitato" itsOff: "Disabilitato" on: "Acceso" off: "Spento" -emailRequiredForSignup: "L'ndirizzo e-mail è obbligatorio per registrarsi" +emailRequiredForSignup: "L'indirizzo e-mail è obbligatorio per registrarsi" unread: "Non lette" filter: "Filtri" controlPanel: "Pannello di controllo" @@ -902,11 +921,12 @@ makeReactionsPublicDescription: "La lista delle reazioni che avete fatto è a di classic: "Classico" muteThread: "Silenziare conversazione" unmuteThread: "Riattiva la conversazione" -followingVisibility: "Visibilità dei profili seguiti" +followingVisibility: "Visibilità dei Following" followersVisibility: "Visibilità dei profili che ti seguono" continueThread: "Altre conversazioni" deleteAccountConfirm: "Così verrà eliminato il profilo. Vuoi procedere?" incorrectPassword: "La password è errata." +incorrectTotp: "Il codice OTP è sbagliato, oppure scaduto." voteConfirm: "Votare per「{choice}」?" hide: "Nascondere" useDrawerReactionPickerForMobile: "Mostra sul drawer da dispositivo mobile" @@ -931,6 +951,9 @@ oneHour: "1 ora" oneDay: "1 giorno" oneWeek: "1 settimana" oneMonth: "Un mese" +threeMonths: "3 mesi" +oneYear: "1 anno" +threeDays: "3 giorni" reflectMayTakeTime: "Potrebbe essere necessario un po' di tempo perché ciò abbia effetto." failedToFetchAccountInformation: "Impossibile recuperare le informazioni sul profilo" rateLimitExceeded: "Superato il limite di richieste." @@ -948,10 +971,10 @@ check: "Verifica" driveCapOverrideLabel: "Modificare la capienza del Drive per questo profilo" driveCapOverrideCaption: "Se viene specificato meno di 0, viene annullato." requireAdminForView: "Per visualizzarli, è necessario aver effettuato l'accesso con un profilo amministratore." -isSystemAccount: "Questi profili vengono creati e gestiti automaticamente dal sistema" -typeToConfirm: "Per eseguire questa operazione, digitare {x}" +isSystemAccount: "Si tratta di un profilo creato e gestito automaticamente dal sistema." +typeToConfirm: "Digita {x} per continuare" deleteAccount: "Eliminazione profilo" -document: "Documento" +document: "Documentazione" numberOfPageCache: "Numero di pagine cache" numberOfPageCacheDescription: "Aumenta l'usabilità, ma aumenta anche il carico e l'utilizzo della memoria." logoutConfirm: "Vuoi davvero uscire da Misskey? " @@ -1004,7 +1027,7 @@ neverShow: "Non mostrare più" remindMeLater: "Rimanda" didYouLikeMisskey: "Ti piace Misskey?" pleaseDonate: "Misskey è il software libero utilizzato su {host}. Offrendo una donazione è più facile continuare a svilupparlo!" -correspondingSourceIsAvailable: "" +correspondingSourceIsAvailable: "Il codice sorgente corrispondente è disponibile su {anchor}." roles: "Ruoli" role: "Ruolo" noRole: "Ruolo non trovato" @@ -1024,7 +1047,7 @@ permissionDeniedError: "Errore, attività non autorizzata" permissionDeniedErrorDescription: "Non si dispone dell'autorizzazione per eseguire questa operazione." preset: "Preimpostato" selectFromPresets: "Seleziona preimpostato" -achievements: "Obiettivi raggiunti" +achievements: "Conquiste" gotInvalidResponseError: "Risposta del server non valida" gotInvalidResponseErrorDescription: "Il server potrebbe essere irraggiungibile o in manutenzione. Riprova più tardi." thisPostMayBeAnnoying: "Questa nota potrebbe essere offensiva" @@ -1065,12 +1088,13 @@ notesSearchNotAvailable: "Non è possibile cercare tra le Note." license: "Licenza" unfavoriteConfirm: "Vuoi davvero rimuovere la preferenza?" myClips: "Le mie Clip" -drivecleaner: "Drive cleaner" +drivecleaner: "Pulizia del Drive" retryAllQueuesNow: "Ritenta di consumare tutte le code" retryAllQueuesConfirmTitle: "Vuoi ritentare adesso?" retryAllQueuesConfirmText: "Potrebbe sovraccaricare il server temporaneamente." enableChartsForRemoteUser: "Abilita i grafici per i profili remoti" enableChartsForFederatedInstances: "Abilita i grafici per le istanze federate" +enableStatsForFederatedInstances: "Informazioni statistiche sui server federati" showClipButtonInNoteFooter: "Aggiungi il bottone Clip tra le azioni delle Note" reactionsDisplaySize: "Grandezza delle reazioni" limitWidthOfReaction: "Limita la larghezza delle reazioni e ridimensionale" @@ -1086,7 +1110,7 @@ accountMovedShort: "Questo profilo è stato migrato" operationForbidden: "Operazione non consentita" forceShowAds: "Mostra sempre i banner" addMemo: "Aggiungi Memo" -editMemo: "Modifica Memo" +editMemo: "Modifica il promemoria" reactionsList: "Chi ha reagito?" renotesList: "Chi ha Rinotato?" notificationDisplay: "Stile delle notifiche" @@ -1106,16 +1130,21 @@ preservedUsernames: "Nomi utente riservati" preservedUsernamesDescription: "Elenca, uno per linea, i nomi utente che non possono essere registrati durante la creazione del profilo. La restrizione non si applica agli amministratori. Inoltre, i profili già registrati sono esenti." createNoteFromTheFile: "Crea Nota da questo file" archive: "Archivio" +archived: "Archiviato" +unarchive: "Annulla archiviazione" channelArchiveConfirmTitle: "Vuoi davvero archiviare {name}?" channelArchiveConfirmDescription: "Un canale archiviato non compare nell'elenco canali, nemmeno nei risultati di ricerca. Non può ricevere nemmeno nuove Note." thisChannelArchived: "Questo canale è stato archiviato." displayOfNote: "Visualizzazione delle Note" initialAccountSetting: "Impostazioni iniziali del profilo" -youFollowing: "Seguiti" +youFollowing: "Following" preventAiLearning: "Impedisci l'apprendimento della IA" preventAiLearningDescription: "Aggiungendo il campo \"noai\" alla risposta HTML, si indica ai Robot esterni di non usare testi e allegati per addestrare sistemi di Machine Learning (IA predittiva/generativa). Anche se è impossibile sapere se la richiesta venga onorata o semplicemente ignorata." options: "Opzioni del ruolo" specifyUser: "Profilo specifico" +lookupConfirm: "Vuoi davvero richiedere informazioni?" +openTagPageConfirm: "Vuoi davvero aprire la pagina dell'hashtag?" +specifyHost: "Host specifici" failedToPreviewUrl: "Anteprima non disponibile" update: "Aggiorna" rolesThatCanBeUsedThisEmojiAsReaction: "Ruoli che possono usare questa emoji come reazione" @@ -1215,7 +1244,7 @@ code: "Codice" reloadRequiredToApplySettings: "Per applicare le impostazioni, occorre ricaricare." remainingN: "Rimangono: {n}" overwriteContentConfirm: "Vuoi davvero sostituire l'attuale contenuto?" -seasonalScreenEffect: "Schermate in base alla stagione" +seasonalScreenEffect: "Abilita gli effetti speciali stagionali" decorate: "Decora" addMfmFunction: "Aggiungi decorazioni" enableQuickAddMfmFunction: "Attiva il selettore di funzioni MFM" @@ -1250,6 +1279,172 @@ inquiry: "Contattaci" tryAgain: "Per favore riprova" confirmWhenRevealingSensitiveMedia: "Richiedi conferma prima di mostrare gli allegati espliciti" sensitiveMediaRevealConfirm: "Questo allegato è esplicito, vuoi vederlo?" +createdLists: "Liste create" +createdAntennas: "Antenne create" +fromX: "Da {x}" +genEmbedCode: "Ottieni il codice di incorporamento" +noteOfThisUser: "Elenco di Note di questo profilo" +clipNoteLimitExceeded: "Non è possibile aggiungere ulteriori Note a questa Clip." +performance: "Prestazioni" +modified: "Modificato" +discard: "Scarta" +thereAreNChanges: "Ci sono {n} cambiamenti" +signinWithPasskey: "Accedi con passkey" +unknownWebAuthnKey: "Questa è una passkey sconosciuta." +passkeyVerificationFailed: "La verifica della passkey non è riuscita." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verifica della passkey è riuscita, ma l'accesso senza password è disabilitato." +messageToFollower: "Messaggio ai follower" +target: "Riferimento" +testCaptchaWarning: "Questa funzione è destinata al test CAPTCHA. Da non utilizzare in ambiente di produzione." +prohibitedWordsForNameOfUser: "Parole proibite (nome utente)" +prohibitedWordsForNameOfUserDescription: "Il sistema rifiuta di rinominare un utente, se il nome contiene qualsiasi parola nell'elenco. Sono esenti i profili con privilegi di moderazione." +yourNameContainsProhibitedWords: "Il nome che hai scelto contiene una o più parole vietate" +yourNameContainsProhibitedWordsDescription: "Se desideri comunque utilizzare questo nome, contatta l''amministrazione." +thisContentsAreMarkedAsSigninRequiredByAuthor: "L'autore richiede di iscriversi per vedere il contenuto" +lockdown: "Isolamento" +pleaseSelectAccount: "Per favore, seleziona un profilo" +availableRoles: "Ruoli disponibili" +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." +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" +copyPreferenceId: "Copia ID preferenze" +resetToDefaultValue: "Ripristina a predefinito" +overrideByAccount: "Sovrascrivere col profilo" +untitled: "Senza titolo" +noName: "Senza nome" +skip: "Salta" +restore: "Ripristina" +syncBetweenDevices: "Sincronizzazione tra i dispositivi" +preferenceSyncConflictTitle: "Sul server esiste già il valore impostato" +preferenceSyncConflictText: "Le impostazione sincronizzata salverà il valore sul server. Però, bada che esiste già un valore sul server. Quale vorresti sovrascrivere?" +preferenceSyncConflictChoiceServer: "Valore del server" +preferenceSyncConflictChoiceDevice: "Valore del dispositivo" +preferenceSyncConflictChoiceCancel: "Annulla la sincronizzazione" +paste: "Incolla" +emojiPalette: "Tavolozza emoji" +postForm: "Finestra di pubblicazione" +textCount: "Il numero di caratteri" +information: "Informazioni" +chat: "Chat" +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: "Comprimi" +_chat: + noMessagesYet: "Ancora nessun messaggio" + newMessage: "Nuovo messaggio" + individualChat: "Chat individuale" + individualChat_description: "Puoi chattare con una persona specifica." + roomChat: "Stanza di chat" + roomChat_description: "Puoi chattare con più persone.\nInoltre, anche le persone che non consentono chat personalizzate possono chattare se gli altri accettano." + createRoom: "Crea stanza" + inviteUserToChat: "Invita a chattare altre persone" + yourRooms: "Le tue stanze" + joiningRooms: "Stanze a cui partecipi" + invitations: "Invita" + noInvitations: "Nessun invito" + history: "Cronologia" + noHistory: "Nessuna cronologia" + noRooms: "Nessuna stanza" + inviteUser: "Invita" + sentInvitations: "Inviti spediti" + join: "Entra" + ignore: "Ignora" + leave: "Esci" + members: "Membri" + searchMessages: "Cerca messaggi" + home: "Home" + send: "Inviare" + newline: "Nuova riga" + muteThisRoom: "Silenzia stanza" + deleteRoom: "Elimina stanza" + chatNotAvailableForThisAccountOrServer: "Questo server, o questo profilo ha disabilitato la chat." + chatNotAvailableInOtherAccount: "La chat non è disponibile nel profilo dell'altra persona." + cannotChatWithTheUser: "Impossibile chattare con questa persona" + cannotChatWithTheUser_description: "La chat potrebbe non essere disponibile, oppure l'altra persona potrebbe non esserlo." + chatWithThisUser: "Chatta con questa persona" + thisUserAllowsChatOnlyFromFollowers: "Questa persona permette di chattare soltanto i propri Follower." + thisUserAllowsChatOnlyFromFollowing: "Questa persona permette di chattare soltanto ai suoi Follow." + thisUserAllowsChatOnlyFromMutualFollowing: "Questa persona permette di chattare solo a relazioni reciproche." + thisUserNotAllowedChatAnyone: "Questa persona non permette di chattare a nessuno." + chatAllowedUsers: "Persone ammesse alla chat" + chatAllowedUsers_note: "Puoi chattare con le persone a cui hai già inviato un messaggio, indipendentemente da questa impostazione." + _chatAllowedUsers: + everyone: "Chiunque" + followers: "Solo i tuoi Follower" + following: "Solo i tuoi Follow" + mutual: "Solo relazioni reciproche" + none: "Nessuno" +_emojiPalette: + palettes: "Tavolozza" + enableSyncBetweenDevicesForPalettes: "Attiva la sincronizzazione tra dispositivi" + paletteForMain: "Tavolozza principale" + paletteForReaction: "Tavolozza per reazioni" +_settings: + driveBanner: "Permette di gestire e configurare il Drive, controllare il consumo di spazio e configurare il caricamento dei file." + pluginBanner: "Consentono di migliorare le funzionalità. Le estensioni si possono configurare e gestire singolarmente." + notificationsBanner: "Puoi impostare il tipo di notifiche da ricevere dal server e anche le notifiche push." + api: "API" + webhook: "Webhook" + serviceConnection: "Integrazione servizi" + serviceConnectionBanner: "Puoi gestire i codici di accesso e i Webhook per collegare App o servizi esterni." + accountData: "Dati del profilo" + accountDataBanner: "Puoi gestire i dati del tuo profilo, esportando e importando." + muteAndBlockBanner: "Puoi configurare la visibiltà dei contenuti e limitare le attività provenienti da profili specifici." + accessibilityBanner: "Puoi personalizzare e migliorare la lettura sul tuo dispositivo in modo che sia più chiaro e reattivo." + privacyBanner: "Puoi configurare la privacy del tuo profilo, come la visibilità delle Note, la visibilità del profilo nelle ricerche e l'approvazione delle relazioni tra profili." + securityBanner: "Puoi gestire la sicurezza del tuo account, la password, i modi di accesso, la generazione di codici OTP per accesso multi fattore (MFA/2FA) e la passkey." + preferencesBanner: "Puoi personalizzare il comportamento del tuo dispositivo." + appearanceBanner: "Puoi personalizzare l'aspetto nel dispositivo, in base alle tue preferenze." + soundsBanner: "Puoi personalizzare i suoni emessi dagli eventi sul tuo dispositivo." + timelineAndNote: "Note e Timeline" + makeEveryTextElementsSelectable: "Imposta ogni elemento come selezionabile" + makeEveryTextElementsSelectable_description: "Potrebbe ridurre l'usabilità in alcune situazioni." + showNavbarSubButtons: "Mostra i pulsanti secondari nella barra di navigazione" + ifOn: "Quando attivato" + ifOff: "Quando disattivato" + _chat: + showSenderName: "Mostra il nome del mittente" + sendOnEnter: "Invio spedisce" +_preferencesProfile: + profileName: "Nome del profilo" + profileNameDescription: "Impostare il nome che indentifica questo dispositivo." + profileNameDescription2: "Es: \"PC principale\" o \"Cellulare\"" +_preferencesBackup: + autoBackup: "Backup automatico" + restoreFromBackup: "Ripristinare da backup" + noBackupsFoundTitle: "Nessun backup trovato" + noBackupsFoundDescription: "Impossibile trovare un backup creato automaticamente. Se se hai salvato il file di backup manualmente, puoi importarlo e ripristinarlo." + selectBackupToRestore: "Seleziona un backup da ripristinare" + 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" +_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." + requireSigninToViewContentsDescription2: "La visualizzazione verrà disabilitata a server che non supportano l'anteprima URL (OGP), all'incorporamento nelle pagine Web e alla citazione delle Note." + requireSigninToViewContentsDescription3: "Queste restrizioni potrebbero non applicarsi al contenuto federato su server remoti." + makeNotesFollowersOnlyBefore: "Rendi visibili solo ai Follower le Note pubblicate in precedenza" + makeNotesFollowersOnlyBeforeDescription: "Mentre questa funzione è abilitata, le Note antecedenti al momento impostato, saranno visibili solo ai profili Follower. Disabilitandola nuovamente, verrà ripristinata anche la visibilità pubblica della Nota." + makeNotesHiddenBefore: "Nascondi le Note pubblicate in precedenza" + makeNotesHiddenBeforeDescription: "Mentre questa funzione è abilitata, le Note antecedenti al momento impostato, saranno visibili soltanto a te (private). Disabilitandola nuovamente, verrà ripristinata anche la visibilità pubblica della Nota." + mayNotEffectForFederatedNotes: "Le Note già federate su server remoti potrebbero non essere modificate." + mayNotEffectSomeSituations: "Queste restrizioni sono semplificate. In alcuni casi, potrebbero anche non avvenire. Ad esempio visionando un server remoto o durante la moderazione." + notesHavePassedSpecifiedPeriod: "Note antecedenti al periodo specificato" + notesOlderThanSpecifiedDateAndTime: "Note antecedenti al momento specificato" +_abuseUserReport: + forward: "Inoltra" + forwardDescription: "Inoltra il report al server remoto, per mezzo di account di sistema, anonimo." + resolve: "Risolvi" + accept: "Approva" + reject: "Rifiuta" + resolveTutorial: "Se moderi una segnalazione legittima, scegli \"Approva\" per risolvere positivamente.\nSe la segnalazione non è legittima, seleziona \"Rifiuta\" per risolvere negativamente." _delivery: status: "Stato della consegna" stop: "Sospensione" @@ -1277,16 +1472,16 @@ _bubbleGame: _announcement: forExistingUsers: "Solo ai profili attuali" forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio." - needConfirmationToRead: "Richiede la conferma di lettura" - needConfirmationToReadDescription: "Sarà visualizzata una finestra di dialogo che richiede la conferma di lettura. Inoltre, non è soggetto a conferme di lettura massicce." + needConfirmationToRead: "Conferma di lettura obbligatoria" + needConfirmationToReadDescription: "I profili riceveranno una finestra di dialogo che richiede di accettare obbligatoriamente per procedere. Tale richiesta è esente da \"conferma tutte\"." end: "Archivia l'annuncio" tooManyActiveAnnouncementDescription: "L'esperienza delle persone può peggiorare se ci sono troppi annunci attivi. Considera anche l'archiviazione degli annunci conclusi." readConfirmTitle: "Segnare come già letto?" readConfirmText: "Hai già letto \"{title}˝?" shouldNotBeUsedToPresentPermanentInfo: "Ti consigliamo di utilizzare gli annunci per pubblicare informazioni tempestive e limitate nel tempo, anziché informazioni importanti a lungo andare nel tempo, poiché potrebbero risultare difficili da ritrovare e peggiorare la fruibilità del servizio, specialmente alle nuove persone iscritte." dialogAnnouncementUxWarn: "Ti consigliamo di usarli con cautela, poiché è molto probabile che avere più di un annuncio in stile \"finestra di dialogo\" peggiori sensibilmente la fruibilità del servizio, specialmente alle nuove persone iscritte." - silence: "Silenziare gli annunci" - silenceDescription: "Se attivi questa opzione, non riceverai notifiche sugli annunci, evitando di contrassegnarle come già lette." + silence: "Annuncio silenzioso" + silenceDescription: "Attivando questa opzione, non invierai la notifica, evitando che debba essere contrassegnata come già letta." _initialAccountSetting: accountCreated: "Il tuo profilo è stato creato!" letsStartAccountSetup: "Per iniziare, impostiamo il tuo profilo." @@ -1304,7 +1499,7 @@ _initialAccountSetting: skipAreYouSure: "Vuoi davvero saltare la configurazione iniziale?" laterAreYouSure: "Vuoi davvero rimandare la configurazione iniziale?" _initialTutorial: - launchTutorial: "Guarda il tutorial" + launchTutorial: "Inizia il tutorial" title: "Tutorial" wellDone: "Ottimo lavoro!" skipAreYouSure: "Vuoi davvero interrompere il tutorial?" @@ -1314,13 +1509,13 @@ _initialTutorial: _note: title: "Cosa sono le Note?" description: "Gli status su Misskey sono chiamati \"Note\". Le Note sono elencate in ordine cronologico nelle timeline e vengono aggiornate in tempo reale." - reply: "Puoi rispondere alle Note. Puoi anche rispondere alle risposte e continuare i dialoghi come un conversazioni." - renote: "Puoi ri-condividere le Note, facendole rifluire sulla Timeline. Puoi anche aggiungere testo e citare altri profili." - reaction: "Puoi aggiungere una reazione. Nella pagina successiva spiegheremo i dettagli." - menu: "Puoi svolgere varie attività, come visualizzare i dettagli delle Note o copiare i collegamenti." + reply: "Puoi rispondere alle Note, alle altre risposte e dialogare in conversazioni." + renote: "Puoi ri-condividere le Note, ritorneranno sulla Timeline. Aggiungendo del testo, scriverai una Citazione." + reaction: "Puoi aggiungere una reazione. Nella pagina successiva ti spiego come." + menu: "Per altre attività, ad esempio, vedere i dettagli delle Note o copiare i collegamenti." _reaction: title: "Cosa sono le Reazioni?" - description: "Puoi reagire alle Note. Le sensazioni che non si riescono a trasmettere con i \"Mi piace\" si possono esprimere facilmente inviando una reazione." + description: "Reazioni alle Note. Le sensazioni che non si possono descrivere con \"Mi piace\" si esprimono facilmente con le reazioni." letsTryReacting: "Puoi aggiungere una Reazione cliccando il bottone \"+\" (più) della relativa Nota. Prova ad aggiungerne una a questa Nota di esempio!" reactToContinue: "Aggiungere la Reazione ti consentirà di procedere col tutorial." reactNotification: "Quando qualcuno reagisce alle tue Note, ricevi una notifica in tempo reale." @@ -1328,12 +1523,12 @@ _initialTutorial: _timeline: title: "Come funziona la Timeline" description1: "Misskey fornisce alcune Timeline (sequenze cronologiche di Note). Una di queste potrebbe essere stata disattivata dagli amministratori." - home: "le Note provenienti dai profili che segui (follow)." + home: "le Note provenienti dai profili che segui (Following)." local: "tutte le Note pubblicate dai profili di questa istanza." social: "sia le Note della Timeline Home che quelle della Timeline Locale, insieme!" global: "le Note da pubblicate da tutte le altre istanze federate con la nostra." description2: "Nella parte superiore dello schermo, puoi scegliere una Timeline o l'altra in qualsiasi momento." - description3: "Ci sono anche sequenze temporali di elenchi, sequenze temporali di canali, ecc. Per ulteriori dettagli, consultare il {link}.\nPuoi vedere anche Timeline delle liste di profili (se ne hai create), canali, ecc... Per i dettagli, visita {link}." + description3: "Ci sono anche sequenze temporali di elenchi, sequenze temporali di canali, ecc. Per ulteriori dettagli, consultare la {link}.\nPuoi vedere anche Timeline delle liste di profili (se ne hai create), canali, ecc... Per i dettagli, c'è la {link}." _postNote: title: "La Nota e le sue impostazioni" description1: "Quando scrivi una Nota su Misskey, hai a disposizione varie opzioni. Il modulo di invio è simile a questo." @@ -1366,10 +1561,10 @@ _initialTutorial: title: "Il tutorial è finito! 🎉" description: "Queste sono solamente alcune delle funzionalità principali di Misskey. Per ulteriori informazioni, {link}." _timelineDescription: - home: "Nella Timeline Home, la tua cronologia principale, puoi vedere le Note provenienti dai profili che segui (follow)." - local: "La Timeline Locale, è una cronologia di Note pubblicate da tutti i profili iscritti su questo server." - social: "La Timeline Sociale, unisce in ordine cronologico l'elenco di Note presenti nella Timeline Home e quella Locale." - global: "La Timeline Federata ti consente di vedere le Note pubblicate dai profili di tutti gli altri server federati a questo." + home: "Nella Timeline Home, la tua cronologia principale, puoi vedere le Note provenienti dai profili che segui (Following)." + local: "La Timeline Locale è un flusso di Note pubblicate dai profili iscritti a questo server." + social: "La Timeline Sociale elenca, in ordine cronologico, il flusso di Note nella Timeline Home e Locale." + global: "Nella Timeline Federata trovi il flusso di Note provenienti da profili iscritti ad altri server, federati a questo." _serverRules: description: "In Europa è necessario mostrare l'informativa sul trattamento dei dati personali, prima della registrazione al servizio." _serverSettings: @@ -1384,13 +1579,17 @@ _serverSettings: fanoutTimelineDescription: "Attivando questa funzionalità migliori notevolmente la capacità delle Timeline di collezionare Note, riducendo il carico sul database. Tuttavia, aumenterà l'impiego di memoria RAM per Redis. Disattiva se il tuo server ha poca RAM o la funzionalità è irregolare." fanoutTimelineDbFallback: "Elaborazione dati alternativa" fanoutTimelineDbFallbackDescription: "Attivando l'elaborazione alternativa, verrà interrogato ulteriormente il database se la timeline non è nella cache. \nDisattivando, si può ridurre ulteriormente il carico del server, evitando l'elaborazione alternativa, ma limitando l'intervallo recuperabile delle timeline." + reactionsBufferingDescription: "Attivando questa opzione, puoi migliorare significativamente le prestazioni durante la creazione delle reazioni e ridurre il carico sul database. Tuttavia, aumenterà l'impiego di memoria Redis." inquiryUrl: "URL di contatto" inquiryUrlDescription: "Specificare l'URL al modulo di contatto, oppure le informazioni con i dati di contatto dell'amministrazione." + openRegistration: "Registrazioni aperte" + openRegistrationWarning: "L’apertura della registrazione comporta dei rischi. Ti consigliamo di attivarla solo se hai predisposto il monitoraggio continuo del tuo server e puoi rispondere immediatamente se si verifica un problema." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Per prevenire SPAM, questa impostazione verrà disattivata automaticamente, se non si rileva alcuna attività di moderazione durante un certo periodo di tempo." _accountMigration: moveFrom: "Migra un altro profilo dentro a questo" moveFromSub: "Crea un alias verso un altro profilo remoto" - moveFromLabel: "Profilo da cui migrare #{n}" - moveFromDescription: "Se desideri spostare i profili follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@istanza.it" + moveFromLabel: "Profilo da cui migrare n. {n}" + moveFromDescription: "Se desideri spostare i Follower da un altro profilo a questo, devi prima creare un alias qui. Assicurati averlo creato PRIMA di eseguire l'attività! Inserisci l'indirizzo del profilo mittente in questo modo: @persona@vecchia.istanza.it" moveTo: "Migrare questo profilo verso un un altro" moveToLabel: "Profilo verso cui migrare" moveCannotBeUndone: "La migrazione è irreversibile, non può essere interrotta o annullata." @@ -1399,7 +1598,7 @@ _accountMigration: startMigration: "Avvia la migrazione" migrationConfirm: "Vuoi davvero migrare questo profilo su {account}? L'azione è irreversibile e non potrai più utilizzare questo profilo nel suo stato originale.\nInoltre, assicurati di aver già creato un alias sull'account a cui ti stai trasferendo." movedAndCannotBeUndone: "Il tuo profilo è stato migrato.\nLa migrazione non può essere annullata." - postMigrationNote: "Questo profilo smetterà di seguire gli altri profili remoti a 24 ore dal termine della migrazione.\nSia i Follow che i Follower scenderanno a zero. I tuoi follower saranno comunque in grado di vedere le Note per soli follower, poiché non smetteranno di seguirti." + postMigrationNote: "Questo profilo smetterà di seguire gli altri profili remoti a 24 ore dal termine della migrazione.\nSia i Following che i Follower scenderanno a zero. I tuoi Follower saranno comunque in grado di vedere le Note per soli Follower, poiché non smetteranno di seguirti." movedTo: "Profilo verso cui migrare" _achievements: earnedAt: "Data di conseguimento" @@ -1466,13 +1665,13 @@ _achievements: title: "Principiante III" description: "Hai totalizzato 15 accessi!" _login30: - title: "Misskist I" + title: "Missalcolista I" description: "Hai totalizzato 30 accessi!" _login60: - title: "Misskeist II" + title: "Missalcolista II" description: "Hai totalizzato 60 accessi!" _login100: - title: "Misskeist III" + title: "Missalcolista III" description: "Hai totalizzato 100 accessi!" flavor: "Violent Misskeist" _login200: @@ -1558,10 +1757,10 @@ _achievements: description: "Hai superato i 1.000 profili Follower" _collectAchievements30: title: "Collezionista di successi" - description: "Hai raggiunto 30 obiettivi" + description: "Hai raggiunto 30 conquiste" _viewAchievements3min: title: "Mi piacciono i risultati" - description: "Guarda la tua collezione di obiettivi per almeno 3 minuti" + description: "Ammira la tua collezione di conquiste per almeno 3 minuti" _iLoveMisskey: title: "I LOVE Misskey" description: "Pubblica «I ♥ #Misskey»" @@ -1717,6 +1916,12 @@ _role: canSearchNotes: "Ricercare nelle Note" canUseTranslator: "Tradurre le Note" avatarDecorationLimit: "Numero massimo di decorazioni foto profilo installabili" + canImportAntennas: "Può importare Antenne" + canImportBlocking: "Può importare Blocchi" + canImportFollowing: "Può importare Following" + canImportMuting: "Può importare Silenziati" + canImportUserLists: "Può importare liste di Profili" + canChat: "Chat consentita" _condition: roleAssignedTo: "Assegnato a ruoli manualmente" isLocal: "Profilo locale" @@ -1787,7 +1992,7 @@ _gallery: unlike: "Non mi piace più" _email: _follow: - title: "Adesso ti segue" + title: "Follower aggiuntivo" _receiveFollowRequest: title: "Hai ricevuto una richiesta di follow" _plugin: @@ -1821,7 +2026,7 @@ _registry: domain: "Dominio" createKey: "Crea chiave" _aboutMisskey: - about: "Misskey è un software libero e open source, sviluppato da syuilo dal 2014." + about: "Misskey è software libero, open source, sviluppato da Syuilo fin dal lontano 2014." contributors: "Principali sostenitori" allContributors: "Tutti i sostenitori" source: "Codice sorgente" @@ -1846,12 +2051,12 @@ _serverDisconnectedBehavior: quiet: "Visualizza avviso in modo discreto" _channel: create: "Nuovo canale" - edit: "Gerisci canale" + edit: "Modifica il canale" setBanner: "Scegli intestazione" removeBanner: "Rimuovi intestazione" - featured: "Di tendenza" + featured: "Popolari nel canale" owned: "I miei canali" - following: "Seguiti" + following: "Following" usersCount: "{n} partecipanti" notesCount: "{n} note" nameAndDescription: "Nome e descrizione" @@ -1874,12 +2079,13 @@ _instanceMute: _theme: explore: "Esplora temi" install: "Installa un tema" - manage: "Gestione temi" + manage: "Gestione dei temi" code: "Codice tema" description: "Descrizione" installed: "{name} è installato" installedThemes: "Temi installati" builtinThemes: "Temi integrati" + instanceTheme: "Tema dell'istanza" alreadyInstalled: "Questo tema è già installato" invalid: "Il formato tema non è valido" make: "Crea un tema" @@ -1934,7 +2140,6 @@ _theme: buttonBg: "Sfondo del pulsante" buttonHoverBg: "Sfondo del pulsante (sorvolato)" inputBorder: "Inquadra casella di testo" - listItemHoverBg: "Sfondo della voce di elenco (sorvolato)" driveFolderBg: "Sfondo della cartella di disco" wallpaperOverlay: "Sovrapposizione dello sfondo" badge: "Distintivo" @@ -1947,6 +2152,7 @@ _sfx: noteMy: "Mia nota" notification: "Notifiche" reaction: "Quando seleziono una reazione" + chatMessage: "Messaggio di chat" _soundSettings: driveFile: "Suoni del Drive" driveFileWarn: "Seleziona file dal dispositivo" @@ -1954,6 +2160,7 @@ _soundSettings: driveFileTypeWarnDescription: "Per favore, scegli un file di tipo audio" driveFileDurationWarn: "La durata dell'audio è troppo lunga" driveFileDurationWarnDescription: "Scegliere un audio lungo potrebbe interferire con l'uso di Misskey. Vuoi continuare lo stesso?" + driveFileError: "Impossibile caricare l'audio. Si prega di modificare le impostazioni" _ago: future: "Futuro" justNow: "Adesso" @@ -2017,16 +2224,16 @@ _permissions: "read:favorites": "Visualizza i tuoi preferiti" "write:favorites": "Gestisci i tuoi preferiti" "read:following": "Vedi le informazioni di follow" - "write:following": "Following di altri profili" + "write:following": "Aggiungere e togliere Following" "read:messaging": "Visualizzare la chat" "write:messaging": "Gestire la chat" "read:mutes": "Vedi i profili silenziati" - "write:mutes": "Gestisci i profili silenziati" + "write:mutes": "Gestione dei profili silenziati" "write:notes": "Creare / Eliminare note" "read:notifications": "Visualizzare notifiche" - "write:notifications": "Gestire notifiche" + "write:notifications": "Gestione delle notifiche" "read:reactions": "Vedi reazioni" - "write:reactions": "Gerisci reazioni" + "write:reactions": "Gestione delle reazioni" "write:votes": "Votare" "read:pages": "Visualizzare pagine" "write:pages": "Gestire pagine" @@ -2035,7 +2242,7 @@ _permissions: "read:user-groups": "Vedere i gruppi di utenti" "write:user-groups": "Gestire i gruppi di utenti" "read:channels": "Visualizza canali" - "write:channels": "Gerisci canali" + "write:channels": "Gestione dei canali" "read:gallery": "Visualizza la galleria." "write:gallery": "Gestione della galleria" "read:gallery-likes": "Visualizza i contenuti della galleria." @@ -2092,6 +2299,8 @@ _permissions: "read:clip-favorite": "Vedere Clip preferite" "read:federation": "Vedere la federazione" "write:report-abuse": "Inviare segnalazioni" + "write:chat": "Gestire la chat" + "read:chat": "Visualizzare le chat" _auth: shareAccessTitle: "Permessi dell'applicazione" shareAccess: "Vuoi autorizzare {name} ad accedere al tuo profilo?" @@ -2100,11 +2309,14 @@ _auth: permissionAsk: "Questa app richiede le seguenti autorizzazioni:" pleaseGoBack: "Si prega di ritornare sulla app" callback: "Ritornando sulla app" + accepted: "Accesso concesso" denied: "Accesso negato" + 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" _antennaSources: all: "Tutte le note" - homeTimeline: "Note dagli utenti che segui" + homeTimeline: "Note dai tuoi Following" users: "Note dagli utenti selezionati" userList: "Note dagli utenti della lista selezionata" userBlacklist: "Tutte le Note tranne quelle di uno o più profili specificati" @@ -2123,7 +2335,7 @@ _widgets: notifications: "Notifiche" timeline: "Timeline" calendar: "Calendario" - trends: "Di tendenza" + trends: "Hashtag popolari" clock: "Orologio" rss: "Lettura RSS" rssTicker: "Nastro RSS" @@ -2145,8 +2357,8 @@ _widgets: userList: "Elenco utenti" _userList: chooseList: "Seleziona una lista" - clicker: "Cliccaggio" - birthdayFollowings: "Chi nacque oggi" + clicker: "Cliccheria" + birthdayFollowings: "Compleanni del giorno" _cw: hide: "Nascondere" show: "Continua la lettura..." @@ -2208,13 +2420,16 @@ _profile: metadataContent: "Contenuto" changeAvatar: "Modifica immagine profilo" changeBanner: "Cambia intestazione" - verifiedLinkDescription: "Puoi verificare il tuo profilo mostrando una icona. Devi inserire la URL alla pagina che contiene un link al tuo profilo." + verifiedLinkDescription: "Puoi verificare il tuo profilo mostrando una icona. Devi inserire la URL alla pagina che contiene un link al tuo profilo.\nPer verificare il profilo tramite la spunta di conferma, devi inserire la url alla pagina che contiene un link al tuo profilo Misskey. Deve avere attributo rel='me'." avatarDecorationMax: "Puoi aggiungere fino a {max} decorazioni." + followedMessage: "Messaggio, quando qualcuno ti segue" + followedMessageDescription: "Puoi impostare un breve messaggio da mostrare agli altri profili quando ti seguono." + followedMessageDescriptionForLockedAccount: "Quando approvi una richiesta di follow, verrà visualizzato questo testo." _exportOrImport: allNotes: "Tutte le note" favoritedNotes: "Note preferite" clips: "Clip" - followingList: "Follow" + followingList: "Following" muteList: "Elenco profili silenziati" blockingList: "Elenco profili bloccati" userLists: "Liste" @@ -2272,9 +2487,6 @@ _pages: newPage: "Crea pagina" editPage: "Modifica pagina" readPage: "Visualizzando fonte " - created: "Pagina creata!" - updated: "Pagina aggiornata con successo!" - deleted: "Pagina eliminata" pageSetting: "Impostazioni pagina" nameAlreadyExists: "Esiste già una pagina con lo stesso URL." invalidNameTitle: "L'URL di pagina definito non è valido" @@ -2302,6 +2514,7 @@ _pages: eyeCatchingImageSet: "Imposta un'immagine attraente" eyeCatchingImageRemove: "Elimina immagine attraente" chooseBlock: "Aggiungi blocco" + enterSectionTitle: "Inserisci il titolo della sezione" selectType: "Seleziona tipo" contentBlocks: "Contenuto" inputBlocks: "Blocchi di input" @@ -2329,13 +2542,14 @@ _notification: youGotReply: "{name} ti ha risposto" youGotQuote: "{name} ha citato la tua Nota e ha detto" youRenoted: "{name} ha rinotato" - youWereFollowed: "Adesso ti segue" + youWereFollowed: "Follower aggiuntivo" youReceivedFollowRequest: "Hai ricevuto una richiesta di follow" yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata" pollEnded: "Risultati del sondaggio." newNote: "Nuove Note" unreadAntennaNote: "Antenna {name}" roleAssigned: "Ruolo assegnato" + chatRoomInvitationReceived: "Invito in una stanza di chat" emptyPushNotificationMessage: "Le notifiche push sono state aggiornate." achievementEarned: "Obiettivo raggiunto" testNotification: "Provare la notifica" @@ -2347,23 +2561,32 @@ _notification: renotedBySomeUsers: "{n} Rinota" followedBySomeUsers: "{n} follower" flushNotification: "Azzera le notifiche" + exportOfXCompleted: "Abbiamo completato l'esportazione di {x}" + login: "Autenticazione avvenuta" + createToken: "È stato creato un token di accesso" + createTokenDescription: "In caso contrario, eliminare il token di accesso tramite ({text})." _types: all: "Tutto" note: "Nuove Note" - follow: "Nuovi profili follower" + follow: "Follower" mention: "Menzioni" reply: "Risposte" renote: "Rinota" quote: "Cita" reaction: "Reazioni" pollEnded: "Sondaggio chiuso." - receiveFollowRequest: "Richiesta di follow ricevuta" - followRequestAccepted: "Richiesta di follow accettata" + receiveFollowRequest: "Richieste di follow in arrivo" + followRequestAccepted: "Richieste di follow accettate" roleAssigned: "Ruolo concesso" + chatRoomInvitationReceived: "Invito in una stanza di chat" achievementEarned: "Risultato raggiunto" + exportCompleted: "Esportazione completata" + login: "Accessi" + createToken: "Creare un token di accesso" + test: "Notifiche di test" app: "Notifiche da applicazioni" _actions: - followBack: "Segui" + followBack: "Following ricambiato" reply: "Rispondi" renote: "Rinota" _deck: @@ -2387,6 +2610,7 @@ _deck: 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" _columns: main: "Principale" widgets: "Riquadri" @@ -2405,16 +2629,17 @@ _disabledTimeline: title: "Timeline disabilitata" description: "Il ruolo in cui sei non ti permette di leggere questa timeline" _drivecleaner: - orderBySizeDesc: "Dal più grande al più piccolo" - orderByCreatedAtAsc: "Dal più vecchio al più recente" + orderBySizeDesc: "Dal file più grosso al più piccolo" + orderByCreatedAtAsc: "Dal file più vecchio al più recente" _webhookSettings: createWebhook: "Creazione Webhook" modifyWebhook: "Modifica Webhook" name: "Nome" secret: "Segreto" + trigger: "Trigger" active: "Attivo" _events: - follow: "Quando segui un profilo" + follow: "Quando aggiungi Following" followed: "Quando ti segue un profilo" note: "Quando pubblichi una Nota" reply: "Quando rispondono ad una Nota" @@ -2424,7 +2649,11 @@ _webhookSettings: _systemEvents: abuseReport: "Quando arriva una segnalazione" abuseReportResolved: "Quando una segnalazione è risolta" + userCreated: "Quando viene creato un profilo" + inactiveModeratorsWarning: "Quando un profilo moderatore rimane inattivo per un determinato periodo" + inactiveModeratorsInvitationOnlyChanged: "Quando la moderazione è rimasta inattiva per un determinato periodo e il sistema è cambiato in modalità \"solo inviti\"" deleteConfirm: "Vuoi davvero eliminare il Webhook?" + testRemarks: "Clicca il bottone a destra dell'interruttore, per provare l'invio di un webhook con dati fittizi." _abuseReport: _notificationRecipient: createRecipient: "Aggiungi destinatario della segnalazione" @@ -2468,6 +2697,8 @@ _moderationLogTypes: 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" @@ -2483,6 +2714,12 @@ _moderationLogTypes: 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" _fileViewer: title: "Dettagli del file" type: "Tipo di file" @@ -2496,10 +2733,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Prima di installare, assicurati che la fonte sia affidabile." _plugin: title: "Vuoi davvero installare questo componente aggiuntivo?" - metaTitle: "Informazioni sul componente aggiuntivo" _theme: title: "Vuoi davvero installare questa variazione grafica?" - metaTitle: "Informazioni sulla variazione grafica" _meta: base: "Combinazione base di colori" _vendorInfo: @@ -2614,3 +2849,140 @@ _mediaControls: pip: "Sovraimpressione" playbackRate: "Velocità di riproduzione" loop: "Ripetizione infinita" +_contextMenu: + title: "Menu contestuale" + app: "Applicazione" + appWithShift: "Applicazione Shift+Tasto" + native: "Interfaccia utente del browser" +_gridComponent: + _error: + requiredValue: "Campo obbligatorio" + columnTypeNotSupport: "Solo le colonne type:text permettono la convalida delle Espresioni Regolari" + patternNotMatch: "Il valore non coincide con {pattern}" + notUnique: "Il valore deve essere univoco" +_roleSelectDialog: + notSelected: "Niente selezioato" +_customEmojisManager: + _gridCommon: + copySelectionRows: "Copia le righe selezionate" + copySelectionRanges: "Copia l'intervallo selezionato" + deleteSelectionRows: "Elimina le righe selezionate" + deleteSelectionRanges: "Elimina le righe nell'intervallo selezionato" + searchSettings: "Impostazioni di ricerca" + searchSettingCaption: "Imposta condizioni di ricerca dettagliate." + searchLimit: "Risultati visualizzati" + sortOrder: "Ordine" + registrationLogs: "Storico della registrazione" + registrationLogsCaption: "Lo storico verrà visualizzato in base alla attività sulle emoji. Scompare quando si esegue un'operazione di aggiornamento/eliminazione o si modifica/ricarica la pagina." + alertEmojisRegisterFailedDescription: "Attenzione, è impossibile modificare la emoji. Si prega di controllare lo storico per ulteriori dettagli." + _logs: + showSuccessLogSwitch: "Mostra le azioni a buon fine" + failureLogNothing: "Non ci sono errori nello storico delle emoji" + logNothing: "Lo storico è vuoto." + _remote: + selectionRowDetail: "Dettagli della riga selezionata" + importSelectionRows: "Importa le righe selezionate" + importSelectionRangesRows: "Importa le righe nell'intervallo selezionato" + importEmojisButton: "Importa le emoji selezionate" + confirmImportEmojisTitle: "Importazione emoji" + confirmImportEmojisDescription: "Importazione di {count} emoji ricevute da remoto. Si prega di prestare molta attenzione al tipo di licenza delle emoji. Vuoi confermare?" + _local: + tabTitleList: "Elenco delle emoji registrate" + tabTitleRegister: "Registrazione emoji" + _list: + emojisNothing: "Non ci sono emoji registrate." + markAsDeleteTargetRows: "Selezionare le righe come eliminabili" + markAsDeleteTargetRanges: "Selezionare le righe nell'intervallo come eliminabili" + alertUpdateEmojisNothingDescription: "Non ci sono emoji aggiornate." + alertDeleteEmojisNothingDescription: "Non ci sono emoji da eliminare." + confirmMovePage: "Vuoi davvero spostare la pagina?" + confirmChangeView: "Vuoi davvero cambiare la vista?" + confirmUpdateEmojisDescription: "Aggiornamento di {count} emoji. Vuoi davvero continuare?" + confirmDeleteEmojisDescription: "Eliminazione delle {count} emoji selezionate. Vuoi davvero continuare?" + confirmResetDescription: "Verranno ripristinate tutte le modifiche apportate finora." + confirmMovePageDesciption: "Sono state modificate le emoji in questa pagina.\nUscendo senza salvare, tutte le modifiche verranno ignorate." + dialogSelectRoleTitle: "Cerca emoji per ruolo" + _register: + uploadSettingTitle: "Caricamento impostazioni" + uploadSettingDescription: "Questa schermata ti permette di scegliere il comportamento durante il caricamento delle emoji." + directoryToCategoryLabel: "Inseriscile in una cartella omonima alla categoria" + directoryToCategoryCaption: "Crea il campo categoria in base alla cartella." + emojiInputAreaCaption: "Seleziona l'emoji da registrare utilizzando uno dei metodi." + emojiInputAreaList1: "Trascina una immagine o una cartella in quest'area" + emojiInputAreaList2: "Clicca per scegliere file dal tuo dispositivo" + emojiInputAreaList3: "Clicca per selezionare dal Drive" + confirmRegisterEmojisDescription: "Registrazione delle emoji elencate come nuove emoji personalizzate. Vuoi davvero procedere? (Per evitare sovraccarichi, puoi registrare al massimo {count} emoji per volta)" + 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" + header: "Mostra la testata" + autoload: "Carica automaticamente di più (sconsigliato)" + maxHeight: "Altezza massima" + maxHeightDescription: "Specifica un valore per evitare che continui a crescere verticalmente. Il valore 0 disabilita il limite d'altezza." + maxHeightWarn: "L'altezza massima è disabilitata (0). Se l'effetto è indesiderato, prova a impostare l'altezza massima a un valore specifico." + 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" + codeGenerated: "Codice generato" + codeGeneratedDescription: "Incolla il codice appena generato sul tuo sito web." +_selfXssPrevention: + warning: "Avviso" + title: "\"Incolla qualcosa su questa schermata\" è tutta una truffa." + description1: "Incollando qualcosa qui, malintenzionati potrebbero prendere il controllo del tuo profilo o rubare i tuoi dati personali." + description2: "Se non sai esattamente cosa stai facendo, %c smetti subito e chiudi questa finestra." + description3: "Per favore, controlla questo collegamento per avere maggiori dettagli. {link}" +_followRequest: + recieved: "Richieste in ingresso" + sent: "Richieste in uscita" +_remoteLookupErrors: + _federationNotAllowed: + title: "Server irraggiungibile" + description: "La comunicazione con questo server potrebbe essere disattivata. Hai bloccato il server? Oppure potrebbero averlo bloccato gli amministratori. Contattali per ulteriori informazioni." + _uriInvalid: + title: "URL non valido" + description: "Controlla che l'indirizzo sia valido e sia privo di caratteri non validi." + _requestFailed: + title: "Richiesta fallita" + description: "La comunicazione col server non è riuscita. Potrebbe essere inattivo. Assicurati anche che la URL sia valida." + _responseInvalid: + title: "Risposta non valida" + description: "La comunicazione col server è andata a buon fine, ma abbiamo ricevuto dati non validi." + _noSuchObject: + title: "Non trovato" + description: "La risorsa richiesta non è stata trovata. Verificare nuovamente la URL." +_captcha: + verify: "Per favore, controlla la verifica CAPTCHA" + testSiteKeyMessage: "Puoi provare l'anteprima inserendo valori di test, sia per la chiave del sito che per la chiave segreta.\nSi prega di controllare la pagina qui sotto per i dettagli." + _error: + _requestFailed: + title: "Errore durante la richiesta del CAPTCHA" + text: "Riprova più tardi o controlla nuovamente le impostazioni." + _verificationFailed: + title: "Convalida CAPTCHA non riuscita" + text: "Si prega di verificare nuovamente se le impostazioni sono corrette." + _unknown: + title: "Errore CAPTCHA" + text: "Si è verificato un errore imprevisto." +_bootErrors: + title: "Caricamento non riuscito" + serverError: "Dopo una breve attesa, e dopo aver ricaricato la pagina, se il problema persiste, contatta l'amministrazione comunicando il seguente ID di errore." + solution: "Di seguito, alcune probabili soluzioni al problema." + solution1: "Aggiornare browser e il sistema operativo all'ultima versione" + solution2: "Disattivare gli adblocker" + solution3: "Cancellare la cache del browser" + solution4: "(Per chi utilizza il Browser Tor) Impostare dom.webaudio.enabled = vero" + otherOption: "Altre opzioni" + otherOption1: "Nelle impostazioni, cancellare le impostazioni del client e svuotare la cache" + otherOption2: "Avviare il client predefinito" + otherOption3: "Avviare lo strumento di riparazione" +_search: + searchScopeAll: "Tutte" + searchScopeLocal: "Locale" + searchScopeServer: "Specifiche del server" + searchScopeUser: "Profilo specifico" + pleaseEnterServerHost: "Inserire il nome host" + pleaseSelectUser: "Per favore, seleziona un profilo" + serverHostPlaceholder: "Es: misskey.example.com" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8e48508e78..0e20001d6b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -5,9 +5,13 @@ introMisskey: "ようこそ!Misskeyは、オープンソースの分散型マ poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyのサーバーのひとつです。" monthAndDay: "{month}月 {day}日" search: "検索" +reset: "リセット" notifications: "通知" username: "ユーザー名" password: "パスワード" +initialPasswordForSetup: "初期設定開始用パスワード" +initialPasswordIsIncorrect: "初期設定開始用のパスワードが違います。" +initialPasswordForSetupDescription: "Misskeyを自分でインストールした場合は、設定ファイルに入力したパスワードを使用してください。\nMisskeyのホスティングサービスなどを使用している場合は、提供されたパスワードを使用してください。\nパスワードを設定していない場合は、空欄にしたまま続行してください。" forgotPassword: "パスワードを忘れた" fetchingAsApObject: "連合に照会中" ok: "OK" @@ -45,6 +49,7 @@ pin: "ピン留め" unpin: "ピン留め解除" copyContent: "内容をコピー" copyLink: "リンクをコピー" +copyRemoteLink: "リモートのリンクをコピー" copyLinkRenote: "リノートのリンクをコピー" delete: "削除" deleteAndEdit: "削除して編集" @@ -236,6 +241,8 @@ silencedInstances: "サイレンスしたサーバー" silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定します。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになります。ブロックしたインスタンスには影響しません。" mediaSilencedInstances: "メディアサイレンスしたサーバー" mediaSilencedInstancesDescription: "メディアサイレンスしたいサーバーのホストを改行で区切って設定します。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われ、カスタム絵文字が使用できないようになります。ブロックしたインスタンスには影響しません。" +federationAllowedHosts: "連合を許可するサーバー" +federationAllowedHostsDescription: "連合を許可するサーバーのホストを改行で区切って設定します。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしたユーザー" blockedUsers: "ブロックしたユーザー" @@ -282,7 +289,6 @@ deleteAreYouSure: "「{x}」を削除しますか?" resetAreYouSure: "リセットしますか?" areYouSure: "よろしいですか?" saved: "保存しました" -messaging: "チャット" upload: "アップロード" keepOriginalUploading: "オリジナル画像を保持" keepOriginalUploadingDescription: "画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。" @@ -295,7 +301,7 @@ uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がか explore: "みつける" messageRead: "既読" noMoreHistory: "これより過去の履歴はありません" -startMessaging: "チャットを開始" +startChat: "チャットを始める" nUsersRead: "{n}人が読みました" agreeTo: "{0}に同意" agree: "同意する" @@ -334,6 +340,7 @@ renameFolder: "フォルダー名を変更" deleteFolder: "フォルダーを削除" folder: "フォルダー" addFile: "ファイルを追加" +showFile: "ファイルを表示" emptyDrive: "ドライブは空です" emptyFolder: "フォルダーは空です" unableToDelete: "削除できません" @@ -376,7 +383,6 @@ enableLocalTimeline: "ローカルタイムラインを有効にする" enableGlobalTimeline: "グローバルタイムラインを有効にする" disablingTimelinesInfo: "これらのタイムラインを無効化しても、利便性のため管理者およびモデレーターは引き続き利用することができます。" registration: "登録" -enableRegistration: "誰でも新規登録できるようにする" invite: "招待" driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量" driveCapacityPerRemoteAccount: "リモートユーザーひとりあたりのドライブ容量" @@ -448,6 +454,7 @@ totpDescription: "認証アプリを使ってワンタイムパスワードを moderator: "モデレーター" moderation: "モデレーション" moderationNote: "モデレーションノート" +moderationNoteDescription: "モデレーター間でだけ共有されるメモを記入することができます。" addModerationNote: "モデレーションノートを追加する" moderationLogs: "モデログ" nUsersMentioned: "{n}人が投稿" @@ -483,8 +490,6 @@ noteOf: "{user}のノート" quoteAttached: "引用付き" quoteQuestion: "引用として添付しますか?" attachAsFileQuestion: "クリップボードのテキストが長いです。テキストファイルとして添付しますか?" -noMessagesYet: "まだチャットはありません" -newMessageExists: "新しいメッセージがあります" onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" signinRequired: "続行する前に、登録またはログインが必要です" signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があります" @@ -509,7 +514,10 @@ uiLanguage: "UIの表示言語" aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" -disableDrawer: "メニューをドロワーで表示しない" +menuStyle: "メニューのスタイル" +style: "スタイル" +drawer: "ドロワー" +popup: "ポップアップ" showNoteActionsOnlyHover: "ノートのアクションをホバー時のみ表示する" showReactionsCount: "ノートのリアクション数を表示する" noHistory: "履歴はありません" @@ -577,6 +585,7 @@ masterVolume: "マスター音量" notUseSound: "サウンドを出力しない" useSoundOnlyWhenActive: "Misskeyがアクティブな時のみサウンドを出力する" details: "詳細" +renoteDetails: "リノートの詳細" chooseEmoji: "絵文字を選択" unableToProcess: "操作を完了できません" recentUsed: "最近使用" @@ -674,14 +683,19 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" smtpSecureInfo: "STARTTLS使用時はオフにします。" testEmail: "配信テスト" wordMute: "ワードミュート" +wordMuteDescription: "指定した語句を含むノートを最小化します。最小化されたノートをクリックすることで表示することができます。" hardWordMute: "ハードワードミュート" +showMutedWord: "ミュートされたワードを表示" +hardWordMuteDescription: "指定した語句を含むノートを隠します。ワードミュートとは異なり、ノートは完全に表示されなくなります。" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:" instanceMute: "サーバーミュート" userSaysSomething: "{name}が何かを言いました" +userSaysSomethingAbout: "{name}が「{word}」について何かを言いました" makeActive: "アクティブにする" display: "表示" copy: "コピー" +copiedToClipboard: "クリップボードにコピーされました" metrics: "メトリクス" overview: "概要" logs: "ログ" @@ -710,10 +724,7 @@ abuseReported: "内容が送信されました。ご報告ありがとうござ reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートサーバーに通報を転送する" -forwardReportIsAnonymous: "リモートサーバーからはあなたの情報は見れず、匿名のシステムアカウントとして表示されます。" send: "送信" -abuseMarkAsResolved: "対応済みにする" openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" @@ -915,6 +926,7 @@ followersVisibility: "フォロワーの公開範囲" continueThread: "さらにスレッドを見る" deleteAccountConfirm: "アカウントが削除されます。よろしいですか?" incorrectPassword: "パスワードが間違っています。" +incorrectTotp: "ワンタイムパスワードが間違っているか、期限切れになっています。" voteConfirm: "「{choice}」に投票しますか?" hide: "隠す" useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示" @@ -939,6 +951,9 @@ oneHour: "1時間" oneDay: "1日" oneWeek: "1週間" oneMonth: "1ヶ月" +threeMonths: "3ヶ月" +oneYear: "1年" +threeDays: "3日" reflectMayTakeTime: "反映されるまで時間がかかる場合があります。" failedToFetchAccountInformation: "アカウント情報の取得に失敗しました" rateLimitExceeded: "レート制限を超えました" @@ -1027,7 +1042,7 @@ youCannotCreateAnymore: "これ以上作成することはできません。" cannotPerformTemporary: "一時的に利用できません" cannotPerformTemporaryDescription: "操作回数が制限を超過するため一時的に利用できません。しばらく時間を置いてから再度お試しください。" invalidParamError: "パラメータエラー" -invalidParamErrorDescription: "リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる等の可能性もあります。" +invalidParamErrorDescription: "リクエストパラメータに問題があります。通常これはバグですが、入力した文字数が多すぎる・許可されていない文字を入力している等の可能性もあります。" permissionDeniedError: "操作が拒否されました" permissionDeniedErrorDescription: "このアカウントにはこの操作を行うための権限がありません。" preset: "プリセット" @@ -1079,6 +1094,7 @@ retryAllQueuesConfirmTitle: "今すぐ再試行しますか?" retryAllQueuesConfirmText: "一時的にサーバーの負荷が増大することがあります。" enableChartsForRemoteUser: "リモートユーザーのチャートを生成" enableChartsForFederatedInstances: "リモートサーバーのチャートを生成" +enableStatsForFederatedInstances: "リモートサーバーの情報を取得" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" reactionsDisplaySize: "リアクションの表示サイズ" limitWidthOfReaction: "リアクションの最大横幅を制限し、縮小して表示する" @@ -1220,7 +1236,7 @@ releaseToRefresh: "離してリロード" refreshing: "リロード中" pullDownToRefresh: "引っ張ってリロード" disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする" -useGroupedNotifications: "通知をグルーピングして表示する" +useGroupedNotifications: "通知をグルーピング" signupPendingError: "メールアドレスの確認中に問題が発生しました。リンクの有効期限が切れている可能性があります。" cwNotationRequired: "「内容を隠す」がオンの場合は注釈の記述が必要です。" doReaction: "リアクションする" @@ -1269,6 +1285,178 @@ fromX: "{x}から" genEmbedCode: "埋め込みコードを生成" noteOfThisUser: "このユーザーのノート一覧" clipNoteLimitExceeded: "これ以上このクリップにノートを追加できません。" +performance: "パフォーマンス" +modified: "変更あり" +discard: "破棄" +thereAreNChanges: "{n}件の変更があります" +signinWithPasskey: "パスキーでログイン" +unknownWebAuthnKey: "登録されていないパスキーです。" +passkeyVerificationFailed: "パスキーの検証に失敗しました。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証に成功しましたが、パスワードレスログインが無効になっています。" +messageToFollower: "フォロワーへのメッセージ" +target: "対象" +testCaptchaWarning: "CAPTCHAのテストを目的とした機能です。本番環境で使用しないでください。" +prohibitedWordsForNameOfUser: "禁止ワード(ユーザーの名前)" +prohibitedWordsForNameOfUserDescription: "このリストに含まれる文字列がユーザーの名前に含まれる場合、ユーザーの名前の変更を拒否します。モデレーター権限を持つユーザーはこの制限の影響を受けません。" +yourNameContainsProhibitedWords: "変更しようとした名前に禁止された文字列が含まれています" +yourNameContainsProhibitedWordsDescription: "名前に禁止されている文字列が含まれています。この名前を使用したい場合は、サーバー管理者にお問い合わせください。" +thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示にはログインが必要と設定されています" +lockdown: "ロックダウン" +pleaseSelectAccount: "アカウントを選択してください" +availableRoles: "利用可能なロール" +acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします。" +federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。" +federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。" +confirmOnReact: "リアクションする際に確認する" +reactAreYouSure: "\" {emoji} \" をリアクションしますか?" +markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?" +unmarkAsSensitiveConfirm: "このメディアのセンシティブ指定を解除しますか?" +preferences: "環境設定" +accessibility: "アクセシビリティ" +preferencesProfile: "設定のプロファイル" +copyPreferenceId: "設定IDをコピー" +resetToDefaultValue: "初期値に戻す" +overrideByAccount: "アカウントで上書き" +untitled: "無題" +noName: "名前はありません" +skip: "スキップ" +restore: "復元" +syncBetweenDevices: "デバイス間で同期" +preferenceSyncConflictTitle: "サーバーに設定値が存在します" +preferenceSyncConflictText: "同期が有効にされた設定項目は設定値をサーバーに保存しますが、この設定項目のサーバーに保存された設定値が見つかりました。どちらの設定値で上書きしますか?" +preferenceSyncConflictChoiceServer: "サーバーの設定値" +preferenceSyncConflictChoiceDevice: "デバイスの設定値" +preferenceSyncConflictChoiceCancel: "同期の有効化をキャンセル" +paste: "ペースト" +emojiPalette: "絵文字パレット" +postForm: "投稿フォーム" +textCount: "文字数" +information: "情報" +chat: "チャット" +migrateOldSettings: "旧設定情報を移行" +migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。" +compress: "圧縮" +right: "右" +bottom: "下" +top: "上" + +_chat: + noMessagesYet: "まだメッセージはありません" + newMessage: "新しいメッセージ" + individualChat: "個人チャット" + individualChat_description: "特定ユーザーとの一対一のチャットができます。" + roomChat: "ルームチャット" + roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。" + createRoom: "ルームを作成" + inviteUserToChat: "ユーザーを招待してチャットを始めましょう" + yourRooms: "作成したルーム" + joiningRooms: "参加中のルーム" + invitations: "招待" + noInvitations: "招待はありません" + history: "履歴" + noHistory: "履歴はありません" + noRooms: "ルームはありません" + inviteUser: "ユーザーを招待" + sentInvitations: "送信した招待" + join: "参加" + ignore: "無視" + leave: "ルームから退出" + members: "メンバー" + searchMessages: "メッセージを検索" + home: "ホーム" + send: "送信" + newline: "改行" + muteThisRoom: "このルームをミュート" + deleteRoom: "ルームを削除" + chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。" + chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。" + cannotChatWithTheUser: "このユーザーとのチャットを開始できません" + cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。" + chatWithThisUser: "チャットする" + thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。" + thisUserAllowsChatOnlyFromFollowing: "このユーザーはフォローしているユーザーからのみチャットを受け付けています。" + thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみチャットを受け付けています。" + thisUserNotAllowedChatAnyone: "このユーザーは誰からもチャットを受け付けていません。" + chatAllowedUsers: "チャットを許可する相手" + chatAllowedUsers_note: "自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。" + _chatAllowedUsers: + everyone: "誰でも" + followers: "自分のフォロワーのみ" + following: "自分がフォローしているユーザーのみ" + mutual: "相互フォローのユーザーのみ" + none: "誰も許可しない" + +_emojiPalette: + palettes: "パレット" + enableSyncBetweenDevicesForPalettes: "パレットのデバイス間同期を有効にする" + paletteForMain: "メインで使用するパレット" + paletteForReaction: "リアクションで使用するパレット" + +_settings: + driveBanner: "ドライブの管理と設定、使用量の確認、ファイルをアップロードする際の設定を行えます。" + pluginBanner: "プラグインを利用するとクライアントの機能を拡張することができます。プラグインのインストール、個別の設定と管理が行えます。" + notificationsBanner: "サーバーからの受信する通知の種類と範囲や、プッシュ通知の設定が行えます。" + api: "API" + webhook: "Webhook" + serviceConnection: "サービス連携" + serviceConnectionBanner: "外部のアプリ・サービスと連携するためのアクセストークンやWebhookの管理と設定が行えます。" + accountData: "アカウントのデータ" + accountDataBanner: "アカウントデータのアーカイブをエクスポート/インポートして管理できます。" + muteAndBlockBanner: "非表示にするコンテンツの設定や、特定のユーザーからのアクションを制限する設定と管理を行えます。" + accessibilityBanner: "クライアントの視覚や動作に関するパーソナライズを行い、より最適に使用できるように設定できます。" + privacyBanner: "コンテンツの公開範囲、見つけやすさ、フォローの承認制などアカウントのプライバシーに関する設定を行えます。" + securityBanner: "パスワード、ログイン方法、認証アプリ、パスキーなどアカウントのセキュリティに関する設定を行えます。" + preferencesBanner: "好みに応じた、クライアントの全体的な動作の設定が行えます。" + appearanceBanner: "好みに応じた、クライアントの見た目・表示方法に関する設定が行えます。" + soundsBanner: "クライアントで再生するサウンドの設定が行えます。" + timelineAndNote: "タイムラインとノート" + makeEveryTextElementsSelectable: "全てのテキスト要素を選択可能にする" + makeEveryTextElementsSelectable_description: "有効にすると、一部のシチュエーションでのユーザビリティが低下する場合があります。" + useStickyIcons: "アイコンをスクロールに追従させる" + showNavbarSubButtons: "ナビゲーションバーに副ボタンを表示" + ifOn: "オンのとき" + ifOff: "オフのとき" + + _chat: + showSenderName: "送信者の名前を表示" + sendOnEnter: "Enterで送信" + +_preferencesProfile: + profileName: "プロファイル名" + profileNameDescription: "このデバイスを識別する名前を設定してください。" + profileNameDescription2: "例: 「メインPC」、「スマホ」など" + +_preferencesBackup: + autoBackup: "自動バックアップ" + restoreFromBackup: "バックアップから復元" + noBackupsFoundTitle: "バックアップが見つかりませんでした" + noBackupsFoundDescription: "自動で作成されたバックアップは見つかりませんでしたが、バックアップファイルを手動で保存している場合、それをインポートして復元することはできます。" + selectBackupToRestore: "復元するバックアップを選択してください" + youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効にするにはプロファイル名の設定が必要です。" + autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になっていません。" + backupFound: "設定のバックアップが見つかりました" + +_accountSettings: + requireSigninToViewContents: "コンテンツの表示にログインを必須にする" + requireSigninToViewContentsDescription1: "あなたが作成した全てのノートなどのコンテンツを表示するのにログインを必須にします。クローラーに情報が収集されるのを防ぐ効果が期待できます。" + requireSigninToViewContentsDescription2: "URLプレビュー(OGP)、Webページへの埋め込み、ノートの引用に対応していないサーバーからの表示も不可になります。" + requireSigninToViewContentsDescription3: "リモートサーバーに連合されたコンテンツでは、これらの制限が適用されない場合があります。" + makeNotesFollowersOnlyBefore: "過去のノートをフォロワーのみ表示可能にする" + makeNotesFollowersOnlyBeforeDescription: "この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートがフォロワーのみ表示可能になります。無効に戻すと、ノートの公開状態も元に戻ります。" + makeNotesHiddenBefore: "過去のノートを非公開化する" + makeNotesHiddenBeforeDescription: "この機能が有効になっている間、設定された日時より過去、または設定された時間を経過しているノートが自分のみ表示可能(非公開化)になります。無効に戻すと、ノートの公開状態も元に戻ります。" + mayNotEffectForFederatedNotes: "リモートサーバーに連合されたノートには効果が及ばない場合があります。" + mayNotEffectSomeSituations: "これらの制限は簡易的なものです。リモートサーバーでの閲覧やモデレーション時など、一部のシチュエーションでは適用されない場合があります。" + notesHavePassedSpecifiedPeriod: "指定した時間を経過しているノート" + notesOlderThanSpecifiedDateAndTime: "指定した日時より前のノート" + +_abuseUserReport: + forward: "転送" + forwardDescription: "匿名のシステムアカウントとして、リモートサーバーに通報を転送します。" + resolve: "解決" + accept: "是認" + reject: "否認" + resolveTutorial: "内容が正当である通報に対応した場合は「是認」を選択し、肯定的にケースが解決されたことをマークします。\n内容が正当でない通報の場合は「否認」を選択し、否定的にケースが解決されたことをマークします。" _delivery: status: "配信状態" @@ -1414,6 +1602,9 @@ _serverSettings: reactionsBufferingDescription: "有効にすると、リアクション作成時のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。" inquiryUrl: "問い合わせ先URL" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定します。" + openRegistration: "アカウントの作成をオープンにする" + openRegistrationWarning: "登録を開放することはリスクが伴います。サーバーを常に監視し、トラブルが発生した際にすぐに対応できる体制がある場合のみオンにすることを推奨します。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターのアクティビティが検出されなかった場合、スパム防止のためこの設定は自動でオフになります。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに移行" @@ -1753,6 +1944,7 @@ _role: canImportFollowing: "フォローのインポートを許可" canImportMuting: "ミュートのインポートを許可" canImportUserLists: "リストのインポートを許可" + canChat: "チャットを許可" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -1937,6 +2129,7 @@ _theme: installed: "{name}をインストールしました" installedThemes: "インストールされたテーマ" builtinThemes: "標準のテーマ" + instanceTheme: "サーバーのテーマ" alreadyInstalled: "そのテーマは既にインストールされています" invalid: "テーマの形式が間違っています" make: "テーマを作る" @@ -1992,7 +2185,6 @@ _theme: buttonBg: "ボタンの背景" buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" - listItemHoverBg: "リスト項目の背景 (ホバー)" driveFolderBg: "ドライブフォルダーの背景" wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" @@ -2006,6 +2198,7 @@ _sfx: noteMy: "ノート(自分)" notification: "通知" reaction: "リアクション選択時" + chatMessage: "チャットのメッセージ" _soundSettings: driveFile: "ドライブの音声を使用" @@ -2158,6 +2351,8 @@ _permissions: "read:clip-favorite": "クリップのいいねを見る" "read:federation": "連合に関する情報を取得する" "write:report-abuse": "違反を報告する" + "write:chat": "チャットを操作する" + "read:chat": "チャットを閲覧する" _auth: shareAccessTitle: "アプリへのアクセス許可" @@ -2167,8 +2362,11 @@ _auth: permissionAsk: "このアプリは次の権限を要求しています" pleaseGoBack: "アプリケーションに戻ってやっていってください" callback: "アプリケーションに戻っています" + accepted: "アクセスを許可しました" denied: "アクセスを拒否しました" + scopeUser: "以下のユーザーとして操作しています" pleaseLogin: "アプリケーションにアクセス許可を与えるには、ログインが必要です。" + byClickingYouWillBeRedirectedToThisUrl: "アクセスを許可すると、自動で以下のURLに遷移します" _antennaSources: all: "全てのノート" @@ -2285,6 +2483,9 @@ _profile: changeBanner: "バナー画像を変更" verifiedLinkDescription: "内容にURLを設定すると、リンク先のWebサイトに自分のプロフィールへのリンクが含まれている場合に所有者確認済みアイコンを表示させることができます。" avatarDecorationMax: "最大{max}つまでデコレーションを付けられます。" + followedMessage: "フォローされた時のメッセージ" + followedMessageDescription: "フォローされた時に相手に表示する短いメッセージを設定できます。" + followedMessageDescriptionForLockedAccount: "フォローを承認制にしている場合、フォローリクエストを許可した時に表示されます。" _exportOrImport: allNotes: "全てのノート" @@ -2353,9 +2554,6 @@ _pages: newPage: "ページの作成" editPage: "ページの編集" readPage: "ソースを表示中" - created: "ページを作成しました" - updated: "ページを更新しました" - deleted: "ページを削除しました" pageSetting: "ページ設定" nameAlreadyExists: "指定されたページURLは既に存在しています" invalidNameTitle: "不正なページURLです" @@ -2413,7 +2611,7 @@ _notification: youGotMention: "{name}からのメンション" youGotReply: "{name}からのリプライ" youGotQuote: "{name}による引用" - youRenoted: "{name}がRenoteしました" + youRenoted: "{name}がリノートしました" youWereFollowed: "フォローされました" youReceivedFollowRequest: "フォローリクエストが来ました" yourFollowRequestAccepted: "フォローリクエストが承認されました" @@ -2421,6 +2619,7 @@ _notification: newNote: "新しい投稿" unreadAntennaNote: "アンテナ {name}" roleAssigned: "ロールが付与されました" + chatRoomInvitationReceived: "チャットルームへ招待されました" emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" testNotification: "通知テスト" @@ -2432,6 +2631,10 @@ _notification: renotedBySomeUsers: "{n}人がリノートしました" followedBySomeUsers: "{n}人にフォローされました" flushNotification: "通知の履歴をリセットする" + exportOfXCompleted: "{x}のエクスポートが完了しました" + login: "ログインがありました" + createToken: "アクセストークンが作成されました" + createTokenDescription: "心当たりがない場合は「{text}」を通じてアクセストークンを削除してください。" _types: all: "すべて" @@ -2439,24 +2642,32 @@ _notification: follow: "フォロー" mention: "メンション" reply: "リプライ" - renote: "Renote" + renote: "リノート" quote: "引用" reaction: "リアクション" pollEnded: "アンケートが終了" receiveFollowRequest: "フォロー申請を受け取った" followRequestAccepted: "フォローが受理された" roleAssigned: "ロールが付与された" + chatRoomInvitationReceived: "チャットルームへ招待された" achievementEarned: "実績の獲得" + exportCompleted: "エクスポートが完了した" + login: "ログイン" + createToken: "アクセストークンの作成" + test: "通知のテスト" app: "連携アプリからの通知" _actions: followBack: "フォローバック" reply: "返信" - renote: "Renote" + renote: "リノート" _deck: alwaysShowMainColumn: "常にメインカラムを表示" columnAlign: "カラムの寄せ" + columnGap: "カラム間のマージン" + deckMenuPosition: "デッキメニューの位置" + navbarPosition: "ナビゲーションバーの位置" addColumn: "カラムを追加" newNoteNotificationSettings: "新着ノート通知の設定" configureColumn: "カラムの設定" @@ -2475,6 +2686,7 @@ _deck: useSimpleUiForNonRootPages: "非ルートページは簡易UIで表示" usedAsMinWidthWhenFlexible: "「幅を自動調整」が有効の場合、これが幅の最小値となります" flexible: "幅を自動調整" + enableSyncBetweenDevicesForProfiles: "プロファイル情報のデバイス間同期を有効にする" _columns: main: "メイン" @@ -2519,6 +2731,8 @@ _webhookSettings: abuseReport: "ユーザーから通報があったとき" abuseReportResolved: "ユーザーからの通報を処理したとき" userCreated: "ユーザーが作成されたとき" + inactiveModeratorsWarning: "モデレーターが一定期間非アクティブになったとき" + inactiveModeratorsInvitationOnlyChanged: "モデレーターが一定期間非アクティブだったため、システムにより招待制へと変更されたとき" deleteConfirm: "Webhookを削除しますか?" testRemarks: "スイッチの右にあるボタンをクリックするとダミーのデータを使用したテスト用Webhookを送信できます。" @@ -2566,6 +2780,8 @@ _moderationLogTypes: markSensitiveDriveFile: "ファイルをセンシティブ付与" unmarkSensitiveDriveFile: "ファイルをセンシティブ解除" resolveAbuseReport: "通報を解決" + forwardAbuseReport: "通報を転送" + updateAbuseReportNote: "通報のモデレーションノート更新" createInvitation: "招待コードを作成" createAd: "広告を作成" deleteAd: "広告を削除" @@ -2585,6 +2801,8 @@ _moderationLogTypes: deletePage: "ページを削除" deleteFlash: "Playを削除" deleteGalleryPost: "ギャラリーの投稿を削除" + deleteChatRoom: "チャットルームを削除" + updateProxyAccountDescription: "プロキシアカウントの説明を更新" _fileViewer: title: "ファイルの詳細" @@ -2600,10 +2818,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "配布元が信頼できるかを確認した上でインストールしてください。" _plugin: title: "このプラグインをインストールしますか?" - metaTitle: "プラグイン情報" _theme: title: "このテーマをインストールしますか?" - metaTitle: "テーマ情報" _meta: base: "基本のカラースキーム" _vendorInfo: @@ -2731,6 +2947,69 @@ _contextMenu: appWithShift: "Shiftキーでアプリケーション" native: "ブラウザのUI" +_gridComponent: + _error: + requiredValue: "この値は必須項目です" + columnTypeNotSupport: "正規表現によるバリデーションはtype:textのカラムのみサポートします。" + patternNotMatch: "この値は{pattern}のパターンに一致しません" + notUnique: "この値は一意である必要があります" + +_roleSelectDialog: + notSelected: "選択されていません" + +_customEmojisManager: + _gridCommon: + copySelectionRows: "選択行をコピー" + copySelectionRanges: "選択範囲をコピー" + deleteSelectionRows: "選択行を削除" + deleteSelectionRanges: "選択範囲の値をクリア" + searchSettings: "検索設定" + searchSettingCaption: "検索条件を詳細に設定します。" + searchLimit: "表示件数" + sortOrder: "並び順" + registrationLogs: "登録ログ" + registrationLogsCaption: "絵文字更新・削除時のログが表示されます。更新・削除操作を行ったり、ページを遷移・リロードすると消えます。" + alertEmojisRegisterFailedDescription: "絵文字の更新・削除に失敗しました。詳細は登録ログをご確認ください。" + _logs: + showSuccessLogSwitch: "成功ログを表示" + failureLogNothing: "失敗ログはありません。" + logNothing: "ログはありません。" + _remote: + selectionRowDetail: "選択行の詳細" + importSelectionRows: "選択行をインポート" + importSelectionRangesRows: "選択範囲の行をインポート" + importEmojisButton: "チェックされた絵文字をインポート" + confirmImportEmojisTitle: "絵文字のインポート" + confirmImportEmojisDescription: "リモートから受信した{count}個の絵文字のインポートを行います。絵文字のライセンスに十分な注意を払ってください。実行しますか?" + _local: + tabTitleList: "登録済み絵文字一覧" + tabTitleRegister: "絵文字の登録" + _list: + emojisNothing: "登録された絵文字はありません。" + markAsDeleteTargetRows: "選択行を削除対象にする" + markAsDeleteTargetRanges: "選択範囲の行を削除対象にする" + alertUpdateEmojisNothingDescription: "変更された絵文字はありません。" + alertDeleteEmojisNothingDescription: "削除対象の絵文字はありません。" + confirmMovePage: "ページを移動しますか?" + confirmChangeView: "表示を変更しますか?" + confirmUpdateEmojisDescription: "{count}個の絵文字を更新します。実行しますか?" + confirmDeleteEmojisDescription: "チェックがつけられた{count}個の絵文字を削除します。実行しますか?" + confirmResetDescription: "今までに加えた変更がすべてリセットされます。" + confirmMovePageDesciption: "このページの絵文字に変更が加えられています。\n保存せずにこのままページを移動すると、このページで加えた変更はすべて破棄されます。" + dialogSelectRoleTitle: "絵文字に設定されたロールで検索" + _register: + uploadSettingTitle: "アップロード設定" + uploadSettingDescription: "この画面で絵文字アップロードを行う際の動作を設定できます。" + directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する" + directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。" + emojiInputAreaCaption: "いずれかの方法で登録する絵文字を選択してください。" + emojiInputAreaList1: "この枠に画像ファイルまたはディレクトリをドラッグ&ドロップ" + emojiInputAreaList2: "このリンクをクリックしてPCから選択する" + emojiInputAreaList3: "このリンクをクリックしてドライブから選択する" + confirmRegisterEmojisDescription: "リストに表示されている絵文字を新たなカスタム絵文字として登録します。よろしいですか?(負荷を避けるため、一度の操作で登録可能な絵文字は{count}件までです)" + confirmClearEmojisDescription: "編集内容を破棄し、リストに表示されている絵文字をクリアします。よろしいですか?" + confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードします。実行しますか?" + _embedCodeGen: title: "埋め込みコードをカスタマイズ" header: "ヘッダーを表示" @@ -2745,3 +3024,67 @@ _embedCodeGen: generateCode: "埋め込みコードを作成" codeGenerated: "コードが生成されました" codeGeneratedDescription: "生成されたコードをウェブサイトに貼り付けてご利用ください。" + +_selfXssPrevention: + warning: "警告" + title: "「この画面に何か貼り付けろ」はすべて詐欺です。" + description1: "ここに何かを貼り付けると、悪意のあるユーザーにアカウントを乗っ取られたり、個人情報を盗まれたりする可能性があります。" + description2: "貼り付けようとしているものが何なのかを正確に理解していない場合は、%c今すぐ作業を中止してこのウィンドウを閉じてください。" + description3: "詳しくはこちらをご確認ください。 {link}" + +_followRequest: + recieved: "受け取った申請" + sent: "送った申請" + +_remoteLookupErrors: + _federationNotAllowed: + title: "このサーバーとは通信できません" + description: "このサーバーとの通信が無効化されているか、このサーバーをブロックしている・ブロックされている可能性があります。\nサーバー管理者にお問い合わせください。" + _uriInvalid: + title: "URIが不正です" + description: "入力されたURIに問題があります。URIに使用できない文字を入力していないか確認してください。" + _requestFailed: + title: "リクエストに失敗しました" + description: "このサーバーとの通信に失敗しました。相手サーバーがダウンしている可能性があります。また、不正なURIや存在しないURIを入力していないか確認してください。" + _responseInvalid: + title: "レスポンスが不正です" + description: "このサーバーと通信することはできましたが、得られたデータが不正なものでした。第三者のサーバーを介してリモートのコンテンツを照会している場合は、発信元のサーバーで取得できるURIを使用して照会し直してください。" + _noSuchObject: + title: "見つかりません" + description: "要求されたリソースは見つかりませんでした。URIをもう一度お確かめください。" + +_captcha: + verify: "CAPTCHAを通過してください" + testSiteKeyMessage: "サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できます。\n詳細は下記ページをご確認ください。" + _error: + _requestFailed: + title: "CAPTCHAのリクエストに失敗しました" + text: "しばらく後に実行するか、設定をもう一度ご確認ください。" + _verificationFailed: + title: "CAPTCHAの検証に失敗しました" + text: "設定が正しいかどうかもう一度確認ください。" + _unknown: + title: "CAPTCHAエラー" + text: "想定外のエラーが発生しました。" + +_bootErrors: + title: "読み込みに失敗しました" + serverError: "少し待ってからリロードしてもまだ問題が解決されない場合、以下のError IDを添えてサーバー管理者に連絡してください。" + solution: "以下を行うと解決する可能性があります。" + solution1: "ブラウザおよびOSを最新バージョンに更新する" + solution2: "アドブロッカーを無効にする" + solution3: "ブラウザのキャッシュをクリアする" + solution4: "(Tor Browser) dom.webaudio.enabledをtrueに設定する" + otherOption: "その他のオプション" + otherOption1: "クライアント設定とキャッシュを削除" + otherOption2: "簡易クライアントを起動" + otherOption3: "修復ツールを起動" + +_search: + searchScopeAll: "全て" + searchScopeLocal: "ローカル" + searchScopeServer: "サーバー指定" + searchScopeUser: "ユーザー指定" + pleaseEnterServerHost: "サーバーのホストを入力してください" + pleaseSelectUser: "ユーザーを選択してください" + serverHostPlaceholder: "例: misskey.example.com" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 98045b43ac..ec11cd8df5 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -5,14 +5,18 @@ introMisskey: "ようお越し!Misskeyは、オープンソースの分散型 poweredByMisskeyDescription: "{name}は、オープンソースのプラットフォームMisskeyのサーバーのひとつなんやで。" monthAndDay: "{month}月 {day}日" search: "探す" +reset: "リセット" notifications: "通知" username: "ユーザー名" password: "パスワード" +initialPasswordForSetup: "初期設定開始用パスワード" +initialPasswordIsIncorrect: "初期設定開始用のパスワードがちゃうで。" +initialPasswordForSetupDescription: "Miskkeyを自分でインストールしたんやったら、設定ファイルに入れたパスワードを使ってや。\nホスティングサービスを使っとるんやったら、サービスから言われたやつを使うんやで。\n別に何も設定しとらんのやったら、何も入れずに空けといてな。" forgotPassword: "パスワード忘れたん?" fetchingAsApObject: "今ちと連合に照会しとるで" ok: "ええで" gotIt: "ほい" -cancel: "やめとく" +cancel: "やめる" noThankYou: "やめとく" enterUsername: "ユーザー名を入れてや" renotedBy: "{user}がリノートしたで" @@ -23,7 +27,7 @@ settings: "設定" notificationSettings: "通知の設定" basicSettings: "基本設定" otherSettings: "ほかの設定" -openInWindow: "ウィンドウで開くで" +openInWindow: "ウィンドウで開く" profile: "プロフィール" timeline: "タイムライン" noAccountDescription: "自己紹介食ってもた" @@ -42,9 +46,10 @@ favorited: "お気に入りに入れたで。" alreadyFavorited: "もうお気に入りに入れとるがな。" cantFavorite: "アカン、お気に入りに入れれんかったわ。" pin: "ピン留めしとく" -unpin: "やっぱピン留めせん" +unpin: "ピン留めやめる" copyContent: "内容をコピー" copyLink: "リンクをコピー" +copyRemoteLink: "リモートのリンクをコピーするで?" copyLinkRenote: "リノートのリンクをコピーするで?" delete: "ほかす" deleteAndEdit: "ほかして直す" @@ -60,7 +65,7 @@ copyFileId: "ファイルIDをコピー" copyFolderId: "フォルダーIDをコピー" copyProfileUrl: "プロフィールURLをコピー" searchUser: "ユーザーを探す" -searchThisUsersNotes: "ユーザーのノートを検索" +searchThisUsersNotes: "ユーザーのノートを探す" reply: "返事" loadMore: "まだまだあるで!" showMore: "まだまだあるで!" @@ -135,8 +140,8 @@ reactionSettingDescription2: "ドラッグで並び替え、クリックで削 rememberNoteVisibility: "公開範囲覚えといて" attachCancel: "のっけるのやめる" deleteFile: "ファイルをほかす" -markAsSensitive: "ちょっとこれはアカン" -unmarkAsSensitive: "そこまでアカンことないやろ" +markAsSensitive: "ちょっと見せられへんわ" +unmarkAsSensitive: "別にええんじゃね?" enterFileName: "ファイル名を入れてや" mute: "ミュート" unmute: "ミュートやめたる" @@ -149,13 +154,13 @@ unsuspend: "溶かす" blockConfirm: "ブロックしてもええんか?" unblockConfirm: "ブロックやめたるってほんまか?" suspendConfirm: "凍結してしもうてええか?" -unsuspendConfirm: "解凍するけどええか?" +unsuspendConfirm: "溶かしたるけどええか?" selectList: "リストを選ぶ" editList: "リストいじる" selectChannel: "チャンネルを選ぶ" selectAntenna: "アンテナを選ぶ" editAntenna: "アンテナいじる" -createAntenna: "アンテナを作成" +createAntenna: "アンテナを作る" selectWidget: "ウィジェットを選ぶ" editWidgets: "ウィジェットをいじる" editWidgetsExit: "いじるのをやめる" @@ -169,12 +174,12 @@ settingGuide: "ええ感じの設定" cacheRemoteFiles: "リモートのファイルをキャッシュする" cacheRemoteFilesDescription: "この設定を入れとったら、リモートのファイルを端から端までこのサーバーのキャッシュん中突っ込むようになるで。画像映し出すんがめっちゃ速うなるけど、サーバーの容量をやたらと食うようになるで。リモートの人がどんだけ長くキャッシュを持っとくかはドライブ容量の制限で決めとくで。制限を超えたら古いのから順々に消してって、かわりにリンクになるで。この設定を切ったら、リモートのファイルは最初っからリンクとして扱うことにするけど、画像のサムネ作るのとかみんなのプライバシー守るために、default.ymlのproxyRemoteFilesをtrueにしといたほうがええよ。" youCanCleanRemoteFilesCache: "ファイル管理にある🗑️ボタンでキャッシュ全部ほかすで。" -cacheRemoteSensitiveFiles: "リモートのきわどいファイルをキャッシュに突っ込む" +cacheRemoteSensitiveFiles: "リモートのきわどいファイルをキャッシュする" cacheRemoteSensitiveFilesDescription: "この設定を切ると、リモートのきわどいファイルはキャッシュせず直でリンクするようになるで。" flagAsBot: "Botにするで" flagAsBotDescription: "もしこのアカウントをプログラム使うて運用するんやったら、このフラグをオンにしてや。オンにすれば、反応がバーッて連鎖せんように開発者が使うたり、Misskeyのシステム上での扱いがBotに合ったもんになるからな。" flagAsCat: "猫や。かわええな。" -flagAsCatDescription: "ネコになりたいんならこれつけとき。" +flagAsCatDescription: "猫になりたいんならこれつけとき。" flagShowTimelineReplies: "タイムラインにノートへの返信を表示するで" flagShowTimelineRepliesDescription: "オンにしたら、タイムラインにユーザーのノートの他にもそのユーザーの他のノートへの返信を表示するで。" autoAcceptFollowed: "フォローしとるユーザーからのフォローリクエストを勝手に許可しとく" @@ -183,9 +188,9 @@ reloadAccountsList: "アカウントリストの情報を更新" loginFailed: "ログインに失敗してもうた…" showOnRemote: "リモートで見る" continueOnRemote: "リモートで続行" -chooseServerOnMisskeyHub: "Misskey Hubからサーバーを選択" +chooseServerOnMisskeyHub: "Misskey Hubからサーバーを選ぶ" specifyServerHost: "サーバーのドメインを直接指定" -inputHostName: "ドメインを入力せえや" +inputHostName: "ドメインを入力してや" general: "全般" wallpaper: "壁紙" setWallpaper: "壁紙を設定" @@ -236,6 +241,8 @@ silencedInstances: "サーバーサイレンスされてんねん" silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定すんで。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになり、フォロワーでないローカルアカウントにはメンションできなくなんねん。ブロックしたインスタンスには影響せーへんで。" mediaSilencedInstances: "メディアサイレンスしたサーバー" mediaSilencedInstancesDescription: "メディアサイレンスしたいサーバーのホストを改行で区切って設定するで。メディアサイレンスされたサーバーに所属するアカウントによるファイルはすべてセンシティブとして扱われてな、カスタム絵文字が使えへんようになるで。ブロックしたインスタンスには影響せえへんで。" +federationAllowedHosts: "連合を許すサーバー" +federationAllowedHostsDescription: "連合してもいいサーバーのホストを行ごとに区切って設定してや。" muteAndBlock: "ミュートとブロック" mutedUsers: "ミュートしとるユーザー" blockedUsers: "ブロックしとるユーザー" @@ -282,7 +289,6 @@ deleteAreYouSure: "「{x}」はほかしてええか?" resetAreYouSure: "リセットしてええん?" areYouSure: "いいん?" saved: "保存したで!" -messaging: "チャット" upload: "アップロード" keepOriginalUploading: "オリジナル画像のまんま" keepOriginalUploadingDescription: "画像を上げるときにオリジナル版のまんまにするで。オフにしたら、上げたときにブラウザでWeb公開用の画像を生成するで。 " @@ -295,7 +301,6 @@ uploadFromUrlMayTakeTime: "アップロード終わるんにちょい時間か explore: "みつける" messageRead: "もう読んだ" noMoreHistory: "これより昔のんはあらへんで" -startMessaging: "チャットやるで" nUsersRead: "{n}人が読んでもうた" agreeTo: "{0}に同意したで" agree: "せやな" @@ -334,6 +339,7 @@ renameFolder: "フォルダー名を変える" deleteFolder: "フォルダーをほかす" folder: "フォルダー" addFile: "ファイルを追加" +showFile: "ファイル出す" emptyDrive: "ドライブは空っぽや" emptyFolder: "このフォルダーは空や" unableToDelete: "消せんかったわ" @@ -376,7 +382,6 @@ enableLocalTimeline: "ローカルタイムラインを使えるようにする enableGlobalTimeline: "グローバルタイムラインを使えるようにするわ" disablingTimelinesInfo: "ここらへんのタイムラインを使えんようにしてしもても、管理者とモデレーターは使えるままになってるで、そうやなかったら不便やからな。" registration: "登録" -enableRegistration: "一見さんでも誰でもいらっしゃ~い" invite: "来てや" driveCapacityPerLocalAccount: "ローカルユーザーはんひとりあたりのドライブ容量" driveCapacityPerRemoteAccount: "リモートユーザーはんひとりあたりのドライブ容量" @@ -448,6 +453,7 @@ totpDescription: "認証アプリ使うてワンタイムパスワードを入 moderator: "モデレーター" moderation: "モデレーション" moderationNote: "モデレーションノート" +moderationNoteDescription: "モデレーターの中だけで共有するメモを入れれるで。" addModerationNote: "モデレーションノートを追加するで" moderationLogs: "モデログ" nUsersMentioned: "{n}人が投稿" @@ -483,8 +489,6 @@ noteOf: "{user}はんのノート" quoteAttached: "引用付いとるで" quoteQuestion: "引用として添付してもええか?" attachAsFileQuestion: "クリップボードのテキストが長すぎるからテキストファイルとして添付してもええか?" -noMessagesYet: "まだチャットはあらへんで" -newMessageExists: "新しいメッセージがきたで" onlyOneFileCanBeAttached: "ごめんな、メッセージに添付できるファイルはひとつだけなんよ。" signinRequired: "ログインしてくれへん?" signinOrContinueOnRemote: "続行するには、お使いのサーバーに移動するか、このサーバーに登録・ログインする必要があるで" @@ -509,7 +513,10 @@ uiLanguage: "UIの表示言語" aboutX: "{x}について" emojiStyle: "絵文字のスタイル" native: "ネイティブ" -disableDrawer: "メニューをドロワーで表示せえへん" +menuStyle: "メニューのスタイル" +style: "スタイル" +drawer: "ドロワー" +popup: "ポップアップ" showNoteActionsOnlyHover: "ノートの操作部をホバー時のみ表示するで" showReactionsCount: "ノートのリアクション数を表示する" noHistory: "履歴はないわ。" @@ -577,6 +584,7 @@ masterVolume: "全体のやかましさ" notUseSound: "音出さへん" useSoundOnlyWhenActive: "Misskeyがアクティブなときだけ音出す" details: "もっと" +renoteDetails: "リノートの詳細" chooseEmoji: "絵文字を選ぶ" unableToProcess: "なんか奥の方で詰まってもうた" recentUsed: "最近使ったやつ" @@ -592,6 +600,8 @@ ascendingOrder: "小さい順" descendingOrder: "大きい順" scratchpad: "スクラッチパッド" scratchpadDescription: "スクラッチパッドではAiScriptを色々試すことができるんや。Misskeyに対して色々できるコードを書いて動かしてみたり、結果を見たりできるで。" +uiInspector: "UIインスペクター" +uiInspectorDescription: "メモリ上にあるUIコンポーネントのインスタンス一覧を見れるで。UIコンポーネントはUi:C:系関数で生成されるで。" output: "出力" script: "スクリプト" disablePagesScript: "Pagesのスクリプトを無効にしてや" @@ -672,11 +682,15 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" smtpSecureInfo: "STARTTLS使っとる時はオフにしてや。" testEmail: "配信テスト" wordMute: "ワードミュート" +wordMuteDescription: "指定した語句が入ってるノートを最小化するで。最小化されたノートをクリックしたら、表示できるようになるで。" hardWordMute: "ハードワードミュート" +showMutedWord: "ミュートされたワードを表示するで" +hardWordMuteDescription: "指定した語句が入ってるノートを隠すで。ワードミュートとちゃうて、ノートは完全に表示されんようになるで。" regexpError: "正規表現エラー" regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが出てきたで:" instanceMute: "サーバーミュート" userSaysSomething: "{name}が何か言うとるわ" +userSaysSomethingAbout: "{name}が「{word}」についてなんか言うてたで" makeActive: "使うで" display: "表示" copy: "コピー" @@ -708,10 +722,7 @@ abuseReported: "無事内容が送信されたみたいやで。おおきに〜 reporter: "通報者" reporteeOrigin: "通報先" reporterOrigin: "通報元" -forwardReport: "リモートサーバーに通報を転送するで" -forwardReportIsAnonymous: "リモートサーバーからはあんたの情報は見えんなって、匿名のシステムアカウントとして表示されるで。" send: "送信" -abuseMarkAsResolved: "対応したで" openInNewTab: "新しいタブで開く" openInSideView: "サイドビューで開く" defaultNavigationBehaviour: "デフォルトのナビゲーション" @@ -913,6 +924,7 @@ followersVisibility: "フォロワーの公開範囲" continueThread: "さらにスレッドを見るで" deleteAccountConfirm: "アカウントを消すで?ええんか?" incorrectPassword: "パスワードがちゃうわ。" +incorrectTotp: "ワンタイムパスワードが間違っとるか、期限が切れとるみたいやな。" voteConfirm: "「{choice}」に投票するんか?" hide: "隠す" useDrawerReactionPickerForMobile: "ケータイとかのときドロワーで表示するで" @@ -937,6 +949,9 @@ oneHour: "1時間" oneDay: "1日" oneWeek: "1週間" oneMonth: "1ヶ月" +threeMonths: "3ヶ月" +oneYear: "1年" +threeDays: "3日" reflectMayTakeTime: "反映されるまで時間がかかることがあるで" failedToFetchAccountInformation: "アカウントの取得に失敗したみたいや…" rateLimitExceeded: "レート制限が超えたみたいやで" @@ -1077,6 +1092,7 @@ retryAllQueuesConfirmTitle: "もっかいやってみるか?" retryAllQueuesConfirmText: "一時的にサーバー重なるかもしれへんで。" enableChartsForRemoteUser: "リモートユーザーのチャートを作る" enableChartsForFederatedInstances: "リモートサーバーのチャートを作る" +enableStatsForFederatedInstances: "リモートサーバの情報を取得" showClipButtonInNoteFooter: "ノートのアクションにクリップを追加" reactionsDisplaySize: "ツッコミの表示のでかさ" limitWidthOfReaction: "ツッコミの最大横幅を制限して、ちっさく表示するで" @@ -1263,6 +1279,63 @@ confirmWhenRevealingSensitiveMedia: "センシティブなメディアを表示 sensitiveMediaRevealConfirm: "センシティブなメディアやで。表示するんか?" createdLists: "作成したリスト" createdAntennas: "作成したアンテナ" +fromX: "{x}から" +genEmbedCode: "埋め込みコードを作る" +noteOfThisUser: "このユーザーのノート全部" +clipNoteLimitExceeded: "これ以上このクリップにノート追加でけへんわ。" +performance: "パフォーマンス" +modified: "変更あり" +discard: "やめる" +thereAreNChanges: "{n}個の変更があるみたいや" +signinWithPasskey: "パスキーでログイン" +unknownWebAuthnKey: "登録されてへんパスキーやな。" +passkeyVerificationFailed: "パスキーの検証に失敗したで。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "パスキーの検証は成功したんやけど、パスワードレスログインが無効になっとるわ。" +messageToFollower: "フォロワーへのメッセージ" +target: "対象" +testCaptchaWarning: "CAPTCHAのテストを目的としてるで。絶対に本番環境で使わんといてな。絶対やで。" +prohibitedWordsForNameOfUser: "禁止ワード(ユーザー名)" +prohibitedWordsForNameOfUserDescription: "このリストの中にある文字列がユーザー名に入っとったら、その名前に変更できひんようになるで。モデレーター権限があるユーザーは除外や。" +yourNameContainsProhibitedWords: "その名前は禁止した文字列が含まれとるで" +yourNameContainsProhibitedWordsDescription: "その名前は禁止した文字列が含まれとるわ。どうしてもって言うなら、サーバー管理者に言うしかないで。" +thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者が、表示にログインが要るって設定してるで" +lockdown: "ロックダウン" +pleaseSelectAccount: "アカウント選んでや" +availableRoles: "使えるロール" +acknowledgeNotesAndEnable: "注意事項をわかった上でオンにする。" +federationSpecified: "このサーバーはホワイトリスト連合で運用されてるで。管理者が指定したサーバー以外とはやり取りできひんで。" +federationDisabled: "このサーバーは連合が無効化されてるで。他のサーバーのユーザーとやり取りすることはできひんで。" +confirmOnReact: "ツッコむときに確認とる" +reactAreYouSure: "\" {emoji} \" でツッコむ?" +postForm: "投稿フォーム" +information: "情報" +_chat: + invitations: "来てや" + noHistory: "履歴はないわ。" + members: "メンバーはん" + home: "ホーム" + send: "送信" +_settings: + webhook: "Webhook" +_accountSettings: + requireSigninToViewContents: "ログインしてもらってからコンテンツ見てもらう" + requireSigninToViewContentsDescription1: "あなたが作成した全部のノートとかのコンテンツを見れるようにするのにログインがいるようにするで。クローラーにいろいろ収集されるんを防げるかもしれん。" + requireSigninToViewContentsDescription2: "URLプレビュー(OGP)、Webページへの埋め込み、ノートの引用に対応してないサーバーからの表示ができんくなるで。" + requireSigninToViewContentsDescription3: "リモートサーバーに連合されたコンテンツは、これらの制限が適用されんかもしれんで。" + makeNotesFollowersOnlyBefore: "昔のノートをフォロワーだけに見てもらう" + makeNotesFollowersOnlyBeforeDescription: "この機能が有効になってる間は、設定された日時より前、それか設定された時間が経ったノートがフォロワーのみ見れるようになるで。無効に戻すと、ノートの公開状態も戻るで。" + makeNotesHiddenBefore: "昔のノートを見れんようにする" + makeNotesHiddenBeforeDescription: "この機能が有効になってる間は、設定された日時より前、それか設定された時間が経ったノートがフォロワーのみ見れるようになるで。無効に戻すと、ノートの公開状態も戻るで。" + mayNotEffectForFederatedNotes: "リモートサーバーに連合されたノートには効果が及ばんかもしれん。" + notesHavePassedSpecifiedPeriod: "決めた時間が経ったノート" + notesOlderThanSpecifiedDateAndTime: "決めた日時より前のノート" +_abuseUserReport: + forward: "転送" + forwardDescription: "匿名のシステムアカウントってことにして、リモートサーバーに通報を転送するで。" + resolve: "解決" + accept: "ええよ" + reject: "あかんよ" + resolveTutorial: "内容がええなら「ええよ」を選ぶんや。肯定的に解決されたことにして記録するで。\n逆に、内容がだめなら「あかんよ」を選びいや。否定的に解決されたって記録しとくで。" _delivery: status: "配信状態" stop: "配信せぇへん" @@ -1397,8 +1470,12 @@ _serverSettings: fanoutTimelineDescription: "入れると、おのおのタイムラインを取得するときにめちゃめちゃ動きが良うなって、データベースが軽くなるわ。でも、Redisのメモリ使う量が増えるから注意な。サーバーのメモリが足りんときとか、動きが変なときは切れるで。" fanoutTimelineDbFallback: "データベースにフォールバックする" fanoutTimelineDbFallbackDescription: "有効にしたら、タイムラインがキャッシュん中に入ってないときにDBにもっかい問い合わせるフォールバック処理ってのをやっとくで。切ったらフォールバック処理をやらんからサーバーはもっと軽くなんねんけど、タイムラインの取得範囲がちょっと減るで。" + reactionsBufferingDescription: "有効にしたら、リアクション作るときのパフォーマンスがすっごい上がって、データベースへの負荷が減るで。代わりに、Redisのメモリ使用は増えるで。" inquiryUrl: "問い合わせ先URL" inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定するで。" + openRegistration: "アカウントの作成をオープンにする" + openRegistrationWarning: "登録を解放するのはリスクが伴うで。サーバーをいっつも監視して、なんか起きたらすぐに対応できるんやったら、オンにしてもええと思う。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターがおらんかったら、スパムを防ぐためにこの設定は勝手に切られるで。" _accountMigration: moveFrom: "別のアカウントからこのアカウントに引っ越す" moveFromSub: "別のアカウントへエイリアスを作る" @@ -1730,6 +1807,11 @@ _role: canSearchNotes: "ノート探せるかどうか" canUseTranslator: "翻訳使えるかどうか" avatarDecorationLimit: "アイコンデコのいっちばんつけれる数" + canImportAntennas: "アンテナのインポートを許す" + canImportBlocking: "ブロックのインポートを許す" + canImportFollowing: "フォローのインポートを許す" + canImportMuting: "ミュートのインポートを許す" + canImportUserLists: "リストのインポートを許す" _condition: roleAssignedTo: "マニュアルロールにアサイン済み" isLocal: "ローカルユーザー" @@ -1947,7 +2029,6 @@ _theme: buttonBg: "ボタンの背景" buttonHoverBg: "ボタンの背景 (ホバー)" inputBorder: "入力ボックスの縁取り" - listItemHoverBg: "リスト項目の背景 (ホバー)" driveFolderBg: "ドライブフォルダーの背景" wallpaperOverlay: "壁紙のオーバーレイ" badge: "バッジ" @@ -2106,6 +2187,7 @@ _permissions: "read:clip-favorite": "クリップのいいね見る" "read:federation": "連合の情報取得" "write:report-abuse": "違反報告" + "write:chat": "チャットを操作するで" _auth: shareAccessTitle: "アプリへのアクセス許してやったらどうや" shareAccess: "「{name}」がアカウントにアクセスすることを許可してええか?" @@ -2114,8 +2196,11 @@ _auth: permissionAsk: "このアプリは次の権限を要求しとるで" pleaseGoBack: "アプリケーションに戻ってええよ" callback: "アプリケーションに戻っとるで" + accepted: "アクセスを許可したで" denied: "アクセスを拒否ったで" + scopeUser: "以下のユーザーとしていじってるで" pleaseLogin: "アプリにアクセスさせるんやったら、ログインしてや。" + byClickingYouWillBeRedirectedToThisUrl: "アクセスを許したら、自動で下のURLに遷移するで" _antennaSources: all: "みんなのノート" homeTimeline: "フォローしとるユーザーのノート" @@ -2224,6 +2309,9 @@ _profile: changeBanner: "バナー画像を変更するで" verifiedLinkDescription: "内容をURLに設定すると、リンク先のwebサイトに自分のプロフのリンクが含まれてる場合に所有者確認済みアイコンを表示させることができるで。" avatarDecorationMax: "最大{max}つまでデコつけれんで" + followedMessage: "フォローされたら返すメッセージ" + followedMessageDescription: "フォローされたときに相手に返す短めのメッセージを決めれるで。" + followedMessageDescriptionForLockedAccount: "フォローが承認制なら、フォローリクエストをOKしたときに見せるで。" _exportOrImport: allNotes: "全てのノート" favoritedNotes: "お気に入りにしたノート" @@ -2286,9 +2374,6 @@ _pages: newPage: "ページを作る" editPage: "ページの編集" readPage: "ソースを表示中" - created: "ページを作成したで" - updated: "ページを更新したで" - deleted: "ページを削除したで" pageSetting: "ページ設定" nameAlreadyExists: "指定されたページURLはもうあるみたいや" invalidNameTitle: "正しくないページURLみたいやで" @@ -2316,6 +2401,7 @@ _pages: eyeCatchingImageSet: "アイキャッチ画像を設定" eyeCatchingImageRemove: "アイキャッチ画像を削除" chooseBlock: "ブロックを追加" + enterSectionTitle: "セクションタイトルを入れる" selectType: "種類を選択" contentBlocks: "コンテンツ" inputBlocks: "入力" @@ -2361,13 +2447,17 @@ _notification: renotedBySomeUsers: "{n}人がリノートしたで" followedBySomeUsers: "{n}人にフォローされたで" flushNotification: "通知の履歴をリセットする" + exportOfXCompleted: "{x}のエクスポートが終わったわ" + login: "ログインしとったで" + createToken: "アクセストークンが作成されたで" + createTokenDescription: "心当たりないんやったら「{text}」でアクセストークンを削除してやって。" _types: all: "すべて" note: "あんたらの新規投稿" follow: "フォロー" mention: "メンション" reply: "リプライ" - renote: "Renote" + renote: "リノート" quote: "引用" reaction: "ツッコミ" pollEnded: "アンケートが終了したで" @@ -2375,11 +2465,14 @@ _notification: followRequestAccepted: "フォローが受理されたで" roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" + exportCompleted: "エクスポート終わった" + login: "ログイン" + test: "通知テスト" app: "連携アプリからの通知や" _actions: followBack: "フォローバック" reply: "返事" - renote: "Renote" + renote: "リノート" _deck: alwaysShowMainColumn: "いつもメインカラムを表示" columnAlign: "カラムの寄せ" @@ -2440,7 +2533,10 @@ _webhookSettings: abuseReport: "ユーザーから通報があったとき" abuseReportResolved: "ユーザーからの通報を処理したとき" userCreated: "ユーザーが作成されたとき" + inactiveModeratorsWarning: "モデレーターがしばらくおらんかったとき" + inactiveModeratorsInvitationOnlyChanged: "モデレーターがしばらくおらんかったから、システムが招待制に変えたとき" deleteConfirm: "ほんまにWebhookをほかしてもええんか?" + testRemarks: "スイッチ右のボタンを押すとダミーデータを使ったテスト用Webhookを送れるで。" _abuseReport: _notificationRecipient: createRecipient: "通報の通知先を追加" @@ -2484,6 +2580,8 @@ _moderationLogTypes: markSensitiveDriveFile: "ファイルをセンシティブ付与" unmarkSensitiveDriveFile: "ファイルをセンシティブ解除" resolveAbuseReport: "苦情を解決" + forwardAbuseReport: "通報を転送" + updateAbuseReportNote: "通報のモデレーションノート更新" createInvitation: "招待コード作る" createAd: "広告を作んで" deleteAd: "広告ほかす" @@ -2495,6 +2593,14 @@ _moderationLogTypes: unsetUserBanner: "この子のバナー元に戻す" createSystemWebhook: "SystemWebhookを作成" updateSystemWebhook: "SystemWebhookを更新" + deleteSystemWebhook: "SystemWebhookを削除" + createAbuseReportNotificationRecipient: "通報の通知先作る" + updateAbuseReportNotificationRecipient: "通報の通知先更新" + deleteAbuseReportNotificationRecipient: "通報の通知先消す" + deleteAccount: "アカウント消す" + deletePage: "ページ消す" + deleteFlash: "Playをほかす" + deleteGalleryPost: "ギャラリーの投稿をほかす" _fileViewer: title: "ファイルの詳しい情報" type: "ファイルの種類" @@ -2508,10 +2614,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "配ってるとこが信頼できるか確認した上でインストールしてな。" _plugin: title: "このプラグイン、インストールする?" - metaTitle: "プラグイン情報" _theme: title: "このテーマインストールする?" - metaTitle: "テーマ情報" _meta: base: "" _vendorInfo: @@ -2626,3 +2730,136 @@ _mediaControls: pip: "ピクチャインピクチャ" playbackRate: "再生速度" loop: "ループ再生" +_contextMenu: + title: "コンテキストメニュー" + app: "アプリ" + appWithShift: "Shiftキーでアプリ" + native: "ブラウザのUI" +_gridComponent: + _error: + requiredValue: "この値は必須項目やで" + columnTypeNotSupport: "正規表現によるバリデーションはtype:textのカラムだけサポートしてるで" + patternNotMatch: "この値は{pattern}のパターンに一致しいひんで" + notUnique: "この値は一意でなあかんで" +_roleSelectDialog: + notSelected: "選択されとらんで" +_customEmojisManager: + _gridCommon: + copySelectionRows: "選択行をコピーするで" + copySelectionRanges: "選択範囲をコピーするで" + deleteSelectionRows: "選択行を削除するで" + deleteSelectionRanges: "選択範囲の値をクリアするで" + searchSettings: "検索設定" + searchSettingCaption: "検索条件を詳しく設定するで。" + searchLimit: "表示件数" + sortOrder: "並び順" + registrationLogs: "登録ログ" + registrationLogsCaption: "絵文字更新・削除時のログが表示されるで。更新・削除操作をしたり、ページを遷移・リロードしたら消えるから気ぃつけてな。" + alertEmojisRegisterFailedDescription: "絵文字の更新・削除に失敗したで。詳細は登録ログを確認してな。" + _logs: + showSuccessLogSwitch: "成功ログを表示するで" + failureLogNothing: "失敗ログはあらへん。" + logNothing: "失敗ログはあらへん。" + _remote: + selectionRowDetail: "選択行の詳細やで" + importSelectionRows: "選択行をインポートするで" + importSelectionRangesRows: "選択範囲の行をインポートするで" + importEmojisButton: "チェックされた絵文字をインポートするで" + confirmImportEmojisTitle: "絵文字のインポートするで" + confirmImportEmojisDescription: "リモートから受信した{count}個の絵文字をインポートするで。絵文字のライセンスには十分気ぃつけてな。実行してもええか?" + _local: + tabTitleList: "登録済み絵文字一覧" + tabTitleRegister: "絵文字の登録" + _list: + emojisNothing: "登録された絵文字はないで。" + markAsDeleteTargetRows: "選択行を削除対象にするで" + markAsDeleteTargetRanges: "選択範囲の行を削除対象にするで" + alertUpdateEmojisNothingDescription: "変更された絵文字はないで。" + alertDeleteEmojisNothingDescription: "削除対象の絵文字はないで。" + confirmMovePage: "ページを移動してもええんか?" + confirmChangeView: "表示を変更してもええんか?" + confirmUpdateEmojisDescription: "{count}個の絵文字を更新するで。実行してもええか?" + confirmDeleteEmojisDescription: "チェックがつけられた{count}個の絵文字を削除するで。ほんまにええか?" + confirmResetDescription: "今までやった変更が全部リセットされるで。" + confirmMovePageDesciption: "このページの絵文字に変更が加えられてるで。\n保存せずページを移動してまうと、このページで加えた変更が全てパーになるで。" + dialogSelectRoleTitle: "絵文字に設定されたロールで検索" + _register: + uploadSettingTitle: "アップロード設定" + uploadSettingDescription: "この画面で絵文字アップロードするときの動きを設定できるで。" + directoryToCategoryLabel: "ディレクトリ名を\"category\"に入力する" + directoryToCategoryCaption: "ディレクトリをドラッグ・ドロップした時に、ディレクトリ名を\"category\"に入力します。" + emojiInputAreaCaption: "どれかの方法で登録する絵文字を選択して。" + emojiInputAreaList1: "この枠に画像ファイルかディレクトリをドラッグ&ドロップ" + emojiInputAreaList2: "このリンクをクリックしてPCから選択する" + emojiInputAreaList3: "このリンクをクリックしてドライブから選択する" + confirmRegisterEmojisDescription: "リストに表示されてる絵文字を新たなカスタム絵文字として登録するで。ほんまにええか? (サーバーがしんどくなるから、一回で登録できる絵文字は{count}件までやで)" + confirmClearEmojisDescription: "編集内容をほかして、リストに表示されている絵文字をクリアするで。ほんまにええか?" + confirmUploadEmojisDescription: "ドラッグ&ドロップされた{count}個のファイルをドライブにアップロードするで。ほんまにええか?" +_embedCodeGen: + title: "埋め込みコードをカスタム" + header: "ヘッダー出す" + autoload: "勝手に続きを読み込む(非推奨)" + maxHeight: "高さの最大値" + maxHeightDescription: "0は最大値を指定せえへんけど、ウィジェットが伸び続けるから絶対1以上にしといてや。" + maxHeightWarn: "高さの最大値が無効になっとるで。意図してへん変更なら、普通の値に戻してや。" + previewIsNotActual: "プレビュー画面で出せる範囲をはみ出したから、ホンマの表示とはちゃうとおもうで。" + rounded: "角丸める" + border: "外枠に枠線つける" + applyToPreview: "プレビューに反映" + generateCode: "埋め込みコード作る" + codeGenerated: "コード作ったで" + codeGeneratedDescription: "作ったコードはウェブサイトに貼っつけて使ってや。" +_selfXssPrevention: + warning: "警告" + title: "「この画面になんか貼り付けろ」は全部詐欺やで。" + description1: "ここになんかはつっつけると、悪いユーザーにアカウント乗っ取られたり、個人情報盗まれたりするかもやで" + description2: "はっつけようとしてるものがなんなんかわからんのやったら、%c今すぐ作業やめてウィンドウを閉じて。" + description3: "詳しくはこれを見て。{link}" +_followRequest: + recieved: "もらった申請" + sent: "送った申請" +_remoteLookupErrors: + _federationNotAllowed: + title: "このサーバーと通信できん" + description: "このサーバーとの通信は無効化されてるか、このサーバーをブロックしてるんか、ブロックされてるかもしれん。\nサーバー管理者に問い合わせてや。" + _uriInvalid: + title: "URIがおかしいで" + description: "入力されたURIに問題があるで。URIに使えん文字を入れてないから確かめて。" + _requestFailed: + title: "リクエスト失敗してもうたで" + description: "このサーバーとの通信に失敗してもうたわ。相手サーバーがダウンしてるかもしれん。あと、おかしいURIとか、ありえんURIを入れてないか確かめて。" + _responseInvalid: + title: "レスポンスがおかしいで" + description: "このサーバーと通信することはできたけど、もらったデータがおかしかったで。" + _noSuchObject: + title: "見つからへんね" + description: "求められたリソースが見つからんかったで。URIをもっかい確かめてや。" +_captcha: + verify: "CAPTCHAしばいたって" + testSiteKeyMessage: "サイトキーとシークレットキーにテスト用の値を入力することでプレビューを確認できるで。\n詳細は下記ページを確認してな。" + _error: + _requestFailed: + title: "CAPTCHAのリクエストに失敗してもうた" + text: "しばらく後で実行するか、設定をもっかい確認してや。" + _verificationFailed: + title: "CAPTCHAのリクエストに失敗してもうた" + text: "設定がほんまに合ってるかもっかい確認してや。" + _unknown: + title: "CAPTCHAエラー" + text: "思いもせんかったエラーが起きたわ。" +_bootErrors: + title: "読み込みに失敗したで" + serverError: "少し待ってからリロードしてもまだ問題が解決されんのやったら、以下のError IDを添えてサーバー管理者に連絡して。" + solution: "以下のことやったら解決するかもやで。" + solution1: "ブラウザとかOSを最新バージョンに更新する" + solution2: "アドブロッカーを無効にする" + solution3: "ブラウザのキャッシュをクリアする" + solution4: "(Tor Browser) dom.webaudio.enabledをtrueに設定する" + otherOption: "ほかのオプション" + otherOption1: "クライアント設定とキャッシュをほかす" + otherOption2: "簡易クライアントを起動" + otherOption3: "修復ツールを起動" +_search: + searchScopeAll: "みんな" + searchScopeLocal: "ローカル" + searchScopeUser: "ユーザー指定" diff --git a/locales/kn-IN.yml b/locales/kn-IN.yml index b3ad46f2b1..222599572a 100644 --- a/locales/kn-IN.yml +++ b/locales/kn-IN.yml @@ -77,6 +77,8 @@ _profile: username: "ಬಳಕೆಹೆಸರು" _notification: youWereFollowed: "ಹಿಂಬಾಲಿಸಿದರು" + _types: + login: "ಪ್ರವೇಶ" _actions: reply: "ಉತ್ತರಿಸು" _deck: diff --git a/locales/ko-GS.yml b/locales/ko-GS.yml index 9323ed2a26..6e0ed8ce81 100644 --- a/locales/ko-GS.yml +++ b/locales/ko-GS.yml @@ -263,7 +263,6 @@ deleteAreYouSure: "‘{x}’(얼)럴 뭉캡니꺼?" resetAreYouSure: "아시로 데돌립니꺼?" areYouSure: "갠찮십니꺼?" saved: "저장햇십니다" -messaging: "대화" upload: "올리기" keepOriginalUploading: "온본 두기" keepOriginalUploadingDescription: "이미지럴 올릴 때 온본얼 고대로 둡니다. 꺼모 올릴 때 브라우저서 웹 공개 이미지럴 맨겁니다." @@ -276,7 +275,6 @@ uploadFromUrlMayTakeTime: "올리기가 껕날라먼 시간이 쪼매 걸릴 깁 explore: "살펴보기" messageRead: "이럿어예" noMoreHistory: "요카마 옛날 기록이 어ᇝ십니다" -startMessaging: "대화하기" nUsersRead: "{n}멩이 이럿십니다" agreeTo: "{0}에 동이하기" agree: "동이합니다" @@ -356,7 +354,6 @@ enableLocalTimeline: "로컬 타임라인 키기" enableGlobalTimeline: "글로벌 타임라인 키기" disablingTimelinesInfo: "요 타임라인얼 꺼도 간리자하고 중재자넌 고대로 설 수 잇십니다." registration: "맨걸기" -enableRegistration: "누라도 새로 맨걸 수 잇거로 하기" invite: "초대하기" driveCapacityPerLocalAccount: "로컬 사용자 하나마중 드라이브 커기" driveCapacityPerRemoteAccount: "웬겍 사용자 하나마중 드라이브 커기" @@ -458,8 +455,6 @@ retype: "다시 서기" noteOf: "{user}님으 노트" quoteAttached: "따옴" quoteQuestion: "따와가 작성하겠십니까?" -noMessagesYet: "아직 대화가 없십니다" -newMessageExists: "새 메시지가 있십니다" onlyOneFileCanBeAttached: "메시지엔 파일 하나까제밖에 몬 넣십니다" invitations: "초대하기" invitationCode: "초대장" @@ -468,7 +463,7 @@ tooShort: "억수로 짜립니다" tooLong: "억수로 집니다" passwordMatched: "맞십니다" passwordNotMatched: "안 맞십니다" -signinWith: "{n}서 로그인" +signinWith: "{x} 서 로그인" signinFailed: "로그인 몬 했십니다. 고 이름이랑 비밀번호 제대로 썼는가 확인해 주이소." or: "아니면" language: "언어" @@ -476,7 +471,6 @@ uiLanguage: "UI 표시 언어" aboutX: "{x}에 대해서" emojiStyle: "이모지 모양" native: "기본" -disableDrawer: "드로어 메뉴 쓰지 않기" showNoteActionsOnlyHover: "마우스 올맀을 때만 노트 액션 버턴 보이기" noHistory: "기록이 없십니다" signinHistory: "로그인 기록" @@ -583,6 +577,9 @@ describeFile: "캡션 옇기" enterFileDescription: "캡션 서기" author: "맨던 사람" manage: "간리" +large: "커게" +medium: "엔갆게" +small: "쪼맪게" emailServer: "전자우펜 서버" email: "전자우펜" emailAddress: "전자우펜 주소" @@ -599,7 +596,6 @@ reportAbuseOf: "{name}님얼 신고하기" reporter: "신고한 사람" reporteeOrigin: "신고덴 사람" reporterOrigin: "신고한 곳" -forwardReport: "웬겍 서버에 신고 보내기" waitingFor: "{x}(얼)럴 지달리고 잇십니다" random: "무작이" system: "시스템" @@ -613,12 +609,14 @@ followersCount: "팔로워 수" noteFavoritesCount: "질겨찾기한 노트 수" clips: "클립 맨걸기" clearCache: "캐시 비우기" +nUsers: "{n} 사용자" typingUsers: "{users} 님이 서고 잇어예" unlikeConfirm: "좋네예럴 무룹니꺼?" info: "정보" selectAccount: "계정 개리기" user: "사용자" administration: "간리" +middle: "엔갆게" translatedFrom: "{x}서 번옉" on: "킴" off: "껌" @@ -633,6 +631,7 @@ oneMonth: "한 달" file: "파일" typeToConfirm: "게속할라먼 {x}럴 누질라 주이소" pleaseSelect: "개리 주이소" +remoteOnly: "웬겍만" tools: "도구" like: "좋네예!" unlike: "좋네예 무루기" @@ -643,12 +642,21 @@ role: "옉할" noRole: "옉할이 어ᇝ십니다" thisPostMayBeAnnoyingCancel: "아이예" likeOnly: "좋네예마" +hiddenTags: "수ᇚ훈 해시태그" myClips: "내 클립" +preservedUsernames: "예약 사용자 이럼" +specifyUser: "사용자 지정" icon: "아바타" replies: "답하기" renotes: "리노트" attach: "옇기" surrender: "아이예" +information: "정보" +_chat: + invitations: "초대하기" + noHistory: "기록이 없십니다" + members: "구성원" + home: "덜머리" _delivery: stop: "고만 보내예" _type: @@ -709,6 +717,16 @@ _achievements: description: "0분 0초에 노트를 섰어예" _tutorialCompleted: description: "길라잡이럴 껕냇십니다" +_role: + displayOrder: "보기 순서" + _priority: + middle: "엔갆게" + _options: + canHideAds: "강고 수ᇚ후기" + _condition: + isRemote: "웬겍 사용자" + isCat: "갱이 사용자" + isBot: "자동 사용자" _gallery: my: "내 걸" liked: "좋네예한 걸" @@ -792,10 +810,13 @@ _notification: _types: follow: "팔로잉" mention: "멘션" + renote: "리노트" quote: "따오기" reaction: "반엉" + login: "로그인" _actions: reply: "답하기" + renote: "리노트" _deck: _columns: notifications: "알림" @@ -821,3 +842,9 @@ _reversi: black: "꺼멍" white: "허영" total: "합게" +_remoteLookupErrors: + _noSuchObject: + title: "몬 찾앗십니다" +_search: + searchScopeAll: "말캉" + searchScopeUser: "사용자 지정" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 34c1cc3ebf..9ee5f19513 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -5,9 +5,13 @@ introMisskey: "환영합니다! Misskey는 오픈 소스 분산형 마이크로 poweredByMisskeyDescription: "{name} 서버는 오픈소스 플랫폼 Misskey의 서버 가운데 하나입니다." monthAndDay: "{month}월 {day}일" search: "검색" +reset: "초기화" notifications: "알림" username: "유저명" password: "비밀번호" +initialPasswordForSetup: "초기 설정용 비밀번호" +initialPasswordIsIncorrect: "초기 설정용 비밀번호가 올바르지 않습니다." +initialPasswordForSetupDescription: "Misskey를 직접 설치하는 경우, 설정 파일에 입력해둔 비밀번호를 사용하세요.\nMisskey 설치를 도와주는 호스팅 서비스 등을 사용하는 경우, 서비스 제공자로부터 받은 비밀번호를 사용하세요.\n비밀번호를 따로 설정하지 않은 경우, 아무것도 입력하지 않아도 됩니다." forgotPassword: "비밀번호 재설정" fetchingAsApObject: "연합에서 찾아보는 중" ok: "확인" @@ -39,12 +43,13 @@ favorite: "즐겨찾기" favorites: "즐겨찾기" unfavorite: "즐겨찾기에서 제거" favorited: "즐겨찾기에 등록했습니다." -alreadyFavorited: "이미 즐겨찾기에 등록했습니다." +alreadyFavorited: "이미 즐겨찾기에 등록되어 있습니다." cantFavorite: "즐겨찾기에 등록하지 못했습니다." pin: "프로필에 고정" unpin: "프로필에서 고정 해제" copyContent: "내용 복사" copyLink: "링크 복사" +copyRemoteLink: "리모트 서버의 링크로 복사하기" copyLinkRenote: "리노트 링크 복사" delete: "삭제" deleteAndEdit: "삭제 후 편집" @@ -52,14 +57,15 @@ deleteAndEditConfirm: "이 노트를 삭제한 뒤 다시 편집하시겠습니 addToList: "리스트에 추가" addToAntenna: "안테나에 추가" sendMessage: "메시지 보내기" -copyRSS: "RSS 주소 복사" +copyRSS: "RSS 복사" copyUsername: "유저명 복사" copyUserId: "유저 ID 복사" copyNoteId: "노트 ID 복사" copyFileId: "파일 ID 복사" copyFolderId: "폴더 ID 복사" copyProfileUrl: "프로필 URL 복사" -searchUser: "유저 검색" +searchUser: "사용자 검색" +searchThisUsersNotes: "사용자의 노트 검색" reply: "답글" loadMore: "더 보기" showMore: "더 보기" @@ -154,6 +160,7 @@ editList: "리스트 편집" selectChannel: "채널 선택" selectAntenna: "안테나 선택" editAntenna: "안테나 편집" +createAntenna: "안테나 만들기" selectWidget: "위젯 선택" editWidgets: "위젯 편집" editWidgetsExit: "편집 종료" @@ -194,6 +201,7 @@ followConfirm: "{name}님을 팔로우 하시겠습니까?" proxyAccount: "프록시 계정" proxyAccountDescription: "프록시 계정은 특정 조건 하에서 유저의 리모트 팔로우를 대행하는 계정입니다. 예를 들면, 유저가 리모트 유저를 리스트에 넣었을 때, 리스트에 들어간 유저를 아무도 팔로우한 적이 없다면 액티비티가 서버로 배달되지 않기 때문에, 대신 프록시 계정이 해당 유저를 팔로우하도록 합니다." host: "호스트" +selectSelf: "본인을 선택" selectUser: "유저 선택" recipient: "수신인" annotation: "내용에 대한 주석" @@ -209,6 +217,7 @@ perDay: "1일마다" stopActivityDelivery: "액티비티 보내지 않기" blockThisInstance: "이 서버를 차단" silenceThisInstance: "서버를 사일런스" +mediaSilenceThisInstance: "서버의 미디어를 사일런스" operations: "작업" software: "소프트웨어" version: "버전" @@ -230,6 +239,10 @@ blockedInstances: "차단된 서버" blockedInstancesDescription: "차단하려는 서버의 호스트 이름을 줄바꿈으로 구분하여 설정합니다. 차단된 인스턴스는 이 인스턴스와 통신할 수 없게 됩니다." silencedInstances: "사일런스한 서버" silencedInstancesDescription: "사일런스하려는 서버의 호스트명을 한 줄에 하나씩 입력합니다. 사일런스된 서버에 소속된 유저는 모두 '사일런스'된 상태로 취급되며, 이 서버로부터의 팔로우가 프로필 설정과 무관하게 승인제로 변경되고, 팔로워가 아닌 로컬 유저에게는 멘션할 수 없게 됩니다. 정지된 서버에는 적용되지 않습니다." +mediaSilencedInstances: "미디어를 사일런스한 서버" +mediaSilencedInstancesDescription: "미디어를 사일런스 하려는 서버의 호스트를 한 줄에 하나씩 입력합니다. 미디어가 사일런스된 서버의 유저가 업로드한 파일은 모두 민감한 미디어로 처리되며, 커스텀 이모지를 사용할 수 없게 됩니다. 또한, 차단한 인스턴스에는 적용되지 않습니다." +federationAllowedHosts: "연합을 허가하는 서버" +federationAllowedHostsDescription: "연합을 허가하는 서버의 호스트를 엔터로 구분해서 설정합니다." muteAndBlock: "뮤트 및 차단" mutedUsers: "뮤트한 유저" blockedUsers: "차단한 유저" @@ -276,7 +289,6 @@ deleteAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?" resetAreYouSure: "초기화 하시겠습니까?" areYouSure: "계속 진행하시겠습니까?" saved: "저장했습니다" -messaging: "대화" upload: "업로드" keepOriginalUploading: "원본 이미지를 유지" keepOriginalUploadingDescription: "이미지를 업로드할 때에 원본을 그대로 유지합니다. 비활성화하면 업로드할 때 브라우저에서 웹 공개용 이미지를 생성합니다." @@ -289,7 +301,6 @@ uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 explore: "둘러보기" messageRead: "읽음" noMoreHistory: "이것보다 과거의 기록이 없습니다" -startMessaging: "대화 시작하기" nUsersRead: "{n}명이 읽음" agreeTo: "{0}에 동의" agree: "동의합니다" @@ -328,6 +339,7 @@ renameFolder: "폴더 이름 바꾸기" deleteFolder: "폴더 삭제" folder: "폴더" addFile: "파일 추가" +showFile: "파일 표시하기" emptyDrive: "드라이브가 비어 있습니다" emptyFolder: "폴더가 비어 있습니다" unableToDelete: "삭제할 수 없습니다" @@ -370,10 +382,9 @@ enableLocalTimeline: "로컬 타임라인 활성화" enableGlobalTimeline: "글로벌 타임라인 활성화" disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리자 및 모더레이터는 계속 사용할 수 있습니다." registration: "등록" -enableRegistration: "신규 회원가입을 활성화" invite: "초대" driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량" -driveCapacityPerRemoteAccount: "리모트 유저 한 명당 드라이브 용량" +driveCapacityPerRemoteAccount: "원격 사용자별 드라이브 용량" inMb: "메가바이트 단위" bannerUrl: "배너 이미지 URL" backgroundImageUrl: "배경 이미지 URL" @@ -442,6 +453,7 @@ totpDescription: "인증 앱을 사용하여 일회성 비밀번호 입력" moderator: "모더레이터" moderation: "조정" moderationNote: "조정 기록" +moderationNoteDescription: "모더레이터 역할을 가진 유저만 보이는 메모를 적을 수 있습니다." addModerationNote: "조정 기록 추가하기" moderationLogs: "모더레이션 로그" nUsersMentioned: "{n}명이 언급함" @@ -477,8 +489,6 @@ noteOf: "{user}의 노트" quoteAttached: "인용함" quoteQuestion: "인용해서 작성하시겠습니까?" attachAsFileQuestion: "붙여넣으려는 글이 너무 깁니다. 텍스트 파일로 첨부하시겠습니까?" -noMessagesYet: "아직 대화가 없습니다" -newMessageExists: "새 메시지가 있습니다" onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하나까지입니다" signinRequired: "진행하기 전에 로그인을 해 주세요" signinOrContinueOnRemote: "계속하려면 사용하는 서버로 이동하거나 이 서버에 로그인해야 합니다." @@ -503,7 +513,10 @@ uiLanguage: "UI 표시 언어" aboutX: "{x}에 대하여" emojiStyle: "이모지 스타일" native: "기본" -disableDrawer: "드로어 메뉴를 사용하지 않기" +menuStyle: "메뉴 스타일" +style: "스타일" +drawer: "서랍" +popup: "팝업" showNoteActionsOnlyHover: "마우스가 올라간 때에만 노트 동작 버튼을 표시하기" showReactionsCount: "노트의 반응 수를 표시하기" noHistory: "기록이 없습니다" @@ -571,6 +584,7 @@ masterVolume: "마스터 볼륨" notUseSound: "음소거 하기" useSoundOnlyWhenActive: "Misskey를 활성화한 때에만 소리를 출력하기" details: "자세히" +renoteDetails: "리노트 상세 내용" chooseEmoji: "이모지 선택" unableToProcess: "작업을 완료할 수 없습니다" recentUsed: "최근 사용" @@ -586,6 +600,8 @@ ascendingOrder: "오름차순" descendingOrder: "내림차순" scratchpad: "스크래치 패드" scratchpadDescription: "스크래치 패드는 AiScript 의 테스트 환경을 제공합니다. Misskey 와 상호 작용하는 코드를 작성, 실행 및 결과를 확인할 수 있습니다." +uiInspector: "UI 인스펙터" +uiInspectorDescription: "메모리에 있는 UI 컴포넌트의 인스턴트 목록을 볼 수 있습니다. UI 컴포넌트는 Ui:C: 계열 함수로 만들어집니다." output: "출력" script: "스크립트" disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" @@ -666,14 +682,19 @@ smtpSecure: "SMTP 연결에 Implicit SSL/TTS 사용" smtpSecureInfo: "STARTTLS 사용 시에는 해제합니다." testEmail: "이메일 전송 테스트" wordMute: "단어 뮤트" +wordMuteDescription: "정해진 단어가 포함된 노트를 최소화 한 상태로 표시합니다. 최소화 된 노트는 클릭해서 표시할 수 있습니다." hardWordMute: "하드 단어 뮤트" +showMutedWord: "뮤트한 단어를 표시하기" +hardWordMuteDescription: "정한 단어가 들어간 노트를 숨깁니다. 단어 뮤트와 차이점은 노트가 아예 보이지 않습니다." regexpError: "정규 표현식 오류" regexpErrorDescription: "{tab}단어 뮤트 {line}행의 정규 표현식에 오류가 발생했습니다:" instanceMute: "서버 뮤트" userSaysSomething: "{name}님이 무언가를 말했습니다" +userSaysSomethingAbout: "{name}님이 \"{word}\"를 언급했습니다." makeActive: "활성화" display: "보기" copy: "복사" +copiedToClipboard: "클립보드에 복사되었습니다." metrics: "통계" overview: "요약" logs: "로그" @@ -702,10 +723,7 @@ abuseReported: "신고를 보냈습니다. 신고해 주셔서 감사합니다." reporter: "신고자" reporteeOrigin: "피신고자" reporterOrigin: "신고자" -forwardReport: "리모트 서버에도 신고 내용 보내기" -forwardReportIsAnonymous: "리모트 서버에서는 나의 정보를 볼 수 없으며, 익명의 시스템 계정으로 표시됩니다." send: "전송" -abuseMarkAsResolved: "해결됨으로 표시" openInNewTab: "새 탭에서 열기" openInSideView: "사이드뷰로 열기" defaultNavigationBehaviour: "기본 탐색 동작" @@ -907,6 +925,7 @@ followersVisibility: "팔로워의 공개 범위" continueThread: "글타래 더 보기" deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 됩니다. 계속하시겠습니까? " incorrectPassword: "비밀번호가 올바르지 않습니다." +incorrectTotp: "OTP 번호가 틀렸거나 유효기간이 만료되어 있을 수 있습니다." voteConfirm: "\"{choice}\"에 투표하시겠습니까?" hide: "숨기기" useDrawerReactionPickerForMobile: "모바일에서 드로어 메뉴로 표시" @@ -931,6 +950,9 @@ oneHour: "1시간" oneDay: "1일" oneWeek: "일주일" oneMonth: "1개월" +threeMonths: "3개월" +oneYear: "1년" +threeDays: "3일" reflectMayTakeTime: "반영되기까지 시간이 걸릴 수 있습니다." failedToFetchAccountInformation: "계정 정보를 가져오지 못했습니다" rateLimitExceeded: "요청 제한 횟수를 초과하였습니다" @@ -1071,6 +1093,7 @@ retryAllQueuesConfirmTitle: "지금 다시 시도하시겠습니까?" retryAllQueuesConfirmText: "일시적으로 서버의 부하가 증가할 수 있습니다." enableChartsForRemoteUser: "리모트 유저의 차트를 생성" enableChartsForFederatedInstances: "리모트 서버의 차트를 생성" +enableStatsForFederatedInstances: "리모트 서버 정보 받아오기" showClipButtonInNoteFooter: "노트 동작에 클립을 추가" reactionsDisplaySize: "리액션 표시 크기" limitWidthOfReaction: "리액션의 최대 폭을 제한하고 작게 표시하기" @@ -1106,6 +1129,8 @@ preservedUsernames: "예약한 사용자 이름" preservedUsernamesDescription: "예약할 사용자명을 한 줄에 하나씩 입력합니다. 여기에서 지정한 사용자명으로는 계정을 생성할 수 없게 됩니다. 단, 관리자 권한으로 계정을 생성할 때에는 해당되지 않으며, 이미 존재하는 계정도 영향을 받지 않습니다." createNoteFromTheFile: "이 파일로 노트를 작성" archive: "아카이브" +archived: "아카이브 됨" +unarchive: "보관 취소" channelArchiveConfirmTitle: "{name} 채널을 보존하시겠습니까?" channelArchiveConfirmDescription: "보존한 채널은 채널 목록과 검색 결과에 표시되지 않으며 새로운 노트도 작성할 수 없습니다." thisChannelArchived: "이 채널은 보존되었습니다." @@ -1116,6 +1141,9 @@ preventAiLearning: "기계학습(생성형 AI)으로의 사용을 거부" preventAiLearningDescription: "외부의 문장 생성 AI나 이미지 생성 AI에 대해 제출한 노트나 이미지 등의 콘텐츠를 학습의 대상으로 사용하지 않도록 요구합니다. 다만, 이 요구사항을 지킬 의무는 없기 때문에 학습을 완전히 방지하는 것은 아닙니다." options: "옵션" specifyUser: "사용자 지정" +lookupConfirm: "조회 할까요?" +openTagPageConfirm: "해시태그의 페이지를 열까요?" +specifyHost: "호스트 지정" failedToPreviewUrl: "미리 볼 수 없음" update: "업데이트" rolesThatCanBeUsedThisEmojiAsReaction: "이 이모지를 리액션으로 사용할 수 있는 역할" @@ -1232,7 +1260,7 @@ lastNDays: "최근 {n}일" backToTitle: "타이틀로 가기" hemisphere: "거주 지역" withSensitive: "민감한 파일이 포함된 노트 보기" -userSaysSomethingSensitive: "{name} 같은 민감한 파일이 포함된 글" +userSaysSomethingSensitive: "{name}의 민감한 파일이 포함된 게시물" enableHorizontalSwipe: "스와이프하여 탭 전환" loading: "불러오는 중" surrender: "그만두기" @@ -1249,6 +1277,124 @@ alwaysConfirmFollow: "팔로우일 때 항상 확인하기" inquiry: "문의하기" tryAgain: "다시 시도해 주세요." confirmWhenRevealingSensitiveMedia: "민감한 미디어를 열 때 두 번 확인" +sensitiveMediaRevealConfirm: "민감한 미디어입니다. 표시할까요?" +createdLists: "만든 리스트" +createdAntennas: "만든 안테나" +fromX: "{x}에서" +genEmbedCode: "임베디드 코드 만들기" +noteOfThisUser: "이 유저의 노트 목록" +clipNoteLimitExceeded: "더 이상 이 클립에 노트를 추가 할 수 없습니다." +performance: "퍼포먼스" +modified: "변경 있음" +discard: "파기" +thereAreNChanges: "{n}건 변경이 있습니다." +signinWithPasskey: "패스키로 로그인" +unknownWebAuthnKey: "등록되지 않은 패스키입니다." +passkeyVerificationFailed: "패스키 검증을 실패했습니다." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "입력된 패스키는 정상적이나, 비밀번호 없이 로그인 하는 기능이 비활성화 되어있습니다." +messageToFollower: "팔로워에게 보낼 메시지" +target: "대상" +testCaptchaWarning: "CAPTCHA를 테스트하기 위한 기능입니다. 실제 환경에서는 사용하지 마세요." +prohibitedWordsForNameOfUser: "금지 단어 (사용자 이름)" +prohibitedWordsForNameOfUserDescription: "이 목록에 포함되는 키워드가 사용자 이름에 있는 경우, 일반 사용자는 이름을 바꿀 수 없습니다. 모더레이터 권한을 가진 사용자는 제한 대상에서 제외됩니다." +yourNameContainsProhibitedWords: "바꾸려는 이름에 금지된 키워드가 포함되어 있습니다." +yourNameContainsProhibitedWordsDescription: "이름에 금지된 키워드가 있습니다. 이름을 사용해야 하는 경우, 서버 관리자에 문의하세요." +thisContentsAreMarkedAsSigninRequiredByAuthor: "게시자에 의해 로그인해야 볼 수 있도록 설정되어 있습니다." +lockdown: "잠금" +pleaseSelectAccount: "계정을 선택해주세요." +availableRoles: "사용 가능한 역할" +acknowledgeNotesAndEnable: "활성화 하기 전에 주의 사항을 확인했습니다." +federationSpecified: "이 서버는 화이트 리스트 제도로 운영 중 입니다. 정해진 리모트 서버가 아닌 경우 연합되지 않습니다." +federationDisabled: "이 서버는 연합을 하지 않고 있습니다. 리모트 서버 유저와 통신을 할 수 없습니다." +confirmOnReact: "리액션할 때 확인" +reactAreYouSure: "\" {emoji} \"로 리액션하시겠습니까?" +markAsSensitiveConfirm: "이 미디어를 민감한 미디어로 설정하시겠습니까?" +unmarkAsSensitiveConfirm: "이 미디어의 민감한 미디어 지정을 해제하시겠습니까?" +preferences: "환경설정" +accessibility: "접근성" +preferencesProfile: "설정 프로필" +copyPreferenceId: "설정한 ID를 복사" +resetToDefaultValue: "기본값으로 되돌리기" +overrideByAccount: "계정으로 덮어쓰기" +untitled: "제목 없음" +noName: "이름이 없습니다." +skip: "건너뛰기" +restore: "복원" +syncBetweenDevices: "장치간 동기화" +preferenceSyncConflictTitle: "서버에 설정값이 존재합니다." +preferenceSyncConflictText: "동기화를 활성화 한 항목의 설정 값은 서버에 저장되지만, 해당 항목은 이미 서버에 설정 값이 저장되어져 있습니다. 어느 쪽의 설정 값을 덮어씌울까요?" +preferenceSyncConflictChoiceServer: "서버 설정값" +preferenceSyncConflictChoiceDevice: "장치 설정값" +preferenceSyncConflictChoiceCancel: "동기화 취소" +paste: "붙여넣기" +emojiPalette: "이모지 팔레트" +postForm: "글 입력란" +textCount: "문자 수" +information: "정보" +_chat: + invitations: "초대" + noHistory: "기록이 없습니다" + members: "멤버" + home: "홈" + send: "전송" +_emojiPalette: + palettes: "팔레트" + enableSyncBetweenDevicesForPalettes: "팔레트의 디바이스 간 동기화를 활성화" + paletteForMain: "메인으로 사용할 팔레트" + paletteForReaction: "리액션으로 사용할 팔레트" +_settings: + driveBanner: "드라이브 관리, 사용량 확인, 파일 업로드에 관한 설정을 합니다." + pluginBanner: "플러그인을 사용하면 클라이언트 기능을 확장할 수 있습니다. 플러그인 설치와 개별적인 설정을 합니다." + notificationsBanner: "서버에서 받는 알림의 종류 및 범위, 푸시 알림 설정을 합니다." + api: "API" + webhook: "Webhook" + serviceConnection: "서비스 연동" + serviceConnectionBanner: "외부 앱, 서비스와 연결하기 위한 액세스 토큰과 웹 훅 관리 설정을 합니다." + accountData: "계정 데이터" + accountDataBanner: "계정 데이터의 아카이브를 추출하기/가져오기 하여 관리할 수 있습니다." + muteAndBlockBanner: "숨길 컨텐츠의 설정과, 특정 유저의 리액션을 제한하는 설정을 관리합니다." + accessibilityBanner: "좀 더 쾌적하게 사용할 수 있도록 클라이언트의 시각 및 움직임에 관한 개인화 설정을 합니다." + privacyBanner: "컨텐츠, 계정의 발견 범위, 팔로우 승인제 등의 계정의 프라이버시에 관한 설정을 합니다." + securityBanner: "비밀번호, 로그인 방법, OTP, 패스 키 등의 계정의 보안에 관련된 설정을 합니다." + preferencesBanner: "취향에 알맞는 클라이언트의 전체적인 동작을 설정합니다." + appearanceBanner: "취향에 알맞는 클라이언트의 디스플레이, 표시 방법에 관한 설정을 합니다." + soundsBanner: "클라이언트에서 재생할 소리에 대한 설정을 합니다." + timelineAndNote: "타임라인과 노트" + makeEveryTextElementsSelectable: "모든 텍스트 요소를 선택할 수 있도록 함" + makeEveryTextElementsSelectable_description: "활성화 시, 일부 동작에서 사용자의 접근성이 나빠질 수도 있습니다." +_preferencesProfile: + profileName: "프로필 이름" + profileNameDescription: "이 디바이스를 식별할 이름을 설정해 주세요." + profileNameDescription2: "예: '메인PC', '스마트폰' 등" +_preferencesBackup: + autoBackup: "자동 백업" + restoreFromBackup: "백업으로 복구" + noBackupsFoundTitle: "백업을 찾을 수 없습니다" + noBackupsFoundDescription: "자동으로 생성된 백업은 찾을 수 없었지만, 수동으로 백업 파일을 저장한 경우 해당 파일을 가져와 복원할 수 있습니다." + selectBackupToRestore: "복원할 백업을 선택하세요" + youNeedToNameYourProfileToEnableAutoBackup: "자동 백업을 활성화하려면 프로필 이름을 설정해야 합니다." + autoPreferencesBackupIsNotEnabledForThisDevice: "이 장치에서 설정 자동 백업이 활성화되어 있지 않습니다." + backupFound: "설정 백업이 발견되었습니다" +_accountSettings: + requireSigninToViewContents: "콘텐츠 열람을 위해 로그인을 필수로 설정하기" + requireSigninToViewContentsDescription1: "자신이 작성한 모든 노트 등의 콘텐츠를 보기 위해 로그인을 필수로 설정합니다. 크롤러가 정보 수집하는 것을 방지하는 효과를 기대할 수 있습니다." + requireSigninToViewContentsDescription2: "URL 미리보기(OGP), 웹페이지에 삽입, 노트 인용을 지원하지 않는 서버에서 볼 수 없게 됩니다." + requireSigninToViewContentsDescription3: "원격 서버에 연합된 콘텐츠에는 이러한 제한이 적용되지 않을 수 있습니다." + makeNotesFollowersOnlyBefore: "과거 노트는 팔로워만 볼 수 있도록 설정하기" + makeNotesFollowersOnlyBeforeDescription: "이 기능이 활성화되어 있는 동안, 설정된 날짜 및 시간보다 과거 또는 설정된 시간이 지난 노트는 팔로워만 볼 수 있게 됩니다. 비활성화하면 노트의 공개 상태도 원래대로 돌아갑니다." + makeNotesHiddenBefore: "과거 노트 비공개로 전환하기" + makeNotesHiddenBeforeDescription: "이 기능이 활성화되어 있는 동안 설정한 날짜 및 시간보다 과거 또는 설정한 시간이 지난 노트는 본인만 볼 수 있게(비공개로 전환) 됩니다. 비활성화하면 노트의 공개 상태도 원래대로 돌아갑니다." + mayNotEffectForFederatedNotes: "원격 서버에 연합된 노트에는 효과가 없을 수도 있습니다." + mayNotEffectSomeSituations: "여기서 설정하는 제한은 모더레이션이나 리모트 서버에서 볼 때 등 일부 환경에서는 적용되지 않을 수도 있습니다." + notesHavePassedSpecifiedPeriod: "지정한 시간이 경과된 노트" + notesOlderThanSpecifiedDateAndTime: "지정된 날짜 및 시간 이전의 노트" +_abuseUserReport: + forward: "전달" + forwardDescription: "익명 시스템 계정을 사용하여 리모트 서버에 신고 내용을 전달할 수 있습니다." + resolve: "해결됨" + accept: "인용" + reject: "기각" + resolveTutorial: "적절한 신고 내용에 대응한 경우, \"인용\"을 선택하여 \"해결됨\"으로 기록합니다.\n적절하지 않은 신고를 받은 경우, \"기각\"을 선택하여 \"기각\"으로 기록합니다." _delivery: status: "전송 상태" stop: "정지됨" @@ -1383,8 +1529,12 @@ _serverSettings: fanoutTimelineDescription: "활성화하면 각종 타임라인을 가져올 때의 성능을 대폭 향상하며, 데이터베이스의 부하를 줄일 수 있습니다. 단, Redis의 메모리 사용량이 증가합니다. 서버의 메모리 용량이 작거나, 서비스가 불안정해지는 경우 비활성화할 수 있습니다." fanoutTimelineDbFallback: "데이터베이스를 예비로 사용하기" fanoutTimelineDbFallbackDescription: "활성화하면 타임라인의 캐시되어 있지 않은 부분에 대해 DB에 질의하여 정보를 가져옵니다. 비활성화하면 이를 실행하지 않음으로써 서버의 부하를 줄일 수 있지만, 타임라인에서 가져올 수 있는 게시물 범위가 한정됩니다." + reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다." inquiryUrl: "문의처 URL" inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다." + openRegistration: "회원 가입을 활성화 하기" + openRegistrationWarning: "회원 가입을 개방하는 것은 리스크가 따릅니다. 서버를 항상 감시할 수 있고, 문제가 발생했을 때 바로 대응할 수 있는 상태에서만 활성화 하는 것을 권장합니다." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "일정 기간동안 모더레이터의 활동이 감지되지 않는 경우, 스팸 방지를 위해 이 설정은 자동으로 꺼집니다." _accountMigration: moveFrom: "다른 계정에서 이 계정으로 이사" moveFromSub: "다른 계정에 대한 별칭을 생성" @@ -1716,10 +1866,15 @@ _role: canSearchNotes: "노트 검색 이용 가능 여부" canUseTranslator: "번역 기능의 사용" avatarDecorationLimit: "아바타 장식의 최대 붙임 개수" + canImportAntennas: "안테나 가져오기 허용" + canImportBlocking: "차단 목록 가져오기 허용" + canImportFollowing: "팔로우 가져오기 허용" + canImportMuting: "뮤트 목록 가져오기 허용" + canImportUserLists: "리스트 목록 가져오기 허용" _condition: roleAssignedTo: "수동 역할에 이미 할당됨" isLocal: "로컬 사용자" - isRemote: "리모트 사용자" + isRemote: "원격 사용자" isCat: "고양이 사용자" isBot: "봇 사용자" isSuspended: "정지된 사용자" @@ -1879,6 +2034,7 @@ _theme: installed: "{name} 테마가 설치되었습니다" installedThemes: "설치된 테마" builtinThemes: "표준 테마" + instanceTheme: "서버 테마" alreadyInstalled: "이미 설치된 테마입니다" invalid: "테마 형식이 올바르지 않습니다" make: "테마 만들기" @@ -1933,7 +2089,6 @@ _theme: buttonBg: "버튼 배경" buttonHoverBg: "버튼 배경 (호버)" inputBorder: "입력 필드 테두리" - listItemHoverBg: "리스트 항목 배경 (호버)" driveFolderBg: "드라이브 폴더 배경" wallpaperOverlay: "배경화면 오버레이" badge: "배지" @@ -1949,10 +2104,11 @@ _sfx: _soundSettings: driveFile: "드라이브에 있는 오디오를 사용" driveFileWarn: "드라이브에 있는 파일을 선택하세요." - driveFileTypeWarn: "이 파일은 지원되지 않습니다." + driveFileTypeWarn: "이 파이" driveFileTypeWarnDescription: "오디오 파일을 선택하세요." driveFileDurationWarn: "오디오가 너무 깁니다" driveFileDurationWarnDescription: "긴 오디오로 설정할 경우 미스키 사용에 지장이 갈 수도 있습니다. 그래도 괜찮습니까?" + driveFileError: "오디오를 불러올 수 없습니다. 설정을 바꿔주세요." _ago: future: "미래" justNow: "방금 전" @@ -2091,6 +2247,7 @@ _permissions: "read:clip-favorite": "클립의 좋아요 보기" "read:federation": "연합 정보 불러오기" "write:report-abuse": "위반 내용 신고하기" + "write:chat": "대화를 시작하거나 메시지를 보냅니다" _auth: shareAccessTitle: "어플리케이션의 접근 허가" shareAccess: "‘{name}’에서 계정에 접근하는 것을 허용하시겠습니까?" @@ -2099,8 +2256,11 @@ _auth: permissionAsk: "이 앱은 다음의 권한을 요청합니다" pleaseGoBack: "앱으로 돌아가서 시도해 주세요" callback: "앱으로 돌아갑니다" + accepted: "접근 권한이 부여되었습니다." denied: "접근이 거부되었습니다" + scopeUser: "다음 사용자로 활동하고 있습니다." pleaseLogin: "어플리케이션의 접근을 허가하려면 로그인하십시오." + byClickingYouWillBeRedirectedToThisUrl: "접근을 허용하면 자동으로 다음 URL로 이동합니다." _antennaSources: all: "모든 노트" homeTimeline: "팔로우중인 유저의 노트" @@ -2209,6 +2369,9 @@ _profile: changeBanner: "배너 이미지 변경" verifiedLinkDescription: "내용에 자신의 프로필로 향하는 링크가 포함된 페이지의 URL을 삽입하면 소유자 인증 마크가 표시됩니다." avatarDecorationMax: "최대 {max}개까지 장식을 할 수 있습니다." + followedMessage: "팔로우 받았을 때 메시지" + followedMessageDescription: "팔로우 받았을 때 상대방에게 보여줄 단문 메시지를 설정할 수 있습니다." + followedMessageDescriptionForLockedAccount: "팔로우를 승인제로 한 경우, 팔로우 요청을 수락했을 때 보여줍니다." _exportOrImport: allNotes: "모든 노트" favoritedNotes: "즐겨찾기한 노트" @@ -2271,9 +2434,6 @@ _pages: newPage: "페이지 만들기" editPage: "페이지 수정" readPage: "소스 표시 중" - created: "페이지를 만들었습니다" - updated: "페이지를 수정했습니다" - deleted: "페이지가 삭제되었습니다" pageSetting: "페이지 설정" nameAlreadyExists: "지정한 페이지 URL이 이미 존재합니다" invalidNameTitle: "유효하지 않은 페이지 URL입니다" @@ -2301,6 +2461,7 @@ _pages: eyeCatchingImageSet: "아이캐치 이미지를 설정" eyeCatchingImageRemove: "아이캐치 이미지를 삭제" chooseBlock: "블록 추가" + enterSectionTitle: "섹션 타이틀을 입력하기" selectType: "종류 선택" contentBlocks: "콘텐츠" inputBlocks: "입력" @@ -2346,6 +2507,10 @@ _notification: renotedBySomeUsers: "{n}명이 리노트했습니다" followedBySomeUsers: "{n}명에게 팔로우됨" flushNotification: "알림 이력을 초기화" + exportOfXCompleted: "{x} 추출에 성공했습니다." + login: "로그인 알림이 있습니다" + createToken: "액세스 토큰이 생성되었습니다" + createTokenDescription: "만약 기억이 나지 않는다면 '{text}'를 통해 액세스 토큰을 삭제해 주세요." _types: all: "전부" note: "사용자의 새 글" @@ -2360,6 +2525,10 @@ _notification: followRequestAccepted: "팔로우 요청이 승인되었을 때" roleAssigned: "역할이 부여 됨" achievementEarned: "도전 과제 획득" + exportCompleted: "추출을 성공함" + login: "로그인" + createToken: "액세스 토큰 만들기" + test: "알림 테스트" app: "연동된 앱을 통한 알림" _actions: followBack: "팔로우" @@ -2386,6 +2555,7 @@ _deck: useSimpleUiForNonRootPages: "루트 이외의 페이지로 접속한 경우 UI 간략화하기" usedAsMinWidthWhenFlexible: "'폭 자동 조정'이 활성화된 경우 최소 폭으로 사용됩니다" flexible: "폭 자동 조정" + enableSyncBetweenDevicesForProfiles: "프로파일 정보의 디바이스 간 동기화를 활성화" _columns: main: "메인" widgets: "위젯" @@ -2411,6 +2581,7 @@ _webhookSettings: modifyWebhook: "Webhook 수정" name: "이름" secret: "시크릿" + trigger: "트리거" active: "활성화" _events: follow: "누군가를 팔로우했을 때" @@ -2424,12 +2595,15 @@ _webhookSettings: abuseReport: "유저로부터 신고를 받았을 때" abuseReportResolved: "받은 신고를 처리했을 때" userCreated: "유저가 생성되었을 때" + inactiveModeratorsWarning: "모더레이터가 일정 기간동안 활동하지 않은 경우" + inactiveModeratorsInvitationOnlyChanged: "모더레이터가 일정 기간 활동하지 않아 시스템에 의해 초대제로 바뀐 경우" deleteConfirm: "Webhook을 삭제할까요?" + testRemarks: "스위치 오른쪽에 있는 버튼을 클릭하여 더미 데이터를 사용한 테스트용 웹 훅을 보낼 수 있습니다." _abuseReport: _notificationRecipient: createRecipient: "신고 수신자 추가" modifyRecipient: "신고 수신자 편집" - recipientType: "알림 수신 유형" + recipientType: "알림 종류" _recipientType: mail: "이메일" webhook: "Webhook" @@ -2437,7 +2611,7 @@ _abuseReport: mail: "모더레이터 권한을 가진 사용자의 이메일 주소에 알림을 보냅니다 (신고를 받은 때에만)" webhook: "지정한 SystemWebhook에 알림을 보냅니다 (신고를 받은 때와 해결했을 때에 송신)" keywords: "키워드" - notifiedUser: "신고 알림을 보낼 유저" + notifiedUser: "알릴 사용자" notifiedWebhook: "사용할 Webhook" deleteConfirm: "수신자를 삭제하시겠습니까?" _moderationLogTypes: @@ -2468,6 +2642,8 @@ _moderationLogTypes: markSensitiveDriveFile: "파일에 열람주의를 설정" unmarkSensitiveDriveFile: "파일에 열람주의를 해제" resolveAbuseReport: "신고 처리" + forwardAbuseReport: "신고 전달" + updateAbuseReportNote: "신고 조정 노트 갱신" createInvitation: "초대 코드 생성" createAd: "광고 생성" deleteAd: "광고 삭제" @@ -2483,6 +2659,11 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "신고 알림 수신자 생성" updateAbuseReportNotificationRecipient: "신고 알림 수신자 편집" deleteAbuseReportNotificationRecipient: "신고 알림 수신자 삭제" + deleteAccount: "계정을 삭제" + deletePage: "페이지를 삭제" + deleteFlash: "Play를 삭제" + deleteGalleryPost: "갤러리 포스트를 삭제" + updateProxyAccountDescription: "프록시 계정의 설명 업데이트" _fileViewer: title: "파일 상세" type: "파일 유형" @@ -2496,10 +2677,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "제공자를 신뢰할 수 있는 경우에만 설치하십시오." _plugin: title: "이 플러그인을 설치하시겠습니까?" - metaTitle: "플러그인 정보" _theme: title: "이 테마를 설치하시겠습니까?" - metaTitle: "테마 정보" _meta: base: "기본 컬러 스키마" _vendorInfo: @@ -2603,7 +2782,7 @@ _urlPreviewSetting: timeoutDescription: "미리보기를 로딩하는데 걸리는 시간이 정한 시간보다 오래 걸리는 경우, 미리보기를 생성하지 않습니다." maximumContentLength: "Content-Length의 최대치 (byte)" maximumContentLengthDescription: "Content-Length가 이 값을 넘어서면 미리보기를 생성하지 않습니다." - requireContentLength: "Content-Length를 얻었을 때만 미리보기 만들기" + requireContentLength: "Content-Length를 받아온 경우에만 " requireContentLengthDescription: "상대 서버가 Content-Length를 되돌려주지 않는다면 미리보기를 만들지 않습니다." userAgent: "User-Agent" userAgentDescription: "미리보기를 얻을 때 사용한 User-Agent를 설정합니다. 비어 있다면 기본값의 User-Agent를 사용합니다." @@ -2614,3 +2793,140 @@ _mediaControls: pip: "화면 속 화면" playbackRate: "재생 속도" loop: "반복 재생" +_contextMenu: + title: "컨텍스트 메뉴" + app: "애플리케이션" + appWithShift: "Shift 키로 애플리케이션" + native: "브라우저의 UI" +_gridComponent: + _error: + requiredValue: "이 값은 필수 항목입니다." + columnTypeNotSupport: "정규표현 규칙이 type:text인 칼럼만 지원합니다." + patternNotMatch: "이 값은 {pattern} 패턴과 일치하지 않습니다." + notUnique: "이 값은 다른 값과 중복되지 않아야 합니다." +_roleSelectDialog: + notSelected: "선택하지 않았습니다." +_customEmojisManager: + _gridCommon: + copySelectionRows: "선택한 행을 복사하기" + copySelectionRanges: "선택범위를 복사하기" + deleteSelectionRows: "선택한 행을 삭제" + deleteSelectionRanges: "선택한 행을 삭제" + searchSettings: "검색 설정" + searchSettingCaption: "고급 검색을 설정합니다." + searchLimit: "표시 건수" + sortOrder: "정렬 순서" + registrationLogs: "등록 로그" + registrationLogsCaption: "이모지를 갱신하거나 삭제할 때 로그가 표시됩니다. 갱신 또는 삭제하거나, 페이지 이동, 새로 고침하면 삭제됩니다." + alertEmojisRegisterFailedDescription: "이모지를 갱신 또는 삭제하지 못했습니다. 자세한 내용은 등록 로그를 확인해주세요." + _logs: + showSuccessLogSwitch: "성공 로그를 표시" + failureLogNothing: "실패 로그가 없습니다." + logNothing: "로그가 없습니다." + _remote: + selectionRowDetail: "선택 행 (상세)" + importSelectionRows: "선택 행을 가져오기" + importSelectionRangesRows: "선택한 범위 안의 행을 가져오기" + importEmojisButton: "선택한 이모지를 가져오기" + confirmImportEmojisTitle: "이모지 가져오기" + confirmImportEmojisDescription: "리모트 서버에서 받아온 이모지 {count}개를 이 서버로 가져옵니다. 이모지의 저작권, 라이선스를 확실히 확인하셨다면 실행해주세요." + _local: + tabTitleList: "등록한 이모지 리스트" + tabTitleRegister: "이모지 등록" + _list: + emojisNothing: "등록한 이모지가 없습니다." + markAsDeleteTargetRows: "선택한 행을 삭제할 대상으로 하기" + markAsDeleteTargetRanges: "선택한 범위의 행을 삭제 대상으로 하기" + alertUpdateEmojisNothingDescription: "변경할 이모지가 없습니다." + alertDeleteEmojisNothingDescription: "삭제 대상의 이모지는 없습니다." + confirmMovePage: "페이지를 이동할까요?" + confirmChangeView: "표시를 바꿀까요?" + confirmUpdateEmojisDescription: "{count}개의 이모지를 갱신합니다. 실행할까요?" + confirmDeleteEmojisDescription: "선택한 이모지 {count}개를 삭제합니다. 실행할까요?" + confirmResetDescription: "지금까지 했던 변경 내용이 모두 초기화됩니다." + confirmMovePageDesciption: "이 페이지의 이모지에 변경이 있습니다.\n저장하지 않은 상태로 페이지를 이동하면, 이 페이지에서 바꾼 변경 내용이 모두 지워집니다." + dialogSelectRoleTitle: "이모지에 설정된 역할을 검색" + _register: + uploadSettingTitle: "업로드 설정" + uploadSettingDescription: "여기서 이모지를 업로드 할 때의 동작을 설정할 수 있습니다." + directoryToCategoryLabel: "디렉토리 이름을 \"category\"로 입력하기" + directoryToCategoryCaption: "디렉토리를 드래그 앤 드롭한 경우, 디렉토리 이름을 \"category\"로 입력합니다." + emojiInputAreaCaption: "이모지를 등록할 방법을 선택해주세요." + emojiInputAreaList1: "이 틀 안에 이미지 파일 또는 디렉토리를 끌어서 가져오기" + emojiInputAreaList2: "이 링크를 클릭해서 PC에서 선택하기" + emojiInputAreaList3: "이 링크를 클릭해서 드라이브에서 선택하기" + confirmRegisterEmojisDescription: "리스트에 표시되어진 이모지를 새로운 커스텀 이모지로 등록합니다. 실행할까요? (부하를 피하기 위해, 한 번에 등록할 수 있는 이모지는 {count}건까지 입니다.)" + confirmClearEmojisDescription: "편집 내용을 지우고, 목록에 표시되어진 이모지를 지웁니다. 실행할까요?" + confirmUploadEmojisDescription: "드래그 앤 드롭한 {count}개의 파일을 드라이브에 업로드 합니다. 실행할까요?" +_embedCodeGen: + title: "임베디드 코드를 커스터마이즈" + header: "해더를 표시" + autoload: "자동으로 다음 코드를 실행 (비권장)" + maxHeight: "최대 높이" + maxHeightDescription: "최대 값을 무시하려면 0을 입력하세요. 위젯이 상하로 길어지는 것을 방지하려면, 임의의 값을 입력해 주세요." + maxHeightWarn: "높이 최대 값이 설정되어져 있지 않습니다(0). 의도적으로 설정 하지 않았다면 임의의 값을 설정해주세요." + previewIsNotActual: "미리보기로 표시할 수 있는 크기보다 큽니다. 실제로 넣은 코드의 표시가 다른 경우가 있습니다." + rounded: "외곽선을 둥글게 하기" + border: "외곽선에 테두리를 씌우기" + applyToPreview: "미리보기에 반영" + generateCode: "임베디드 코드를 만들기" + codeGenerated: "코드를 만들었습니다." + codeGeneratedDescription: "만들어진 코드를 웹 사이트에 붙여서 사용하세요." +_selfXssPrevention: + warning: "경고" + title: "“이 화면에 뭔가를 붙여넣어라\"는 것은 모두 사기입니다." + description1: "여기에 무언가를 붙여넣으면 악의적인 사용자에게 계정을 탈취당하거나 개인정보를 도용당할 수 있습니다." + description2: "붙여 넣으려는 항목이 무엇인지 정확히 이해하지 못하는 경우, %c지금 바로 작업을 중단하고 이 창을 닫으십시오." + description3: "자세한 내용은 여기를 확인해 주세요. {link}" +_followRequest: + recieved: "받은 신청" + sent: "보낸 신청" +_remoteLookupErrors: + _federationNotAllowed: + title: "이 서버와 통신할 수 없음" + description: "이 서버와의 통신이 비활성화 되었거나, 이 서버를 차단 중이거나 서버에게 차단되었을 수 있습니다.\n서버 관리자에게 문의하세요." + _uriInvalid: + title: "URI가 잘못되었습니다." + description: "입력한 URI에 문제가 있습니다. URI에 쓸 수 없는 문자를 넣었는지 확인해보세요." + _requestFailed: + title: "요청을 실패했습니다." + description: "해당 서버와 통신을 실패했습니다. 상대방 서버에 접속 불가능한 상태일 수도 있습니다. 또는 잘못된 URI 또는 없는 URI를 입력했는지 확인해보세요." + _responseInvalid: + title: "유효하지 않은 반응입니다." + description: "이 서버와 통신할 수 있지만, 데이터가 올바르지 않습니다." + _noSuchObject: + title: "찾을 수 없습니다" + description: "요구된 리소스를 찾을 수 없습니다. URI를 다시 한 번 확인해보세요." +_captcha: + verify: "CAPTCHA를 먼저 해결하세요." + testSiteKeyMessage: "사이트 키와 비밀 키에 테스트용 값을 입력하여 미리보기를 확인할 수 있습니다.\n자세한 내용은 아래 페이지를 확인해보세요." + _error: + _requestFailed: + title: "CAPTCHA 요구에 실패했습니다." + text: "잠시 후에 다시 실행하거나, 설정을 다시 한 번 확인해보세요." + _verificationFailed: + title: "CAPTCHA 검증을 실패했습니다." + text: "설정이 올바른지 다시 한 번 확인해보세요." + _unknown: + title: "CAPTCHA 에러" + text: "알 수 없는 에러가 발생했습니다." +_bootErrors: + title: "로딩이 실패함" + serverError: "잠시 기다렸다가 다시 로드해도 여전히 문제가 해결되지 않으면 아래 Error ID와 함께 서버 관리자에게 연락해 주세요." + solution: "다음과 같은 방법으로 해결할 수 있습니다." + solution1: "브라우저 및 OS를 최신 버전으로 업데이트하기" + solution2: "광고 차단 비활성화하기" + solution3: "브라우저 캐시 지우기" + solution4: "(Tor Browser) dom.webaudio.enabled를 true로 설정하세요" + otherOption: "기타 옵션" + otherOption1: "클라이언트 설정 및 캐시 삭제" + otherOption2: "간편 클라이언트 실행" + otherOption3: "복구 툴 실행" +_search: + searchScopeAll: "전체" + searchScopeLocal: "로컬" + searchScopeServer: "서버 지정" + searchScopeUser: "사용자 지정" + pleaseEnterServerHost: "서버의 호스트를 입력해 주세요." + pleaseSelectUser: "유저를 선택해주세요" + serverHostPlaceholder: "예: misskey.example.com" diff --git a/locales/lo-LA.yml b/locales/lo-LA.yml index 1bead5635d..455a71f302 100644 --- a/locales/lo-LA.yml +++ b/locales/lo-LA.yml @@ -223,7 +223,6 @@ remove: "ລຶບ" removed: "ລຶບແລ້ວ" resetAreYouSure: "ຣີ​ເຊັດບໍ?" saved: "ບັນທຶກແລ້ວ" -messaging: "ແຊັຕ" upload: "ອັບໂຫຼດ" keepOriginalUploading: "ຮັກສາຮູບພາບຕົ້ນສະບັບ" fromDrive: "ຈາກ Drive" @@ -233,7 +232,6 @@ uploadFromUrlDescription: "URL ຂອງໄຟລ໌ທີ່ທ່ານຕ້ uploadFromUrlRequested: "ຮ້ອງຂໍການອັບໂຫລດແລ້ວ" explore: "ສຳຫຼວດ" messageRead: "ອ່ານແລ້ວ" -startMessaging: "ເລີ່ມການສົນທະນາໃໝ່" nUsersRead: "ອ່ານໂດຍ {n}" agree: "ຍອມຮັບ" termsOfService: "ເງື່ອນໄຂການບໍລິການ" @@ -299,7 +297,6 @@ enableLocalTimeline: "ເປີດໃຊ້ທາມລາຍທ້ອງຖິ enableGlobalTimeline: "ເປີດໃຊ້ທາມລາຍທົ່ວໂລກ" disablingTimelinesInfo: "ຜູ້ດູແລລະບບແລະຜູ້ຄວບຄຸມຈະສາມາດເຂົ້າເຖີງໄທມ໌ໄລນ໌ທັ້ງເບີດ ເຖີງວ່າຈະບໍ່ໄດ້ເປີດໃຊ້ງານກໍ່ຕາມ" registration: "ລົງທະບຽນ" -enableRegistration: "ເປີດໃຊ້ການລົງທະບຽນຜູ້ໃຊ້ໃໝ່" invite: "ເຊີນ" driveCapacityPerLocalAccount: "ຄວາມຈຸຂອງ drive ຕໍ່ຜູ້ໃຊ້ທ້ອງຖິ່ນ" driveCapacityPerRemoteAccount: "ຄວາມຈຸຂອງ drive ຕໍ່ຜູ້ໃຊ້ໄລຍະໄກ" @@ -395,6 +392,12 @@ searchByGoogle: "ຄົ້ນຫາ" file: "ໄຟລ໌" replies: "ຕອບ​ກັບ" renotes: "Renote" +information: "ກ່ຽວກັບ" +_chat: + invitations: "ເຊີນ" + noHistory: "​ບໍ່​ມີປະຫວັດ" + members: "ສະມາຊິກ" + home: "ໜ້າຫຼັກ" _delivery: stop: "ໂຈະ" _type: @@ -456,6 +459,7 @@ _notification: renote: "Renote" quote: "ອ້າງອີງ" reaction: "Reaction" + login: "ເຂົ້າ​ສູ່​ລະ​ບົບ" _actions: reply: "ຕອບ​ກັບ" renote: "Renote" @@ -474,3 +478,8 @@ _abuseReport: mail: "ອີເມວ" _moderationLogTypes: suspend: "ລະງັບ" +_remoteLookupErrors: + _noSuchObject: + title: "ບໍ່ພົບ" +_search: + searchScopeAll: "ທັງໝົດ" diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml index eb48cf72da..38e4814373 100644 --- a/locales/nl-NL.yml +++ b/locales/nl-NL.yml @@ -8,6 +8,9 @@ search: "Zoeken" notifications: "Meldingen" username: "Gebruikersnaam" password: "Wachtwoord" +initialPasswordForSetup: "Initiële wachtwoord voor configuratie" +initialPasswordIsIncorrect: "Initiële wachtwoord voor configuratie is onjuist" +initialPasswordForSetupDescription: "Gebruik het initiële wachtwoord uit de configuratie, als je Misskey zelf hebt geïnstalleerd.\nAls je een Misskey hosting provider gebruikt, gebruik dan het gegeven wachtwoord.\nAls je geen wachtwoord hebt gezet, laat het dan leeg om verder te gaan." forgotPassword: "Wachtwoord vergeten" fetchingAsApObject: "Ophalen vanuit de Fediverse" ok: "Ok" @@ -108,9 +111,12 @@ enterEmoji: "Voer een emoji in" renote: "Herdelen" unrenote: "Stop herdelen" renoted: "Herdeeld" +renotedToX: "Renoted naar {name}" cantRenote: "Dit bericht kan niet worden herdeeld" cantReRenote: "Een herdeling kan niet worden herdeeld" quote: "Quote" +renoteToChannel: "Renote naar kanaal" +renoteToOtherChannel: "Renote naar ander kanaal" pinnedNote: "Vastgemaakte notitie" pinned: "Vastmaken aan profielpagina" you: "Jij" @@ -119,6 +125,10 @@ sensitive: "NSFW" add: "Toevoegen" reaction: "Reacties" reactions: "Reacties" +emojiPicker: "Emoji kiezer" +pinnedEmojisForReactionSettingDescription: "Kies de emojis die als eerste getoond worden tijdens het reageren" +pinnedEmojisSettingDescription: "Kies de emojis die als eerste getoond worden tijdens het reageren" +emojiPickerDisplay: "Emoji kiezer weergave" reactionSettingDescription2: "Sleep om opnieuw te ordenen, Klik om te verwijderen, Druk op \"+\" om toe te voegen" rememberNoteVisibility: "Vergeet niet de notitie zichtbaarheidsinstellingen" attachCancel: "Verwijder bijlage" @@ -140,7 +150,7 @@ selectAntenna: "Kies een antenne" selectWidget: "Kies een widget" editWidgets: "Bewerk widgets" editWidgetsExit: "Klaar" -customEmojis: "Maatwerk emoji" +customEmojis: "Eigen emoji" emoji: "Emoji" emojis: "Emoji" emojiName: "Naam emoji" @@ -246,7 +256,6 @@ removeAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" deleteAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" resetAreYouSure: "Resetten?" saved: "Opgeslagen" -messaging: "Chat" upload: "Uploaden" keepOriginalUploading: "Origineel beeld behouden." keepOriginalUploadingDescription: "Bewaar de originele versie bij het uploaden van afbeeldingen. Indien uitgeschakeld, wordt bij het uploaden een alternatieve versie voor webpublicatie genereert." @@ -259,7 +268,6 @@ uploadFromUrlMayTakeTime: "Het kan even duren voordat het uploaden voltooid is." explore: "Verkennen" messageRead: "Lezen" noMoreHistory: "Er is geen verdere geschiedenis" -startMessaging: "Start een gesprek" nUsersRead: "gelezen door {n}" agreeTo: "Ik stem in met {0}" start: "Aan de slag" @@ -333,7 +341,6 @@ enableLocalTimeline: "Inschakelen lokale tijdlijn" enableGlobalTimeline: "Inschakelen globale tijdlijn " disablingTimelinesInfo: "Beheerders en moderators hebben altijd toegang tot alle tijdlijnen, ook als ze niet actief zijn." registration: "Registreren" -enableRegistration: "Inschakelen registratie nieuwe gebruikers " invite: "Uitnodigen" driveCapacityPerLocalAccount: "Opslagruimte per lokale gebruiker" driveCapacityPerRemoteAccount: "Opslagruimte per externe gebruiker" @@ -404,7 +411,31 @@ help: "Help" inputMessageHere: "Voer hier je bericht in" close: "Sluiten" invites: "Uitnodigen" +members: "Leden" +transfer: "Overdracht" +title: "Titel" +text: "Tekst" +enable: "Inschakelen" +next: "Volgende" +retype: "Opnieuw invoeren" +noteOf: "Notitie van {user}" +quoteAttached: "Citaat" +quoteQuestion: "Toevoegen als citaat?" invitations: "Uitnodigen" +dashboard: "Overzicht" +local: "Lokaal" +remote: "Remote" +total: "Totaal" +weekOverWeekChanges: "Wijzigingen sinds vorige week" +dayOverDayChanges: "Dagelijkse wijzigingen" +appearance: "Weergave" +clientSettings: "Clientinstellingen" +accountSettings: "Accountinstellingen" +promotion: "Promotie" +promote: "Promoot" +numberOfDays: "Aantal dagen" +hideThisNote: "Verberg deze notitie" +showFeaturedNotesInTimeline: "Laat featured notities in tijdlijn zien" sound: "Geluid" smtpHost: "Server" smtpUser: "Gebruikersnaam" @@ -429,6 +460,11 @@ loggedInAsBot: "Momenteel als bot ingelogd" icon: "Avatar" replies: "Antwoord" renotes: "Herdelen" +information: "Over" +_chat: + invitations: "Uitnodigen" + members: "Leden" + home: "Startpagina" _delivery: stop: "Opgeschort" _type: @@ -486,6 +522,7 @@ _notification: renote: "Herdelen" quote: "Quote" reaction: "Reacties" + login: "Inloggen" _actions: reply: "Antwoord" renote: "Herdelen" @@ -501,3 +538,10 @@ _webhookSettings: _moderationLogTypes: suspend: "Opschorten" resetPassword: "Wachtwoord terugzetten" +_reversi: + total: "Totaal" +_remoteLookupErrors: + _noSuchObject: + title: "Niet gevonden" +_search: + searchScopeAll: "Alle" diff --git a/locales/no-NO.yml b/locales/no-NO.yml index cd00ecf9ab..44b2cd2704 100644 --- a/locales/no-NO.yml +++ b/locales/no-NO.yml @@ -260,7 +260,6 @@ enableLocalTimeline: "Aktiver lokal tidslinje" enableGlobalTimeline: "Aktiver global tidslinje" disablingTimelinesInfo: "Administratorer og Moderatorer vil alltid ha tilgang til alle tidslinjer, selv om de ikke er aktivert." registration: "Registrer" -enableRegistration: "Aktiver registrering av nye brukere" invite: "Inviter" basicInfo: "Grunnleggende informasjon" pinnedUsers: "Festede brukrere" @@ -300,8 +299,6 @@ text: "Tekst" next: "Neste" retype: "Gjenta" quoteAttached: "Sitat" -noMessagesYet: "Ingen meldinger ennå" -newMessageExists: "Det er nye meldinger" onlyOneFileCanBeAttached: "Du kan bare legge ved én fil i en melding" invitations: "Inviter" available: "Tilgjengelig" @@ -464,6 +461,12 @@ icon: "Avatar" replies: "Svar" renotes: "Renote" surrender: "Avbryt" +information: "Informasjon" +_chat: + invitations: "Inviter" + members: "Medlemmer" + home: "Hjem" + send: "Send" _delivery: stop: "Suspendert" _initialAccountSetting: @@ -701,6 +704,7 @@ _notification: renote: "Renotes" quote: "Sitater" reaction: "Reaksjoner" + login: "Logg inn" _actions: reply: "Svar" renote: "Renote" @@ -727,3 +731,8 @@ _abuseReport: mail: "E-post" _moderationLogTypes: suspend: "Suspender" +_remoteLookupErrors: + _noSuchObject: + title: "Ikke funnet" +_search: + searchScopeAll: "Alle" diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml index 73eff0941a..d52473b8c1 100644 --- a/locales/pl-PL.yml +++ b/locales/pl-PL.yml @@ -269,7 +269,6 @@ deleteAreYouSure: "Czy na pewno chcesz usunąć „{x}”?" resetAreYouSure: "Czy na pewno chcesz zresetować?" areYouSure: "Na pewno?" saved: "Zapisano" -messaging: "Wiadomości" upload: "Wyślij" keepOriginalUploading: "Zachowaj oryginalny obraz" keepOriginalUploadingDescription: "Zapisuje oryginalnie przesłany obraz w niezmienionej postaci. Jeśli ta opcja jest wyłączona, po przesłaniu zostanie wygenerowana wersja do wyświetlenia w Internecie." @@ -282,7 +281,6 @@ uploadFromUrlMayTakeTime: "Wysyłanie może chwilę potrwać." explore: "Eksploruj" messageRead: "Przeczytano" noMoreHistory: "Nie ma dalszej historii" -startMessaging: "Rozpocznij czat" nUsersRead: "przeczytano przez {n}" agreeTo: "Wyrażam zgodę na {0}" agree: "Zatwierdź" @@ -362,7 +360,6 @@ enableLocalTimeline: "Włącz lokalną oś czasu" enableGlobalTimeline: "Włącz globalną oś czasu" disablingTimelinesInfo: "Administratorzy i moderatorzy będą zawsze mieć dostęp do wszystkich osi czasu, nawet gdy są one wyłączone." registration: "Zarejestruj się" -enableRegistration: "Włącz rejestrację nowych użytkowników" invite: "Zaproś" driveCapacityPerLocalAccount: "Powierzchnia dyskowa na lokalnego użytkownika" driveCapacityPerRemoteAccount: "Powierzchnia dyskowa na zdalnego użytkownika" @@ -467,8 +464,6 @@ retype: "Wprowadź ponownie" noteOf: "Wpisy {user}" quoteAttached: "Zacytowano" quoteQuestion: "Czy na pewno chcesz umieścić cytat?" -noMessagesYet: "Nie napisano jeszcze wiadomości" -newMessageExists: "Masz nową wiadomość" onlyOneFileCanBeAttached: "Możesz załączyć tylko jeden plik do wiadomości" signinRequired: "Proszę się zalogować" invitations: "Zaproś" @@ -492,7 +487,10 @@ uiLanguage: "Język wyświetlania UI" aboutX: "O {x}" emojiStyle: "Styl emoji" native: "Natywny" -disableDrawer: "Nie używaj menu w stylu szuflady" +menuStyle: "Styl Menu" +style: "Styl" +drawer: "Schowek" +popup: "Wyskakujące okienka" showNoteActionsOnlyHover: "Pokazuj akcje notatek tylko po najechaniu myszką" showReactionsCount: "Wyświetl liczbę reakcji na notatkę" noHistory: "Brak historii" @@ -575,6 +573,7 @@ ascendingOrder: "Rosnąco" descendingOrder: "Malejąco" scratchpad: "Brudnopis" scratchpadDescription: "Brudnopis zawiera eksperymentalne środowisko dla AiScript. Możesz pisać, wykonywać i sprawdzać wyniki w interakcji z Misskey." +uiInspector: "Inspektor UI" output: "Wyjście" script: "Skrypt" disablePagesScript: "Wyłącz AiScript na Stronach" @@ -655,6 +654,7 @@ smtpSecure: "Użyj niejawnego SSL/TLS dla połączeń SMTP" smtpSecureInfo: "Wyłącz, jeżeli używasz STARTTLS" testEmail: "Przetestuj dostarczanie wiadomości e-mail" wordMute: "Wyciszenie słowa" +hardWordMute: "Wyciszaj przekleństwa" regexpError: "Błąd wyrażenia regularnego" regexpErrorDescription: "Wystąpił błąd w wyrażeniu regularnym w linii {line} twoich {tab} wyciszeń:" instanceMute: "Wyciszone instancje" @@ -690,10 +690,7 @@ abuseReported: "Twoje zgłoszenie zostało wysłane. Dziękujemy." reporter: "Zgłaszający" reporteeOrigin: "Pochodzenie zgłoszonego" reporterOrigin: "Pochodzenie zgłaszającego" -forwardReport: "Przekaż zgłoszenie do innej instancji" -forwardReportIsAnonymous: "Zamiast twojego konta, anonimowe konto systemowe będzie wyświetlone jako zgłaszający na instancji zdalnej." send: "Wyślij" -abuseMarkAsResolved: "Oznacz zgłoszenie jako rozwiązane" openInNewTab: "Otwórz w nowej karcie" openInSideView: "Otwórz w bocznym widoku" defaultNavigationBehaviour: "Domyślne zachowanie nawigacji" @@ -830,6 +827,7 @@ administration: "Zarządzanie" accounts: "Konta" switch: "Przełącz" noMaintainerInformationWarning: "Informacje o administratorze nie są skonfigurowane." +noInquiryUrlWarning: "Adres URL zapytania nie został ustawiony" noBotProtectionWarning: "Zabezpieczenie przed botami nie jest skonfigurowane." configure: "Skonfiguruj" postToGallery: "Opublikuj w galerii" @@ -894,6 +892,7 @@ followersVisibility: "Widoczność obserwujących" continueThread: "Pokaż kontynuację wątku" deleteAccountConfirm: "Spowoduje to nieodwracalne usunięcie Twojego konta. Kontynuować?" incorrectPassword: "Nieprawidłowe hasło." +incorrectTotp: "Hasło pojedynczego użytku jest nie poprawne, lub straciło ważność" voteConfirm: "Potwierdzić swój głos na \"{choice}\"?" hide: "Ukryj" useDrawerReactionPickerForMobile: "Wyświetlaj wybornik reakcji jako szufladę na urządzeniach mobilnych" @@ -918,6 +917,10 @@ oneHour: "1 godzina" oneDay: "1 dzień" oneWeek: "1 tydzień" oneMonth: "jeden miesiąc" +threeMonths: "3 miesiące" +oneYear: "Rok" +threeDays: "3 dni" +reflectMayTakeTime: "Może minąć trochę czasu, zanim będzie to uwzględnione" failedToFetchAccountInformation: "Nie udało się uzyskać informacji o koncie" rateLimitExceeded: "Limit szybkości przekroczony" cropImage: "Przytnij obraz" @@ -928,9 +931,11 @@ file: "Pliki" recentNHours: "W ciągu ostatnich {n} godzin" recentNDays: "W ciągu ostatnich {n} dni" noEmailServerWarning: "Serwer Email nie jest skonfigurowany" +thereIsUnresolvedAbuseReportWarning: "Istnieją niewyjaśnione raporty" recommended: "Zalecane" check: "Zweryfikuj" driveCapOverrideLabel: "Zmień limit pojemności dysku użytkownika" +driveCapOverrideCaption: "Resetuje pojemność do wartości domyślnej, przez wpisanie wartości 0 lub niższej" requireAdminForView: "Aby to zobaczyć, musisz być administratorem" isSystemAccount: "To jest konto stworzone i zarządzane przez system" typeToConfirm: "Wprowadź {x}, aby potwierdzić" @@ -999,17 +1004,29 @@ unassign: "Cofnij przydzielenie" color: "Kolor" manageCustomEmojis: "Zarządzaj niestandardowymi Emoji" manageAvatarDecorations: "Zarządzaj dekoracjami awatara" +youCannotCreateAnymore: "Limit kreacji został przekroczony" +cannotPerformTemporary: "Opcja tymczasowo niedostępna" +cannotPerformTemporaryDescription: "Ta akcja nie może zostać wykonana, z powodu przekroczenia limitu wykonań. Prosimy poczekać chwilę i spróbować ponownie" invalidParamError: "Błąd parametrów" +invalidParamErrorDescription: "Wartości, które zostały podane są niepoprawne. Zwykle jest to spowodowane bugiem, lecz również może być to spowodowane przekroczeniem limitu wartości, lub podobnym problemem" permissionDeniedError: "Odrzucono operacje" permissionDeniedErrorDescription: "Konto nie posiada uprawnień" preset: "Konfiguracja" selectFromPresets: "Wybierz konfiguracje" achievements: "Osiągnięcia" +gotInvalidResponseError: "Niepoprawna odpowiedź serwera" +gotInvalidResponseErrorDescription: "Wystąpił problem z Twoim połączeniem z Internetem, lub z serwerem. {Spróbuj ponownie} wkrótce." +thisPostMayBeAnnoying: "Ten wpis może obrażać pozostałych użytkowników" +thisPostMayBeAnnoyingHome: "Opublikuj na domowej osi czasu" thisPostMayBeAnnoyingCancel: "Odrzuć" +thisPostMayBeAnnoyingIgnore: "Zignoruj i wyślij" +collapseRenotes: "Zwiń wpisy, które już zobaczyłeś" +collapseRenotesDescription: "Zwiń wpisy, na które już zareagowałeś lub udostępniłeś" internalServerError: "Wewnętrzny błąd serwera" internalServerErrorDescription: "Niespodziewany błąd po stronie serwera" copyErrorInfo: "Kopiuj informacje o błędzie" joinThisServer: "Dołącz do chaty" +exploreOtherServers: "Szukaj innej instancji" disableFederationOk: "Wyłącz federacje" invitationRequiredToRegister: "Ten serwer wymaga zaproszenia. Tylko osoby z zaproszeniem mogą się zarejestrować" emailNotSupported: "Wysyłanie wiadomości E-mail nie jest obsługiwane na tym serwerze" @@ -1023,6 +1040,14 @@ flip: "Odwróć" lastNDays: "W ciągu ostatnich {n} dni" surrender: "Odrzuć" gameRetry: "Spróbuj ponownie" +postForm: "Formularz tworzenia wpisu" +information: "Informacje" +_chat: + invitations: "Zaproś" + noHistory: "Brak historii" + members: "Członkowie" + home: "Strona główna" + send: "Wyślij" _delivery: stop: "Zawieszono" _type: @@ -1209,7 +1234,6 @@ _theme: buttonBg: "Tło przycisku" buttonHoverBg: "Tło przycisku (po najechaniu)" inputBorder: "Obramowanie pola wejścia" - listItemHoverBg: "Tło elementu listy (po najechaniu)" driveFolderBg: "Tło folderu na dysku" wallpaperOverlay: "Nakładka tapety" badge: "Odznaka" @@ -1280,6 +1304,7 @@ _permissions: "write:gallery": "Edytuj swoją galerię" "read:gallery-likes": "Wyświetlanie listy polubionych postów w galerii" "write:gallery-likes": "Edytowanie listy polubionych postów w galerii" + "write:chat": "Tworzenie lub usuwanie wiadomości czatu" _auth: shareAccessTitle: "Przyznawanie uprawnień aplikacji" shareAccess: "Czy chcesz autoryzować „{name}” do dostępu do tego konta?" @@ -1439,9 +1464,6 @@ _pages: newPage: "Utwórz stronę" editPage: "Edytuj tę stronę" readPage: "Aktywowano widok źródła" - created: "Pomyślnie utworzono stronę!" - updated: "Pomyślnie zaktualizowano stronę!" - deleted: "Strona została usunięta" pageSetting: "Ustawienia strony" nameAlreadyExists: "Określony adres URL strony już istnieje" invalidNameTitle: "Podany adres URL strony jest nieprawidłowy" @@ -1510,6 +1532,7 @@ _notification: reaction: "Reakcja" receiveFollowRequest: "Otrzymano prośbę o możliwość obserwacji" followRequestAccepted: "Przyjęto prośbę o możliwość obserwacji" + login: "Zaloguj się" app: "Powiadomienia z aplikacji" _actions: followBack: "zaobserwował cię z powrotem" @@ -1562,3 +1585,9 @@ _moderationLogTypes: resetPassword: "Zresetuj hasło" _reversi: total: "Łącznie" +_remoteLookupErrors: + _noSuchObject: + title: "Nie znaleziono" +_search: + searchScopeAll: "Wszystkie" + searchScopeLocal: "Lokalne" diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml index 87f934201c..1660969e15 100644 --- a/locales/pt-PT.yml +++ b/locales/pt-PT.yml @@ -8,8 +8,11 @@ search: "Pesquisar" notifications: "Notificações" username: "Nome de usuário" password: "Senha" +initialPasswordForSetup: "Senha para a configuração inicial" +initialPasswordIsIncorrect: "Senha para configuração inicial está incorreta" +initialPasswordForSetupDescription: "Use a senha configurada no arquivo de configuração se você instalou o Misskey manualmente.\nSe você estiver utilizando um serviço de hospedagem, utilize a senha fornecida.\nSe uma senha não foi configurada, deixe em branco e continue." forgotPassword: "Esqueci-me da senha" -fetchingAsApObject: "Buscando no Fediverso" +fetchingAsApObject: "Buscando no Fediverso..." ok: "OK" gotIt: "Entendi" cancel: "Cancelar" @@ -82,7 +85,7 @@ exportRequested: "A sua solicitação de exportação foi enviada. Isso pode lev importRequested: "A sua solicitação de importação foi enviada. Isso pode levar algum tempo." lists: "Listas" noLists: "Não possui nenhuma lista" -note: "Post" +note: "Publicar" notes: "Posts" following: "Seguindo" followers: "Seguidores" @@ -196,7 +199,7 @@ followConfirm: "Tem certeza que quer seguir {name}?" proxyAccount: "Conta proxy" proxyAccountDescription: "Uma conta de proxy é uma conta que assume o acompanhamento remoto de um usuário sob certas condições específicas. Por exemplo, quando um usuário inclui um usuário remoto em uma lista, mas ninguém na lista está seguindo o usuário remoto, a atividade não é entregue ao servidor. Nesse caso, a conta de proxy entra em ação para seguir o usuário remoto em vez disso." host: "Host" -selectSelf: "Escolher manualmente" +selectSelf: "Selecionar a mim" selectUser: "Selecionar usuário" recipient: "Destinatário" annotation: "Anotação" @@ -236,6 +239,8 @@ silencedInstances: "Instâncias silenciadas" silencedInstancesDescription: "Liste o nome de hospedagem dos servidores que você deseja silenciar, separados por linha. Todas as contas desses servidores serão silenciada e poderão enviar solicitações para seguir, mas não poderão mencionar usuários locais sem segui-los. Isso não afetará servidores bloqueados." mediaSilencedInstances: "Instâncias com mídia silenciadas" mediaSilencedInstancesDescription: "Liste o nome de hospedagem dos servidores cuja mídia você deseja silenciar, separados por linha. Todas as contas desses servidores serão consideradas sensíveis e não poderão utilizar emojis personalizados. Isso não afetará servidores bloqueados." +federationAllowedHosts: "Servidores com federação permitida" +federationAllowedHostsDescription: "Especifique o endereço dos servidores em que deseja permitir a federação separados por linha." muteAndBlock: "Silenciar e bloquear" mutedUsers: "Usuários silenciados" blockedUsers: "Usuários bloqueados" @@ -272,7 +277,7 @@ more: "Mais!" featured: "Destaques" usernameOrUserId: "Nome de usuário ou ID do usuário" noSuchUser: "Usuário não encontrado" -lookup: "Buscando" +lookup: "Consultar" announcements: "Avisos" imageUrl: "URL da imagem" remove: "Remover" @@ -282,7 +287,6 @@ deleteAreYouSure: "Deseja excluir \"{x}\"?" resetAreYouSure: "Deseja reiniciar?" areYouSure: "Tem certeza?" saved: "Salvo" -messaging: "Chat" upload: "Fazer upload" keepOriginalUploading: "Manter a imagem original" keepOriginalUploadingDescription: "Ao fazer o upload de uma imagem, ela será mantida em sua versão original. Caso desative esta opção, o navegador irá gerar uma versão da imagem otimizada para publicação na web durante o upload." @@ -295,8 +299,7 @@ uploadFromUrlMayTakeTime: "Pode levar algum tempo para que o upload seja conclu explore: "Explorar" messageRead: "Lida" noMoreHistory: "Não existe histórico anterior" -startMessaging: "Iniciar conversação" -nUsersRead: "{n} Pessoas leem" +nUsersRead: "{n} pessoas leram" agreeTo: "Eu concordo com {0}" agree: "Concordar" agreeBelow: "Eu concordo com o seguinte" @@ -312,7 +315,7 @@ birthday: "Aniversário" yearsOld: "{age} anos" registeredDate: "Data de registro" location: "Localização" -theme: "tema" +theme: "Tema" themeForLightMode: "Temas usados ​​no modo de luz" themeForDarkMode: "Temas usados ​​no modo escuro" light: "Claro" @@ -334,6 +337,7 @@ renameFolder: "Renomear Pasta" deleteFolder: "Excluir pasta" folder: "Pasta" addFile: "Adicionar arquivo" +showFile: "Mostrar arquivos" emptyDrive: "O drive está vazio" emptyFolder: "A pasta está vazia" unableToDelete: "Não é possível excluir" @@ -376,7 +380,6 @@ enableLocalTimeline: "Ativar linha do tempo local" enableGlobalTimeline: "Ativar linha do tempo global" disablingTimelinesInfo: "Se você desabilitar essas linhas do tempo, administradores e moderadores ainda poderão usá-las por conveniência." registration: "Registar" -enableRegistration: "Permitir que qualquer pessoa se registre" invite: "Convidar" driveCapacityPerLocalAccount: "Capacidade do drive por usuário local" driveCapacityPerRemoteAccount: "Capacidade do drive por usuário remoto" @@ -448,6 +451,7 @@ totpDescription: "Digite a senha de uso único informado pelo aplicativo autenti moderator: "Moderador" moderation: "Moderação" moderationNote: "Nota de moderação" +moderationNoteDescription: "Você pode preencher notas que serão compartilhadas apenas com moderadores." addModerationNote: "Adicionar nota de moderação" moderationLogs: "Logs de moderação" nUsersMentioned: "Postado por {n} pessoas" @@ -483,8 +487,6 @@ noteOf: "Publicação de {user}" quoteAttached: "Com citação" quoteQuestion: "Anexar como citação?" attachAsFileQuestion: "O texto na área de transferência é muito longo. Você gostaria de anexá-lo como um arquivo de texto?" -noMessagesYet: "Sem conversas até o momento" -newMessageExists: "Há uma nova mensagem" onlyOneFileCanBeAttached: "Apenas um arquivo pode ser anexado a uma mensagem" signinRequired: "É necessário se inscrever ou fazer login antes de continuar" signinOrContinueOnRemote: "Para continuar, você precisa mover o seu servidor ou entrar/cadastrar-se nesse servidor." @@ -509,7 +511,10 @@ uiLanguage: "Idioma de exibição da interface " aboutX: "Sobre {x}" emojiStyle: "Estilo de emojis" native: "Nativo" -disableDrawer: "Não mostrar o menu em formato de gaveta" +menuStyle: "Estilo do menu" +style: "Estilo" +drawer: "Gaveta" +popup: "Pop-up" showNoteActionsOnlyHover: "Exibir as ações da nota somente ao passar o cursor sobre ela" showReactionsCount: "Ver o número de reações nas notas" noHistory: "Ainda não há histórico" @@ -577,6 +582,7 @@ masterVolume: "volume principal" notUseSound: "Desabilitar som" useSoundOnlyWhenActive: "Apenas reproduzir sons quando Misskey estiver aberto." details: "Detalhes" +renoteDetails: "Detalhes da repostagem" chooseEmoji: "Selecione um emoji" unableToProcess: "Não é possível concluir a operação" recentUsed: "Usado recentemente" @@ -592,6 +598,8 @@ ascendingOrder: "Ascendente" descendingOrder: "Descendente" scratchpad: "Bloco de rascunho" scratchpadDescription: "O Bloco de rascunho fornece um ambiente experimental para AiScript. Permite escrever, executar e verificar os resultados do código para interagir com o Misskey." +uiInspector: "Inspecionador de interface" +uiInspectorDescription: "Você pode ver a lista de servidores de componentes de interface na memória. Componentes da interface serão gerados pela função Ui:C:." output: "Resultado" script: "Script" disablePagesScript: "Desabilitar scripts nas páginas" @@ -672,7 +680,7 @@ smtpSecure: "Use SSL/TLS implícito para conexões SMTP" smtpSecureInfo: "Desative esta opção ao utilizar STARTTLS." testEmail: "Testar envio de e-mail" wordMute: "Silenciar palavras" -hardWordMute: "SIlenciamento pesado de palavra" +hardWordMute: "Silenciar palavras (esconder posts)" regexpError: "Erro na expressão regular" regexpErrorDescription: "Ocorreu um erro na expressão regular na linha {line} da palavra mutada {tab}:" instanceMute: "Instâncias silenciadas" @@ -700,7 +708,7 @@ fileIdOrUrl: "ID do arquivo ou URL" behavior: "Comportamento" sample: "Exemplo" abuseReports: "Denúncias" -reportAbuse: "Denúncias" +reportAbuse: "Denunciar" reportAbuseRenote: "Reportar repostagem" reportAbuseOf: "Denunciar {name}" fillAbuseReportDescription: "Por favor, forneça detalhes sobre o motivo da denúncia. Se houver uma nota específica envolvida, inclua também a URL dela." @@ -708,10 +716,7 @@ abuseReported: "Denúncia enviada. Obrigado por sua ajuda." reporter: "Denunciante" reporteeOrigin: "Origem da denúncia" reporterOrigin: "Origem do denunciante" -forwardReport: "Encaminhar a denúncia para o servidor remoto" -forwardReportIsAnonymous: "No servidor remoto, suas informações não serão visíveis, e você será apresentado como uma conta do sistema anônima." send: "Enviar" -abuseMarkAsResolved: "Marcar denúncia como resolvida" openInNewTab: "Abrir em nova aba" openInSideView: "Abrir em visão lateral" defaultNavigationBehaviour: "Navegação padrão" @@ -843,7 +848,7 @@ switchAccount: "Trocar conta" enabled: "Ativado" disabled: "Desativado" quickAction: "Ações rápidas" -user: "Usuários" +user: "Usuário" administration: "Administrar" accounts: "Contas" switch: "Trocar" @@ -913,6 +918,7 @@ followersVisibility: "Visibilidade dos seguidores" continueThread: "Ver mais desta conversa" deleteAccountConfirm: "Deseja realmente excluir a conta?" incorrectPassword: "Senha inválida." +incorrectTotp: "A senha de uso único está incorreta ou expirou." voteConfirm: "Deseja confirmar o seu voto em \"{choice}\"?" hide: "Ocultar" useDrawerReactionPickerForMobile: "Mostrar em formato de gaveta" @@ -937,6 +943,9 @@ oneHour: "1 hora" oneDay: "1 dia" oneWeek: "1 semana" oneMonth: "1 mês" +threeMonths: "3 meses" +oneYear: "1 ano" +threeDays: "3 dias" reflectMayTakeTime: "As mudanças podem demorar a aparecer." failedToFetchAccountInformation: "Não foi possível obter informações da conta" rateLimitExceeded: "Taxa limite excedido" @@ -1062,7 +1071,7 @@ resetPasswordConfirm: "Deseja realmente mudar a sua senha?" sensitiveWords: "Palavras sensíveis" sensitiveWordsDescription: "A visibilidade de todas as notas contendo as palavras configuradas será colocadas como \"Início\" automaticamente. Você pode listar várias delas separando-as por linha." sensitiveWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)" -prohibitedWords: "Palavras proibídas" +prohibitedWords: "Palavras proibidas" prohibitedWordsDescription: "Habilita um erro ao tentar publicar uma nota contendo as palavras escolhidas. Várias palavras podem ser escolhidas, separando-as por linha." prohibitedWordsDescription2: "Utilizar espaços irá criar expressões aditivas (AND) e cercar palavras-chave com barras irá transformá-las em expressões regulares (RegEx)" hiddenTags: "Hashtags escondidas" @@ -1077,6 +1086,7 @@ retryAllQueuesConfirmTitle: "Gostaria de tentar novamente agora?" retryAllQueuesConfirmText: "Isso irá temporariamente aumentar a carga do servidor." enableChartsForRemoteUser: "Gerar gráficos estatísticos de usuários remotos" enableChartsForFederatedInstances: "Gerar gráficos estatísticos de instâncias remotas" +enableStatsForFederatedInstances: "Receber estatísticas de servidores remotos" showClipButtonInNoteFooter: "Adicionar \"Clip\" ao menu de ação de notas" reactionsDisplaySize: "Tamanho de exibição das reações" limitWidthOfReaction: "Limita o comprimento máximo de reações e as exibe em tamanho reduzido" @@ -1263,6 +1273,59 @@ confirmWhenRevealingSensitiveMedia: "Confirmar ao revelar mídia sensível" sensitiveMediaRevealConfirm: "Essa mídia pode ser sensível. Deseja revelá-la?" createdLists: "Listas criadas" createdAntennas: "Antenas criadas" +fromX: "De {x}" +genEmbedCode: "Gerar código de embed" +noteOfThisUser: "Notas por este usuário" +clipNoteLimitExceeded: "Não é possível adicionar mais notas ao clipe." +performance: "Desempenho" +modified: "Modificado" +discard: "Descartar" +thereAreNChanges: "Há {n} mudança(s)" +signinWithPasskey: "Entrar com Passkey" +unknownWebAuthnKey: "Passkey desconhecida" +passkeyVerificationFailed: "A verificação com Passkey falhou." +passkeyVerificationSucceededButPasswordlessLoginDisabled: "A verificação com Passkey teve êxito, mas a entrada sem senha está desabilitada." +messageToFollower: "Mensagem aos seguidores" +target: "Alvo" +testCaptchaWarning: "Essa função é utilizada apenas para testar CAPTCHA. Não a use num ambiente de produção." +prohibitedWordsForNameOfUser: "Palavras proibidas para nomes de usuário" +prohibitedWordsForNameOfUserDescription: "Se quaisquer palavras dessa lista forem incluídas no nome de usuário, seu uso será negado. Usuários com privilégios de moderador não serão afetados pela restrição." +yourNameContainsProhibitedWords: "O seu nome possui palavras proibidas" +yourNameContainsProhibitedWordsDescription: "Se você deseja utilizar esse nome, entre em contato com o administrador do servidor." +thisContentsAreMarkedAsSigninRequiredByAuthor: "O autor exige que você esteja cadastrado para ver" +lockdown: "Lockdown" +pleaseSelectAccount: "Selecione uma conta" +availableRoles: "Cargos disponíveis" +acknowledgeNotesAndEnable: "Ative após compreender as precauções." +postForm: "Campo de postagem" +information: "Informações" +_chat: + invitations: "Convidar" + noHistory: "Ainda não há histórico" + members: "Membros" + home: "Início" + send: "Enviar" +_settings: + webhook: "Webhook" +_accountSettings: + requireSigninToViewContents: "Exigir cadastro para ver o conteúdo" + requireSigninToViewContentsDescription1: "Exigir cadastro para ver todas as notas e outro conteúdo que você criou. Isso previne 'crawlers' de coletar os seus dados." + requireSigninToViewContentsDescription2: "Conteúdo não será exibido nas prévias de URL (OGP), incorporado em outras páginas web ou em servidores que não têm suporte a citações." + requireSigninToViewContentsDescription3: "Essas restrições podem não ser aplicadas a conteúdo federado de outros servidores." + makeNotesFollowersOnlyBefore: "Tornar notas passadas visíveis apenas para seguidores." + makeNotesFollowersOnlyBeforeDescription: "Com essa função ativada, apenas seguidores podem ver as notas anteriores à data e hora marcadas. Se isso for desativado, o status de publicação da nota será reestabelecido." + makeNotesHiddenBefore: "Tornar notas passadas privadas" + makeNotesHiddenBeforeDescription: "Com essa função ativada, apenas você poderá ver as notas anteriores à data e hora marcadas. Se isso for desativado, o status de publicação da nota será reestabelecido." + mayNotEffectForFederatedNotes: "Notas federadas a servidores remotos podem não ser afetadas." + notesHavePassedSpecifiedPeriod: "Notas que duraram um tempo específico." + notesOlderThanSpecifiedDateAndTime: "Notas antes do tempo específico." +_abuseUserReport: + forward: "Encaminhar" + forwardDescription: "Encaminhar a denúncia ao servidor remoto como uma conta anônima do sistema." + resolve: "Resolver" + accept: "Aceitar" + reject: "Rejeitar" + resolveTutorial: "Se a denúncia for legítima em conteúdo, selecione \"Aceitar\" para marcar o caso como resolvido afirmativamente.\nSe a denúncia for ilegítima em conteúdo, selecione \"Rejeitar\" para marcar o caso como resolvido negativamente." _delivery: status: "Estado de entrega" stop: "Suspenso" @@ -1397,8 +1460,12 @@ _serverSettings: fanoutTimelineDescription: "Melhora significativamente a performance do retorno da linha do tempo e reduz o impacto no banco de dados quando habilitado. Em contrapartida, o uso de memória do Redis aumentará. Considere desabilitar em casos de baixa disponibilidade de memória ou instabilidade do servidor." 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." 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" + openRegistrationWarning: "Abrir cadastros contém riscos. É recomendado apenas habilitá-los se houver um sistema de monitoramento contínuo e resolução imediata de problemas." + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "Se nenhuma atividade da moderação for detectada por um tempo, essa configuração será desativada para prevenir spam." _accountMigration: moveFrom: "Migrar outra conta para essa" moveFromSub: "Criar um 'alias' a outra conta" @@ -1419,7 +1486,7 @@ _achievements: _types: _notes1: title: "Configurando o meu misskey" - description: "Post uma nota pela primeira vez" + description: "Poste uma nota pela primeira vez" flavor: "Divirta-se com o Misskey!" _notes10: title: "Algumas notas" @@ -1730,6 +1797,11 @@ _role: canSearchNotes: "Permitir a busca de notas" canUseTranslator: "Uso do tradutor" avatarDecorationLimit: "Número máximo de decorações de avatar que podem ser aplicadas" + canImportAntennas: "Permitir importação de antenas" + canImportBlocking: "Permitir importação de bloqueios" + canImportFollowing: "Permitir importação de usuários seguidos" + canImportMuting: "Permitir importação de silenciamentos" + canImportUserLists: "Permitir importação de listas" _condition: roleAssignedTo: "Atribuído a cargos manuais" isLocal: "Usuário local" @@ -1947,7 +2019,6 @@ _theme: buttonBg: "Plano de fundo de botão" buttonHoverBg: "Plano de fundo de botão (Selecionado)" inputBorder: "Borda de campo digitável" - listItemHoverBg: "Plano de fundo do item de uma lista (Selecionado)" driveFolderBg: "Plano de fundo da pasta no Drive" wallpaperOverlay: "Sobreposição do papel de parede." badge: "Emblema" @@ -2106,6 +2177,7 @@ _permissions: "read:clip-favorite": "Ver Clipes favoritados" "read:federation": "Ver dados de federação" "write:report-abuse": "Reportar violação" + "write:chat": "Compor ou editar mensagens de chat" _auth: shareAccessTitle: "Conceder permissões do aplicativo" shareAccess: "Você gostaria de autorizar \"{name}\" para acessar essa conta?" @@ -2114,8 +2186,11 @@ _auth: permissionAsk: "O aplicativo solicita as seguintes permissões" pleaseGoBack: "Por favor, volte ao aplicativo" callback: "Retornando ao aplicativo" + accepted: "Acesso permitido" denied: "Acesso negado" + scopeUser: "Operar como o usuário a seguir" pleaseLogin: "Por favor, entre para autorizar aplicativos." + byClickingYouWillBeRedirectedToThisUrl: "Quando o acesso for permitido, você será redirecionado para o seguinte endereço" _antennaSources: all: "Todas as notas" homeTimeline: "Notas de usuários seguidos" @@ -2224,6 +2299,9 @@ _profile: changeBanner: "Mudar banner" verifiedLinkDescription: "Ao inserir um URL que contém um link para essa conta, um ícone de verificação será exibido ao lado do campo" avatarDecorationMax: "Você pode adicionar até {max} decorações." + followedMessage: "Mensagem exibida quando alguém segue você" + followedMessageDescription: "Você pode definir uma curta mensagem que será exibida aos usuários que seguirem você." + followedMessageDescriptionForLockedAccount: "Se você aceita pedidos de seguidor manualmente, isso será exibido quando você aceitá-los." _exportOrImport: allNotes: "Todas as notas" favoritedNotes: "Notas nos favoritos" @@ -2286,9 +2364,6 @@ _pages: newPage: "Criar uma Página" editPage: "Editar essa Página" readPage: "Ver a fonte dessa Página" - created: "Página criada com sucesso" - updated: "Página atualizada com sucesso" - deleted: "Página excluída com sucesso" pageSetting: "Configurações da página" nameAlreadyExists: "O URL de Página especificado já existe" invalidNameTitle: "O URL de Página especificado é inválido" @@ -2316,6 +2391,7 @@ _pages: eyeCatchingImageSet: "Escolher miniatura" eyeCatchingImageRemove: "Excluir miniatura" chooseBlock: "Adicionar bloco" + enterSectionTitle: "Insira um título à seção" selectType: "Selecionar um tipo" contentBlocks: "Conteúdo" inputBlocks: "Inserir" @@ -2361,6 +2437,8 @@ _notification: renotedBySomeUsers: "{n} usuários repostaram a nota" followedBySomeUsers: "{n} usuários te seguiram" flushNotification: "Limpar notificações" + exportOfXCompleted: "Exportação de {x} foi concluída" + login: "Alguém entrou na conta" _types: all: "Todas" note: "Novas notas" @@ -2368,13 +2446,16 @@ _notification: mention: "Menção" reply: "Respostas" renote: "Repostar" - quote: "Citar" + quote: "Citações" reaction: "Reações" pollEnded: "Enquetes terminando" receiveFollowRequest: "Recebeu pedidos de seguidor" followRequestAccepted: "Aceitou pedidos de seguidor" roleAssigned: "Cargo dado" achievementEarned: "Conquista desbloqueada" + exportCompleted: "A exportação foi concluída" + login: "Iniciar sessão" + test: "Notificação teste" app: "Notificações de aplicativos conectados" _actions: followBack: "te seguiu de volta" @@ -2440,7 +2521,10 @@ _webhookSettings: abuseReport: "Quando receber um relatório de abuso" abuseReportResolved: "Quando relatórios de abuso forem resolvidos " userCreated: "Quando um usuário é criado" + inactiveModeratorsWarning: "Quando moderadores estiverem inativos por um tempo" + inactiveModeratorsInvitationOnlyChanged: "Quando um moderador está inativo por um tempo e os cadastros passam a exigir convites" deleteConfirm: "Você tem certeza de que deseja excluir o Webhook?" + testRemarks: "Clique no botão à direita do interruptor para enviar um Webhook de teste com dados fictícios." _abuseReport: _notificationRecipient: createRecipient: "Adicionar destinatário para relatórios de abuso" @@ -2484,6 +2568,8 @@ _moderationLogTypes: markSensitiveDriveFile: "Arquivo marcado como sensível" unmarkSensitiveDriveFile: "Arquivo desmarcado como sensível" resolveAbuseReport: "Relatório resolvido" + forwardAbuseReport: "Denúncia encaminhada" + updateAbuseReportNote: "Nota de moderação da denúncia atualizada" createInvitation: "Convite gerado" createAd: "Propaganda criada" deleteAd: "Propaganda excluída" @@ -2499,6 +2585,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "Criar um destinatário para relatórios de abuso" updateAbuseReportNotificationRecipient: "Atualizar destinatários para relatórios de abuso" deleteAbuseReportNotificationRecipient: "Remover um destinatário para relatórios de abuso" + deleteAccount: "Remover conta" + deletePage: "Remover página" + deleteFlash: "Remover Play" + deleteGalleryPost: "Remover a publicação da galeria" _fileViewer: title: "Detalhes do arquivo" type: "Tipo de arquivo" @@ -2512,10 +2602,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "Tenha certeza de que o distribuidor desse recurso é confiável antes da instalação." _plugin: title: "Deseja instalar esse plugin?" - metaTitle: "Informações do plugin" _theme: title: "Deseja instalar esse tema?" - metaTitle: "Informações do tema" _meta: base: "Paleta de cores base" _vendorInfo: @@ -2635,3 +2723,46 @@ _contextMenu: app: "Aplicativo" appWithShift: "Aplicativo com a tecla shift" native: "Nativo" +_embedCodeGen: + title: "Personalizar código do embed" + header: "Exibir cabeçalho" + autoload: "Carregar mais automaticamente (obsoleto)" + maxHeight: "Altura máxima" + maxHeightDescription: "Colocar em 0 desabilita a altura máxima. Especifique um valor para prevenir uma expansão vertical contínua." + maxHeightWarn: "O limite de altura máxima está desabilitado (0). Se isso não for intencional, insira um valor para a altura máxima." + previewIsNotActual: "A exibição difere do embed original porque ela excede o tamanho da tela de prévia." + rounded: "Tornar arredondado" + border: "Adicionar uma borda ao quadro externo" + applyToPreview: "Aplicar para a prévia" + generateCode: "Gerar código de embed" + codeGenerated: "O código foi gerado" + codeGeneratedDescription: "Coloque o código no seu website para incorporar o conteúdo." +_selfXssPrevention: + warning: "AVISO" + title: "\"Cole algo nessa tela\" é uma fraude" + description1: "Se você colar algo aqui, um usuário malicioso pode sabotar a sua conta ou roubar informações pessoais." + description2: "Se você não entender exatamente o que está colando, %cpare agora e feche essa janela." + description3: "Para mais informação, clique no link. {link}" +_followRequest: + recieved: "Aplicação recebida" + sent: "Aplicação enviada" +_remoteLookupErrors: + _federationNotAllowed: + title: "Não foi possível se comunicar com o servidor" + description: "Comunicação com esse servidor pode ter sido desabilitada ou o servidor pode ter sido bloqueado.\nPor favor, entre em contato com o administrador do servidor." + _uriInvalid: + title: "Endereço inválido" + description: "Há um problema com o endereço inserido. Por favor, confira se você não inseriu caracteres inválidos." + _requestFailed: + title: "Solicitação falhou" + description: "Comunicação com esse servidor falhou. O servidor pode estar inativo. Além disso, confira se você não inseriu um endereço inválido ou inexistente." + _responseInvalid: + title: "Resposta inválida" + description: "Foi possível comunicar com o servidor, porém os dados obtidos foram incorretos." + _noSuchObject: + title: "Não encontrado" + description: "O recurso solicitado não foi encontrado, confira o endereço." +_search: + searchScopeAll: "Todos" + searchScopeLocal: "Local" + searchScopeUser: "Usuário específico" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml index b4c9b90de9..5e0d3f221f 100644 --- a/locales/ro-RO.yml +++ b/locales/ro-RO.yml @@ -254,7 +254,6 @@ removeAreYouSure: "Ești sigur că vrei să înlături {x}?" deleteAreYouSure: "Ești sigur că vrei să ștergi {x}?" resetAreYouSure: "Sigur vrei să resetezi?" saved: "Salvat" -messaging: "Chat" upload: "Încarcă" keepOriginalUploading: "Păstrează imaginea originală" keepOriginalUploadingDescription: "Salvează imaginea originala încărcată fără modificări. Dacă e oprită, o versiune pentru afișarea pe web va fi generată la încărcare." @@ -267,7 +266,6 @@ uploadFromUrlMayTakeTime: "S-ar putea să ia puțin până se finalizează înc explore: "Explorează" messageRead: "Citit" noMoreHistory: "Nu există mai mult istoric" -startMessaging: "Începe un chat nou" nUsersRead: "citit de {n}" agreeTo: "Sunt de acord cu {0}" start: "Să începem" @@ -341,7 +339,6 @@ enableLocalTimeline: "Activează cronologia locală" enableGlobalTimeline: "Activeaza cronologia globală" disablingTimelinesInfo: "Administratorii și Moderatorii vor avea mereu access la toate cronologiile, chiar dacă nu sunt activate." registration: "Inregistrare" -enableRegistration: "Activează înregistrările pentru utilizatori noi" invite: "Invită" driveCapacityPerLocalAccount: "Capacitatea Drive-ului per utilizator local" driveCapacityPerRemoteAccount: "Capacitatea Drive-ului per utilizator extern" @@ -430,8 +427,6 @@ retype: "Introdu din nou" noteOf: "Notă de {user}" quoteAttached: "Citat" quoteQuestion: "Vrei să adaugi ca citat?" -noMessagesYet: "Niciun mesaj încă" -newMessageExists: "Ai mesaje noi" onlyOneFileCanBeAttached: "Poți atașa un singur fișier la un mesaj" signinRequired: "Te rog autentifică-te" invitations: "Invită" @@ -453,7 +448,6 @@ or: "Sau" language: "Limbă" uiLanguage: "Limba interfeței" aboutX: "Despre {x}" -disableDrawer: "Nu folosi meniuri în stil sertar" noHistory: "Nu există istoric" signinHistory: "Istoric autentificări" doing: "Se procesează..." @@ -626,10 +620,7 @@ abuseReported: "Raportul tău a fost trimis. Mulțumim." reporter: "Raportorul" reporteeOrigin: "Originea raportatului" reporterOrigin: "Originea raportorului" -forwardReport: "Redirecționează raportul către instanța externă" -forwardReportIsAnonymous: "În locul contului tău, va fi afișat un cont anonim, de sistem, ca raportor către instanța externă." send: "Trimite" -abuseMarkAsResolved: "Marchează raportul ca rezolvat" openInNewTab: "Deschide în tab nou" openInSideView: "Deschide în vedere laterală" defaultNavigationBehaviour: "Comportament de navigare implicit" @@ -651,6 +642,13 @@ show: "Arată" icon: "Avatar" replies: "Răspunde" renotes: "Re-notează" +information: "Despre" +_chat: + invitations: "Invită" + noHistory: "Nu există istoric" + members: "Membri" + home: "Acasă" + send: "Trimite" _delivery: stop: "Suspendat" _type: @@ -715,6 +713,7 @@ _notification: renote: "Re-notează" quote: "Citează" reaction: "Reacție" + login: "Autentifică-te" _actions: reply: "Răspunde" renote: "Re-notează" @@ -737,3 +736,8 @@ _moderationLogTypes: resetPassword: "Resetează parola" _reversi: total: "Total" +_remoteLookupErrors: + _noSuchObject: + title: "Nu a fost găsit" +_search: + searchScopeAll: "Tot" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml index 88f59155d6..496bd147ae 100644 --- a/locales/ru-RU.yml +++ b/locales/ru-RU.yml @@ -2,23 +2,26 @@ _lang_: "Русский" headlineMisskey: "Сеть, сплетённая из заметок" introMisskey: "Добро пожаловать! Misskey — это децентрализованный сервис микроблогов с открытым исходным кодом.\nПишите «заметки» — делитесь со всеми происходящим вокруг или рассказывайте о себе 📡\nСтавьте «реакции» — выражайте свои чувства и эмоции от заметок других 👍\nОткройте для себя новый мир 🚀" -poweredByMisskeyDescription: "{name} – сервис на платформе с открытым исходным кодом Misskey, называемый инстансом Misskey." +poweredByMisskeyDescription: "{name} – сервис на платформе с открытым исходным кодом Misskey, называемый экземпляром Misskey." monthAndDay: "{day}.{month}" search: "Поиск" notifications: "Уведомления" username: "Имя пользователя" password: "Пароль" +initialPasswordForSetup: "Пароль для начала настройки" +initialPasswordIsIncorrect: "Пароль для запуска настройки неверен" +initialPasswordForSetupDescription: "Если вы установили Misskey самостоятельно, используйте пароль, который вы указали в файле конфигурации.\nЕсли вы используете что-то вроде хостинга Misskey, используйте предоставленный пароль.\nЕсли вы не установили пароль, оставьте его пустым и продолжайте." forgotPassword: "Забыли пароль?" fetchingAsApObject: "Приём с других сайтов" -ok: "Окей" +ok: "Подтвердить" gotIt: "Ясно!" cancel: "Отмена" noThankYou: "Нет, спасибо" enterUsername: "Введите имя пользователя" -renotedBy: "{user} делится" +renotedBy: "{user} делает репост" noNotes: "Нет ни одной заметки" noNotifications: "Нет уведомлений" -instance: "Инстанс" +instance: "Экземпляр" settings: "Настройки" notificationSettings: "Настройки уведомлений" basicSettings: "Основные настройки" @@ -45,22 +48,24 @@ pin: "Закрепить в профиле" unpin: "Открепить от профиля" copyContent: "Скопировать содержимое" copyLink: "Скопировать ссылку" +copyLinkRenote: "Скопировать ссылку на репост" delete: "Удалить" deleteAndEdit: "Удалить и отредактировать" -deleteAndEditConfirm: "Удалить эту заметку и создать отредактированную? Все реакции, ссылки и ответы на существующую будут будут потеряны." +deleteAndEditConfirm: "Удалить этот пост и отредактировать заново? Все реакции, репосты и ответы на него также будут удалены." addToList: "Добавить в список" addToAntenna: "Добавить к антенне" sendMessage: "Отправить сообщение" copyRSS: "Скопировать RSS" copyUsername: "Скопировать имя пользователя" -copyUserId: "Скопировать идентификатор пользователя" -copyNoteId: "Скопировать идентификатор заметки" +copyUserId: "Скопировать ID пользователя" +copyNoteId: "Скопировать ID поста" copyFileId: "Скопировать ID файла" copyFolderId: "Скопировать ID папки" -copyProfileUrl: "Скопировать URL профиля " +copyProfileUrl: "Скопировать ссылку на профиль" searchUser: "Поиск людей" +searchThisUsersNotes: "Искать по заметкам пользователя" reply: "Ответ" -loadMore: "Показать еще" +loadMore: "Загрузить ещё" showMore: "Показать ещё" showLess: "Закрыть" youGotNewFollower: "Новый подписчик" @@ -107,11 +112,14 @@ enterEmoji: "Введите эмодзи" renote: "Репост" unrenote: "Отмена репоста" renoted: "Репост совершён." +renotedToX: "Репостнуть в {name}." cantRenote: "Это нельзя репостить." cantReRenote: "Невозможно репостить репост." quote: "Цитата" inChannelRenote: "В канале" inChannelQuote: "Заметки в канале" +renoteToChannel: "Репостнуть в канал" +renoteToOtherChannel: "Репостнуть в другой канал" pinnedNote: "Закреплённая заметка" pinned: "Закрепить в профиле" you: "Вы" @@ -150,6 +158,7 @@ editList: "Редактировать список" selectChannel: "Выберите канал" selectAntenna: "Выберите антенну" editAntenna: "Редактировать антенну" +createAntenna: "Создать антенну" selectWidget: "Выберите виджет" editWidgets: "Редактировать виджеты" editWidgetsExit: "Готово" @@ -157,11 +166,12 @@ customEmojis: "Собственные эмодзи" emoji: "Эмодзи" emojis: "Эмодзи" emojiName: "Название эмодзи" -emojiUrl: "URL эмодзи" +emojiUrl: "Ссылка на эмодзи" addEmoji: "Добавить эмодзи" settingGuide: "Рекомендуемые настройки" cacheRemoteFiles: "Кешировать внешние файлы" cacheRemoteFilesDescription: "Когда эта настройка отключена, файлы с других сайтов будут загружаться прямо оттуда. Это сэкономит место на сервере, но увеличит трафик, так как не будут создаваться эскизы." +youCanCleanRemoteFilesCache: "Вы можете очистить кэш, нажав на кнопку 🗑️ в меню управления файлами." cacheRemoteSensitiveFiles: "Кэшировать внешние файлы «не для всех»" cacheRemoteSensitiveFilesDescription: "Если отключено, файлы «не для всех» загружаются непосредственно с удалённых серверов, не кэшируясь." flagAsBot: "Аккаунт бота" @@ -175,6 +185,10 @@ addAccount: "Добавить учётную запись" reloadAccountsList: "Обновить список учётных записей" loginFailed: "Неудачная попытка входа" showOnRemote: "Перейти к оригиналу на сайт" +continueOnRemote: "Продолжить на удалённом сервере" +chooseServerOnMisskeyHub: "Выбрать сервер с Misskey Hub" +specifyServerHost: "Укажите сервер напрямую" +inputHostName: "Введите домен" general: "Общее" wallpaper: "Обои" setWallpaper: "Установить обои" @@ -185,6 +199,7 @@ followConfirm: "Подписаться на {name}?" proxyAccount: "Учётная запись прокси" proxyAccountDescription: "Учетная запись прокси предназначена служить подписчиком на пользователей с других сайтов. Например, если пользователь добавит кого-то с другого сайта а список, деятельность того не отобразится, пока никто с этого же сайта не подписан на него. Чтобы это стало возможным, на него подписывается прокси." host: "Хост" +selectSelf: "Выбрать себя" selectUser: "Выберите пользователя" recipient: "Кому" annotation: "Описание" @@ -199,6 +214,7 @@ perHour: "По часам" perDay: "По дням" stopActivityDelivery: "Остановить отправку обновлений активности" blockThisInstance: "Блокировать этот инстанс" +silenceThisInstance: "Заглушить этот инстанс" operations: "Операции" software: "Программы" version: "Версия" @@ -218,6 +234,8 @@ clearCachedFiles: "Очистить кэш" clearCachedFilesConfirm: "Удалить все закэшированные файлы с других сайтов?" blockedInstances: "Заблокированные инстансы" blockedInstancesDescription: "Введите список инстансов, которые хотите заблокировать. Они больше не смогут обмениваться с вашим инстансом." +silencedInstances: "Заглушённые инстансы" +federationAllowedHosts: "Серверы, поддерживающие федерацию" muteAndBlock: "Скрытие и блокировка" mutedUsers: "Скрытые пользователи" blockedUsers: "Заблокированные пользователи" @@ -236,7 +254,7 @@ noJobs: "Нет заданий" federating: "Федерируется" blocked: "Заблокировано" suspended: "Заморожено" -all: "Всё" +all: "Все" subscribing: "Подписка" publishing: "Публикация" notResponding: "Нет ответа" @@ -264,11 +282,10 @@ deleteAreYouSure: "Хотите удалить «{x}»?" resetAreYouSure: "На самом деле сбросить?" areYouSure: "Вы уверены?" saved: "Сохранено" -messaging: "Сообщения" upload: "Загрузить" keepOriginalUploading: "Сохранить исходное изображение" keepOriginalUploadingDescription: "Сохраняет исходную версию при загрузке изображений. Если выключить, то при загрузке браузер генерирует изображение для публикации." -fromDrive: "С «диска»" +fromDrive: "С Диска" fromUrl: "По ссылке" uploadFromUrl: "Загрузить по ссылке" uploadFromUrlDescription: "Ссылка на файл, который хотите загрузить" @@ -277,7 +294,6 @@ uploadFromUrlMayTakeTime: "Загрузка может занять некото explore: "Обзор" messageRead: "Прочитали" noMoreHistory: "История закончилась" -startMessaging: "Начать общение" nUsersRead: "Прочитали {n}" agreeTo: "Я соглашаюсь с {0}" agree: "Согласен" @@ -308,6 +324,7 @@ selectFile: "Выберите файл" selectFiles: "Выберите файлы" selectFolder: "Выберите папку" selectFolders: "Выберите папки" +fileNotSelected: "Файл не выбран" renameFile: "Переименовать файл" folderName: "Имя папки" createFolder: "Создать папку" @@ -315,6 +332,7 @@ renameFolder: "Переименовать папку" deleteFolder: "Удалить папку" folder: "Папка" addFile: "Добавить файл" +showFile: "Посмотреть файл" emptyDrive: "Диск пуст" emptyFolder: "Папка пуста" unableToDelete: "Удаление невозможно" @@ -357,10 +375,9 @@ enableLocalTimeline: "Включить локальную ленту" enableGlobalTimeline: "Включить глобальную ленту" disablingTimelinesInfo: "У администраторов и модераторов есть доступ ко всем лентам, даже если они отключены." registration: "Регистрация" -enableRegistration: "Разрешить регистрацию" invite: "Пригласить" -driveCapacityPerLocalAccount: "Объём диска на одного локального пользователя" -driveCapacityPerRemoteAccount: "Объём диска на одного пользователя с другого сайта" +driveCapacityPerLocalAccount: "Объём Диска на одного локального пользователя" +driveCapacityPerRemoteAccount: "Объём Диска на одного пользователя с другого экземпляра" inMb: "В мегабайтах" bannerUrl: "Ссылка на изображение в шапке" backgroundImageUrl: "Ссылка на фоновое изображение" @@ -379,6 +396,7 @@ mcaptcha: "mCaptcha" enableMcaptcha: "Включить mCaptcha" mcaptchaSiteKey: "Ключ сайта" mcaptchaSecretKey: "Секретный ключ" +mcaptchaInstanceUrl: "Ссылка на сервер mCaptcha" recaptcha: "reCAPTCHA" enableRecaptcha: "Включить reCAPTCHA" recaptchaSiteKey: "Ключ сайта" @@ -393,7 +411,8 @@ manageAntennas: "Настройки антенн" name: "Название" antennaSource: "Источник антенны" antennaKeywords: "Ключевые слова" -antennaExcludeKeywords: "Исключения" +antennaExcludeKeywords: "Чёрный список слов" +antennaExcludeBots: "Исключать ботов" antennaKeywordsDescription: "Пишите слова через пробел в одной строке, чтобы ловить их появление вместе; на отдельных строках располагайте слова, или группы слов, чтобы ловить любые из них." notifyAntenna: "Уведомлять о новых заметках" withFileAntenna: "Только заметки с вложениями" @@ -426,6 +445,8 @@ totp: "Приложение-аутентификатор" totpDescription: "Описание приложения-аутентификатора" moderator: "Модератор" moderation: "Модерация" +moderationNote: "Примечания модератора" +moderationLogs: "Журнал модерации" nUsersMentioned: "Упомянуло пользователей: {n}" securityKeyAndPasskey: "Ключ безопасности и парольная фраза" securityKey: "Ключ безопасности" @@ -458,10 +479,10 @@ retype: "Введите ещё раз" noteOf: "Что пишет {user}" quoteAttached: "Цитата" quoteQuestion: "Хотите добавить цитату?" -noMessagesYet: "Пока ни одного сообщения" -newMessageExists: "Новое сообщение" +attachAsFileQuestion: "Текста в буфере обмена слишком много. Прикрепить как текстовый файл?" onlyOneFileCanBeAttached: "К сообщению можно прикрепить только один файл" signinRequired: "Пожалуйста, войдите" +signinOrContinueOnRemote: "Чтобы продолжить, вам необходимо войти в аккаунт на своём сервере или зарегистрироваться / войти в аккаунт на этом." invitations: "Приглашения" invitationCode: "Код приглашения" checking: "Проверка" @@ -471,7 +492,7 @@ usernameInvalidFormat: "Можно использовать только лат tooShort: "Слишком короткий" tooLong: "Слишком длинный" weakPassword: "Слабый пароль" -normalPassword: "Годный пароль" +normalPassword: "Хороший пароль" strongPassword: "Надёжный пароль" passwordMatched: "Совпали" passwordNotMatched: "Не совпадают" @@ -483,8 +504,10 @@ uiLanguage: "Язык интерфейса" aboutX: "Описание {x}" emojiStyle: "Стиль эмодзи" native: "Системные" -disableDrawer: "Не использовать выдвижные меню" +menuStyle: "Стиль меню" +style: "Стиль" showNoteActionsOnlyHover: "Показывать кнопки у заметок только при наведении" +showReactionsCount: "Видеть количество реакций на заметках" noHistory: "История пока пуста" signinHistory: "Журнал посещений" enableAdvancedMfm: "Включить расширенный MFM" @@ -547,7 +570,7 @@ popout: "Развернуть" volume: "Громкость" masterVolume: "Основная регулировка громкости" notUseSound: "Выключить звук" -useSoundOnlyWhenActive: "Использовать звук, когда Misskey активен." +useSoundOnlyWhenActive: "Воспроизводить звук только когда Misskey активен." details: "Подробнее" chooseEmoji: "Выберите эмодзи" unableToProcess: "Не удаётся завершить операцию" @@ -601,7 +624,7 @@ poll: "Опрос" useCw: "Скрывать содержимое под предупреждением" enablePlayer: "Включить проигрыватель" disablePlayer: "Выключить проигрыватель" -expandTweet: "Развернуть твит" +expandTweet: "Развернуть заметку" themeEditor: "Редактор темы оформления" description: "Описание" describeFile: "Добавить подпись" @@ -613,7 +636,7 @@ plugins: "Расширения" preferencesBackups: "Резервная копия" deck: "Пульт" undeck: "Покинуть пульт" -useBlurEffectForModal: "Размывка под формой поверх всего" +useBlurEffectForModal: "Размытие за формой ввода заметки" useFullReactionPicker: "Полнофункциональный выбор реакций" width: "Ширина" height: "Высота" @@ -644,7 +667,7 @@ smtpSecure: "Использовать SSL/TLS для SMTP-соединений" smtpSecureInfo: "Выключите при использовании STARTTLS." testEmail: "Проверка доставки электронной почты" wordMute: "Скрытие слов" -hardWordMute: "" +hardWordMute: "Строгое скрытие слов" regexpError: "Ошибка в регулярном выражении" regexpErrorDescription: "В списке {tab} скрытых слов, в строке {line} обнаружена синтаксическая ошибка:" instanceMute: "Глушение инстансов" @@ -680,10 +703,7 @@ abuseReported: "Жалоба отправлена. Большое спасибо reporter: "Сообщивший" reporteeOrigin: "О ком сообщено" reporterOrigin: "Кто сообщил" -forwardReport: "Отправить жалобу на инстанс автора." -forwardReportIsAnonymous: "Жалоба на удалённый инстанс будет отправлена анонимно. Вместо ваших данных у получателя будет отображена системная учётная запись." send: "Отправить" -abuseMarkAsResolved: "Отметить жалобу как решённую" openInNewTab: "Открыть в новой вкладке" openInSideView: "Открывать в боковой колонке" defaultNavigationBehaviour: "Поведение навигации по умолчанию" @@ -726,6 +746,7 @@ lockedAccountInfo: "Даже если вы вручную подтверждае alwaysMarkSensitive: "Отмечать файлы как «содержимое не для всех» по умолчанию" loadRawImages: "Сразу показывать изображения в полном размере" disableShowingAnimatedImages: "Не проигрывать анимацию" +highlightSensitiveMedia: "Выделять содержимое не для всех" verificationEmailSent: "Вам отправлено письмо для подтверждения. Пройдите, пожалуйста, по ссылке из письма, чтобы завершить проверку." notSet: "Не настроено" emailVerified: "Адрес электронной почты подтверждён." @@ -743,7 +764,7 @@ makeExplorable: "Опубликовать профиль в «Обзоре»." makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе «Обзор»." showGapBetweenNotesInTimeline: "Показывать разделитель между заметками в ленте" duplicate: "Дубликат" -left: "Влево" +left: "Слева" center: "По центру" wide: "Толстый" narrow: "Тонкий" @@ -822,7 +843,7 @@ noMaintainerInformationWarning: "Не заполнены сведения об noBotProtectionWarning: "Ботозащита не настроена" configure: "Настроить" postToGallery: "Опубликовать в галерею" -postToHashtag: "Написать заметку с этим хэштегом" +postToHashtag: "Написать заметку с этим хештегом" gallery: "Галерея" recentPosts: "Недавние публикации" popularPosts: "Популярные публикации" @@ -839,13 +860,13 @@ emailNotConfiguredWarning: "Не указан адрес электронной ratio: "Соотношение" previewNoteText: "Предварительный просмотр" customCss: "Индивидуальный CSS" -customCssWarn: "Используйте эту настройку только если знаете, что делаете. Ошибки здесь чреваты тем, что сайт перестанет нормально работать у вас." +customCssWarn: "Используйте эту настройку только если знаете, что делаете. Ошибки здесь чреваты тем, что у вас перестанет нормально работать сайт." global: "Всеобщая" squareAvatars: "Квадратные аватарки" sent: "Отправить" received: "Получено" searchResult: "Результаты поиска" -hashtags: "Хэштег" +hashtags: "Хештеги" troubleshooting: "Разрешение проблем" useBlurEffect: "Размытие в интерфейсе" learnMore: "Подробнее" @@ -857,7 +878,7 @@ accountDeletionInProgress: "В настоящее время выполняет usernameInfo: "Имя, которое отличает вашу учетную запись от других на этом сервере. Вы можете использовать алфавит (a~z, A~Z), цифры (0~9) или символы подчеркивания (_). Имена пользователей не могут быть изменены позже." aiChanMode: "Режим Ай" devMode: "Режим разработчика" -keepCw: "Сохраняйте Предупреждения о содержимом" +keepCw: "Сохраняйте предупреждения о содержимом" pubSub: "Учётные записи Pub/Sub" lastCommunication: "Последнее сообщение" resolved: "Решено" @@ -878,6 +899,8 @@ makeReactionsPublicDescription: "Список сделанных вами реа classic: "Классика" muteThread: "Скрыть цепочку" unmuteThread: "Отменить сокрытие цепочки" +followingVisibility: "Видимость подписок" +followersVisibility: "Видимость подписчиков" continueThread: "Показать следующие ответы" deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?" incorrectPassword: "Пароль неверен." @@ -987,6 +1010,7 @@ assign: "Назначить" unassign: "Отменить назначение" color: "Цвет" manageCustomEmojis: "Управлять пользовательскими эмодзи" +manageAvatarDecorations: "Управление украшениями аватара" youCannotCreateAnymore: "Вы достигли лимита создания." cannotPerformTemporary: "Временно недоступен" cannotPerformTemporaryDescription: "Это действие временно невозможно выполнить из-за превышения лимита выполнения." @@ -1003,7 +1027,8 @@ thisPostMayBeAnnoying: "Это сообщение может быть непри thisPostMayBeAnnoyingHome: "Этот пост может быть отправлен на главную" thisPostMayBeAnnoyingCancel: "Этот пост не может быть отменен." thisPostMayBeAnnoyingIgnore: "Этот пост может быть проигнорирован " -collapseRenotes: "Свернуть репосты" +collapseRenotes: "Сворачивать увиденные репосты" +collapseRenotesDescription: "Сворачивать посты с которыми вы взаимодействовали." internalServerError: "Внутренняя ошибка сервера" internalServerErrorDescription: "Внутри сервера произошла непредвиденная ошибка." copyErrorInfo: "Скопировать код ошибки" @@ -1027,20 +1052,28 @@ resetPasswordConfirm: "Сбросить пароль?" sensitiveWords: "Чувствительные слова" sensitiveWordsDescription: "Установите общедоступный диапазон заметки, содержащей заданное слово, на домашний. Можно сделать несколько настроек, разделив их переносами строк." sensitiveWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение." +prohibitedWords: "Запрещённые слова" +prohibitedWordsDescription: "Включает вывод ошибки при попытке опубликовать пост, содержащий указанное слово/набор слов.\nМножество слов может быть указано, разделяемые новой строкой." prohibitedWordsDescription2: "Разделение пробелом создаёт спецификацию AND, а разделение косой чертой создаёт регулярное выражение." +hiddenTags: "Скрытые хештеги" notesSearchNotAvailable: "Поиск заметок недоступен" license: "Лицензия" unfavoriteConfirm: "Удалить избранное?" -myClips: "Мои клипы" +myClips: "Мои подборки" drivecleaner: "Очиститель дисков" retryAllQueuesNow: "Повторить все очереди сейчас" retryAllQueuesConfirmTitle: "Хотите попробовать ещё раз?" retryAllQueuesConfirmText: "Нагрузка на сервер может увеличиться" enableChartsForRemoteUser: "Создание диаграмм для удалённых пользователей" enableChartsForFederatedInstances: "Создание диаграмм для удалённых серверов" +showClipButtonInNoteFooter: "Показать кнопку добавления в подборку в меню действий с заметкой" +reactionsDisplaySize: "Размер реакций" +limitWidthOfReaction: "Ограничить максимальную ширину реакций и отображать их в уменьшенном размере." noteIdOrUrl: "ID или ссылка на заметку" video: "Видео" videos: "Видео" +audio: "Звук" +audioFiles: "Звуковые файлы" dataSaver: "Экономия трафика" accountMigration: "Перенос учётной записи" accountMoved: "Учётная запись перенесена" @@ -1052,12 +1085,13 @@ editMemo: "Изменить памятку" reactionsList: "Список реакций" renotesList: "Репосты" notificationDisplay: "Отображение уведомлений" -leftTop: "Влево вверх" -rightTop: "Вправо вверх" -leftBottom: "Влево вниз" -rightBottom: "Вправо вниз" -vertical: "Вертикальная" -horizontal: "Сбоку" +leftTop: "Слева вверху" +rightTop: "Справа сверху" +leftBottom: "Слева внизу" +rightBottom: "Справа внизу" +stackAxis: "Положение уведомлений" +vertical: "Вертикально" +horizontal: "Горизонтально" position: "Позиция" serverRules: "Правила сервера" pleaseConfirmBelowBeforeSignup: "Для регистрации на данном сервере, необходимо согласится с нижеследующими положениями." @@ -1067,59 +1101,129 @@ preservedUsernames: "Зарезервированные имена пользо preservedUsernamesDescription: "Перечислите зарезервированные имена пользователей, отделяя их строками. Они станут недоступны при создании учётной записи. Это ограничение не применяется при создании учётной записи администраторами. Также, уже существующие учётные записи останутся без изменений." createNoteFromTheFile: "Создать заметку из этого файла" archive: "Архив" +unarchive: "Разархивировать" channelArchiveConfirmTitle: "Переместить {name} в архив?" channelArchiveConfirmDescription: "Архивированные каналы перестанут отображаться в списке каналов или результатах поиска. В них также нельзя будет добавлять новые записи." +thisChannelArchived: "Этот канал находится в архиве." displayOfNote: "Отображение заметок" initialAccountSetting: "Настройка профиля" -youFollowing: "Подписки" +youFollowing: "Вы подписаны" preventAiLearning: "Отказаться от использования в машинном обучении (Генеративный ИИ)" +preventAiLearningDescription: "Запросить краулеров не использовать опубликованный текст или изображения и т.д. для машинного обучения (Прогнозирующий / Генеративный ИИ) датасетов. Это достигается путём добавления \"noai\" HTTP-заголовка в ответ на соответствующий контент. Полного предотвращения через этот заголовок не избежать, так как он может быть просто проигнорирован." options: "Настройки ролей" specifyUser: "Указанный пользователь" +lookupConfirm: "Хотите узнать?" +openTagPageConfirm: "Открыть страницу этого хештега?" +specifyHost: "Указать сайт" failedToPreviewUrl: "Предварительный просмотр недоступен" update: "Обновить" rolesThatCanBeUsedThisEmojiAsReaction: "Роли тех, кому можно использовать эти эмодзи как реакцию" rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription: "Если здесь ничего не указать, в качестве реакции эту эмодзи сможет использовать каждый." +rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn: "Эти роли должны быть общедоступными." +cancelReactionConfirm: "Вы действительно хотите удалить свою реакцию?" later: "Позже" goToMisskey: "К Misskey" additionalEmojiDictionary: "Дополнительные словари эмодзи" installed: "Установлено" branding: "Бренд" +enableServerMachineStats: "Опубликовать характеристики сервера" enableIdenticonGeneration: "Включить генерацию иконки пользователя" turnOffToImprovePerformance: "Отключение этого параметра может повысить производительность." +createInviteCode: "Создать код приглашения" +createCount: "Количество приглашений" expirationDate: "Дата истечения" -unused: "Неиспользуемый" +noExpirationDate: "Бессрочно" +unused: "Неиспользованное" +used: "Использован" expired: "Срок действия приглашения истёк" doYouAgree: "Согласны?" icon: "Аватар" replies: "Ответы" renotes: "Репост" loadReplies: "Показать ответы" +pinnedList: "Закреплённый список" +keepScreenOn: "Держать экран включённым" +showRenotes: "Показывать репосты" +mutualFollow: "Взаимные подписки" +followingOrFollower: "Подписки или подписчики" +fileAttachedOnly: "Только заметки с файлами" +showRepliesToOthersInTimeline: "Показывать ответы в ленте" +showRepliesToOthersInTimelineAll: "Показывать в ленте ответы пользователей, на которых вы подписаны" +hideRepliesToOthersInTimelineAll: "Скрывать в ленте ответы пользователей, на которых вы подписаны" sourceCode: "Исходный код" +sourceCodeIsNotYetProvided: "Исходный код пока не доступен. Свяжитесь с администратором, чтобы исправить эту проблему." +repositoryUrl: "Ссылка на репозиторий" +repositoryUrlDescription: "Если вы используете Misskey как есть (без изменений в исходном коде), введите https://github.com/misskey-dev/misskey" +privacyPolicy: "Политика Конфиденциальности" +privacyPolicyUrl: "Ссылка на Политику Конфиденциальности" +attach: "Прикрепить" +angle: "Угол" flip: "Переворот" +disableStreamingTimeline: "Отключить обновление ленты в режиме реального времени" +useGroupedNotifications: "Отображать уведомления сгруппировано" +doReaction: "Добавить реакцию" code: "Код" +remainingN: "Остаётся: {n}" +seasonalScreenEffect: "Эффект времени года на экране" +decorate: "Украсить" +addMfmFunction: "Добавить MFM" lastNDays: "Последние {n} сут" +hemisphere: "Место проживания" +enableHorizontalSwipe: "Смахните в сторону, чтобы сменить вкладки" surrender: "Этот пост не может быть отменен." +useNativeUIForVideoAudioPlayer: "Использовать интерфейс браузера при проигрывании видео и звука" +keepOriginalFilename: "Сохранять исходное имя файла" +keepOriginalFilenameDescription: "Если вы выключите данную настройку, имена файлов будут автоматически заменены случайной строкой при загрузке." +alwaysConfirmFollow: "Всегда подтверждать подписку" +inquiry: "Связаться" +messageToFollower: "Сообщение подписчикам" +postForm: "Форма отправки" +information: "Описание" +_chat: + invitations: "Пригласить" + noHistory: "История пока пуста" + members: "Участники" + home: "Главная" + send: "Отправить" +_settings: + webhook: "Вебхук" _delivery: stop: "Заморожено" _type: none: "Публикация" +_announcement: + tooManyActiveAnnouncementDescription: "Большое количество оповещений может ухудшить пользовательский опыт. Рассмотрите архивирование неактуальных оповещений. " _initialAccountSetting: accountCreated: "Аккаунт успешно создан!" letsStartAccountSetup: "Давайте настроим вашу учётную запись." profileSetting: "Настройки профиля" privacySetting: "Настройки конфиденциальности" initialAccountSettingCompleted: "Первоначальная настройка успешно завершена!" + startTutorial: "Пройти Обучение" skipAreYouSure: "Пропустить настройку?" _initialTutorial: + launchTutorial: "Пройти обучение" _note: description: "Посты в Misskey называются 'Заметками.' Заметки отсортированы в хронологическом порядке в ленте и обновляются в режиме реального времени." + _reaction: + reactToContinue: "Добавьте реакцию, чтобы продолжить." + _postNote: + _visibility: + public: "Твоя заметка будет видна всем." + doNotSendConfidencialOnDirect2: "Администратор целевого сервера может видеть что вы отправляете. Будьте осторожны с конфиденциальной информацией, когда отправляете личные заметки пользователям с ненадёжных серверов." _timelineDescription: home: "В персональной ленте располагаются заметки тех, на которых вы подписаны." - local: "Местная лента показывает заметки всех пользователей этого сайта." + local: "Местная лента показывает заметки всех пользователей этого экземпляра." social: "В социальной ленте собирается всё, что есть в персональной и местной лентах." - global: "В глобальную ленту попадает вообще всё со связанных инстансов." + global: "В глобальную ленту попадает вообще всё со связанных экземпляров." _serverSettings: iconUrl: "Адрес на иконку роли" +_accountMigration: + moveFrom: "Перенести другую учётную запись сюда" + moveTo: "Перенести учётную запись на другой сервер" + moveAccountDescription: "Это действие перенесёт ваш аккаунт на другой сервер.\n ・Подписчики с этого аккаунта автоматически подпишутся на новый\n ・Этот аккаунт отпишется от всех пользователей, на которых подписан сейчас\n ・Вы не сможете создавать новые заметки и т.д. на этом аккаунте\n\nТогда как перенос подписчиков происходит автоматически, вы должны будете подготовиться, сделав некоторые шаги, чтобы перенести список пользователей, на которых вы подписаны. Чтобы сделать это, экспортируйте список подписчиков в файл, который затем импортируете на новом аккаунте в меню настроек. То же самое необходимо будет сделать со списками, также как и со скрытыми и заблокированными пользователями.\n\n(Это объяснение применяется к Misskey v13.12.0 и выше. Другое ActivityPub программное обеспечение, такое, как Mastodon, может работать по-другому." + startMigration: "Перенести" + movedAndCannotBeUndone: "Аккаунт был перемещён. Это действие необратимо." _achievements: earnedAt: "Разблокировано в" _types: @@ -1395,6 +1499,7 @@ _role: canPublicNote: "Может публиковать общедоступные заметки" canInvite: "Может создавать пригласительные коды" canManageCustomEmojis: "Управлять пользовательскими эмодзи" + canManageAvatarDecorations: "Управление украшениями аватара" driveCapacity: "Доступное пространство на «диске»" alwaysMarkNsfw: "Всегда отмечать файлы как «не для всех»" pinMax: "Доступное количество закреплённых заметок" @@ -1408,6 +1513,7 @@ _role: rateLimitFactor: "Ограничение активности" descriptionOfRateLimitFactor: "Меньшее значение — слабые ограничения, большее — сильные" canHideAds: "Может скрыть рекламу" + canImportFollowing: "Можно импортировать подписчиков" _condition: isLocal: "Местный" isRemote: "Неместный" @@ -1505,6 +1611,11 @@ _aboutMisskey: donate: "Пожертвование на Misskey" morePatrons: "Большое спасибо и многим другим, кто принял участие в этом проекте! 🥰" patrons: "Материальная поддержка" + projectMembers: "Участники проекта" +_displayOfSensitiveMedia: + respect: "Скрывать содержимое не для всех" + ignore: "Показывать содержимое не для всех" + force: "Скрывать всё содержимое" _instanceTicker: none: "Не показывать" remote: "Только для других сайтов" @@ -1533,7 +1644,7 @@ _wordMute: muteWordsDescription: "Пишите слова через пробел в одной строке, чтобы фильтровать их появление вместе; а если хотите фильтровать любое из них, пишите в отдельных строках." muteWordsDescription2: "Здесь можно использовать регулярные выражения — просто заключите их между двумя дробными чертами (/)." _instanceMute: - instanceMuteDescription: "Заметки и репосты с указанных здесь инстансов, а также ответы пользователям оттуда же не будут отображаться." + instanceMuteDescription: "Любые активности, затрагивающие инстансы из данного списка, будут скрыты." instanceMuteDescription2: "Пишите каждый инстанс на отдельной строке" title: "Скрывает заметки с заданных инстансов." heading: "Список скрытых инстансов" @@ -1582,7 +1693,7 @@ _theme: navActive: "Текст на боковой панели (активирован)" navIndicator: "Индикатор на боковой панели" link: "Ссылка" - hashtag: "Хэштег" + hashtag: "Хештег" mention: "Упоминание" mentionMe: "Упоминания вас" renote: "Репост" @@ -1600,7 +1711,6 @@ _theme: buttonBg: "Фон кнопки" buttonHoverBg: "Текст кнопки" inputBorder: "Рамка поля ввода" - listItemHoverBg: "Фон пункта списка (под указателем)" driveFolderBg: "Фон папки «Диска»" wallpaperOverlay: "Слой обоев" badge: "Значок" @@ -1612,6 +1722,10 @@ _sfx: note: "Заметки" noteMy: "Собственные заметки" notification: "Уведомления" + reaction: "При выборе реакции" +_soundSettings: + driveFile: "Использовать аудиофайл с Диска." + driveFileWarn: "Выбрать аудиофайл с Диска." _ago: future: "Из будущего" justNow: "Только что" @@ -1690,6 +1804,8 @@ _permissions: "write:gallery": "Редактирование галереи" "read:gallery-likes": "Просмотр списка понравившегося в галерее" "write:gallery-likes": "Изменение списка понравившегося в галерее" + "write:admin:reset-password": "Сбросить пароль пользователю" + "write:chat": "Писать и удалять сообщения" _auth: shareAccessTitle: "Разрешения для приложений" shareAccess: "Дать доступ для «{name}» к вашей учётной записи?" @@ -1743,6 +1859,7 @@ _widgets: _userList: chooseList: "Выберите список" clicker: "Счётчик щелчков" + birthdayFollowings: "Пользователи, у которых сегодня день рождения" _cw: hide: "Спрятать" show: "Показать" @@ -1796,7 +1913,7 @@ _profile: name: "Имя" username: "Имя пользователя" description: "О себе" - youCanIncludeHashtags: "Можете использовать здесь хэштеги" + youCanIncludeHashtags: "Можете использовать здесь хештеги." metadata: "Дополнительные сведения" metadataEdit: "Редактировать дополнительные сведения" metadataDescription: "Можно добавить до четырёх дополнительных граф в профиль." @@ -1804,6 +1921,8 @@ _profile: metadataContent: "Содержимое" changeAvatar: "Поменять аватар" changeBanner: "Поменять изображение в шапке" + verifiedLinkDescription: "Указывая здесь URL, содержащий ссылку на профиль, иконка владения ресурсом может быть отображена рядом с полем" + avatarDecorationMax: "Вы можете добавить до {max} украшений." _exportOrImport: allNotes: "Все заметки\n" favoritedNotes: "Избранное" @@ -1864,9 +1983,6 @@ _pages: newPage: "Создать страницу" editPage: "Править страницу" readPage: "Читать страницу" - created: "Страница успешно создана." - updated: "Страница успешно обновлена." - deleted: "Страница успешно удалена." pageSetting: "Настройки страницы" nameAlreadyExists: "Указанный адрес страницы уже существует." invalidNameTitle: "Указанный адрес страницы недопустим." @@ -1926,6 +2042,9 @@ _notification: unreadAntennaNote: "Антенна {name}" emptyPushNotificationMessage: "Обновлены push-уведомления" achievementEarned: "Получено достижение" + checkNotificationBehavior: "Проверить внешний вид уведомления" + sendTestNotification: "Отправить тестовое уведомление" + flushNotification: "Очистить уведомления" _types: all: "Все" follow: "Подписки" @@ -1938,6 +2057,7 @@ _notification: receiveFollowRequest: "Получен запрос на подписку" followRequestAccepted: "Запрос на подписку одобрен" achievementEarned: "Получение достижений" + login: "Войти" app: "Уведомления из приложений" _actions: followBack: "отвечает взаимной подпиской" @@ -1977,19 +2097,64 @@ _dialog: _disabledTimeline: title: "Лента отключена" description: "Ваша текущая роль не позволяет пользоваться этой лентой." +_drivecleaner: + orderBySizeDesc: "Размеры файлов по убыванию" + orderByCreatedAtAsc: "По увеличению даты" _webhookSettings: createWebhook: "Создать вебхук" + modifyWebhook: "Изменить Вебхук" name: "Название" + secret: "Секрет" + trigger: "Условие срабатывания" active: "Вкл." + _events: + follow: "Когда подписались на пользователя" + followed: "Когда на вас подписались" + note: "Когда создали заметку" + reply: "Когда получили ответ на заметку" + renote: "Когда вас репостнули" + reaction: "Когда получили реакцию" + mention: "Когда вас упоминают" + _systemEvents: + abuseReport: "Когда приходит жалоба" + abuseReportResolved: "Когда разрешается жалоба" + userCreated: "Когда создан пользователь" + deleteConfirm: "Вы уверены, что хотите удалить этот Вебхук?" _abuseReport: _notificationRecipient: _recipientType: mail: "Электронная почта" + webhook: "Вебхук" + _captions: + webhook: "Отправить уведомление Системному Вебхуку при получении или разрешении жалоб." + notifiedWebhook: "Используемый Вебхук" _moderationLogTypes: suspend: "Заморозить" addCustomEmoji: "Добавлено эмодзи" updateCustomEmoji: "Изменено эмодзи" deleteCustomEmoji: "Удалено эмодзи" + deleteDriveFile: "Файл удалён" resetPassword: "Сброс пароля:" + createInvitation: "Создать код приглашения" + createSystemWebhook: "Создать Системный Вебхук" + updateSystemWebhook: "Обновить Системый Вебхук" + deleteSystemWebhook: "Удалить Системный Вебхук" +_fileViewer: + url: "Ссылка" + attachedNotes: "Закреплённые заметки" +_dataSaver: + _code: + title: "Подсветка кода" +_hemisphere: + N: "Северное полушарие" + S: "Южное полушарие" + caption: "Используется для некоторых настроек клиента для определения сезона." _reversi: total: "Всего" +_remoteLookupErrors: + _noSuchObject: + title: "Не найдено" +_search: + searchScopeAll: "Все" + searchScopeLocal: "Местная" + searchScopeUser: "Указанный пользователь" diff --git a/locales/si-LK.yml b/locales/si-LK.yml index e130d68ed8..c43f3d860d 100644 --- a/locales/si-LK.yml +++ b/locales/si-LK.yml @@ -17,3 +17,6 @@ _sfx: note: "නෝට්" _profile: username: "පරිශීලක නාමය" +_notification: + _types: + login: "පිවිසෙන්න" diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml index 41f8949196..7f7827202f 100644 --- a/locales/sk-SK.yml +++ b/locales/sk-SK.yml @@ -242,7 +242,6 @@ removeAreYouSure: "Naozaj chcete odstrániť \"{x}\"?" deleteAreYouSure: "Naozaj chcete odstrániť \"{x}\"?" resetAreYouSure: "Naozaj resetovať?" saved: "Uložené" -messaging: "Chat" upload: "Nahrať súbor" keepOriginalUploading: "Zachovať pôvodný obrázok" keepOriginalUploadingDescription: "Uloží pôvodný obrázok ako je. Ak je vypnuté, verzia pre web sa vygeneruje pri nahratí." @@ -255,7 +254,6 @@ uploadFromUrlMayTakeTime: "Nahrávanie môže nejaký čas trvať." explore: "Objavovať" messageRead: "Prečítané" noMoreHistory: "To je všetko" -startMessaging: "Začať chat" nUsersRead: "prečítané {n} používateľmi" agreeTo: "Súhlasím s {0}" agreeBelow: "Súhlasím s nasledovným" @@ -331,7 +329,6 @@ enableLocalTimeline: "Povoliť lokálnu časovú os" enableGlobalTimeline: "Povoliť globálnu časovú os" disablingTimelinesInfo: "Administrátori a moderátori majú vždy prístup ku všetkým časovým osiam, aj keď sú vypnuté." registration: "Registrácia" -enableRegistration: "Povoliť registráciu nových používateľov" invite: "Pozvať" driveCapacityPerLocalAccount: "Kapacita disku pre používateľa" driveCapacityPerRemoteAccount: "Kapacita disku pre vzdialeného používateľa" @@ -429,8 +426,6 @@ retype: "Zadajte znovu" noteOf: "Poznámky používateľa {user}" quoteAttached: "Citované" quoteQuestion: "Pripojiť ako citát?" -noMessagesYet: "Zatiaľ žiadne správy" -newMessageExists: "Máte novú správu" onlyOneFileCanBeAttached: "Ku správe môžete priložiť len jeden súbor" signinRequired: "Prihláste sa, prosím!" invitations: "Pozvať" @@ -454,7 +449,6 @@ uiLanguage: "Jazyk používateľského prostredia" aboutX: "O {x}" emojiStyle: "Štýl emoji" native: "Natívne" -disableDrawer: "Nepoužívať šuflíkové menu" showNoteActionsOnlyHover: "Ovládacie prvky poznámky sa zobrazujú len po nabehnutí myši" noHistory: "Žiadna história" signinHistory: "História prihlásení" @@ -632,10 +626,7 @@ abuseReported: "Vaše nahlásenie je odoslané. Veľmi pekne ďakujeme." reporter: "Nahlásil" reporteeOrigin: "Pôvod nahláseného" reporterOrigin: "Pôvod nahlasovača" -forwardReport: "Preposlať nahlásenie na server" -forwardReportIsAnonymous: "Namiesto vášho účtu bude zobrazený anonymný systémový účet na vzdialenom serveri ako autor nahlásenia." send: "Poslať" -abuseMarkAsResolved: "Označiť nahlásenia ako vyriešené" openInNewTab: "Otvoriť v novom tabe" openInSideView: "Otvoriť v bočnom paneli" defaultNavigationBehaviour: "Predvolené správanie navigácie" @@ -922,6 +913,14 @@ renotes: "Preposlať" sourceCode: "Zdrojový kód" flip: "Preklopiť" lastNDays: "Posledných {n} dní" +postForm: "Napísať poznámku" +information: "Informácie" +_chat: + invitations: "Pozvať" + noHistory: "Žiadna história" + members: "Členovia" + home: "Domov" + send: "Poslať" _delivery: stop: "Zmrazené" _type: @@ -1112,7 +1111,6 @@ _theme: buttonBg: "Pozadie tlačidla" buttonHoverBg: "Pozadie tlačidla (pod kurzorom)" inputBorder: "Okraj vstupného poľa" - listItemHoverBg: "Pozadie položky zoznamu (pod kurzorom)" driveFolderBg: "Pozadie priečinu disku" wallpaperOverlay: "Vrstvenie pozadia" badge: "Odznak" @@ -1182,6 +1180,7 @@ _permissions: "write:gallery": "Upravovať vašu galériu" "read:gallery-likes": "Vidieť zoznam obľúbených príspevkov z galérie" "write:gallery-likes": "Upraviť zoznam obľúbených príspevov z galérie" + "write:chat": "Písať alebo odstraňovať správy v chate" _auth: shareAccess: "Prajete si povoliť \"{name}\", aby mal prístup k tomuto účtu?" shareAccessAsk: "Naozaj chcete povoliť tejto aplikácii prístup k tomuto účtu?" @@ -1338,9 +1337,6 @@ _pages: newPage: "Vytvoriť novú stránku" editPage: "Upraviť túto stránku" readPage: "Zobrazenie zdroja aktívne" - created: "Stránka úspešne vytvorená" - updated: "Stránka úspešne upravená" - deleted: "Stránka úspešne odstránená" pageSetting: "Nastavenia stránky" nameAlreadyExists: "Zadaná URL stránku už existuje" invalidNameTitle: "Zadaná URL stránku je nesprávna" @@ -1410,6 +1406,7 @@ _notification: pollEnded: "Hlasovanie skončilo" receiveFollowRequest: "Doručené žiadosti o sledovanie" followRequestAccepted: "Schválené žiadosti o sledovanie" + login: "Prihlásiť sa" app: "Oznámenia z prepojených aplikácií" _actions: followBack: "Sledovať späť\n" @@ -1454,3 +1451,9 @@ _moderationLogTypes: resetPassword: "Resetovať heslo" _reversi: total: "Celkom" +_remoteLookupErrors: + _noSuchObject: + title: "Nenájdené" +_search: + searchScopeAll: "Všetko" + searchScopeLocal: "Lokálne" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml index c1a998b8fb..ceb02ffc4c 100644 --- a/locales/sv-SE.yml +++ b/locales/sv-SE.yml @@ -249,7 +249,6 @@ removeAreYouSure: "Är du säker att du vill radera \"{x}\"?" deleteAreYouSure: "Är du säker att du vill radera \"{x}\"?" resetAreYouSure: "Vill du återställa?" saved: "Sparad" -messaging: "Chatt" upload: "Ladda upp" keepOriginalUploading: "Behåll originalbild" keepOriginalUploadingDescription: "Sparar den originellt uppladdade bilden i sitt i befintliga skick. Om avstängd, kommer en webbversion bli genererad vid uppladdning." @@ -262,7 +261,6 @@ uploadFromUrlMayTakeTime: "Det kan ta tid tills att uppladdningen blir klar." explore: "Utforska" messageRead: "Läs" noMoreHistory: "Det finns ingen mer historik" -startMessaging: "Starta en chatt" nUsersRead: "läst av {n}" agreeTo: "Jag accepterar {0}" agree: "Överens" @@ -333,7 +331,6 @@ disconnectService: "Koppla från" enableLocalTimeline: "Aktivera lokal tidslinje" enableGlobalTimeline: "Aktivera global tidslinje" registration: "Registrera" -enableRegistration: "Aktivera registrering av nya användare" invite: "Inbjudan" inMb: "I megabyte" bannerUrl: "URL till banner-bilden" @@ -385,6 +382,7 @@ passwordLessLoginDescription: "Tillåter lösenordsfri inloggning med endast en resetPassword: "Återställ Lösenord" newPasswordIs: "Det nya lösenordet är \"{password}\"" share: "Dela" +markAsReadAllTalkMessages: "Markera alla meddelanden som lästa" help: "Hjälp" close: "Stäng" invites: "Inbjudan" @@ -393,12 +391,14 @@ transfer: "Överför" text: "Text" enable: "Aktivera" next: "Nästa" +retype: "Ange igen" invitations: "Inbjudan" invitationCode: "Inbjudningskod" available: "Tillgängligt" weakPassword: "Svagt Lösenord" normalPassword: "Medel Lösenord" strongPassword: "Starkt Lösenord" +signinWith: "Logga in med {x}" signinFailed: "Kan inte logga in. Det angivna användarnamnet eller lösenordet är felaktigt." or: "eller" language: "Språk" @@ -410,70 +410,124 @@ existingAccount: "Existerande konto" regenerate: "Regenerera" fontSize: "Textstorlek" openImageInNewTab: "Öppna bild i ny flik" +appearance: "Utseende" clientSettings: "Klientinställningar" accountSettings: "Kontoinställningar" numberOfDays: "Antal dagar" +objectStorageUseSSL: "Använd SSL" +serverLogs: "Serverloggar" deleteAll: "Radera alla" sounds: "Ljud" sound: "Ljud" listen: "Lyssna" none: "Ingen" volume: "Volym" +notUseSound: "Inaktivera ljud" chooseEmoji: "Välj en emoji" recentUsed: "Senast använd" install: "Installera" uninstall: "Avinstallera" +deleteAllFiles: "Radera alla filer" +deleteAllFilesConfirm: "Är du säker på att du vill radera alla filer?" menu: "Meny" +addItem: "Lägg till objekt" serviceworkerInfo: "Måste vara aktiverad för pushnotiser." enableInfiniteScroll: "Ladda mer automatiskt" enablePlayer: "Öppna videospelare" +description: "Beskrivning" permission: "Behörigheter" enableAll: "Aktivera alla" +disableAll: "Inaktivera alla" edit: "Ändra" enableEmail: "Aktivera epost-utskick" email: "E-post" +emailAddress: "E-postadress" smtpHost: "Värd" smtpUser: "Användarnamn" smtpPass: "Lösenord" emptyToDisableSmtpAuth: "Lämna användarnamn och lösenord tomt för att avaktivera SMTP verifiering" +makeActive: "Aktivera" +copy: "Kopiera" +overview: "Översikt" logs: "Logg" +database: "Databas" channel: "kanal" create: "Skapa" other: "Mer" +abuseReports: "Rapporter" +reportAbuse: "Rapporter" +reportAbuseOf: "Rapportera {name}" +abuseReported: "Din rapport har skickats. Tack så mycket." send: "Skicka" openInNewTab: "Öppna i ny flik" createNew: "Skapa ny" +private: "Privat" i18nInfo: "Misskey översätts till många olika språk av volontärer. Du kan hjälpa till med översättningen på {link}." accountInfo: "Kontoinformation" +followersCount: "Antal följare" +yes: "Ja" +no: "Nej" clips: "Klipp" duplicate: "Duplicera" reloadToApplySetting: "Inställningen tillämpas efter sidan laddas om. Vill du göra det nu?" clearCache: "Rensa cache" onlineUsersCount: "{n} användare är online" +nUsers: "{n} användare" nNotes: "{n} Noter" backgroundColor: "Bakgrundsbild" textColor: "Text" +saveAs: "Spara som..." +saveConfirm: "Spara ändringar?" youAreRunningUpToDateClient: "Klienten du använder är uppdaterat." newVersionOfClientAvailable: "Ny version av klienten är tillgänglig." +editCode: "Redigera kod" publish: "Publicera" typingUsers: "{users} skriver" +goBack: "Tillbaka" +addDescription: "Lägg till beskrivning" info: "Om" +online: "Online" +active: "Aktiv" +offline: "Offline" enabled: "Aktiverad" +quickAction: "Snabbåtgärder" user: "Användare" +gallery: "Galleri" +popularPosts: "Populära inlägg" customCssWarn: "Den här inställningen borde bara ändrats av en som har rätta kunskaper. Om du ställer in det här fel så kan klienten sluta fungera rätt." global: "Global" squareAvatars: "Visa fyrkantiga profilbilder" sent: "Skicka" +searchResult: "Sökresultat" +learnMore: "Läs mer" misskeyUpdated: "Misskey har uppdaterats!" +translate: "Översätt" +controlPanel: "Kontrollpanel" +manageAccounts: "Hantera konton" incorrectPassword: "Fel lösenord." +hide: "Dölj" welcomeBackWithName: "Välkommen tillbaka, {name}" clickToFinishEmailVerification: "Tryck på [{ok}] för att slutföra bekräftelsen på e-postadressen." +size: "Storlek" searchByGoogle: "Sök" +indefinitely: "Aldrig" +tenMinutes: "10 minuter" +oneHour: "En timme" +oneDay: "En dag" +oneWeek: "En vecka" +oneMonth: "En månad" +threeMonths: "3 månader" +oneYear: "1 år" +threeDays: "3 dagar" file: "Filer" +deleteAccount: "Radera konto" +label: "Etikett" cannotUploadBecauseNoFreeSpace: "Kan inte ladda upp filen för att det finns inget lagringsutrymme kvar." cannotUploadBecauseExceedsFileSizeLimit: "Kan inte ladda upp filen för att den är större än filstorleksgränsen." +beta: "Beta" enableAutoSensitive: "Automatisk NSFW markering" enableAutoSensitiveDescription: "Tillåter automatiskt detektering och marketing av NSFW media genom Maskininlärning när möjligt. Även om denna inställningen är avaktiverad, kan det vara aktiverat på hela instansen." +move: "Flytta" pushNotification: "Pushnotiser" subscribePushNotification: "Aktivera pushnotiser" unsubscribePushNotification: "Avaktivera pushnotiser" @@ -482,38 +536,92 @@ pushNotificationNotSupported: "Din webbläsare eller instans har inte stöd för windowMaximize: "Maximera" windowMinimize: "Minimera" windowRestore: "Återställ" +tools: "Verktyg" +like: "Gilla" pleaseDonate: "Misskey är en gratis programvara som används på {host}. Donera gärna för att göra utvecklingen ständigt, tack!" +roles: "Roll" +role: "Roll" +color: "Färg" resetPasswordConfirm: "Återställ verkligen ditt lösenord?" dataSaver: "Databesparing" icon: "Profilbild" +forYou: "För dig" replies: "Svara" renotes: "Omnotera" +loadReplies: "Visa svar" +loadConversation: "Visa konversation" +authentication: "Autentisering" +sourceCode: "Källkod" +doReaction: "Lägg till reaktion" +code: "Kod" +gameRetry: "Försök igen" +inquiry: "Kontakt" +tryAgain: "Försök igen senare" +signinWithPasskey: "Logga in med nyckel" +unknownWebAuthnKey: "Okänd nyckel" +information: "Om" +_chat: + invitations: "Inbjudan" + members: "Medlemmar" + home: "Hem" + send: "Skicka" _delivery: stop: "Suspenderad" _type: none: "Publiceras" +_initialAccountSetting: + profileSetting: "Profilinställningar" +_initialTutorial: + _reaction: + title: "Vad är reaktioner?" _achievements: _types: _open3windows: title: "Flera Fönster" description: "Ha minst 3 fönster öppna samtidigt" +_role: + edit: "Redigera roll" _ffVisibility: public: "Publicera" + private: "Privat" +_accountDelete: + accountDelete: "Radera konto" +_ad: + back: "Tillbaka" +_gallery: + like: "Gilla" _email: _follow: title: "följde dig" +_aboutMisskey: + source: "Källkod" + projectMembers: "Projektmedlemmar" _channel: setBanner: "Välj banner" removeBanner: "Ta bort banner" + nameAndDescription: "Namn och beskrivning" +_menuDisplay: + hide: "Dölj" _theme: + description: "Beskrivning" + color: "Färg" keys: mention: "Nämn" renote: "Omnotera" _sfx: note: "Noter" notification: "Notifikationer" +_ago: + justNow: "Just nu" _2fa: + step3Title: "Ange en autentiseringskod" renewTOTPCancel: "Nej tack" +_permissions: + "read:reactions": "Visa dina reaktioner" + "write:reactions": "Redigera dina reaktioner" + "write:admin:delete-account": "Radera användarkonto" + "write:admin:roles": "Hantera roller" + "read:admin:roles": "Visa roller" _antennaSources: all: "Alla noter" homeTimeline: "Noter från följda användare" @@ -530,13 +638,19 @@ _widgets: _userList: chooseList: "Välj lista" _cw: + hide: "Dölj" show: "Ladda mer" + chars: "{count} tecken" + files: "{count} fil(er)" +_poll: + infinite: "Aldrig" _visibility: home: "Hem" followers: "Följare" _profile: name: "Namn" username: "Användarnamn" + metadataLabel: "Etikett" changeAvatar: "Ändra profilbild" changeBanner: "Ändra banner" _exportOrImport: @@ -547,9 +661,12 @@ _exportOrImport: userLists: "Listor" _charts: federation: "Federation" + activeUsers: "Aktiva användare" _timelines: home: "Hem" global: "Global" +_play: + summary: "Beskrivning" _pages: blocks: image: "Bilder" @@ -562,10 +679,13 @@ _notification: renote: "Omnotera" quote: "Citat" reaction: "Reaktioner" + login: "Logga in" _actions: reply: "Svara" renote: "Omnotera" _deck: + addColumn: "Lägg till kolumn" + deleteProfile: "Radera profil" _columns: notifications: "Notifikationer" tl: "Tidslinje" @@ -583,3 +703,12 @@ _abuseReport: _moderationLogTypes: suspend: "Suspendera" resetPassword: "Återställ Lösenord" +_reversi: + blackOrWhite: "Svart/Vit" + rules: "Regler" + black: "Svart" + white: "Vit" +_selfXssPrevention: + warning: "VARNING" +_search: + searchScopeAll: "Allt" diff --git a/locales/th-TH.yml b/locales/th-TH.yml index 2fb4a5253a..e93d65b5c6 100644 --- a/locales/th-TH.yml +++ b/locales/th-TH.yml @@ -8,6 +8,9 @@ search: "ค้นหา" notifications: "เเจ้งเตือน" username: "ชื่อผู้ใช้" password: "รหัสผ่าน" +initialPasswordForSetup: "รหัสผ่านเริ่มต้นสำหรับการตั้งค่า" +initialPasswordIsIncorrect: "รหัสผ่านเริ่มต้นสำหรับตั้งค่านั้นไม่ถูกต้องค่ะ" +initialPasswordForSetupDescription: "ถ้าหากคุณติดตั้ง Misskey เอง ให้ใช้รหัสผ่านที่คุณป้อนในไฟล์กำหนดค่า \nถ้าหากคุณกำลังใช้บริการโฮสต์ Misskey ให้ใช้รหัสผ่านที่ได้รับมา\nถ้ายังไม่มีรหัสผ่าน ให้ข้ามช่องรหัสผ่านไป แล้วกดต่อไป" forgotPassword: "ลืมรหัสผ่าน" fetchingAsApObject: "กำลังดึงข้อมูลจากสหพันธ์..." ok: "ตกลง" @@ -236,6 +239,8 @@ silencedInstances: "ปิดปากเซิร์ฟเวอร์นี้ silencedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการปิดปาก คั่นด้วยการขึ้นบรรทัดใหม่, บัญชีทั้งหมดของเซิร์ฟเวอร์ดังกล่าวจะถือว่าถูกปิดปากเช่นกัน ทำได้เฉพาะคำขอติดตามเท่านั้น และไม่สามารถกล่าวถึงบัญชีในเซิร์ฟเวอร์นี้ได้หากไม่ได้ถูกติดตามกลับ | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก" mediaSilencedInstances: "เซิร์ฟเวอร์ที่ถูกปิดปากสื่อ" mediaSilencedInstancesDescription: "ระบุโฮสต์ของเซิร์ฟเวอร์ที่ต้องการปิดปากสื่อ คั่นด้วยการขึ้นบรรทัดใหม่, ไฟล์ที่ถูกส่งจากบัญชีของเซิร์ฟเวอร์ดังกล่าวจะถือว่าถูกปิดปาก แล้วจะถูกติดเครื่องหมายว่ามีเนื้อหาละเอียดอ่อน และเอโมจิแบบกำหนดเองก็จะใช้ไม่ได้ด้วย | สิ่งนี้ไม่มีผลต่ออินสแตนซ์ที่ถูกบล็อก" +federationAllowedHosts: "เซิร์ฟเวอร์ที่เปิดให้บริการแบบเฟเดอเรชั่น" +federationAllowedHostsDescription: "ระบุชื่อโฮสต์ของเซิร์ฟเวอร์ที่คุณต้องการอนุญาตให้เชื่อมต่อแบบเฟเดอเรชั่น โดยต้องเว้นวรรคแต่ละบรรทัด" muteAndBlock: "ปิดเสียงและบล็อก" mutedUsers: "ผู้ใช้ที่ถูกปิดเสียง" blockedUsers: "ผู้ใช้ที่ถูกบล็อก" @@ -282,7 +287,6 @@ deleteAreYouSure: "ต้องการลบ “{x}” ใช่ไหม?" resetAreYouSure: "รีเซ็ตเลยไหม?" areYouSure: "แน่ใจแล้วใช่ไหมคะ?" saved: "บันทึกแล้ว" -messaging: "แชท" upload: "อัปโหลด" keepOriginalUploading: "เก็บภาพต้นฉบับ" keepOriginalUploadingDescription: "เก็บภาพต้นฉบับไว้เมื่ออัปโหลดภาพ หากปิด รูปภาพสำหรับการเผยแพร่ทางเว็บจะถูกสร้างขึ้นในเบราว์เซอร์เมื่อทำการอัปโหลด" @@ -295,7 +299,6 @@ uploadFromUrlMayTakeTime: "การอัปโหลดอาจใช้เ explore: "สำรวจ" messageRead: "อ่านแล้ว" noMoreHistory: "ไม่มีประวัติเพิ่มเติม" -startMessaging: "เริ่มการสนทนา" nUsersRead: "อ่านโดย {n}" agreeTo: "ฉันยอมรับ {0}" agree: "ยอมรับ" @@ -334,6 +337,7 @@ renameFolder: "เปลี่ยนชื่อโฟลเดอร์" deleteFolder: "ลบโฟลเดอร์" folder: "โฟลเดอร์" addFile: "เพิ่มไฟล์" +showFile: "แสดงไฟล์" emptyDrive: "ไดรฟ์ของคุณว่างเปล่านะ" emptyFolder: "โฟลเดอร์นี้ว่างเปล่า" unableToDelete: "ไม่สามารถลบออกได้" @@ -376,7 +380,6 @@ enableLocalTimeline: "เปิดใช้งานไทม์ไลน์ท enableGlobalTimeline: "เปิดใช้งานไทม์ไลน์ทั่วโลก" disablingTimelinesInfo: "ผู้ดูแลระบบและผู้ควบคุมจะสามารถเข้าถึงไทม์ไลน์ทั้งหมด ถึงแม้ว่าจะไม่ได้เปิดใช้งานก็ตาม" registration: "ลงทะเบียน" -enableRegistration: "เปิดใช้งานการลงทะเบียนผู้ใช้ใหม่" invite: "คำเชิญ" driveCapacityPerLocalAccount: "ความจุของไดรฟ์ต่อผู้ใช้ท้องถิ่น" driveCapacityPerRemoteAccount: "ความจุของไดรฟ์ต่อผู้ใช้ระยะไกล" @@ -448,6 +451,7 @@ totpDescription: "ใช้แอปยืนยันตัวตนเพื moderator: "ผู้ควบคุม" moderation: "การกลั่นกรอง" moderationNote: "โน้ตการกลั่นกรอง" +moderationNoteDescription: "คุณสามารถใส่โน้ตส่วนตัวที่เฉพาะผู้ดูแลระบบเท่านั้นที่สามารถเข้าถึงได้" addModerationNote: "เพิ่มโน้ตการกลั่นกรอง" moderationLogs: "ปูมการควบคุมดูแล" nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} ราย" @@ -483,8 +487,6 @@ noteOf: "โน้ตของ {user}" quoteAttached: "อ้างอิง" quoteQuestion: "ต้องการที่จะแนบมันเพื่ออ้างอิงใช่ไหม?" attachAsFileQuestion: "ข้อความในคลิปบอร์ดยาวเกินไป คุณต้องการแนบเป็นไฟล์ข้อความหรือไม่?" -noMessagesYet: "ยังไม่มีข้อความ" -newMessageExists: "คุณมีข้อความใหม่" onlyOneFileCanBeAttached: "สามารถแนบไฟล์ได้เพียงไฟล์เดียวต่อ 1 ข้อความ" signinRequired: "ก่อนดำเนินการต่อ กรุณาลงทะเบียนหรือเข้าสู่ระบบ" signinOrContinueOnRemote: "เพื่อดำเนินการต่อได้ คุณต้องไปที่เซิร์ฟเวอร์ที่คุณใช้งานอยู่ หรือลงทะเบียน/เข้าสู่ระบบเซิร์ฟเวอร์นี้" @@ -509,7 +511,10 @@ uiLanguage: "ภาษาอินเทอร์เฟซผู้ใช้ง aboutX: "เกี่ยวกับ {x}" emojiStyle: "สไตล์ของเอโมจิ" native: "ภาษาแม่" -disableDrawer: "ไม่แสดงเมนูในรูปแบบลิ้นชัก" +menuStyle: "สไตล์เมนู" +style: "สไตล์" +drawer: "ตัววาด" +popup: "ป๊อปอัพ" showNoteActionsOnlyHover: "แสดงการดำเนินการโน้ตเมื่อโฮเวอร์(วางเมาส์เหนือ)เท่านั้น" showReactionsCount: "แสดงจำนวนรีแอกชั่นในโน้ต" noHistory: "ไม่มีประวัติ" @@ -592,6 +597,8 @@ ascendingOrder: "เรียงลำดับขึ้น" descendingOrder: "เรียงลำดับลง" scratchpad: "Scratchpad" scratchpadDescription: "Scratchpad ให้สภาพแวดล้อมสำหรับการทดลอง AiScript คุณสามารถเขียนโค้ด/สั่งดำเนินการ/ตรวจสอบผลลัพธ์ ของการโต้ตอบกับ Misskey ได้" +uiInspector: "ตัวตรวจสอบ UI" +uiInspectorDescription: "คุณสามารถตรวจสอบรายชื่อเซิร์ฟเวอร์ที่เกี่ยวข้องกับส่วนประกอบอินเตอร์เฟซผู้ใช้ (UI) บนหน่วยความจำของระบบ ส่วนประกอบ UI เหล่านี้จะถูกสร้างขึ้นโดยฟังก์ชัน Ui:C:" output: "เอาท์พุต" script: "สคริปต์" disablePagesScript: "ปิดการใช้งาน AiScript บนเพจ" @@ -708,10 +715,7 @@ abuseReported: "เราได้ส่งรายงานของคุณ reporter: "ผู้รายงาน" reporteeOrigin: "ปลายทางรายงาน" reporterOrigin: "แหล่งผู้รายงาน" -forwardReport: "ส่งต่อรายงานไปยังเซิร์ฟเวอร์ระยะไกล" -forwardReportIsAnonymous: "ข้อมูลของคุณจะไม่ปรากฏบนเซิร์ฟเวอร์ระยะไกลและปรากฏเป็นบัญชีระบบที่ไม่ระบุชื่อ" send: "ส่ง" -abuseMarkAsResolved: "ทำเครื่องหมายรายงานว่าแก้ไขแล้ว" openInNewTab: "เปิดในแท็บใหม่" openInSideView: "เปิดในมุมมองด้านข้าง" defaultNavigationBehaviour: "พฤติกรรมการนำทางที่เป็นค่าเริ่มต้น" @@ -913,6 +917,7 @@ followersVisibility: "การมองเห็นผู้ที่กำล continueThread: "ดูความต่อเนื่องเธรด" deleteAccountConfirm: "การดำเนินการนี้จะลบบัญชีของคุณอย่างถาวรเลยนะ แน่ใจหรอดำเนินการ?" incorrectPassword: "รหัสผ่านไม่ถูกต้อง" +incorrectTotp: "รหัสยืนยันตัวตนแบบใช้ครั้งเดียวที่ท่านได้ระบุมานั้น ไม่ถูกต้องหรือหมดอายุลงแล้วค่ะ" voteConfirm: "ต้องการโหวต “{choice}” ใช่ไหม?" hide: "ซ่อน" useDrawerReactionPickerForMobile: "แสดง ตัวจิ้มรีแอคชั่น เป็นแบบลิ้นชัก เมื่อใช้บนมือถือ" @@ -1077,6 +1082,7 @@ retryAllQueuesConfirmTitle: "ลองใหม่ทั้งหมดจริ retryAllQueuesConfirmText: "สิ่งนี้จะเพิ่มการโหลดเซิร์ฟเวอร์ชั่วคราวนะ" enableChartsForRemoteUser: "สร้างแผนภูมิข้อมูลผู้ใช้ระยะไกล" enableChartsForFederatedInstances: "สร้างแผนภูมิของเซิร์ฟเวอร์ระยะไกล" +enableStatsForFederatedInstances: "ดึงข้อมูลสถิติจากเซิร์ฟเวอร์ที่อยู่ห่างไกล" showClipButtonInNoteFooter: "เพิ่ม “คลิป” ไปยังเมนูสั่งการของโน้ต" reactionsDisplaySize: "ขนาดของรีแอคชั่น" limitWidthOfReaction: "จำกัดความกว้างสูงสุดของรีแอคชั่นและแสดงให้เล็กลง" @@ -1263,6 +1269,42 @@ confirmWhenRevealingSensitiveMedia: "ตรวจสอบก่อนแสด sensitiveMediaRevealConfirm: "สื่อนี้มีเนื้อหาละเอียดอ่อน, ต้องการแสดงใช่ไหม?" createdLists: "รายชื่อที่ถูกสร้าง" createdAntennas: "เสาอากาศที่ถูกสร้าง" +fromX: "จาก {x}" +genEmbedCode: "สร้างรหัสฝัง" +noteOfThisUser: "โน้ตโดยผู้ใช้นี้" +clipNoteLimitExceeded: "ไม่สามารถเพิ่มโน้ตเพิ่มเติมในคลิปนี้ได้อีกแล้ว" +performance: "ประสิทธิภาพ​" +modified: "แก้ไข" +discard: "ละทิ้ง" +thereAreNChanges: "มีอยู่ {n} เปลี่ยนแปลง(s)" +signinWithPasskey: "ลงชื่อเข้าใช้ด้วย Passkey" +unknownWebAuthnKey: "พาสคีย์ไม่ถูกต้องค่ะ" +passkeyVerificationFailed: "การยืนยันกุญแจดิจิทัลไม่สำเร็จค่ะ" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "การยืนยันพาสคีย์สำเร็จแล้ว แต่การลงชื่อเข้าใช้แบบไม่ต้องใส่รหัสผ่านถูกปิดใช้งานแล้ว" +messageToFollower: "ข้อความถึงผู้ติดตาม" +target: "เป้า" +testCaptchaWarning: "ฟังก์ชันนี้มีไว้สำหรับทดสอบ CAPTCHA เท่านั้น\nห้ามนำไปใช้ในระบบจริงโดยเด็ดขาด" +prohibitedWordsForNameOfUser: "คำนี้ไม่สามารถใช้เป็นชื่อผู้ใช้ได้" +prohibitedWordsForNameOfUserDescription: "หากมีสตริงใดๆ ในรายการนี้ปรากฏอยู่ในชื่อของผู้ใช้ ชื่อนั้นจะถูกปฏิเสธ ผู้ใช้ที่มีสิทธิ์แต่ผู้ดูแลระบบนั้นจะไม่ได้รับผลกระทบใดๆจากข้อจำกัดนี้ค่ะ" +yourNameContainsProhibitedWords: "ชื่อของคุณนั้นมีคำที่ต้องห้าม" +yourNameContainsProhibitedWordsDescription: "ถ้าหากคุณต้องการใช้ชื่อนี้ กรุณาติดต่อผู้ดูแลระบบของเซิร์ฟเวอร์นะค่ะ" +postForm: "แบบฟอร์มการโพสต์" +information: "เกี่ยวกับ" +_chat: + invitations: "คำเชิญ" + noHistory: "ไม่มีประวัติ" + members: "สมาชิก" + home: "หน้าหลัก" + send: "ส่ง" +_settings: + webhook: "Webhook" +_abuseUserReport: + forward: "ส่ง​ต่อ" + forwardDescription: "ส่งรายงานไปยังเซิร์ฟเวอร์ระยะไกลโดยใช้บัญชีระบบที่ไม่ระบุตัวตน" + resolve: "แก้ไข" + accept: "ยอมรับ" + reject: "ปฏิเสธ" + resolveTutorial: "ถ้าหากรายงานนี้มีเนื้อหาถูกต้อง ให้เลือก \"ยอมรับ\" เพื่อปิดเคสกรณีนี้โดยถือว่าได้รับการแก้ไขแล้ว\nถ้าหากเนื้อหาในรายงานนี้นั้นไม่ถูกต้อง ให้เลือก \"ปฏิเสธ\" เพื่อปิดเคสกรณีนี้โดยถือว่าไม่ได้รับการแก้ไข" _delivery: status: "สถานะการจัดส่ง" stop: "ระงับการส่ง" @@ -1397,8 +1439,10 @@ _serverSettings: fanoutTimelineDescription: "เพิ่มประสิทธิภาพการดึงข้อมูลไทม์ไลน์อย่างมาก และลดภาระในฐานข้อมูลเมื่อเปิดใช้งาน ในทางกลับกัน การใช้หน่วยความจำของ Redis จะเพิ่มขึ้น ลองปิดการใช้งานนี้ในกรณีที่หน่วยความจำเซิร์ฟเวอร์เหลือน้อยหรือเซิร์ฟเวอร์ไม่เสถียร" fanoutTimelineDbFallback: "ฟอลแบ๊กกลับฐานข้อมูล" fanoutTimelineDbFallbackDescription: "เมื่อเปิดใช้งาน หากไม่ได้แคชไทม์ไลน์ ไทม์ไลน์จะฟอลแบ๊กไปยังฐานข้อมูลสำหรับการ query เพิ่มเติม การปิดใช้งานจะช่วยลดภาระของเซิร์ฟเวอร์ด้วยการกำจัดกระบวนฟอลแบ๊ก แต่มันก็จะจำกัดช่วงเวลาไทม์ไลน์ที่สามารถดึงข้อมูลได้" + reactionsBufferingDescription: "เมื่อเปิดใช้งานฟังก์ชันนี้ก็จะช่วยลด latency ในการสร้างปฏิกิริยา แต่อาจจะส่งผลให้ memory footprint ของ Redis เพิ่มขึ้นนะ" inquiryUrl: "URL สำหรับการติดต่อสอบถาม" inquiryUrlDescription: "ระบุ URL ของหน้าเว็บที่มีแบบฟอร์มสำหรับติดต่อผู้ดูแลเซิร์ฟเวอร์ หรือข้อมูลการติดต่อของผู้ดูแลเซิร์ฟเวอร์" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "ถ้าหากไม่มีการตรวจสอบจากผู้ดูแลระบบหรือไม่มีความเคลื่อนไหวมาเป็นระยะเวลาหนึ่ง ระบบจะทำการปิดใช้งานฟังก์ชันนี้โดยอัตโนมัติ เพื่อลดความเสี่ยงในการถูกโจมตีด้วยสแปมและอื่นๆ" _accountMigration: moveFrom: "ย้ายจากบัญชีอื่นมาที่บัญชีนี้" moveFromSub: "สร้างนามแฝงไปยังบัญชีอื่น" @@ -1730,6 +1774,11 @@ _role: canSearchNotes: "การใช้การค้นหาโน้ต" canUseTranslator: "การใช้งานแปล" avatarDecorationLimit: "จำนวนการตกแต่งไอคอนสูงสุดที่สามารถติดตั้งได้" + canImportAntennas: "อนุญาตให้นำเข้าเสาอากาศ" + canImportBlocking: "อนุญาตให้นำเข้าการบล็อก" + canImportFollowing: "อนุญาตให้นำเข้ารายการต่อไปนี้" + canImportMuting: "อนุญาตให้นำเข้าการปิดกั้น" + canImportUserLists: "อนุญาตให้นำเข้ารายการ" _condition: roleAssignedTo: "มอบหมายให้มีบทบาทแบบทำมือ" isLocal: "ผู้ใช้ท้องถิ่น" @@ -1947,7 +1996,6 @@ _theme: buttonBg: "ปุ่มพื้นหลัง" buttonHoverBg: "ปุ่มพื้นหลัง (โฮเวอร์)" inputBorder: "เส้นขอบของช่องป้อนข้อมูล" - listItemHoverBg: "รายการไอเทมพื้นหลัง (โฮเวอร์)" driveFolderBg: "พื้นหลังโฟลเดอร์ไดรฟ์" wallpaperOverlay: "วอลล์เปเปอร์ซ้อนทับ" badge: "ตรา" @@ -2106,6 +2154,7 @@ _permissions: "read:clip-favorite": "ดูคลิปที่ถูกใจ" "read:federation": "รับข้อมูลเกี่ยวกับสหพันธ์" "write:report-abuse": "รายงานการละเมิด" + "write:chat": "เขียนหรือลบข้อความแชท" _auth: shareAccessTitle: "การให้สิทธิ์แอปพลิเคชัน" shareAccess: "คุณต้องการอนุญาตให้ \"{name}\" เข้าถึงบัญชีนี้เลยมั้ย?" @@ -2224,6 +2273,9 @@ _profile: changeBanner: "เปลี่ยนแบนเนอร์" verifiedLinkDescription: "หากป้อน URL ที่มีลิงก์ไปยังโปรไฟล์ของคุณ ไอคอนการยืนยันความเป็นเจ้าของจะแสดงถัดจากฟิลด์นั้น ๆ" avatarDecorationMax: "คุณสามารถเพิ่มการตกแต่งได้สูงสุด {max}" + followedMessage: "ส่งข้อความเมื่อมีคนกดติดตาม" + followedMessageDescription: "ส่งข้อความเมื่อมีคนกดติดตามแล้ว" + followedMessageDescriptionForLockedAccount: "ถ้าหากคุณตั้งค่าให้คนอื่นต้องขออนุญาตก่อนที่จะติดตามคุณ ระบบจะขึ้นข้อความนี้ในตอนที่คุณอนุมัติให้เขาติดตาม" _exportOrImport: allNotes: "โน้ตทั้งหมด" favoritedNotes: "โน้ตที่ถูกใจไว้" @@ -2286,9 +2338,6 @@ _pages: newPage: "สร้างหน้าเพจใหม่" editPage: "แก้ไขหน้าเพจ" readPage: "กำลังดูแหล่งที่มาของเพจนี้" - created: "สร้างหน้าเพจสำเร็จเรียบร้อยแล้ว" - updated: "แก้ไขหน้าเพจสำเร็จเรียบร้อยแล้ว" - deleted: "ลบหน้าเพจสำเร็จเรียบร้อยแล้ว" pageSetting: "การตั้งค่าหน้าเพจ" nameAlreadyExists: "URL ของหน้าที่ระบุนั้นมีอยู่แล้ว" invalidNameTitle: "URL ของหน้าที่ระบุนั้นไม่ถูกต้อง" @@ -2316,6 +2365,7 @@ _pages: eyeCatchingImageSet: "ตั้งค่าภาพขนาดย่อ" eyeCatchingImageRemove: "ลบภาพขนาดย่อ" chooseBlock: "เพิ่มบล็อค" + enterSectionTitle: "ป้อนชื่อหัวข้อ" selectType: "เลือกชนิด" contentBlocks: "เนื้อหา" inputBlocks: "ป้อนข้อมูล" @@ -2361,6 +2411,8 @@ _notification: renotedBySomeUsers: "รีโน้ตจากผู้ใช้ {n} ราย" followedBySomeUsers: "มีผู้ติดตาม {n} ราย" flushNotification: "ล้างประวัติการแจ้งเตือน" + exportOfXCompleted: "การดำเนินการส่งออก {x} ได้เสร็จสิ้นลงแล้ว" + login: "มีคนล็อกอิน" _types: all: "ทั้งหมด" note: "โน้ตใหม่" @@ -2375,6 +2427,9 @@ _notification: followRequestAccepted: "อนุมัติให้ติดตามแล้ว" roleAssigned: "ให้บทบาท" achievementEarned: "ปลดล็อกความสำเร็จแล้ว" + exportCompleted: "กระบวนการส่งออกข้อมูลได้เสร็จสิ้นสมบูรณ์แล้ว" + login: "เข้าสู่ระบบ" + test: "ทดสอบระบบแจ้งเตือน" app: "การแจ้งเตือนจากแอปที่มีลิงก์" _actions: followBack: "ติดตามกลับด้วย" @@ -2440,7 +2495,10 @@ _webhookSettings: abuseReport: "เมื่อมีการรายงานจากผู้ใช้" abuseReportResolved: "เมื่อมีการจัดการกับการรายงานจากผู้ใช้" userCreated: "เมื่อผู้ใช้ถูกสร้างขึ้น" + inactiveModeratorsWarning: "เมื่อผู้ดูแลระบบไม่ได้ใช้งานมานานระยะหนึ่ง" + inactiveModeratorsInvitationOnlyChanged: "เมื่อผู้ดูแลระบบที่ไม่ได้ใช้งานมานาน และเซิร์ฟเวอร์เปลี่ยนเป็นแบบเชิญเข้าร่วมเท่านั้น" deleteConfirm: "ต้องการลบ Webhook ใช่ไหม?" + testRemarks: "คลิกปุ่มทางด้านขวาของสวิตช์เพื่อส่ง Webhook ทดสอบที่มีข้อมูลจำลอง" _abuseReport: _notificationRecipient: createRecipient: "เพิ่มปลายทางการแจ้งเตือนการรายงาน" @@ -2484,6 +2542,8 @@ _moderationLogTypes: markSensitiveDriveFile: "ทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน" unmarkSensitiveDriveFile: "ยกเลิกทำเครื่องหมายไฟล์ว่ามีเนื้อหาละเอียดอ่อน" resolveAbuseReport: "รายงานได้รับการแก้ไขแล้ว" + forwardAbuseReport: "ได้ส่งรายงานไปแล้ว" + updateAbuseReportNote: "โน้ตการกลั่นกรองที่รายงานไปนั้น ได้รับการอัปเดตแล้ว" createInvitation: "สร้างรหัสเชิญ" createAd: "สร้างโฆษณาแล้ว" deleteAd: "ลบโฆษณาออกแล้ว" @@ -2499,6 +2559,10 @@ _moderationLogTypes: createAbuseReportNotificationRecipient: "สร้างปลายทางการแจ้งเตือนการรายงาน" updateAbuseReportNotificationRecipient: "อัปเดตปลายทางการแจ้งเตือนการรายงาน" deleteAbuseReportNotificationRecipient: "ลบปลายทางการแจ้งเตือนการรายงาน" + deleteAccount: "บัญชีถูกลบไปแล้ว" + deletePage: "เพจถูกลบออกไปแล้ว" + deleteFlash: "Play ถูกลบออกไปแล้ว" + deleteGalleryPost: "โพสต์แกลเลอรี่ถูกลบออกแล้ว" _fileViewer: title: "รายละเอียดไฟล์" type: "ประเภทไฟล์" @@ -2512,10 +2576,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "โปรดตรวจสอบให้แน่ใจว่าแหล่งแจกหน่ายมีความน่าเชื่อถือก่อนทำการติดตั้ง" _plugin: title: "ต้องการติดตั้งปลั๊กอินนี้ใช่ไหม?" - metaTitle: "ข้อมูลส่วนเสริม" _theme: title: "ต้องการติดตั้งธีมนี้ใช่ไหม?" - metaTitle: "ข้อมูลธีม" _meta: base: "โทนสีพื้นฐาน" _vendorInfo: @@ -2635,3 +2697,24 @@ _contextMenu: app: "แอปพลิเคชัน" appWithShift: "แอปฟลิเคชันด้วยปุ่มยกแคร่ (Shift)" native: "UI ของเบราว์เซอร์" +_embedCodeGen: + title: "ปรับแต่งโค้ดฝัง" + header: "แสดงส่วนหัว" + autoload: "โหลดเพิ่มโดยอัตโนมัติ (เลิกใช้แล้ว)" + maxHeight: "ความสูงสุด" + maxHeightDescription: "หากถ้าตั้งค่าเป็น 0 จะทำให้ไม่มีการจำกัดความสูงของวิดเจ็ต แต่ควรตั้งค่าเป็นตัวเลขอื่นๆ เพื่อไม่ให้วิดเจ็ตยืดตัวลงไปเรื่อยๆ" + maxHeightWarn: "การจำกัดความสูงสูงสุดถูกปิดใช้งาน (0) หากไม่ได้ตั้งใจให้เป็นเช่นนี้ โปรดตั้งค่าความสูงสูงสุดให้เป็นค่าอื่นๆแทน" + previewIsNotActual: "การแสดงผลนั้นต่างจากการฝังจริงเพราะเกินขอบเขตที่แสดงบนหน้าจอตัวอย่างนะ" + rounded: "ทำให้มันกลม" + border: "เพิ่มขอบให้กับกรอบด้านนอก" + applyToPreview: "นำไปใช้กับการแสดงตัวอย่าง" + generateCode: "สร้างโค้ดสำหรับการฝัง" + codeGenerated: "รหัสถูกสร้างขึ้นแล้ว" + codeGeneratedDescription: "นำโค้ดที่สร้างแล้วไปวางในเว็บไซต์ของคุณเพื่อฝังเนื้อหา" +_remoteLookupErrors: + _noSuchObject: + title: "ไม่พบหน้าที่ต้องการ" +_search: + searchScopeAll: "ทั้งหมด" + searchScopeLocal: "ท้องถิ่น" + searchScopeUser: "ผู้ใช้เฉพาะ" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml index cf6729a81d..1756e8dbeb 100644 --- a/locales/tr-TR.yml +++ b/locales/tr-TR.yml @@ -8,6 +8,7 @@ search: "Arama" notifications: "Bildirim" username: "Kullanıcı Adı" password: "Şifre" +initialPasswordForSetup: "" forgotPassword: "şifremi unuttum" fetchingAsApObject: "從聯邦宇宙取得中..." ok: "TAMAM" @@ -260,7 +261,6 @@ removeAreYouSure: "\"{x}\" silmek istediğinizden emin misiniz?" deleteAreYouSure: "\"{x}\" silmek istediğinizden emin misiniz?" resetAreYouSure: "Sıfırlansın mı?" saved: "Kaydedildi" -messaging: "Mesajlar" upload: "Yükle" keepOriginalUploading: "Orijinal görseli koru" keepOriginalUploadingDescription: "Orijinal olarak yüklenen görüntüyü olduğu gibi kaydeder. Kapatılırsa, yükleme sırasında web'de görüntülenecek bir sürüm oluşturulur." @@ -273,7 +273,6 @@ uploadFromUrlMayTakeTime: "Yüklemenin tamamlanması biraz süre alabilir." explore: "Keşfet" messageRead: "Okundu" noMoreHistory: "Bundan öncesi yok" -startMessaging: "Yeni bir sohbet başlat" nUsersRead: "{n} kişi okudu" agreeTo: "Kabul Ediyorum: {0}" agree: "Kabul Et" @@ -344,14 +343,12 @@ today: "Bugün" monthX: "{month} ay" pages: "Sayfalar" integration: "Entegrasyon" -enableRegistration: "Kayıtlara izin ver" basicInfo: "Temel bilgiler" pinnedUsers: "Sabitlenmiş kullanıcılar" pinnedNotes: "Sabitlenen" manageAntennas: "Anten ayarları" userList: "Listeler" resetPassword: "Şifre sıfırlama" -noMessagesYet: "Şimdilik mesaj yok" details: "Detaylar" deck: "Güverte" smtpHost: "Sağlayıcı" @@ -378,6 +375,8 @@ addMemo: "Kısa not ekle" icon: "Avatar" replies: "yanıt" renotes: "vazgeçme" +_chat: + home: "Ana sayfa" _delivery: stop: "Askıya alınmış" _type: @@ -446,6 +445,7 @@ _notification: reaction: "Tepkiler" receiveFollowRequest: "Takip isteği alındı" followRequestAccepted: "Takip isteği kabul edildi" + login: "Giriş Yap " _actions: reply: "yanıt" renote: "vazgeçme" @@ -459,3 +459,5 @@ _deck: _moderationLogTypes: suspend: "askıya al" resetPassword: "Şifre sıfırlama" +_search: + searchScopeAll: "Tümü" diff --git a/locales/ug-CN.yml b/locales/ug-CN.yml index e48f64511c..fef26040a5 100644 --- a/locales/ug-CN.yml +++ b/locales/ug-CN.yml @@ -17,3 +17,6 @@ _2fa: renewTOTPCancel: "ئۇنى توختىتىڭ" _widgets: profile: "profile" +_notification: + _types: + login: "كىرىش" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml index 36d741d30e..01849dc484 100644 --- a/locales/uk-UA.yml +++ b/locales/uk-UA.yml @@ -246,7 +246,6 @@ removeAreYouSure: "Ви впевнені, що хочете видалити \"{ deleteAreYouSure: "Ви впевнені, що хочете видалити \"{x}\"?" resetAreYouSure: "Справді скинути?" saved: "Збережено" -messaging: "Чати" upload: "Завантажити" keepOriginalUploading: "Зберегти оригінальне зображення" keepOriginalUploadingDescription: "Зберігає початково завантажене зображення як є. Якщо вимкнено, версія для відображення в Інтернеті буде створена під час завантаження." @@ -259,7 +258,6 @@ uploadFromUrlMayTakeTime: "Завантаження може зайняти де explore: "Огляд" messageRead: "Прочитано" noMoreHistory: "Подальшої історії немає" -startMessaging: "Розпочати діалог" nUsersRead: "Прочитали {n}" agreeTo: "Я погоджуюсь з {0}" agreeBelow: "Я погоджуюся з наведеним нижче" @@ -334,7 +332,6 @@ enableLocalTimeline: "Увімкнути локальну стрічку" enableGlobalTimeline: "Увімкнути глобальну стрічку" disablingTimelinesInfo: "Адміністратори та модератори завжди мають доступ до всіх стрічок, навіть якщо вони вимкнуті." registration: "Реєстрація" -enableRegistration: "Дозволити реєстрацію" invite: "Запросити" driveCapacityPerLocalAccount: "Об'єм диска на одного локального користувача" driveCapacityPerRemoteAccount: "Об'єм диска на одного віддаленого користувача" @@ -428,8 +425,6 @@ retype: "Введіть ще раз" noteOf: "Нотатка {user}" quoteAttached: "Цитата" quoteQuestion: "Ви хочете додати цитату?" -noMessagesYet: "Ще немає повідомлень" -newMessageExists: "Є нові повідомлення" onlyOneFileCanBeAttached: "До повідомлення можна вкласти лише один файл" signinRequired: "Будь ласка, авторизуйтесь" invitations: "Запрошення" @@ -452,7 +447,6 @@ language: "Мова" uiLanguage: "Мова інтерфейсу" aboutX: "Про {x}" native: "місцевий" -disableDrawer: "Не використовувати висувні меню" noHistory: "Історія порожня" signinHistory: "Історія входів" enableAdvancedMfm: "Увімкнути розширений MFM" @@ -631,10 +625,7 @@ abuseReported: "Дякуємо, вашу скаргу було відправл reporter: "Репортер" reporteeOrigin: "Про кого повідомлено" reporterOrigin: "Хто повідомив" -forwardReport: "Переслати звіт на віддалений інстанс" -forwardReportIsAnonymous: "Замість вашого облікового запису анонімний системний обліковий запис буде відображатися як доповідач на віддаленому інстансі" send: "Відправити" -abuseMarkAsResolved: "Позначити скаргу як вирішену" openInNewTab: "Відкрити в новій вкладці" openInSideView: "Відкрити збоку" defaultNavigationBehaviour: "Поведінка навігації за замовчуванням" @@ -914,6 +905,14 @@ renotes: "Поширити" sourceCode: "Вихідний код" flip: "Перевернути" lastNDays: "Останні {n} днів" +postForm: "Створення нотатки" +information: "Інформація" +_chat: + invitations: "Запросити" + noHistory: "Історія порожня" + members: "Учасники" + home: "Домівка" + send: "Відправити" _delivery: stop: "Призупинено" _type: @@ -1306,7 +1305,6 @@ _theme: buttonBg: "Фон кнопки" buttonHoverBg: "Фон кнопки (при наведенні)" inputBorder: "Край поля вводу" - listItemHoverBg: "Фон елементу в списку (при наведенні)" driveFolderBg: "Фон папки на диску" wallpaperOverlay: "Накладання шпалер" badge: "Значок" @@ -1371,6 +1369,7 @@ _permissions: "read:channels": "Переглядати канали" "write:channels": "Змінювати канали" "read:gallery": "Перегляд галереї" + "write:chat": "Створювати та видаляти повідомлення" _auth: shareAccess: "Ви хочете надати \"{name}\" доступ до цього акаунту?" shareAccessAsk: "Ви впевнені, що хочете надати цій програмі доступ до вашого акаунту?" @@ -1519,9 +1518,6 @@ _pages: newPage: "Створити сторінку" editPage: "Редагувати сторінку" readPage: "Перегляд вихідного коду" - created: "Сторінка успішно створена." - updated: "Сторінка успішно оновлена." - deleted: "Сторінку видалено" pageSetting: "Налаштування сторінки" nameAlreadyExists: "Вказана адреса сторінки вже існує." invalidNameTitle: "Вказана адреса сторінки неприпустима." @@ -1588,6 +1584,7 @@ _notification: reaction: "Реакції" receiveFollowRequest: "Запити на підписку" followRequestAccepted: "Прийняті підписки" + login: "Увійти" app: "Сповіщення від додатків" _actions: reply: "Відповісти" @@ -1629,3 +1626,9 @@ _moderationLogTypes: resetPassword: "Скинути пароль" _reversi: total: "Всього" +_remoteLookupErrors: + _noSuchObject: + title: "Не знайдено" +_search: + searchScopeAll: "Всі" + searchScopeLocal: "Локальна" diff --git a/locales/uz-UZ.yml b/locales/uz-UZ.yml index ee4ab83ce7..daa268f1c4 100644 --- a/locales/uz-UZ.yml +++ b/locales/uz-UZ.yml @@ -257,7 +257,6 @@ removeAreYouSure: "“{x}”ni olib tashlamoqchi ekanligingizga ishonchingiz kom deleteAreYouSure: "“{x}”ni chindan ham yo'q qilmoqchimisiz?" resetAreYouSure: "Haqiqatan ham qayta tiklansinmi?" saved: "Saqlandi" -messaging: "Suhbat" upload: "Yuklash" keepOriginalUploading: "Asl rasmni saqlang" keepOriginalUploadingDescription: "Rasmlarni yuklashda asl nusxasini saqlaydi. Agar o'chirilgan bo'lsa, brauzer yuklangandan keyin nashr qilish uchun rasm yaratadi." @@ -270,7 +269,6 @@ uploadFromUrlMayTakeTime: "Yuklash tugallanishi uchun biroz vaqt ketishi mumkin. explore: "Ko'rib chiqish" messageRead: "O‘qildi" noMoreHistory: "Buning ortida hech qanday hikoya yo'q" -startMessaging: "Yangi suhbatni boshlash" nUsersRead: "{n} tomonidan o'qildi" agreeTo: "Men {0} ga roziman" agree: "Rozi bo'lish" @@ -349,7 +347,6 @@ enableLocalTimeline: "Mahalliy vaqt mintaqasini yoqing" enableGlobalTimeline: "Global vaqt mintaqasini yoqing" disablingTimelinesInfo: "Administratorlar va Moderatorlar har doim barcha vaqt jadvallariga kirish huquqiga ega bo'ladilar, hatto ular yoqilmagan bo'lsa ham." registration: "Ro'yxatdan o'tish" -enableRegistration: "Ro'yxatdan o'tishni yoqing" invite: "Taklif qilish" driveCapacityPerLocalAccount: "Har bir mahalliy foydalanuvchi uchun disk maydoni" driveCapacityPerRemoteAccount: "Har bir masofaviy foydalanuvchi uchun disk maydoni" @@ -446,8 +443,6 @@ retype: "Qayta kiriting" noteOf: "{user} tomonidan joylandi\n" quoteAttached: "Iqtibos" quoteQuestion: "Iqtibos sifatida qo'shilsinmi?" -noMessagesYet: "Bu yerda xabarlar yo'q" -newMessageExists: "Yangi xabarlar bor" onlyOneFileCanBeAttached: "Faqat bitta faylni biriktirish mumkin" signinRequired: "Davom etishdan oldin ro'yhatdan o'tishingiz yoki tizimga kirishingiz kerak" invitations: "Taklif qilish" @@ -471,7 +466,6 @@ uiLanguage: "Interfeys tili" aboutX: "{x} haqida" emojiStyle: "Emoji ko'rinishi" native: "Mahalliy" -disableDrawer: "Slayd menyusidan foydalanmang" showNoteActionsOnlyHover: "Eslatma amallarini faqat sichqonchani olib borganda ko‘rsatish" noHistory: "Tarix yo'q" signinHistory: "kirish tarixi" @@ -630,10 +624,7 @@ abuseReported: "Shikoyatingiz yetkazildi. Ma'lumot uchun rahmat." reporter: "Shikoyat qiluvchi" reporteeOrigin: "Xabarning kelib chiqishi" reporterOrigin: "Xabarchining joylashuvi" -forwardReport: "Xabarni masofadagi serverga yuborish" -forwardReportIsAnonymous: "Sizning yuborayotgan xabaringiz o'z akkountingiz emas balki anonim tarzda qoladi" send: "Yuborish" -abuseMarkAsResolved: "Yuborilgan xabarni hal qilingan deb belgilash" openInNewTab: "Yangi tab da ochish" openInSideView: "Yon panelda ochish" defaultNavigationBehaviour: "Standart navigatsiya harakati" @@ -846,6 +837,13 @@ icon: "Avatar" replies: "Javob berish" renotes: "Qayta qayd etish" flip: "Teskari" +information: "Haqida" +_chat: + invitations: "Taklif qilish" + noHistory: "Tarix yo'q" + members: "A'zolar" + home: "Bosh sahifa" + send: "Yuborish" _delivery: stop: "To'xtatilgan" _type: @@ -1009,9 +1007,6 @@ _play: _pages: newPage: "Yangi Sahifa yaratish" editPage: "Ushbu Sahifani tahrirlash" - created: "Sahifa muvaffaqiyatli yaratildi" - updated: "Sahifa muvaffaqiyatli tahrirlandi" - deleted: "Sahifa muvaffaqiyatli o'chirildi" pageSetting: "Sahifa sozlamalari" nameAlreadyExists: "Ko'rsatilgan Sahifa URL'i allaqachon mavjud" invalidNameTitle: "Ko'rsatilgan Sahifa URL'i yaroqsiz" @@ -1058,6 +1053,7 @@ _notification: quote: "Iqtibos keltirish" reaction: "Reaktsiyalar" receiveFollowRequest: "Qabul qilingan kuzatuv so'rovlari" + login: "Kirish" _actions: reply: "Javob berish" renote: "Qayta qayd qilish" @@ -1098,3 +1094,9 @@ _moderationLogTypes: resetPassword: "Parolni tiklash" _reversi: total: "Jami" +_remoteLookupErrors: + _noSuchObject: + title: "Topilmadi" +_search: + searchScopeAll: "Barcha" + searchScopeLocal: "Mahalliy" diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml index aadbf8b16f..3a2df8d83e 100644 --- a/locales/vi-VN.yml +++ b/locales/vi-VN.yml @@ -8,6 +8,9 @@ search: "Tìm kiếm" notifications: "Thông báo" username: "Tên người dùng" password: "Mật khẩu" +initialPasswordForSetup: "Mật khẩu ban đầu để thiết lập" +initialPasswordIsIncorrect: "Mật khẩu ban đầu đã nhập sai" +initialPasswordForSetupDescription: "Nếu bạn tự cài đặt Misskey, hãy sử dụng mật khẩu ban đầu của bạn đã nhập trong tệp cấu hình.\nNếu bạn đang sử dụng dịch vụ nào đó giống như dịch vụ lưu trữ của Misskey, hãy sử dụng mật khẩu ban đầu được cung cấp.\nNếu bạn chưa đặt mật khẩu ban đầu, vui lòng để trống và tiếp tục." forgotPassword: "Quên mật khẩu" fetchingAsApObject: "Đang nạp dữ liệu từ Fediverse..." ok: "Đồng ý" @@ -261,7 +264,6 @@ deleteAreYouSure: "Bạn có chắc muốn xóa \"{x}\"?" resetAreYouSure: "Bạn có chắc muốn đặt lại?" areYouSure: "Bạn chắc chứ?" saved: "Đã lưu" -messaging: "Trò chuyện" upload: "Tải lên" keepOriginalUploading: "Giữ hình ảnh gốc" keepOriginalUploadingDescription: "Giữ nguyên như hình ảnh được tải lên ban đầu. Nếu tắt, một phiên bản để hiển thị trên web sẽ được tạo khi tải lên." @@ -274,7 +276,6 @@ uploadFromUrlMayTakeTime: "Sẽ mất một khoảng thời gian để tải lê explore: "Khám phá" messageRead: "Đã đọc" noMoreHistory: "Không còn gì để đọc" -startMessaging: "Bắt đầu trò chuyện" nUsersRead: "đọc bởi {n}" agreeTo: "Tôi đồng ý {0}" agree: "Đồng ý" @@ -354,7 +355,6 @@ enableLocalTimeline: "Bật bảng tin máy chủ" enableGlobalTimeline: "Bật bảng tin liên hợp" disablingTimelinesInfo: "Quản trị viên và Kiểm duyệt viên luôn có quyền truy cập mọi bảng tin, kể cả khi chúng không được bật." registration: "Đăng ký" -enableRegistration: "Cho phép đăng ký mới" invite: "Mời" driveCapacityPerLocalAccount: "Dung lượng ổ đĩa tối đa cho mỗi người dùng" driveCapacityPerRemoteAccount: "Dung lượng ổ đĩa tối đa cho mỗi người dùng từ xa" @@ -461,8 +461,6 @@ noteOf: "Tút của {user}" quoteAttached: "Trích dẫn" quoteQuestion: "Trích dẫn lại?" attachAsFileQuestion: "Văn bản ở trong bộ nhớ tạm rất dài. Bạn có muốn đăng nó dưới dạng một tệp văn bản không?" -noMessagesYet: "Chưa có tin nhắn" -newMessageExists: "Bạn có tin nhắn mới" onlyOneFileCanBeAttached: "Bạn chỉ có thể đính kèm một tập tin" signinRequired: "Vui lòng đăng nhập" invitations: "Mời" @@ -486,7 +484,6 @@ uiLanguage: "Ngôn ngữ giao diện" aboutX: "Giới thiệu {x}" emojiStyle: "Kiểu cách Emoji" native: "Bản xứ" -disableDrawer: "Không dùng menu thanh bên" showNoteActionsOnlyHover: "Chỉ hiển thị các hành động ghi chú khi di chuột" noHistory: "Không có dữ liệu" signinHistory: "Lịch sử đăng nhập" @@ -676,10 +673,7 @@ abuseReported: "Báo cáo đã được gửi. Cảm ơn bạn nhiều." reporter: "Người báo cáo" reporteeOrigin: "Bị báo cáo" reporterOrigin: "Máy chủ người báo cáo" -forwardReport: "Chuyển tiếp báo cáo cho máy chủ từ xa" -forwardReportIsAnonymous: "Thay vì tài khoản của bạn, một tài khoản hệ thống ẩn danh sẽ được hiển thị dưới dạng người báo cáo ở máy chủ từ xa." send: "Gửi" -abuseMarkAsResolved: "Đánh dấu đã xử lý" openInNewTab: "Mở trong tab mới" openInSideView: "Mở trong thanh bên" defaultNavigationBehaviour: "Thao tác điều hướng mặc định" @@ -1121,6 +1115,14 @@ pullDownToRefresh: "Kéo xuống để làm mới" cwNotationRequired: "Nếu \"Ẩn nội dung\" được bật thì cần phải có chú thích." lastNDays: "{n} ngày trước" surrender: "Từ chối" +postForm: "Mẫu đăng" +information: "Giới thiệu" +_chat: + invitations: "Mời" + noHistory: "Không có dữ liệu" + members: "Thành viên" + home: "Trang chính" + send: "Gửi" _delivery: stop: "Đã vô hiệu hóa" _type: @@ -1550,7 +1552,6 @@ _theme: buttonBg: "Nền nút" buttonHoverBg: "Nền nút (Chạm)" inputBorder: "Đường viền khung soạn thảo" - listItemHoverBg: "Nền mục liệt kê (Chạm)" driveFolderBg: "Nền thư mục Ổ đĩa" wallpaperOverlay: "Lớp phủ hình nền" badge: "Huy hiệu" @@ -1631,6 +1632,7 @@ _permissions: "write:gallery": "Sửa kho ảnh của tôi" "read:gallery-likes": "Xem danh sách các tút đã thích trong thư viện của tôi" "write:gallery-likes": "Sửa danh sách các tút đã thích trong thư viện của tôi" + "write:chat": "Soạn hoặc xóa tin nhắn" _auth: shareAccessTitle: "Cho phép truy cập app" shareAccess: "Bạn có muốn cho phép \"{name}\" truy cập vào tài khoản này không?" @@ -1805,9 +1807,6 @@ _pages: newPage: "Tạo Trang mới" editPage: "Sửa Trang này" readPage: "Xem mã nguồn Trang này" - created: "Trang đã được tạo thành công" - updated: "Trang đã được cập nhật thành công" - deleted: "Trang đã được xóa thành công" pageSetting: "Cài đặt trang" nameAlreadyExists: "URL Trang đã tồn tại" invalidNameTitle: "URL Trang không hợp lệ" @@ -1879,6 +1878,7 @@ _notification: receiveFollowRequest: "Yêu cầu theo dõi" followRequestAccepted: "Yêu cầu theo dõi được chấp nhận" achievementEarned: "Hoàn thành Achievement" + login: "Đăng nhập" app: "Từ app liên kết" _actions: followBack: "đã theo dõi lại bạn" @@ -1932,3 +1932,10 @@ _moderationLogTypes: createInvitation: "Tạo lời mời" _reversi: total: "Tổng cộng" +_remoteLookupErrors: + _noSuchObject: + title: "Không tìm thấy" +_search: + searchScopeAll: "Tất cả" + searchScopeLocal: "Máy chủ này" + searchScopeUser: "Người dùng chỉ định" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index d422c3afc5..a39ca4f8db 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -5,9 +5,13 @@ introMisskey: "欢迎!Misskey是一个开源的、去中心化的“微博客 poweredByMisskeyDescription: "{name} 是开源平台 Misskey 的服务器之一。" monthAndDay: "{month}月 {day}日" search: "搜索" +reset: "重置" notifications: "通知" username: "用户名" password: "密码" +initialPasswordForSetup: "初始化密码" +initialPasswordIsIncorrect: "初始化密码不正确" +initialPasswordForSetupDescription: "如果是自己安装的 Misskey,请输入配置文件里设好的密码。\n如果使用的是 Misskey 的托管服务等,请输入服务商提供的密码。\n如果没有设置密码,请留空并继续。" forgotPassword: "忘记密码" fetchingAsApObject: "在联邦宇宙查询中..." ok: "OK" @@ -45,6 +49,7 @@ pin: "置顶" unpin: "取消置顶" copyContent: "复制内容" copyLink: "复制链接" +copyRemoteLink: "复制远程链接" copyLinkRenote: "复制转帖链接" delete: "删除" deleteAndEdit: "删除并编辑" @@ -90,7 +95,7 @@ followsYou: "正在关注你" createList: "创建列表" manageLists: "管理列表" error: "错误" -somethingHappened: "出现了一些问题!" +somethingHappened: "出错了" retry: "重试" pageLoadError: "页面加载失败。" pageLoadErrorDescription: "这通常是由于网络或浏览器缓存的原因。请清除缓存或等待片刻后重试。" @@ -104,7 +109,7 @@ follow: "关注" followRequest: "关注申请" followRequests: "关注申请" unfollow: "取消关注" -followRequestPending: "关注请求批准中" +followRequestPending: "关注请求待批准" enterEmoji: "输入表情符号" renote: "转发" unrenote: "取消转发" @@ -133,21 +138,21 @@ overwriteFromPinnedEmojisForReaction: "从「置顶(回应)」设置覆盖" overwriteFromPinnedEmojis: "从全局设置覆盖" reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。" rememberNoteVisibility: "保存上次设置的可见性" -attachCancel: "删除附件" +attachCancel: "取消添加附件" deleteFile: "删除文件" markAsSensitive: "标记为敏感内容" unmarkAsSensitive: "取消标记为敏感内容" enterFileName: "输入文件名" mute: "屏蔽" -unmute: "解除静音" -renoteMute: "屏蔽转帖" -renoteUnmute: "解除屏蔽转帖" -block: "拉黑" -unblock: "取消拉黑" +unmute: "取消隐藏" +renoteMute: "隐藏转帖" +renoteUnmute: "解除隐藏转帖" +block: "屏蔽" +unblock: "取消屏蔽" suspend: "冻结" unsuspend: "解除冻结" -blockConfirm: "确定要拉黑吗?" -unblockConfirm: "确定要解除拉黑吗?" +blockConfirm: "确定要屏蔽吗?" +unblockConfirm: "确定要取消屏蔽吗?" suspendConfirm: "要冻结吗?" unsuspendConfirm: "要解除冻结吗?" selectList: "选择列表" @@ -167,7 +172,7 @@ emojiUrl: "emoji 地址" addEmoji: "添加表情符号" settingGuide: "推荐配置" cacheRemoteFiles: "缓存远程文件" -cacheRemoteFilesDescription: "启用此设定时,将在此服务器上缓存远程文件。虽然可以加快图片显示的速度,但是相对的会消耗大量的服务器存储空间。用户角色内的网盘容量决定了这个远程用户能在服务器上保留保留多少缓存。当超出了这个限制时,旧的文件将从缓存中被删除,成为链接。当禁用此设定时,则是从一开始就将远程文件保留为链接。此时推荐将 default.yml 的 proxyRemoteFiles 设置为 true 以优化缩略图生成及保护用户隐私。" +cacheRemoteFilesDescription: "启用此设定时,将在此服务器上缓存远程文件。虽然可以加快图片显示的速度,但是相对的会消耗大量的服务器存储空间。用户角色内的网盘容量决定了这个远程用户能在服务器上保留多少缓存。当超出了这个限制时,旧的文件将从缓存中被删除,成为链接。当禁用此设定时,则是从一开始就将远程文件保留为链接。此时推荐将 default.yml 的 proxyRemoteFiles 设置为 true 以优化缩略图生成及保护用户隐私。" youCanCleanRemoteFilesCache: "可以使用文件管理的🗑️按钮来删除所有的缓存。" cacheRemoteSensitiveFiles: "缓存远程敏感媒体文件" cacheRemoteSensitiveFilesDescription: "如果禁用这项设定,远程服务器的敏感媒体将不会被缓存,而是直接链接。" @@ -192,7 +197,7 @@ setWallpaper: "设置壁纸" removeWallpaper: "移除壁纸" searchWith: "搜索:{q}" youHaveNoLists: "列表为空" -followConfirm: "你确定要关注 {name} 吗?" +followConfirm: "确定要关注 {name} 吗?" proxyAccount: "代理账户" proxyAccountDescription: "代理账户是在某些情况下替代用户进行远程关注用的账户。 例如说,当用户将一位远程用户放入一个列表中时,如果本地服务器上没有任何人关注这位远程用户,则这位远程用户的账户活动将不会被送到本地服务器上。作为替代,此时将使用代理账户进行关注。" host: "主机名" @@ -210,8 +215,8 @@ charts: "图表" perHour: "每小时" perDay: "每天" stopActivityDelivery: "停止发送活动" -blockThisInstance: "阻止此服务器向本服务器推流" -silenceThisInstance: "使服务器静音" +blockThisInstance: "屏蔽此服务器" +silenceThisInstance: "静音此服务器" mediaSilenceThisInstance: "隐藏此服务器的媒体文件" operations: "操作" software: "软件" @@ -226,22 +231,24 @@ disk: "存储" instanceInfo: "服务器信息" statistics: "统计" clearQueue: "清除队列" -clearQueueConfirmTitle: "确定清除队列?" +clearQueueConfirmTitle: "确定要清除队列吗?" clearQueueConfirmText: "未送达的帖子将不会被投递。 通常无需执行此操作。" clearCachedFiles: "清除缓存" -clearCachedFilesConfirm: "确定要清除所有缓存的远程文件?" -blockedInstances: "被封锁的服务器" -blockedInstancesDescription: "设定要封锁的服务器,以换行分隔。被封锁的服务器将无法与本服务器进行交换通讯。子域名也同样会被封锁。" +clearCachedFilesConfirm: "确定要清除所有缓存的远程文件吗?" +blockedInstances: "被屏蔽的服务器" +blockedInstancesDescription: "设定要屏蔽的服务器,以换行分隔。被屏蔽的服务器将无法与本服务器进行交换通讯。子域名也同样会被屏蔽。" silencedInstances: "被静音的服务器" silencedInstancesDescription: "设置要静音的服务器,以换行分隔。被静音的服务器内所有的账户将默认处于「静音」状态,仅能发送关注请求,并且在未关注状态下无法提及本地账户。被阻止的实例不受影响。" mediaSilencedInstances: "已隐藏媒体文件的服务器" mediaSilencedInstancesDescription: "设置要隐藏媒体文件的服务器,以换行分隔。被设置为隐藏媒体文件服务器内所有账号的文件均按照「敏感内容」处理,且将无法使用自定义表情符号。被阻止的实例不受影响。" -muteAndBlock: "静音/拉黑" -mutedUsers: "已静音用户" -blockedUsers: "已拉黑的用户" +federationAllowedHosts: "允许联合的服务器" +federationAllowedHostsDescription: "设定允许联合的服务器,以换行分隔。" +muteAndBlock: "隐藏和屏蔽" +mutedUsers: "已隐藏用户" +blockedUsers: "已屏蔽的用户" noUsers: "无用户" editProfile: "编辑资料" -noteDeleteConfirm: "要删除该帖子吗?" +noteDeleteConfirm: "确定要删除该帖子吗?" pinLimitExceeded: "无法置顶更多了" intro: "Misskey 的部署结束啦!创建管理员账号吧!" done: "完成" @@ -252,8 +259,8 @@ defaultValueIs: "默认值: {value}" noCustomEmojis: "没有自定义表情符号" noJobs: "没有任务" federating: "联合中" -blocked: "已拉黑" -suspended: "停止推流" +blocked: "已屏蔽" +suspended: "停止投递" all: "全部" subscribing: "已订阅" publishing: "投递中" @@ -282,7 +289,6 @@ deleteAreYouSure: "要删掉「{x}」吗?" resetAreYouSure: "恢复默认设置?" areYouSure: "你确定吗?" saved: "已保存" -messaging: "聊天" upload: "本地上传" keepOriginalUploading: "保留原图" keepOriginalUploadingDescription: "上传图片时保留原始图片。关闭时,浏览器会在上传时生成一张用于web发布的图片。" @@ -295,7 +301,7 @@ uploadFromUrlMayTakeTime: "上传可能需要一些时间完成。" explore: "发现" messageRead: "已读" noMoreHistory: "没有更多的历史记录" -startMessaging: "添加聊天" +startChat: "开始聊天" nUsersRead: "{n} 人已读" agreeTo: "勾选则表示已阅读并同意 {0}" agree: "同意" @@ -334,6 +340,7 @@ renameFolder: "重命名文件夹" deleteFolder: "删除文件夹" folder: "文件夹" addFile: "添加文件" +showFile: "显示文件" emptyDrive: "网盘中无文件" emptyFolder: "此文件夹中无文件" unableToDelete: "无法删除" @@ -376,7 +383,6 @@ enableLocalTimeline: "启用本地时间线" enableGlobalTimeline: "启用全局时间线" disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和监察员也可以继续使用。" registration: "注册" -enableRegistration: "允许任何人注册" invite: "邀请" driveCapacityPerLocalAccount: "每个用户的网盘容量" driveCapacityPerRemoteAccount: "每个远程用户的网盘容量" @@ -448,6 +454,7 @@ totpDescription: "使用验证器输入一次性密码" moderator: "监察员" moderation: "管理" moderationNote: "管理笔记" +moderationNoteDescription: "可以用来记录仅在管理员之间共享的笔记。" addModerationNote: "添加管理笔记" moderationLogs: "管理日志" nUsersMentioned: "{n} 被提到" @@ -483,8 +490,6 @@ noteOf: "{user} 的帖子" quoteAttached: "已引用" quoteQuestion: "是否引用此链接内容?" attachAsFileQuestion: "剪贴板内的文字过长。要转换为文本文件并添加吗?" -noMessagesYet: "现在没有新的聊天" -newMessageExists: "新信息" onlyOneFileCanBeAttached: "只能添加一个附件" signinRequired: "请先登录" signinOrContinueOnRemote: "若要继续,需要转到您所使用的实例,或者在此服务器上注册或登录。" @@ -509,7 +514,10 @@ uiLanguage: "显示语言" aboutX: "关于 {x}" emojiStyle: "表情符号的样式" native: "原生" -disableDrawer: "不显示抽屉菜单" +menuStyle: "菜单样式" +style: "样式" +drawer: "抽屉" +popup: "弹窗" showNoteActionsOnlyHover: "仅在悬停时显示帖子操作" showReactionsCount: "显示帖子的回应数" noHistory: "没有历史记录" @@ -557,7 +565,7 @@ objectStorageRegionDesc: "指定一个可用区,例如“xx-east-1”。 如 objectStorageUseSSL: "使用 SSL" objectStorageUseSSLDesc: "如果不使用 https 进行 API 连接,请关闭。" objectStorageUseProxy: "使用代理" -objectStorageUseProxyDesc: "如果您不使用代理进行 API 连接,请将其关闭。" +objectStorageUseProxyDesc: "如果不使用代理进行 API 连接,请关闭。" objectStorageSetPublicRead: "上传时设置为 public-read" s3ForcePathStyleDesc: "启用 s3ForcePathStyle 会强制将存储桶名称指定为 URL 中路径的一部分,而不是主机名。使用自托管 Minio 等时可能需要启用。" serverLogs: "服务器日志" @@ -577,6 +585,7 @@ masterVolume: "主音量" notUseSound: "静音" useSoundOnlyWhenActive: "仅在 Misskey 活跃时输出声音" details: "详情" +renoteDetails: "转帖详情" chooseEmoji: "选择表情符号" unableToProcess: "操作无法完成" recentUsed: "最近使用" @@ -592,6 +601,8 @@ ascendingOrder: "升序" descendingOrder: "降序" scratchpad: "AiScript 控制台" scratchpadDescription: "AiScript 控制台为 AiScript 提供了实验环境。您可以编写代码与 Misskey 交互,运行并查看结果。" +uiInspector: "UI 检查器" +uiInspectorDescription: "查看内存中所有由 UI 组件生成出的实例。UI 组件由 UI:C 系列函数所生成。" output: "输出" script: "脚本" disablePagesScript: "禁用页面脚本" @@ -671,15 +682,20 @@ emptyToDisableSmtpAuth: "用户名和密码留空可以禁用 SMTP 验证" smtpSecure: "在 SMTP 连接中使用隐式 SSL / TLS" smtpSecureInfo: "使用 STARTTLS 时关闭。" testEmail: "邮件发送测试" -wordMute: "文字屏蔽" -hardWordMute: "屏蔽关键词" +wordMute: "隐藏关键词" +wordMuteDescription: "折叠包含指定关键词的帖子。被折叠的帖子可单击展开。" +hardWordMute: "隐藏硬关键词" +showMutedWord: "显示已隐藏的关键词" +hardWordMuteDescription: "隐藏包含指定关键词的帖子。与隐藏关键词不同,帖子将完全不会显示。" regexpError: "正则表达式错误" -regexpErrorDescription: "{tab} 屏蔽文字的第 {line} 行的正则表达式有错误:" -instanceMute: "被屏蔽的服务器" +regexpErrorDescription: "{tab} 隐藏文字的第 {line} 行的正则表达式有错误:" +instanceMute: "已隐藏的服务器" userSaysSomething: "{name} 说了什么,但是被屏蔽词过滤了" +userSaysSomethingAbout: "{name} 说了关于「{word}」的什么" makeActive: "启用" display: "显示" copy: "复制" +copiedToClipboard: "已复制到剪贴板" metrics: "指标" overview: "概览" logs: "日志" @@ -694,7 +710,7 @@ useGlobalSettingDesc: "启用时,将使用账户通知设置。关闭时,则 other: "其他" regenerateLoginToken: "重新生成登录令牌" regenerateLoginTokenDescription: "重新生成用于登录的内部令牌。通常您不需要这样做。重新生成后,您将在所有设备上登出。" -theKeywordWhenSearchingForCustomEmoji: "这将是搜素自定义表情符号时的关键词。" +theKeywordWhenSearchingForCustomEmoji: "这将是搜索自定义表情符号时的关键词。" setMultipleBySeparatingWithSpace: "您可以使用空格分隔多个项目。" fileIdOrUrl: "文件 ID 或者 URL" behavior: "行为" @@ -708,10 +724,7 @@ abuseReported: "内容已发送。感谢您提交信息。" reporter: "举报者" reporteeOrigin: "举报来源" reporterOrigin: "举报者来源" -forwardReport: "将该举报信息转发给远程服务器" -forwardReportIsAnonymous: "在远程实例上显示的报告者是匿名的系统账号,而不是您的账号。" send: "发送" -abuseMarkAsResolved: "处理完毕" openInNewTab: "在新标签页中打开" openInSideView: "在侧边栏中打开" defaultNavigationBehaviour: "默认导航" @@ -731,7 +744,7 @@ confirmToUnclipAlreadyClippedNote: "本帖已包含在便签 \"{name}\" 里。 public: "公开" private: "私密" i18nInfo: "Misskey 已经被志愿者们翻译成了各种语言。如果你也有兴趣,可以通过 {link} 帮助翻译。" -manageAccessTokens: "管理 Access Tokens" +manageAccessTokens: "管理访问令牌" accountInfo: "账户信息" notesCount: "帖子数量" repliesCount: "回复数量" @@ -750,7 +763,7 @@ driveFilesCount: "网盘的文件数" driveUsage: "网盘的空间用量" noCrawle: "要求搜索引擎不索引该用户" noCrawleDescription: "要求搜索引擎不要收录(索引)您的用户页面,帖子,页面等。" -lockedAccountInfo: "即使启用该功能,只要您不将帖子可见范围设置为“仅关注者”,任何人都还是可以看到您的帖子。" +lockedAccountInfo: "即使启用该功能,只要帖子可见范围不是「仅关注者」,任何人都可以看到您的帖子。" alwaysMarkSensitive: "默认将媒体文件标记为敏感内容" loadRawImages: "添加附件图像的缩略图时使用原始图像质量" disableShowingAnimatedImages: "不播放动画" @@ -837,7 +850,7 @@ active: "活动" offline: "离线" notRecommended: "不推荐" botProtection: "Bot防御" -instanceBlocking: "被阻拦的服务器" +instanceBlocking: "屏蔽/静音的服务器" selectAccount: "选择账户" switchAccount: "切换账户" enabled: "已启用" @@ -847,9 +860,9 @@ user: "用户" administration: "管理" accounts: "账户" switch: "切换" -noMaintainerInformationWarning: "管理人员信息未设置。" +noMaintainerInformationWarning: "尚未设置管理员信息。" noInquiryUrlWarning: "尚未设置联络地址。" -noBotProtectionWarning: "Bot 防御未设置。" +noBotProtectionWarning: "尚未设置 Bot 防御。" configure: "设置" postToGallery: "发送到图库" postToHashtag: "投稿到这个标签" @@ -865,11 +878,11 @@ priority: "优先级" high: "高" middle: "中" low: "低" -emailNotConfiguredWarning: "电子邮件地址未设置。" +emailNotConfiguredWarning: "尚未设置电子邮件地址。" ratio: "比率" previewNoteText: "预览文本" customCss: "自定义 CSS" -customCssWarn: "这些设置必须有相关的基础知识,不当的配置可能导致客户端无法正常使用!" +customCssWarn: "这些设置必须有相关的基础知识,不当的配置可能导致客户端无法正常使用。" global: "全局" squareAvatars: "显示方形头像图标" sent: "发送" @@ -906,13 +919,14 @@ manageAccounts: "管理账户" makeReactionsPublic: "将回应设置为公开" makeReactionsPublicDescription: "将您发表过的回应设置成公开可见。" classic: "经典" -muteThread: "屏蔽帖子列表" -unmuteThread: "取消屏蔽帖子列表" +muteThread: "隐藏帖子列表" +unmuteThread: "取消隐藏帖子列表" followingVisibility: "关注的人的公开范围" followersVisibility: "关注者的公开范围" continueThread: "查看更多帖子" deleteAccountConfirm: "将要删除账户。是否确认?" incorrectPassword: "密码错误" +incorrectTotp: "一次性密码不正确或已过期" voteConfirm: "确定投给 “{choice}” ?" hide: "隐藏" useDrawerReactionPickerForMobile: "在移动设备上使用抽屉显示" @@ -929,7 +943,7 @@ searchByGoogle: "Google" instanceDefaultLightTheme: "服务器默认浅色主题" instanceDefaultDarkTheme: "服务器默认深色主题" instanceDefaultThemeDescription: "以对象格式输入主题代码" -mutePeriod: "屏蔽期限" +mutePeriod: "隐藏期限" period: "截止时间" indefinitely: "永久" tenMinutes: "10 分钟" @@ -937,6 +951,9 @@ oneHour: "1 小时" oneDay: "1 天" oneWeek: "1 周" oneMonth: "1 个月" +threeMonths: "3 个月" +oneYear: "1 年" +threeDays: "3 天" reflectMayTakeTime: "可能需要一些时间才能体现出效果。" failedToFetchAccountInformation: "获取账户信息失败" rateLimitExceeded: "已超过速率限制" @@ -1044,7 +1061,7 @@ internalServerErrorDescription: "内部服务器发生了预期外的错误" copyErrorInfo: "复制错误信息" joinThisServer: "在本服务器上注册" exploreOtherServers: "探索其他服务器" -letsLookAtTimeline: "时间线" +letsLookAtTimeline: "看看时间线" disableFederationConfirm: "确定要禁用联合?" disableFederationConfirmWarn: "禁用联合不会将帖子设为私有。在大多数情况下,不需要禁用联合。" disableFederationOk: "联合禁用" @@ -1060,10 +1077,10 @@ nonSensitiveOnlyForLocalLikeOnlyForRemote: "仅限非敏感内容(远程仅点 rolesAssignedToMe: "指派给自己的角色" resetPasswordConfirm: "确定重置密码?" sensitiveWords: "敏感词" -sensitiveWordsDescription: "将包含设置词的帖子的可见范围设置为首页。可以通过用换行符分隔来设置多个。" +sensitiveWordsDescription: "包含这些词的帖子将只在首页可见。可用换行来设定多个词。" sensitiveWordsDescription2: "AND 条件用空格分隔,正则表达式用斜线包裹。" prohibitedWords: "禁用词" -prohibitedWordsDescription: "发布包含设定词汇的帖子时将出错。可用换行设定多个关键字" +prohibitedWordsDescription: "发布包含设定词汇的帖子时将出错。可用换行设定多个关键字。" prohibitedWordsDescription2: "AND 条件用空格分隔,正则表达式用斜线包裹。" hiddenTags: "隐藏标签" hiddenTagsDescription: "设定的标签将不会在时间线上显示。可使用换行来设置多个标签。" @@ -1077,6 +1094,7 @@ retryAllQueuesConfirmTitle: "要再尝试一次吗?" retryAllQueuesConfirmText: "可能会使服务器负荷在一定时间内增加" enableChartsForRemoteUser: "生成远程用户的图表" enableChartsForFederatedInstances: "生成远程服务器的图表" +enableStatsForFederatedInstances: "获取远程服务器的信息" showClipButtonInNoteFooter: "在贴文下方显示便签按钮" reactionsDisplaySize: "回应显示大小" limitWidthOfReaction: "限制回应的最大宽度,并将其缩小显示" @@ -1105,7 +1123,7 @@ vertical: "纵向" horizontal: "横向" position: "位置" serverRules: "服务器规则" -pleaseConfirmBelowBeforeSignup: "在这个服务器上注册账号前,请确认以下信息。" +pleaseConfirmBelowBeforeSignup: "如果要在此服务器上注册,需要确认并同意以下内容。" pleaseAgreeAllToContinue: "必须全部勾选「同意」才能够继续。" continue: "继续" preservedUsernames: "保留的用户名" @@ -1145,10 +1163,10 @@ turnOffToImprovePerformance: "关闭该选项可以提高性能。" createInviteCode: "生成邀请码" createWithOptions: "使用选项来创建" createCount: "发行数" -inviteCodeCreated: "已创建邀请码" -inviteLimitExceeded: "可供发行的邀请码已达上限。" -createLimitRemaining: "可供发行的邀请码:剩余{limit}个" -inviteLimitResetCycle: "可以在{time}内发行最多{limit}个邀请码。" +inviteCodeCreated: "已生成邀请码" +inviteLimitExceeded: "可供生成的邀请码已达上限。" +createLimitRemaining: "可供生成的邀请码:剩余 {limit} 个" +inviteLimitResetCycle: "可以在 {time} 内生成最多 {limit} 个邀请码。" expirationDate: "有效日期" noExpirationDate: "不设置有效日期" inviteCodeUsedAt: "邀请码被使用的日期和时间" @@ -1189,10 +1207,10 @@ followingOrFollower: "关注中或关注者" fileAttachedOnly: "仅限媒体" showRepliesToOthersInTimeline: "在时间线中包含给别人的回复" hideRepliesToOthersInTimeline: "在时间线中隐藏给别人的回复" -showRepliesToOthersInTimelineAll: "在时间线中包含现在关注的所有人的回复" -hideRepliesToOthersInTimelineAll: "在时间线中隐藏现在关注的所有人的回复" -confirmShowRepliesAll: "此操作不可撤销。确认要在时间线中包含现在关注的所有人的回复吗?" -confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏现在关注的所有人的回复吗?" +showRepliesToOthersInTimelineAll: "在时间线中显示所有现在关注的人的回复" +hideRepliesToOthersInTimelineAll: "在时间线中隐藏所有现在关注的人的回复" +confirmShowRepliesAll: "此操作不可撤销。确认要在时间线中显示所有现在关注的人的回复吗?" +confirmHideRepliesAll: "此操作不可撤销。确认要在时间线中隐藏所有现在关注的人的回复吗?" externalServices: "外部服务" sourceCode: "源代码" sourceCodeIsNotYetProvided: "还未提供源代码。要解决此问题请联系管理员。" @@ -1263,6 +1281,171 @@ confirmWhenRevealingSensitiveMedia: "显示敏感内容前需要确认" sensitiveMediaRevealConfirm: "这是敏感内容。是否显示?" createdLists: "已创建的列表" createdAntennas: "已创建的天线" +fromX: "从 {x}" +genEmbedCode: "生成嵌入代码" +noteOfThisUser: "此用户的帖子" +clipNoteLimitExceeded: "无法再往此便签内添加更多帖子" +performance: "性能" +modified: "有变更" +discard: "取消" +thereAreNChanges: "有 {n} 处更改" +signinWithPasskey: "使用通行密钥登录" +unknownWebAuthnKey: "此通行密钥未注册。" +passkeyVerificationFailed: "验证通行密钥失败。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "通行密钥验证成功,但账户未开启无密码登录。" +messageToFollower: "给关注者的消息" +target: "对象" +testCaptchaWarning: "此功能为测试 CAPTCHA 用。请勿在正式环境中使用。" +prohibitedWordsForNameOfUser: "用户名中禁止的词" +prohibitedWordsForNameOfUserDescription: "更改用户名时,如果用户名中包含此列表里的词汇,用户的改名请求将被拒绝。持有管理员权限的用户不受此限制。" +yourNameContainsProhibitedWords: "目标用户名包含违禁词" +yourNameContainsProhibitedWordsDescription: "用户名内含有违禁词。若想使用此用户名,请联系服务器管理员。" +thisContentsAreMarkedAsSigninRequiredByAuthor: "根据发帖者的设定,需要登录才能显示" +lockdown: "锁定" +pleaseSelectAccount: "请选择帐户" +availableRoles: "可用角色" +acknowledgeNotesAndEnable: "理解注意事项后再开启。" +federationSpecified: "此服务器已开启联合白名单。只能与管理员指定的服务器通信。" +federationDisabled: "此服务器已禁用联合。无法与其它服务器上的用户通信。" +confirmOnReact: "发送回应前需要确认" +reactAreYouSure: "要用「{emoji}」进行回应吗?" +markAsSensitiveConfirm: "要将此媒体标记为敏感吗?" +unmarkAsSensitiveConfirm: "要将此媒体解除敏感标记吗?" +preferences: "设置" +accessibility: "辅助功能" +preferencesProfile: "设置的配置" +copyPreferenceId: "复制设置 ID" +resetToDefaultValue: "重置为默认值" +overrideByAccount: "用账户覆盖" +untitled: "未命名" +noName: "没有名字" +skip: "跳过" +restore: "恢复" +syncBetweenDevices: "设备间同步" +preferenceSyncConflictTitle: "服务器上已存在设定值" +preferenceSyncConflictText: "服务器上已有此设置的设定值。要覆盖哪个设定值?" +preferenceSyncConflictChoiceServer: "服务器上的设定值" +preferenceSyncConflictChoiceDevice: "设备上的设定值" +preferenceSyncConflictChoiceCancel: "取消同步" +paste: "粘贴" +emojiPalette: "表情符号调色板" +postForm: "投稿窗口" +textCount: "字数" +information: "关于" +chat: "聊天" +migrateOldSettings: "迁移旧设置信息" +migrateOldSettings_description: "通常设置信息将自动迁移。但如果由于某种原因迁移不成功,则可以手动触发迁移过程。当前的配置信息将被覆盖。" +compress: "压缩" +_chat: + noMessagesYet: "还没有消息" + newMessage: "新消息" + individualChat: "私聊" + individualChat_description: "可以与特定用户进行一对一聊天。" + roomChat: "群聊" + roomChat_description: "可以进行多人聊天。\n就算用户未允许私聊,只要接受了邀请,仍可以聊天。" + createRoom: "创建房间" + inviteUserToChat: "邀请用户来开始聊天" + yourRooms: "已创建的房间" + joiningRooms: "已加入的房间" + invitations: "邀请" + noInvitations: "没有邀请" + history: "历史" + noHistory: "没有历史记录" + noRooms: "没有房间" + inviteUser: "邀请用户" + sentInvitations: "已发送的邀请" + join: "加入" + ignore: "忽略" + leave: "退出房间" + members: "成员" + searchMessages: "搜索消息" + home: "首页" + send: "发送" + newline: "换行" + muteThisRoom: "静音此房间" + deleteRoom: "删除房间" + chatNotAvailableForThisAccountOrServer: "此服务器或者账户还未开启聊天功能。" + chatNotAvailableInOtherAccount: "对方账户目前处于无法使用聊天的状态。" + cannotChatWithTheUser: "无法与此用户聊天" + cannotChatWithTheUser_description: "可能现在无法使用聊天,或者对方未开启聊天。" + chatWithThisUser: "聊天" + thisUserAllowsChatOnlyFromFollowers: "此用户仅接受关注者发起的聊天。" + thisUserAllowsChatOnlyFromFollowing: "此用户仅接受关注的人发起的聊天。" + thisUserAllowsChatOnlyFromMutualFollowing: "此用户仅接受互相关注的人发起的聊天。" + thisUserNotAllowedChatAnyone: "此用户不接受任何人发起的聊天。" + chatAllowedUsers: "谁可以发起聊天" + chatAllowedUsers_note: "主动发起聊天时,对方将不受此设置限制。" + _chatAllowedUsers: + everyone: "任何人" + followers: "仅关注者" + following: "仅关注的人" + mutual: "仅相互关注" + none: "没有人" +_emojiPalette: + palettes: "调色板" + enableSyncBetweenDevicesForPalettes: "启用调色板的设备间同步" + paletteForMain: "主调色板" + paletteForReaction: "回应用调色板" +_settings: + driveBanner: "可在此管理和设置网盘、确认使用量及配置上传文件的设置。" + pluginBanner: "使用插件可以扩展客户端的功能。可以在此安装、单独管理插件。" + notificationsBanner: "可在此设置从服务器接收的通知的种类和范围,以及推送通知的设置。" + api: "API" + webhook: "Webhook" + serviceConnection: "连接服务" + serviceConnectionBanner: "可在此管理用于连接外部应用或服务的访问令牌及 Webhook。" + accountData: "账户数据" + accountDataBanner: "可在此导入或导出帐户数据的存档。" + muteAndBlockBanner: "可在此设置隐藏内容,或限制指定用户能进行的操作。" + accessibilityBanner: "可在此设置客户端的显示及动态效果等辅助设置。" + privacyBanner: "可在此设置如内容可见性、可发现性、批准关注请求等账户隐私设置。" + securityBanner: "可在此设置如密码、登入方式、验证器、Passkey 等账户安全性设置。" + preferencesBanner: "可在此设置客户端的整体运作行为。" + appearanceBanner: "可在此设置客户端的外观及显示方式。" + soundsBanner: "可在此设置客户端播放的声音。" + timelineAndNote: "时间线和帖子" + makeEveryTextElementsSelectable: "使所有的文字均可选择" + makeEveryTextElementsSelectable_description: "若开启,在某些情况下可能降低用户体验。" + useStickyIcons: "使图标跟随滚动" + showNavbarSubButtons: "在导航栏中显示副按钮" + ifOn: "启用时" + ifOff: "关闭时" + _chat: + showSenderName: "显示发送者的名字" + sendOnEnter: "回车键发送" +_preferencesProfile: + profileName: "配置名" + profileNameDescription: "请指定用于识别此设备的名称" + profileNameDescription2: "如「PC」、「手机」等" +_preferencesBackup: + autoBackup: "自动备份" + restoreFromBackup: "从备份恢复" + noBackupsFoundTitle: "没有找到备份" + noBackupsFoundDescription: "没有找到自动备份。若有手动保存备份文件,可将其导入来恢复。" + selectBackupToRestore: "请选择要恢复的备份" + youNeedToNameYourProfileToEnableAutoBackup: "需指定配置名以开启自动备份。" + autoPreferencesBackupIsNotEnabledForThisDevice: "此设备未开启自动备份" + backupFound: "已找到备份" +_accountSettings: + requireSigninToViewContents: "需要登录才能显示内容" + requireSigninToViewContentsDescription1: "您发布的所有帖子将变成需要登入后才会显示。有望防止爬虫收集各种信息。" + requireSigninToViewContentsDescription2: "没有 URL 预览(OGP)、内嵌网页、引用帖子的功能的服务器也将无法显示。" + requireSigninToViewContentsDescription3: "这些限制可能不适用于联合到远程服务器的内容。" + makeNotesFollowersOnlyBefore: "可将过去的帖子设为仅关注者可见" + makeNotesFollowersOnlyBeforeDescription: "开启此设定时,超过设定的时间或日期后,帖子将变为仅关注者可见。关闭后帖子的公开状态将恢复成原本的设定。" + makeNotesHiddenBefore: "将过去的帖子设为私密" + makeNotesHiddenBeforeDescription: "开启此设定时,超过设定的时间或日期后,帖子将变为仅自己可见。关闭后帖子的公开状态将恢复成原本的设定。" + mayNotEffectForFederatedNotes: "与远程服务器联合的帖子在远端可能会没有效果。" + mayNotEffectSomeSituations: "此限制功能非常简单,在与远程服务器联合等情形时可能不适用。" + notesHavePassedSpecifiedPeriod: "超过指定时间的帖子" + notesOlderThanSpecifiedDateAndTime: "指定日期前的帖子" +_abuseUserReport: + forward: "转发" + forwardDescription: "目标是匿名系统账户,将把举报转发给远程服务器。" + resolve: "解决" + accept: "确认" + reject: "拒绝" + resolveTutorial: "如果认可举报并已解决,选择「确认」将案件以肯定的态度标记为已解决。\n如果不认可举报,选择「拒绝」将案件以否定的态度标记为已解决。" _delivery: status: "投递状态" stop: "停止投递" @@ -1371,8 +1554,8 @@ _initialTutorial: description: "对于服务器方针所要求要求的,又或者不适合直接展示的附件,请添加「敏感」标记。\n" tryThisFile: "试试看,将附加到此窗口的图像标注为敏感!" _exampleNote: - note: "拆纳豆包装时出错了…" - method: "要标注附件为敏感内容,请单击该文件以打开菜单,然后单击“标记为敏感内容”。" + note: "拆纳豆包装时失手了…" + method: "要标注附件为敏感内容,请单击该文件以打开菜单,然后单击「标记为敏感内容」。" sensitiveSucceeded: "附加文件时,请遵循服务器的条款来设置正确敏感设定。\n" doItToContinue: "将图像标记为敏感后才能够继续" _done: @@ -1397,8 +1580,12 @@ _serverSettings: fanoutTimelineDescription: "当启用时,可显著提高获取各种时间线时的性能,并减轻数据库的负荷。但是相对的 Redis 的内存使用量将会增加。如果服务器的内存不是很大,又或者运行不稳定的话可以把它关掉。" fanoutTimelineDbFallback: "回退到数据库" fanoutTimelineDbFallbackDescription: "当启用时,若时间线未被缓存,则将额外查询数据库。禁用该功能可通过不执行回退处理进一步减少服务器负载,但会限制可检索的时间线范围。" + reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。" inquiryUrl: "联络地址" inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。" + openRegistration: "开放注册" + openRegistrationWarning: "开放注册有风险。建议仅当能够持续监控服务器并在出现问题时能够立即响应时才打开它。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "若在一段时间内没有检测到管理活动,为防止垃圾信息,此设定将自动关闭。" _accountMigration: moveFrom: "从别的账号迁移到此账户" moveFromSub: "为另一个账户建立别名" @@ -1407,7 +1594,7 @@ _accountMigration: moveTo: "把这个账户迁移到新的账户" moveToLabel: "迁移后的账户" moveCannotBeUndone: "一旦迁移账户,就无法撤销。" - moveAccountDescription: "\n迁移到新帐户。\n ・现有的关注者自动关注新帐户\n ・此帐户的所有关注者都将被删除\n ・您将无法再使用此帐户发帖。\n关注者迁移是自动的,但关注中迁移必须手动完成。请在迁移前在此帐户上导出关注列表,并在迁移后立即在目标帐户上执行导入。\n屏蔽列表也是如此,因此您必须手动迁移它。\n(此描述适用于该服务器(Misskey v13.12.0 或更高版本)。其他 ActivityPub 软件(例如 Mastodon)的行为可能有所不同。)" + moveAccountDescription: "\n迁移到新帐户。\n ・现有的关注者自动关注新帐户\n ・此帐户的所有关注者都将被删除\n ・您将无法再使用此帐户发帖。\n关注者迁移是自动的,但关注中迁移必须手动完成。请在迁移前在此帐户上导出关注列表,并在迁移后立即在目标帐户上执行导入。\n列表、隐藏、屏蔽也是如此,因此您必须手动迁移它。\n(此描述适用于该服务器(Misskey v13.12.0 或更高版本)。其他 ActivityPub 软件(例如 Mastodon)的行为可能有所不同。)" moveAccountHowTo: "要进行账户迁移,请现在目标账户中为此账户建立一个别名。\n建立别名后,请像这样输入目标账户:@username@server.example.com" startMigration: "迁移" migrationConfirm: "确定要把此账户迁移到 {account} 吗?一旦确定后,此操作无法取消,此账户也无法以原来的状态使用。\n同时,请确认迁移后的账户,已创造别名。" @@ -1598,7 +1785,7 @@ _achievements: _postedAt0min0sec: title: "报时" description: "在 0 点发布一篇帖子" - flavor: "嘣 嘣 嘣 Biu——!" + flavor: "嘟 · 嘟 · 嘟 · 哔——" _selfQuote: title: "自我引用" description: "引用了自己的帖子" @@ -1627,7 +1814,7 @@ _achievements: title: "超高校级的幸运" description: "每 10 秒有 0.005% 的概率自动获得" _setNameToSyuilo: - title: "像神一样呐" + title: "上帝情结" description: "将名称设定为 syuilo" _passedSinceAccountCreated1: title: "一周年" @@ -1646,9 +1833,9 @@ _achievements: description: "在元旦登入" flavor: "今年也请对本服务器多多指教!" _cookieClicked: - title: "点击饼干小游戏" - description: "点击了可疑的饼干" - flavor: "是不是软件有问题?" + title: "饼干点点乐" + description: "点击了饼干" + flavor: "穿越了?" _brainDiver: title: "Brain Diver" description: "发布了包含 Brain Diver 链接的帖子" @@ -1665,7 +1852,7 @@ _achievements: _bubbleGameDoubleExplodingHead: title: "两个🤯" description: "你合成出了2个游戏里最大的Emoji" - flavor: "" + flavor: "大约能 装满 这些便当盒 🤯 🤯 (比划)" _role: new: "创建角色" edit: "编辑角色" @@ -1718,7 +1905,7 @@ _role: canUpdateBioMedia: "可以更新头像和横幅" pinMax: "帖子置顶数量限制" antennaMax: "可创建的最大天线数量" - wordMuteMax: "屏蔽词的字数限制" + wordMuteMax: "隐藏词的字数限制" webhookMax: "Webhook 创建数量限制" clipMax: "便签创建数量限制" noteEachClipsMax: "单个便签内的贴文数量限制" @@ -1730,6 +1917,12 @@ _role: canSearchNotes: "是否可以搜索帖子" canUseTranslator: "使用翻译功能" avatarDecorationLimit: "可添加头像挂件的最大个数" + canImportAntennas: "允许导入天线" + canImportBlocking: "允许导入屏蔽列表" + canImportFollowing: "允许导入关注列表" + canImportMuting: "允许导入隐藏列表" + canImportUserLists: "允许导入用户列表" + canChat: "允许聊天" _condition: roleAssignedTo: "已分配给手动角色" isLocal: "是本地用户" @@ -1876,14 +2069,14 @@ _menuDisplay: top: "顶部" hide: "隐藏" _wordMute: - muteWords: "禁用词" + muteWords: "要隐藏的词" muteWordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" muteWordsDescription2: "正则表达式用斜线包裹" _instanceMute: - instanceMuteDescription: "屏蔽服务器中的所有帖子和转帖,包括这些服务器上的用户回复。" + instanceMuteDescription: "隐藏服务器中的所有帖子和转帖,包括这些服务器上的用户回复。" instanceMuteDescription2: "一行一个" - title: "隐藏服务器已设置的帖子。" - heading: "屏蔽服务器" + title: "下面实例中的帖子将被隐藏。" + heading: "已隐藏的服务器" _theme: explore: "寻找主题" install: "安装主题" @@ -1893,6 +2086,7 @@ _theme: installed: "{name} 已安装" installedThemes: "已安装的主题" builtinThemes: "标准主题" + instanceTheme: "服务器主题" alreadyInstalled: "此主题已经安装" invalid: "主题格式错误" make: "制作主题" @@ -1947,7 +2141,6 @@ _theme: buttonBg: "按钮背景" buttonHoverBg: "按钮背景(悬停)" inputBorder: "输入框边框" - listItemHoverBg: "下拉列表项目背景(悬停)" driveFolderBg: "网盘的文件夹背景" wallpaperOverlay: "壁纸叠加层" badge: "徽章" @@ -1960,6 +2153,7 @@ _sfx: noteMy: "我的帖子" notification: "通知" reaction: "选择回应时" + chatMessage: "聊天信息" _soundSettings: driveFile: "使用网盘内的音频" driveFileWarn: "选择网盘上的文件" @@ -2004,12 +2198,12 @@ _2fa: step4: "从现在开始,任何登录操作都将要求您提供动态口令。" securityKeyNotSupported: "您的浏览器不支持安全密钥。" registerTOTPBeforeKey: "要注册安全密钥或 Passkey,请先设置验证器。" - securityKeyInfo: "注册兼容 WebAuthn 的密钥,例如支持 FIDO2 的硬件安全密钥、设备上的生物识别功能、PIN 码以及 Passkey 等。" + securityKeyInfo: "注册兼容 WebAuthn 的密钥,例如支持 FIDO2 的硬件安全密钥、设备上的生物识别功能、PIN 以及 Passkey 等。" registerSecurityKey: "注册安全密钥或 Passkey" securityKeyName: "输入密钥名称" tapSecurityKey: "请按照浏览器说明操作来注册安全密钥或 Passkey。" removeKey: "删除安全密钥" - removeKeyConfirm: "您确定要删除 {name} 吗?" + removeKeyConfirm: "确定要删除 {name} 吗?" whyTOTPOnlyRenew: "当注册了安全密钥时,无法取消使用验证器。" renewTOTP: "重置验证器" renewTOTPConfirm: "当前验证器的验证码及备用代码已失效" @@ -2024,8 +2218,8 @@ _2fa: _permissions: "read:account": "查看账户信息" "write:account": "更改帐户信息" - "read:blocks": "查看黑名单" - "write:blocks": "编辑黑名单" + "read:blocks": "查看屏蔽列表" + "write:blocks": "编辑屏蔽列表" "read:drive": "查看网盘" "write:drive": "管理网盘文件" "read:favorites": "查看收藏夹" @@ -2034,8 +2228,8 @@ _permissions: "write:following": "关注/取消关注" "read:messaging": "查看消息" "write:messaging": "撰写或删除消息" - "read:mutes": "查看屏蔽列表" - "write:mutes": "编辑屏蔽列表" + "read:mutes": "查看隐藏列表" + "write:mutes": "编辑隐藏列表" "write:notes": "撰写或删除帖子" "read:notifications": "查看通知" "write:notifications": "管理通知" @@ -2106,6 +2300,8 @@ _permissions: "read:clip-favorite": "查看便签的点赞" "read:federation": "查看联合相关信息" "write:report-abuse": "举报用户" + "write:chat": "撰写或删除消息" + "read:chat": "查看聊天" _auth: shareAccessTitle: "应用程序授权许可" shareAccess: "您要授权允许 “{name}” 访问您的帐户吗?" @@ -2114,8 +2310,11 @@ _auth: permissionAsk: "这个应用程序需要以下权限" pleaseGoBack: "请返回到应用程序" callback: "回到应用程序" + accepted: "已允许访问" denied: "拒绝访问" + scopeUser: "以下面的用户进行操作" pleaseLogin: "在对应用进行授权许可之前,请先登录" + byClickingYouWillBeRedirectedToThisUrl: "允许访问后将会自动重定向到以下 URL" _antennaSources: all: "所有帖子" homeTimeline: "已关注用户的帖子" @@ -2214,7 +2413,7 @@ _profile: name: "昵称" username: "用户名" description: "个人简介" - youCanIncludeHashtags: "你可以在个人简介中包含一些#标签。" + youCanIncludeHashtags: "可以在个人简介中包含 #标签。" metadata: "附加信息" metadataEdit: "附加信息编辑" metadataDescription: "最多可以在个人资料中以表格形式显示四条其他信息。" @@ -2224,13 +2423,16 @@ _profile: changeBanner: "修改横幅" verifiedLinkDescription: "如果将内容设置为 URL,当链接所指向的网页内包含自己的个人资料链接时,可以显示一个已验证图标。" avatarDecorationMax: "最多可添加 {max} 个挂件" + followedMessage: "被关注时显示的消息" + followedMessageDescription: "可以设置被关注时向对方显示的短消息。" + followedMessageDescriptionForLockedAccount: "需要批准才能关注的情况下,消息是在请求被批准后显示。" _exportOrImport: allNotes: "所有帖子" favoritedNotes: "收藏的帖子" clips: "便签" followingList: "关注中" - muteList: "屏蔽" - blockingList: "拉黑" + muteList: "隐藏" + blockingList: "屏蔽" userLists: "列表" excludeMutingUsers: "排除屏蔽用户" excludeInactiveUsers: "排除不活跃用户" @@ -2286,9 +2488,6 @@ _pages: newPage: "创建页面" editPage: "编辑页面" readPage: "查看页面" - created: "页面已创建" - updated: "页面已更新" - deleted: "该页面已被删除" pageSetting: "页面设置" nameAlreadyExists: "该页面 URL 已存在" invalidNameTitle: "无效的页面 URL" @@ -2315,7 +2514,7 @@ _pages: fontSansSerif: "无衬线字体" eyeCatchingImageSet: "设置封面图片" eyeCatchingImageRemove: "删除封面图片" - chooseBlock: "添加块" + chooseBlock: "添加内容块" enterSectionTitle: "输入会话标题" selectType: "选择类型" contentBlocks: "内容" @@ -2327,8 +2526,8 @@ _pages: section: "章节" image: "图片" button: "按钮" - dynamic: "动态区块" - dynamicDescription: "这个区块已经废弃。以后请使用{play}。" + dynamic: "动态内容块" + dynamicDescription: "这个内容块已经废弃。以后请使用{play}。" note: "嵌入的帖子" _note: id: "帖子 ID" @@ -2351,6 +2550,7 @@ _notification: newNote: "新的帖子" unreadAntennaNote: "天线 {name}" roleAssigned: "授予的角色" + chatRoomInvitationReceived: "受邀加入聊天室" emptyPushNotificationMessage: "推送通知已更新" achievementEarned: "获得成就" testNotification: "测试通知" @@ -2362,6 +2562,10 @@ _notification: renotedBySomeUsers: "{n} 人转发了" followedBySomeUsers: "被 {n} 人关注" flushNotification: "重置通知历史" + exportOfXCompleted: "已完成 {x} 的导出" + login: "有新的登录" + createToken: "访问令牌已创建" + createTokenDescription: "如果不明白其用途,请遵循「{text}」的指示删除访问令牌。" _types: all: "全部" note: "用户的新帖子" @@ -2375,7 +2579,12 @@ _notification: receiveFollowRequest: "收到关注请求" followRequestAccepted: "关注请求已通过" roleAssigned: "授予的角色" + chatRoomInvitationReceived: "受邀加入聊天室" achievementEarned: "取得的成就" + exportCompleted: "已完成导出" + login: "登录" + createToken: "创建访问令牌" + test: "测试通知" app: "关联应用的通知" _actions: followBack: "回关" @@ -2402,6 +2611,7 @@ _deck: useSimpleUiForNonRootPages: "用简易UI表示非根页面" usedAsMinWidthWhenFlexible: "「自适应宽度」被启用的时候,这就是最小的宽度" flexible: "自适应宽度" + enableSyncBetweenDevicesForProfiles: "启用个人资料信息跨设备同步" _columns: main: "主列" widgets: "小工具" @@ -2441,7 +2651,10 @@ _webhookSettings: abuseReport: "当收到举报时" abuseReportResolved: "当举报被处理时" userCreated: "当用户被创建时" + inactiveModeratorsWarning: "当管理员在一段时间内不活跃时" + inactiveModeratorsInvitationOnlyChanged: "当因为管理员在一段时间内不活跃,导致服务器变为邀请制时" deleteConfirm: "要删除 webhook 吗?" + testRemarks: "点击开关右侧的按钮,可以发送使用假数据的测试 Webhook。" _abuseReport: _notificationRecipient: createRecipient: "新建举报通知" @@ -2485,6 +2698,8 @@ _moderationLogTypes: markSensitiveDriveFile: "标记网盘文件为敏感媒体" unmarkSensitiveDriveFile: "取消标记网盘文件为敏感媒体" resolveAbuseReport: "处理举报" + forwardAbuseReport: "转发举报" + updateAbuseReportNote: "更新举报用管理笔记" createInvitation: "生成邀请码" createAd: "创建了广告" deleteAd: "删除了广告" @@ -2504,6 +2719,8 @@ _moderationLogTypes: deletePage: "删除了页面" deleteFlash: "删除了 Play" deleteGalleryPost: "删除了图库稿件" + deleteChatRoom: "删除聊天室" + updateProxyAccountDescription: "更新代理账户的简介" _fileViewer: title: "文件信息" type: "文件类型" @@ -2517,10 +2734,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "请在安装前确保来源可靠" _plugin: title: "要安装此插件吗?" - metaTitle: "插件信息" _theme: title: "要安装此主题吗?" - metaTitle: "主题信息" _meta: base: "基本配色方案" _vendorInfo: @@ -2640,3 +2855,135 @@ _contextMenu: app: "应用" appWithShift: "Shift 键应用" native: "浏览器的用户界面" +_gridComponent: + _error: + requiredValue: "此值为必填项" + columnTypeNotSupport: "正则表达式验证仅支持 type:text 列。" + patternNotMatch: "此值与 {pattern} 的模式不一致" + notUnique: "此值必须唯一" +_roleSelectDialog: + notSelected: "未选中" +_customEmojisManager: + _gridCommon: + copySelectionRows: "复制所选行" + copySelectionRanges: "复制所选范围" + deleteSelectionRows: "删除所选行" + deleteSelectionRanges: "删除所选范围的行" + searchSettings: "搜索设置" + searchSettingCaption: "设置详细的搜索条件。" + searchLimit: "显示项目数" + sortOrder: "排序方式" + registrationLogs: "注册日志" + registrationLogsCaption: "将显示更新和删除表情符号的日志。执行更新或删除操作,又或者更改或重新加载页面时会消失。" + alertEmojisRegisterFailedDescription: "更新或删除表情符号失败。详情请确认注册日志。" + _logs: + showSuccessLogSwitch: "显示成功日志" + failureLogNothing: "没有失败日志。" + logNothing: "没有日志" + _remote: + selectionRowDetail: "所选行的详细信息" + importSelectionRows: "导入所选行" + importSelectionRangesRows: "导入所选范围的行" + importEmojisButton: "导入已选择的表情符号" + confirmImportEmojisTitle: "导入表情符号" + confirmImportEmojisDescription: "是否导入从远程服务器接收的 {count} 个表情符号?请密切关注表情符号的许可协议。" + _local: + tabTitleList: "已注册的表情符号列表" + tabTitleRegister: "注册表情符号" + _list: + emojisNothing: "没有已注册的表情符号。" + markAsDeleteTargetRows: "将所选行标记为删除对象" + markAsDeleteTargetRanges: "将所选范围的行标记为删除对象" + alertUpdateEmojisNothingDescription: "没有已更改的表情符号。" + alertDeleteEmojisNothingDescription: "没有被标记为删除对象的表情符号。" + confirmMovePage: "要离开此页吗?" + confirmChangeView: "要更改显示吗?" + confirmUpdateEmojisDescription: "要更新 {count} 个表情符号吗?" + confirmDeleteEmojisDescription: "要删除已选择的 {count} 个表情符号吗?" + confirmResetDescription: "至今为止所做的所有修改都将被重置。" + confirmMovePageDesciption: "此页面上的表情符号已更改。\n若不保存就离开此页,此页面上所有的更改都将丢失。" + dialogSelectRoleTitle: "按角色搜索表情符号" + _register: + uploadSettingTitle: "上传设置" + uploadSettingDescription: "可以在此页面设置上传表情符号时的行为。" + directoryToCategoryLabel: "将目录名设为「category」" + directoryToCategoryCaption: "拖放目录时,将目录名设置为「category」" + emojiInputAreaCaption: "请使用其中一种方法选择要注册的表情符号。" + emojiInputAreaList1: "在此区域内拖放图像文件或者目录" + emojiInputAreaList2: "单击此链接以从电脑中选择" + emojiInputAreaList3: "单击此链接以从网盘中选择" + confirmRegisterEmojisDescription: "要将列表内显示的表情符号替换为新的自定义表情符号吗?(为降低服务器负载,一次操作最多只能注册 {count} 个表情符号)" + confirmClearEmojisDescription: "要放弃编辑并将列表内表示的表情符号清空吗?" + confirmUploadEmojisDescription: "要将拖放的 {count} 个文件上传到网盘上吗?" +_embedCodeGen: + title: "自定义嵌入代码" + header: "显示标题" + autoload: "连续加载(不推荐)" + maxHeight: "最大高度" + maxHeightDescription: "若将最大值设为 0 则不限制最大高度。为防止小工具无限增高,建议设置一下。" + maxHeightWarn: "最大高度限制已禁用(0)。若这不是您想要的效果,请将最大高度设一个值。" + previewIsNotActual: "由于超出了预览画面可显示的范围,因此显示内容会与实际嵌入时有所不同。" + rounded: "圆角" + border: "外边框" + applyToPreview: "应用预览" + generateCode: "生成嵌入代码" + codeGenerated: "已生成代码" + codeGeneratedDescription: "将生成的代码贴到网站上来使用。" +_selfXssPrevention: + warning: "警告" + title: "「在此处粘贴什么东西」是欺诈行为。" + description1: "如果在此处粘贴了什么,恶意用户可能会接管账户或者盗取个人资料。" + description2: "如果不能完全理解将要粘贴的内容,%c 请立即停止操作并关闭这个窗口。" + description3: "详情请看这里。{link}" +_followRequest: + recieved: "已收到申请" + sent: "已发送申请" +_remoteLookupErrors: + _federationNotAllowed: + title: "无法与此服务器通信" + description: "与此服务器的通信可能被禁用,又或者是屏蔽了此服务器或被此服务器屏蔽了。\n请联系服务器的管理者。" + _uriInvalid: + title: "URI 有误" + description: "输入的 URI 有问题。请确认是否输入了 URI 中无法使用的字符。" + _requestFailed: + title: "请求失败" + description: "与该服务器的通信失败。对面服务器可能不可用。另外,请确认是否输入了无效或不存在的 URI。" + _responseInvalid: + title: "响应无效" + description: "成功与此服务器通信,但返回的数据无效。" + _noSuchObject: + title: "未找到" + description: "未找到请求的资源。请再次检查 URI。" +_captcha: + verify: "请通过 CAPTCHA 验证" + testSiteKeyMessage: "输入测试用的网站密钥及私密密钥后可以生成预览并检查,\n详情请看以下页面。" + _error: + _requestFailed: + title: "请求 CAPTCHA 失败" + text: "请稍后再试,又或者再检查一次设置。" + _verificationFailed: + title: "验证 CAPTCHA 失败" + text: "请再次确认设置是否正确。" + _unknown: + title: "CAPTCHA 错误" + text: "发生意外错误。" +_bootErrors: + title: "加载失败" + serverError: "请稍等片刻再重试。若问题仍无法解决,请将以下 Error ID 一起发送给管理员。" + solution: "以下方法或许可以解决问题:" + solution1: "将浏览器及操作系统更新到最新版本" + solution2: "禁用广告屏蔽插件" + solution3: "清除浏览器缓存" + solution4: "(Tor Browser)将 dom.webaudio.enabled 设定为 true" + otherOption: "其它选项" + otherOption1: "清除客户端设定与缓存" + otherOption2: "使用简易客户端" + otherOption3: "启动修复工具" +_search: + searchScopeAll: "全部" + searchScopeLocal: "本地" + searchScopeServer: "指定服务器" + searchScopeUser: "指定用户" + pleaseEnterServerHost: "请填写服务器主机名" + pleaseSelectUser: "请选择用户" + serverHostPlaceholder: "如:misskey.example.com" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 57da480d28..71f3c45d6a 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -5,9 +5,13 @@ introMisskey: "歡迎!Misskey 是一個開放原始碼且去中心化的社群 poweredByMisskeyDescription: "{name}是開放原始碼平臺 Misskey 的伺服器之一。" monthAndDay: "{month} 月 {day} 日" search: "搜尋" +reset: "重設" notifications: "通知" username: "使用者名稱" password: "密碼" +initialPasswordForSetup: "啟動初始設定的密碼" +initialPasswordIsIncorrect: "啟動初始設定的密碼錯誤。" +initialPasswordForSetupDescription: "如果您自己安裝了 Misskey,請使用您在設定檔中輸入的密碼。\n如果您使用 Misskey 的託管服務之類的服務,請使用提供的密碼。\n如果您尚未設定密碼,請將其留空並繼續。" forgotPassword: "忘記密碼" fetchingAsApObject: "從聯邦宇宙取得中..." ok: "OK" @@ -45,6 +49,7 @@ pin: "置頂" unpin: "取消置頂" copyContent: "複製內容" copyLink: "複製連結" +copyRemoteLink: "複製遠端的連結" copyLinkRenote: "複製轉發的連結" delete: "刪除" deleteAndEdit: "刪除並編輯" @@ -98,7 +103,7 @@ serverIsDead: "伺服器沒有回應。請稍等片刻再試。" youShouldUpgradeClient: "請重新載入以使用新版客戶端顯示此頁面。" enterListName: "輸入清單名稱" privacy: "隱私" -makeFollowManuallyApprove: "手動審核追隨請求" +makeFollowManuallyApprove: "追隨需要核准" defaultNoteVisibility: "預設可見性" follow: "追隨" followRequest: "追隨請求" @@ -227,7 +232,7 @@ instanceInfo: "伺服器資訊" statistics: "統計" clearQueue: "清除佇列" clearQueueConfirmTitle: "確定要清除佇列嗎?" -clearQueueConfirmText: "未發佈的貼文將不會發佈。您通常不需要確認。" +clearQueueConfirmText: "未成功發佈的貼文將不會再嘗試發佈。通常不需要進行這項操作。" clearCachedFiles: "清除快取資料" clearCachedFilesConfirm: "確定要清除所有遠端暫存資料嗎?" blockedInstances: "已封鎖的伺服器" @@ -236,6 +241,8 @@ silencedInstances: "被禁言的伺服器" silencedInstancesDescription: "設定要禁言的伺服器主機名稱,以換行分隔。隸屬於禁言伺服器的所有帳戶都將被視為「禁言帳戶」,只能發出「追隨請求」,而且無法提及未追隨的本地帳戶。這不會影響已封鎖的實例。" mediaSilencedInstances: "媒體被禁言的伺服器" mediaSilencedInstancesDescription: "設定您想要對媒體設定禁言的伺服器,以換行符號區隔。來自被媒體禁言的伺服器所屬帳戶的所有檔案都會被視為敏感檔案,且自訂表情符號不能使用。被封鎖的伺服器不受影響。" +federationAllowedHosts: "允許聯邦通訊的伺服器" +federationAllowedHostsDescription: "設定允許聯邦通訊的伺服器主機,以換行符號分隔。" muteAndBlock: "靜音和封鎖" mutedUsers: "被靜音的使用者" blockedUsers: "被封鎖的使用者" @@ -282,12 +289,11 @@ deleteAreYouSure: "確定要刪掉「{x}」嗎?" resetAreYouSure: "確定要重設嗎?" areYouSure: "是否確定?" saved: "已儲存" -messaging: "聊天" upload: "上傳" keepOriginalUploading: "保留原圖" keepOriginalUploadingDescription: "上傳圖片時保留原始圖片。關閉時,瀏覽器會在上傳時生成適用於網路傳送的版本。" -fromDrive: "從雲端空間" -fromUrl: "從 URL" +fromDrive: "從雲端空間中選擇" +fromUrl: "從 URL 上傳" uploadFromUrl: "從網址上傳" uploadFromUrlDescription: "您要上傳的檔案網址" uploadFromUrlRequested: "已請求上傳" @@ -295,7 +301,7 @@ uploadFromUrlMayTakeTime: "還需要一些時間才能完成上傳。" explore: "探索" messageRead: "已讀" noMoreHistory: "沒有更多歷史紀錄" -startMessaging: "開始聊天" +startChat: "開始聊天" nUsersRead: "{n} 人已讀" agreeTo: "我同意{0}" agree: "同意" @@ -319,7 +325,7 @@ light: "淺色" dark: "深色" lightThemes: "淺色佈景主題" darkThemes: "深色佈景主題" -syncDeviceDarkMode: "與設備的深色模式同步" +syncDeviceDarkMode: "與裝置的深色模式同步" drive: "雲端硬碟" fileName: "檔案名稱" selectFile: "選擇檔案" @@ -334,6 +340,7 @@ renameFolder: "重新命名資料夾" deleteFolder: "刪除資料夾" folder: "資料夾" addFile: "加入附件" +showFile: "瀏覽文件" emptyDrive: "雲端硬碟為空" emptyFolder: "資料夾為空" unableToDelete: "無法刪除" @@ -360,7 +367,7 @@ normal: "正常" instanceName: "伺服器名稱" instanceDescription: "伺服器介紹" maintainerName: "管理員名稱" -maintainerEmail: "管理員郵箱" +maintainerEmail: "管理員信箱" tosUrl: "服務條款 URL" thisYear: "本年" thisMonth: "本月" @@ -376,7 +383,6 @@ enableLocalTimeline: "啟用本地時間軸" enableGlobalTimeline: "啟用全域時間軸" disablingTimelinesInfo: "為了方便,即使您關閉了時間軸功能,管理員和審查員仍可以繼續使用。" registration: "註冊" -enableRegistration: "開放新使用者註冊" invite: "邀請" driveCapacityPerLocalAccount: "每個本地使用者的雲端硬碟容量" driveCapacityPerRemoteAccount: "每個非本地用戶的雲端空間大小" @@ -448,16 +454,17 @@ totpDescription: "以驗證應用程式輸入一次性密碼" moderator: "審查員" moderation: "審查" moderationNote: "管理筆記" +moderationNoteDescription: "您可以編寫僅在審查員之間共用的註解。" addModerationNote: "新增管理筆記" moderationLogs: "管理日誌" nUsersMentioned: "被 {n} 個人提及" -securityKeyAndPasskey: "安全金鑰、Passkey" +securityKeyAndPasskey: "安全金鑰、通行金鑰" securityKey: "安全金鑰" lastUsed: "上次使用" lastUsedAt: "上次使用:{t}" unregister: "註銷" -passwordLessLogin: "設置無密碼登入" -passwordLessLoginDescription: "不使用密碼,以安全金鑰或 Passkey 登入" +passwordLessLogin: "無密碼登入" +passwordLessLoginDescription: "不使用密碼,以安全金鑰或通行金鑰登入" resetPassword: "重設密碼" newPasswordIs: "新密碼為「{password}」" reduceUiAnimation: "減少介面的動態視覺" @@ -483,8 +490,6 @@ noteOf: "{user}的貼文" quoteAttached: "引用" quoteQuestion: "是否要引用?" attachAsFileQuestion: "剪貼簿的文字較長。請問是否要將其以文字檔的方式附加呢?" -noMessagesYet: "沒有訊息" -newMessageExists: "有新的訊息" onlyOneFileCanBeAttached: "只能加入一個附件" signinRequired: "請先登入" signinOrContinueOnRemote: "若要繼續,需前往您所在的伺服器,或者註冊並登入此伺服器" @@ -509,8 +514,11 @@ uiLanguage: "介面語言" aboutX: "關於{x}" emojiStyle: "表情符號的風格" native: "原生" -disableDrawer: "不顯示下拉式選單" -showNoteActionsOnlyHover: "僅在游標停留時顯示貼文的操作選項" +menuStyle: "選單風格" +style: "風格" +drawer: "側邊欄" +popup: "彈出式視窗" +showNoteActionsOnlyHover: "僅於游標懸停時顯示貼文選項" showReactionsCount: "顯示貼文的反應數目" noHistory: "沒有歷史紀錄" signinHistory: "登入歷史" @@ -547,12 +555,12 @@ useObjectStorage: "使用物件儲存" objectStorageBaseUrl: "Base URL" objectStorageBaseUrlDesc: "用於引用的 URL。如果您使用的是 CDN 或反向代理,請指定其 URL,例如 S3(https://.s3.amazonaws.com)、GCS(https://storage.googleapis.com/)。" objectStorageBucket: "儲存空間(Bucket)" -objectStorageBucketDesc: "請填寫所用服務的儲存空間(Bucket)名稱。 " +objectStorageBucketDesc: "請填寫所用服務的儲存桶(Bucket)名稱。 " objectStoragePrefix: "前綴" objectStoragePrefixDesc: "它儲存在此前綴目錄下。" objectStorageEndpoint: "端點(Endpoint)" objectStorageEndpointDesc: "如使用 AWS S3,請留空。如使用其他服務,請按照其說明文件以「」或「:」的形式設定端點(Endpoint)。" -objectStorageRegion: "地域(Region)" +objectStorageRegion: "區域(Region)" objectStorageRegionDesc: "請填寫一個分區,例如「xx-east-1」。 如果您使用的服務不設分區,請留空或填寫「us-east-1」。" objectStorageUseSSL: "使用 SSL" objectStorageUseSSLDesc: "請在不使用 https 連接 API 時關閉" @@ -575,8 +583,9 @@ popout: "彈出式視窗" volume: "音量" masterVolume: "主音量" notUseSound: "關閉音效" -useSoundOnlyWhenActive: "瀏覽器在前景運作時,Misskey 才會發出音效" +useSoundOnlyWhenActive: "僅在 Misskey 於前景運作時發出音效" details: "詳細資訊" +renoteDetails: "轉發貼文的細節" chooseEmoji: "選擇您的表情符號" unableToProcess: "操作無法完成" recentUsed: "最近使用" @@ -592,6 +601,8 @@ ascendingOrder: "昇冪" descendingOrder: "降冪" scratchpad: "暫存記憶體" scratchpadDescription: "AiScript 控制臺為 AiScript 的實驗環境。您可以在此編寫、執行和確認程式碼與 Misskey 互動的結果。" +uiInspector: "UI 檢查" +uiInspectorDescription: "您可以看到記憶體中存在的 UI 元件實例的清單。 UI 元件由 Ui:C: 系列函數產生。" output: "輸出" script: "腳本" disablePagesScript: "停用頁面的 AiScript 腳本" @@ -667,19 +678,24 @@ smtpHost: "主機" smtpPort: "埠" smtpUser: "使用者名稱" smtpPass: "密碼" -emptyToDisableSmtpAuth: "留空使用者名稱和密碼以關閉SMTP驗證。" +emptyToDisableSmtpAuth: "將使用者名稱和密碼留空以關閉 SMTP 驗證。" smtpSecure: "在 SMTP 連接中使用隱式 SSL/TLS" smtpSecureInfo: "使用 STARTTLS 時關閉。" testEmail: "測試郵件發送" wordMute: "被靜音的文字" +wordMuteDescription: "將包含指定語句的貼文最小化。 點擊最小化的貼文即可顯示。" hardWordMute: "硬文字靜音" +showMutedWord: "顯示靜音字" +hardWordMuteDescription: "隱藏含有指定語句的貼文。 與詞彙靜音不同的是,貼文將完全隱藏不見。" regexpError: "正規表達式錯誤" regexpErrorDescription: "{tab} 靜音文字的第 {line} 行的正規表達式有錯誤:" instanceMute: "被靜音的實例" userSaysSomething: "{name}說了什麼" +userSaysSomethingAbout: "{name} 說了一些關於「{word}」的話" makeActive: "啟用" display: "檢視" copy: "複製" +copiedToClipboard: "已複製到剪貼簿" metrics: "指標" overview: "概覽" logs: "日誌" @@ -693,7 +709,7 @@ useGlobalSetting: "使用全域設定" useGlobalSettingDesc: "啟用時,將使用帳戶通知設定。停用時,則可以單獨設定。" other: "其他" regenerateLoginToken: "重新產生登入權杖" -regenerateLoginTokenDescription: "重新產生用於登入的內部權杖。一般情況下是不需要這樣做的。重新產生後,所有裝置將會被登出。" +regenerateLoginTokenDescription: "重新產生用於登入的內部權杖。通常不需要使用此功能。重新產生後,所有裝置都將被登出。" theKeywordWhenSearchingForCustomEmoji: "這是搜尋自訂表情符號時的關鍵字" setMultipleBySeparatingWithSpace: "您可以使用空格分隔多個項目。" fileIdOrUrl: "檔案 ID 或 URL" @@ -708,10 +724,7 @@ abuseReported: "檢舉完成。感謝您的報告。" reporter: "檢舉者" reporteeOrigin: "檢舉來源" reporterOrigin: "檢舉者來源" -forwardReport: "將報告轉送給遠端伺服器" -forwardReportIsAnonymous: "在遠端實例上看不到您的資訊,顯示的報告者是匿名的系统帳戶。" send: "發送" -abuseMarkAsResolved: "處理完畢" openInNewTab: "在新分頁中開啟" openInSideView: "在側欄中開啟" defaultNavigationBehaviour: "預設導航" @@ -730,7 +743,7 @@ unclip: "解除摘錄" confirmToUnclipAlreadyClippedNote: "此貼文已包含在摘錄「{name}」中。 你想將貼文從這個摘錄中排除嗎?" public: "公開" private: "私密" -i18nInfo: "Misskey 已被志願者們翻譯成各種語言版本。您可以瀏覽 {link} 幫助翻譯。" +i18nInfo: "Misskey 已被志願者們翻譯成各種語言版本。您可以前往 {link} 以協助翻譯。" manageAccessTokens: "管理存取權杖" accountInfo: "帳戶資訊" notesCount: "貼文數量" @@ -750,12 +763,12 @@ driveFilesCount: "雲端硬碟檔案數量" driveUsage: "雲端硬碟使用量" noCrawle: "拒絕搜尋引擎索引" noCrawleDescription: "要求網路搜尋引擎不要索引你的個人資料頁、貼文及頁面等。" -lockedAccountInfo: "即使你通過了追隨者請求,除非你將貼文的可見性設定為 「追隨者」,否則任何人都能看見你的貼文。" +lockedAccountInfo: "即使追隨需要核准,除非你將貼文的可見性設定為 「追隨者」,否則任何人都能看見你的貼文。" alwaysMarkSensitive: "預設標記檔案為敏感內容" loadRawImages: "以原始圖檔顯示附件圖檔的縮圖" disableShowingAnimatedImages: "不播放動態圖檔" highlightSensitiveMedia: "強調敏感標記" -verificationEmailSent: "已發送驗證電子郵件。請點擊進入電子郵件中的鏈接完成驗證。" +verificationEmailSent: "已發送驗證電子郵件。請點擊進入電子郵件中的連結以完成驗證。" notSet: "未設定" emailVerified: "已成功驗證您的電子郵件地址" noteFavoritesCount: "我的最愛貼文的數目" @@ -766,7 +779,7 @@ useSystemFont: "使用系統預設的字型" clips: "摘錄" experimentalFeatures: "實驗中的功能" experimental: "實驗性" -thisIsExperimentalFeature: "這是實驗性的功能。可能會有變更規格和不能正常動作的可能性。" +thisIsExperimentalFeature: "這是一項實驗性功能,其行為會隨需要進行調整,也可能無法正常運作。" developer: "開發者" makeExplorable: "使自己的帳戶更容易被找到" makeExplorableDescription: "如果關閉,帳戶將不會被顯示在「探索」頁面中。" @@ -812,7 +825,7 @@ apply: "套用" receiveAnnouncementFromInstance: "接收來自伺服器的通知" emailNotification: "郵件通知" publish: "發布" -inChannelSearch: "頻道内搜尋" +inChannelSearch: "頻道內搜尋" useReactionPickerForContextMenu: "點擊右鍵開啟反應選擇器" typingUsers: "{users}輸入中" jumpToSpecifiedDate: "跳轉到特定日期" @@ -913,9 +926,10 @@ followersVisibility: "追隨者的可見性" continueThread: "查看更多貼文" deleteAccountConfirm: "將要刪除帳戶。是否確定?" incorrectPassword: "密碼錯誤。" +incorrectTotp: "一次性密碼錯誤,或者已過期。" voteConfirm: "確定投給「{choice}」?" hide: "隱藏" -useDrawerReactionPickerForMobile: "在移動設備上使用抽屜顯示" +useDrawerReactionPickerForMobile: "在行動裝置上使用抽屜顯示" welcomeBackWithName: "歡迎回來,{name}" clickToFinishEmailVerification: "點擊 [{ok}] 完成電子郵件地址認證。" overridedDeviceKind: "裝置類型" @@ -937,6 +951,9 @@ oneHour: "一小時" oneDay: "一天" oneWeek: "一週" oneMonth: "一個月" +threeMonths: "3 個月" +oneYear: "1 年" +threeDays: "3 日" reflectMayTakeTime: "可能需要一些時間才會出現效果。" failedToFetchAccountInformation: "取得帳戶資訊失敗" rateLimitExceeded: "已超過速率限制" @@ -993,7 +1010,7 @@ unsubscribePushNotification: "停用推播通知" pushNotificationAlreadySubscribed: "推播通知啟用中" pushNotificationNotSupported: "瀏覽器或伺服器不支援推播通知" sendPushNotificationReadMessage: "如果已閱讀通知與訊息,就刪除推播通知" -sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」通知將立刻顯示。可能會更消耗裝置電池。" +sendPushNotificationReadMessageCaption: "可能會導致裝置的電池消耗量增加。" windowMaximize: "最大化" windowMinimize: "最小化" windowRestore: "復原" @@ -1009,7 +1026,7 @@ show: "檢視" neverShow: "不再顯示" remindMeLater: "以後再說" didYouLikeMisskey: "您喜歡 Misskey 嗎?" -pleaseDonate: "Misskey 是由 {host} 使用的免費軟體。請贊助我們,讓開發得以持續!" +pleaseDonate: "Misskey是由{host}使用的免費軟體。請贊助我們,讓開發的工作能夠持續!" correspondingSourceIsAvailable: "對應的原始碼可以在 {anchor} 處找到。" roles: "角色" role: "角色" @@ -1077,6 +1094,7 @@ retryAllQueuesConfirmTitle: "要現在重試嗎?" retryAllQueuesConfirmText: "伺服器的負荷可能會暫時增加。" enableChartsForRemoteUser: "生成遠端使用者的圖表" enableChartsForFederatedInstances: "生成遠端伺服器的圖表" +enableStatsForFederatedInstances: "取得遠端伺服器資訊" showClipButtonInNoteFooter: "新增摘錄按鈕至貼文" reactionsDisplaySize: "反應的顯示尺寸" limitWidthOfReaction: "限制反應的最大寬度,並縮小顯示尺寸。" @@ -1105,7 +1123,7 @@ vertical: "直向" horizontal: "橫向" position: "位置" serverRules: "伺服器規則" -pleaseConfirmBelowBeforeSignup: "在本伺服器註冊之前,請確認下列事項。" +pleaseConfirmBelowBeforeSignup: "在本伺服器註冊之前,必須確認並同意以下內容。" pleaseAgreeAllToContinue: "必須全部勾選「同意」才能繼續。" continue: "繼續" preservedUsernames: "保留的使用者名稱" @@ -1161,20 +1179,20 @@ used: "已使用" expired: "過期" doYouAgree: "你同意嗎?" beSureToReadThisAsItIsImportant: "重要,請務必閱讀。" -iHaveReadXCarefullyAndAgree: "我已仔細閱讀並同意「{x}」的内容。" +iHaveReadXCarefullyAndAgree: "我已仔細閱讀並同意「{x}」的內容。" dialog: "對話方塊" icon: "圖示" forYou: "給您" currentAnnouncements: "最新公告" pastAnnouncements: "歷史公告" youHaveUnreadAnnouncements: "有未讀的公告。" -useSecurityKey: "請按照瀏覽器或設備上的說明使用安全金鑰或 Passkey。" +useSecurityKey: "請按照瀏覽器或裝置上的說明來使用安全金鑰或通行金鑰。" replies: "回覆" renotes: "轉發" loadReplies: "閱覽回覆" loadConversation: "閱覽對話" pinnedList: "已置頂的清單" -keepScreenOn: "保持設備螢幕開啟" +keepScreenOn: "保持裝置螢幕開啟" verifiedLink: "已驗證連結" notifyNotes: "開啟貼文通知" unnotifyNotes: "關閉貼文通知" @@ -1186,8 +1204,8 @@ edited: "已編輯" notificationRecieveConfig: "接受通知的設定" mutualFollow: "互相追隨" followingOrFollower: "追隨中或追隨者" -fileAttachedOnly: "顯示包含附件的貼文" -showRepliesToOthersInTimeline: "顯示給其他人的回覆" +fileAttachedOnly: "只顯示包含附件的貼文" +showRepliesToOthersInTimeline: "在時間軸上顯示給其他人的回覆" hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆" showRepliesToOthersInTimelineAll: "在時間軸包含追隨中所有人的回覆" hideRepliesToOthersInTimelineAll: "在時間軸不包含追隨中所有人的回覆" @@ -1227,9 +1245,9 @@ reloadRequiredToApplySettings: "需要重新載入頁面設定才能生效。" remainingN: "剩餘:{n}" overwriteContentConfirm: "確定要覆蓋目前的內容嗎?" seasonalScreenEffect: "隨季節變換畫面的呈現" -decorate: "設置頭像裝飾" +decorate: "裝飾" addMfmFunction: "插入 MFM 功能語法" -enableQuickAddMfmFunction: "顯示高級 MFM 選擇器" +enableQuickAddMfmFunction: "顯示進階 MFM 選擇器" bubbleGame: "氣泡遊戲" sfx: "音效" soundWillBePlayed: "將播放音效" @@ -1254,15 +1272,180 @@ useBackupCode: "使用備用驗證碼" launchApp: "啟動 APP" useNativeUIForVideoAudioPlayer: "使用瀏覽器的 UI 播放影片與音訊" keepOriginalFilename: "保留原始檔名" -keepOriginalFilenameDescription: "如果關閉此設置,上傳時檔案名稱會自動替換為隨機字串。" +keepOriginalFilenameDescription: "如果關閉此設定,上傳時檔案名稱會自動替換為隨機字串。" noDescription: "沒有說明文字" -alwaysConfirmFollow: "點擊追隨時總是顯示確認訊息" +alwaysConfirmFollow: "追隨時總是確認" inquiry: "聯絡我們" tryAgain: "請再試一次。" confirmWhenRevealingSensitiveMedia: "要顯示敏感媒體時需確認" sensitiveMediaRevealConfirm: "這是敏感媒體。確定要顯示嗎?" createdLists: "已建立的清單" createdAntennas: "已建立的天線" +fromX: "自 {x}" +genEmbedCode: "產生嵌入程式碼" +noteOfThisUser: "這個使用者的貼文列表" +clipNoteLimitExceeded: "沒辦法在這個摘錄中增加更多貼文了。" +performance: "性能" +modified: "已變更" +discard: "取消" +thereAreNChanges: "有 {n} 處的變更" +signinWithPasskey: "使用通行金鑰登入" +unknownWebAuthnKey: "未註冊的通行金鑰。" +passkeyVerificationFailed: "驗證通行金鑰失敗。" +passkeyVerificationSucceededButPasswordlessLoginDisabled: "雖然驗證通行金鑰成功,但是無密碼登入的方式是停用的。" +messageToFollower: "給追隨者的訊息" +target: "目標 " +testCaptchaWarning: "此功能用於 CAPTCHA 的測試。請勿在正式環境中使用。" +prohibitedWordsForNameOfUser: "禁止使用的字詞(使用者名稱)" +prohibitedWordsForNameOfUserDescription: "如果使用者名稱包含此清單中的任何字串,則拒絕重新命名使用者。 具有審查員權限的使用者不受此限制的影響。" +yourNameContainsProhibitedWords: "您嘗試更改的名稱包含禁止的字串" +yourNameContainsProhibitedWordsDescription: "名稱中包含禁止使用的字串。 如果您想使用此名稱,請聯絡您的伺服器管理員。" +thisContentsAreMarkedAsSigninRequiredByAuthor: "作者將其設定為需要登入才能顯示。" +lockdown: "鎖定" +pleaseSelectAccount: "請選擇帳戶" +availableRoles: "可用角色" +acknowledgeNotesAndEnable: "了解注意事項後再開啟。" +federationSpecified: "此伺服器以白名單聯邦的方式運作。除了管理員指定的伺服器外,它無法與其他伺服器互動。" +federationDisabled: "此伺服器未開啟站台聯邦。無法與其他伺服器上的使用者互動。" +confirmOnReact: "反應時確認" +reactAreYouSure: "用「 {emoji} 」反應嗎?" +markAsSensitiveConfirm: "要將這個媒體設定為敏感嗎?" +unmarkAsSensitiveConfirm: "要解除這個媒體的敏感設定嗎?" +preferences: "環境設定" +accessibility: "輔助工具" +preferencesProfile: "設定檔案" +copyPreferenceId: "複製設定 ID" +resetToDefaultValue: "還原成預設值" +overrideByAccount: "覆寫帳號" +untitled: "無標題" +noName: "沒有名稱" +skip: "跳過" +restore: "還原" +syncBetweenDevices: "裝置之間的同步化" +preferenceSyncConflictTitle: "伺服器上存在設定值" +preferenceSyncConflictText: "已啟用同步的設定項目會將設定值儲存至伺服器,並已找到該設定項目在伺服器上儲存的設定值。請選擇要使用哪個設定值進行覆寫。" +preferenceSyncConflictChoiceServer: "伺服器設定值" +preferenceSyncConflictChoiceDevice: "裝置的設定值" +preferenceSyncConflictChoiceCancel: "取消啟用同步" +paste: "貼上" +emojiPalette: "表情符號調色盤" +postForm: "發文視窗" +textCount: "字數" +information: "關於" +chat: "聊天" +migrateOldSettings: "遷移舊設定資訊" +migrateOldSettings_description: "通常情況下,這會自動進行,但若因某些原因未能順利遷移,您可以手動觸發遷移處理。請注意,當前的設定資訊將會被覆寫。" +compress: "壓縮" +_chat: + noMessagesYet: "尚無訊息" + newMessage: "新訊息" + individualChat: "ㄧ對一聊天室" + individualChat_description: "可以與特定使用者進行一對一的聊天。" + roomChat: "多人聊天室" + roomChat_description: "可以進行多人聊天。\n此外,即使是未允許個人聊天的使用者,只要對方接受,也可以進行聊天。" + createRoom: "建立聊天室" + inviteUserToChat: "邀請使用者開始聊天" + yourRooms: "已建立的聊天室" + joiningRooms: "已加入的聊天室" + invitations: "邀請" + noInvitations: "沒有邀請" + history: "歷史紀錄" + noHistory: "沒有歷史紀錄" + noRooms: "沒有可用的聊天室" + inviteUser: "邀請使用者" + sentInvitations: "已傳送的邀請" + join: "加入" + ignore: "忽視" + leave: "退出聊天室" + members: "成員" + searchMessages: "搜尋聊天訊息" + home: "首頁" + send: "發送" + newline: "換行" + muteThisRoom: "此聊天室已靜音" + deleteRoom: "刪除聊天室" + chatNotAvailableForThisAccountOrServer: "這個伺服器或這個帳號的聊天功能尚未啟用。" + chatNotAvailableInOtherAccount: "對方的帳號無法使用聊天功能。" + cannotChatWithTheUser: "無法與此使用者聊天" + cannotChatWithTheUser_description: "聊天功能目前無法使用,或對方尚未開放聊天功能。" + chatWithThisUser: "聊天" + thisUserAllowsChatOnlyFromFollowers: "此使用者僅接受來自追隨者的聊天訊息。" + thisUserAllowsChatOnlyFromFollowing: "此使用者僅接受自己追隨的使用者傳送聊天訊息。" + thisUserAllowsChatOnlyFromMutualFollowing: "此使用者只接受互相追隨的使用者傳送聊天訊息。" + thisUserNotAllowedChatAnyone: "此使用者不接受來自任何人的聊天訊息。" + chatAllowedUsers: "允許聊天的對象" + chatAllowedUsers_note: "無論此設定為何,您仍可與自己曾發送過聊天訊息的對象進行聊天。" + _chatAllowedUsers: + everyone: "任何人" + followers: "追隨自己的使用者" + following: "只有您追隨的使用者" + mutual: "互相追隨" + none: "無" +_emojiPalette: + palettes: "調色盤" + enableSyncBetweenDevicesForPalettes: "啟用裝置與裝置之間的調色盤同步化" + paletteForMain: "主要使用的調色盤" + paletteForReaction: "反應用的調色盤" +_settings: + driveBanner: "您可以管理和設定雲端硬碟、確認使用量,以及調整上傳檔案時的設定。" + pluginBanner: "可使用外掛擴充用戶端的功能。您可以安裝外掛,實施個別的設定與管理。" + notificationsBanner: "您可以設定從伺服器接收通知的類型和範圍,以及推送通知。" + api: "API" + webhook: "Webhook" + serviceConnection: "服務整合" + serviceConnectionBanner: "您可以管理和設定存取權杖與 Webhooks,以便與外部應用程式和服務整合。" + accountData: "帳戶資料" + accountDataBanner: "您可以管理帳戶資料的匯出 / 匯入。" + muteAndBlockBanner: "您可以設定和管理要隱藏的內容,並限制特定使用者的行動。" + accessibilityBanner: "可針對客戶端的視覺和行為進行個人化設定,以達到更佳的使用效果。" + privacyBanner: "您可以調整帳戶的隱私設定,例如內容的可見性、尋找內容的容易程度,以及追隨是否需要核准。" + securityBanner: "您可以設定與帳戶安全性相關的設定,例如密碼、登入方式、驗證應用程式和通行金鑰。" + preferencesBanner: "您可以根據喜好設定用戶端的整體行為。" + appearanceBanner: "您可以根據喜好設定與用戶端外觀和顯示方式相關的設定。" + soundsBanner: "您可以調整用戶端播放的聲音設定。" + timelineAndNote: "時間軸及貼文" + makeEveryTextElementsSelectable: "允許選取所有文字" + makeEveryTextElementsSelectable_description: "啟用此功能後,可能會在某些情境下降低可用性。" + useStickyIcons: "使大頭貼跟隨捲動" + showNavbarSubButtons: "在導覽列顯示輔助按鈕" + ifOn: "開啟時" + ifOff: "關閉時" + _chat: + showSenderName: "顯示發送者的名稱" + sendOnEnter: "按下 Enter 發送訊息" +_preferencesProfile: + profileName: "設定檔案名稱" + profileNameDescription: "設定一個名稱來識別此裝置。" + profileNameDescription2: "例如:「主要個人電腦」、「智慧型手機」等" +_preferencesBackup: + autoBackup: "自動備份" + restoreFromBackup: "從備份還原" + noBackupsFoundTitle: "找不到備份檔" + noBackupsFoundDescription: "沒有找到自動建立的備份,但如果您手動儲存了備份檔案,則可以匯入並還原。" + selectBackupToRestore: "選擇要還原的備份" + youNeedToNameYourProfileToEnableAutoBackup: "要啟用自動備份,必須設定檔案名稱。" + autoPreferencesBackupIsNotEnabledForThisDevice: "此裝置未啟用自動備份設定。" + backupFound: "找到設定的備份" +_accountSettings: + requireSigninToViewContents: "須登入以顯示內容" + requireSigninToViewContentsDescription1: "必須登入才會顯示您建立的貼文等內容。可望有效防止資訊被爬蟲蒐集。" + requireSigninToViewContentsDescription2: "來自不支援 URL 預覽 (OGP)、 網頁嵌入和引用貼文的伺服器,也將停止顯示。" + requireSigninToViewContentsDescription3: "這些限制可能不適用於被聯邦發送至遠端伺服器的內容。" + makeNotesFollowersOnlyBefore: "讓過去的貼文僅對追隨者顯示" + makeNotesFollowersOnlyBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對追隨者顯示。 如果您再次停用它,貼文的公開狀態也會恢復原狀。" + makeNotesHiddenBefore: "隱藏過去的貼文" + makeNotesHiddenBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對自己顯示(私密化)。 如果您再次停用它,貼文的公開狀態也會恢復原狀。" + mayNotEffectForFederatedNotes: "聯邦發送至遠端伺服器的貼文可能會不受影響。" + mayNotEffectSomeSituations: "這些限制已經簡化。它們可能不適用於某些情況,例如在遠端伺服器上檢視或管理時。" + notesHavePassedSpecifiedPeriod: "早於指定時間的貼文" + notesOlderThanSpecifiedDateAndTime: "指定時間和日期之前的貼文" +_abuseUserReport: + forward: "轉發" + forwardDescription: "以匿名系統帳戶將檢舉轉發至遠端伺服器。" + resolve: "解決" + accept: "接受" + reject: "拒絕" + resolveTutorial: "如果您已回覆正當的檢舉,請選擇「接受」以將案件標記為已解決。\n 如果檢舉的內容不正當,請選擇「拒絕」將案件標記為已解決。" _delivery: status: "傳送狀態" stop: "停止發送" @@ -1309,7 +1492,7 @@ _initialAccountSetting: theseSettingsCanEditLater: "這裡的設定可以在之後變更。" youCanEditMoreSettingsInSettingsPageLater: "除此之外,還可以在「設定」頁面進行各種設定。之後請確認看看。" followUsers: "為了構築時間軸,試著追隨您感興趣的使用者吧。" - pushNotificationDescription: "啟用推送通知,就可以在設備上接收{name}的通知。" + pushNotificationDescription: "啟用推送通知後,就可以在裝置上接收來自{name}的通知了。" initialAccountSettingCompleted: "初始設定完成了!" haveFun: "盡情享受{name}吧!" youCanContinueTutorial: "您可以繼續學習如何使用{name}(Misskey),也可以就此打住,立即開始使用。" @@ -1329,12 +1512,12 @@ _initialTutorial: description: "在Misskey上發布的內容稱為「貼文」。貼文在時間軸上按時間順序排列,並即時更新。" reply: "您可以回覆貼文,並像討論串一樣繼續對話。" renote: "您可以將此貼文分享到自己的時間軸。您也可以在引用時添加文字。" - reaction: "您可以添加反應。詳細資訊將在下一頁進行說明。" + reaction: "您可以加入反應。詳細資訊將在下一頁進行說明。" menu: "可執行各種操作,如查看貼文詳細資訊和複製連結。" _reaction: title: "什麼是反應?" - description: "您可以在貼文中添加「反應」。您可以使用反應輕鬆隨意地表達「最愛/大心」所無法傳達的細微差別。" - letsTryReacting: "可以透過點擊貼文上的「+」按鈕來添加反應。請嘗試在此範例貼文添加反應!" + description: "您可以在貼文中加上「反應」。有些用「最愛/大心」無法傳達的感想,可以用反應輕鬆地表達出來。" + letsTryReacting: "按一下貼文上的「+」按鈕即可加入反應。試著對此範例貼文加上反應!" reactToContinue: "添加反應以繼續教學課程。" reactNotification: "當有人對您的貼文做出反應時會即時接收到通知。" reactDone: "按下「-」按鈕可以取消反應。" @@ -1368,7 +1551,7 @@ _initialTutorial: useCases: "伺服器的服務條款可能會規範特定的貼文需要使用隱藏內容,除此之外也會用在隱藏劇情洩漏與敏感內容的貼文。" _howToMakeAttachmentsSensitive: title: "如何標記上傳附件為敏感內容?" - description: "如果伺服器服務條款有規範,又或者不希望上傳附件直接被看見,可以設置為「敏感內容」" + description: "如果伺服器的服務條款有規範,又或者不適合直接展示的附件,請記得加上「敏感」標記。" tryThisFile: "試試看!把附加在發文表單的圖像檔案標記為敏感內容。" _exampleNote: note: "打開納豆的包裝失敗了…" @@ -1397,8 +1580,12 @@ _serverSettings: fanoutTimelineDescription: "如果啟用的話,檢索各個時間軸的性能會顯著提昇,資料庫的負荷也會減少。不過,Redis 的記憶體使用量會增加。如果伺服器的記憶體容量比較少或者運行不穩定,可以停用。" fanoutTimelineDbFallback: "資料庫的回退" fanoutTimelineDbFallbackDescription: "若啟用,在時間軸沒有快取的情況下將執行回退處理以額外查詢資料庫。若停用,可以透過不執行回退處理來進一步減少伺服器的負荷,但會限制可取得的時間軸範圍。" + reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是,Redis 記憶體使用量會增加。" inquiryUrl: "聯絡表單網址" inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。" + openRegistration: "允許建立帳戶" + openRegistrationWarning: "開放註冊伴隨著風險。 建議只有在伺服器受到持續監控,並準備好在出現問題時能立即處理的情況下才開放註冊。" + thisSettingWillAutomaticallyOffWhenModeratorsInactive: "如果在一段期間內沒有偵測到任何審查員活動,此設定將自動關閉,以防止垃圾內容。" _accountMigration: moveFrom: "從其他帳戶遷移到這個帳戶" moveFromSub: "為另一個帳戶建立別名" @@ -1412,7 +1599,7 @@ _accountMigration: startMigration: "遷移" migrationConfirm: "確定要將這個帳戶遷移至 {account} 嗎?一旦遷移就無法撤銷,也就無法以原來的狀態使用這個帳戶。\n另外,請確認在要遷移到的帳戶已經建立了一個別名。" movedAndCannotBeUndone: "帳戶已遷移。\n遷移無法撤消。" - postMigrationNote: "在完成遷移的 24 小時後解除此帳戶的追隨。此帳戶的追隨中、追隨者數量變為 0。由於不會解除追隨者,你的追隨者仍然可以繼續檢視這個帳戶發布給追隨者的貼文。" + postMigrationNote: "將在完成遷移的 24 小時後取消追隨所有帳號。\n此帳戶的追隨中/追隨者人數將歸零。由於不會解除粉絲對您的追隨,因此他們仍然可以繼續閱覽此帳戶內僅對追隨者公開的貼文。" movedTo: "要遷移到的帳戶:" _achievements: earnedAt: "獲得日期" @@ -1532,7 +1719,7 @@ _achievements: _markedAsCat: title: "我是貓" description: "已將帳戶設定為貓" - flavor: "還沒有名字。" + flavor: "沒有名字。" _following1: title: "首次追隨" description: "首次追隨了" @@ -1546,7 +1733,7 @@ _achievements: title: "一百位朋友" description: "追隨超過100人了" _following300: - title: "朋友過多" + title: "朋友太多" description: "追隨超過300人了" _followers1: title: "第一個追隨者" @@ -1729,7 +1916,13 @@ _role: canHideAds: "不顯示廣告" canSearchNotes: "可否搜尋貼文" canUseTranslator: "使用翻譯功能" - avatarDecorationLimit: "頭像裝飾的最大設置量" + avatarDecorationLimit: "頭像可掛上的最大裝飾數量" + canImportAntennas: "允許匯入天線" + canImportBlocking: "允許匯入封鎖名單" + canImportFollowing: "允許匯入追隨名單" + canImportMuting: "允許匯入靜音名單" + canImportUserLists: "允許匯入清單" + canChat: "允許聊天" _condition: roleAssignedTo: "手動指派角色完成" isLocal: "本地使用者" @@ -1867,7 +2060,7 @@ _channel: following: "追隨中" usersCount: "有 {n} 人參與" notesCount: "有 {n} 篇貼文" - nameAndDescription: "名稱與說明" + nameAndDescription: "名稱" nameOnly: "僅名稱" allowRenoteToExternal: "允許在頻道外轉發和引用" _menuDisplay: @@ -1883,7 +2076,7 @@ _instanceMute: instanceMuteDescription: "包括對被靜音伺服器上的使用者的回覆,被設定的伺服器上所有貼文及轉發都會被靜音。" instanceMuteDescription2: "設定時以換行進行分隔" title: "將隱藏被設定的伺服器貼文。" - heading: "將伺服器靜音" + heading: "要靜音的伺服器" _theme: explore: "探索佈景主題" install: "安裝佈景主題" @@ -1893,6 +2086,7 @@ _theme: installed: "{name}已安裝" installedThemes: "已經安裝的佈景主題" builtinThemes: "標準佈景主題" + instanceTheme: "伺服器的主題" alreadyInstalled: "已安裝此佈景主題" invalid: "佈景主題格式錯誤" make: "製作佈景主題" @@ -1947,7 +2141,6 @@ _theme: buttonBg: "按鈕背景" buttonHoverBg: "按鈕背景 (漂浮)" inputBorder: "輸入框邊框" - listItemHoverBg: "列表物品背景 (漂浮)" driveFolderBg: "雲端硬碟文件夾背景" wallpaperOverlay: "壁紙覆蓋層" badge: "徽章" @@ -1960,6 +2153,7 @@ _sfx: noteMy: "我的貼文" notification: "通知" reaction: "選擇反應時" + chatMessage: "聊天訊息" _soundSettings: driveFile: "使用雲端硬碟的音效檔案" driveFileWarn: "請選擇雲端硬碟中的檔案" @@ -2003,11 +2197,11 @@ _2fa: setupCompleted: "設定完成" step4: "從現在開始,任何登入操作都將要求您提供權杖。" securityKeyNotSupported: "您的瀏覽器不支援安全金鑰。" - registerTOTPBeforeKey: "如要註冊安全金鑰或 Passkey,請先設定驗證應用程式。" - securityKeyInfo: "您可以設定使用支援 FIDO2 的硬體安全鎖、終端設備的指紋認證,或者 PIN 碼來登入。" - registerSecurityKey: "註冊安全金鑰或 Passkey" + registerTOTPBeforeKey: "如要註冊安全金鑰或通行金鑰,請先設定驗證應用程式。" + securityKeyInfo: "註冊 WebAuthn 衍生的金鑰,例如支援 FIDO2 的硬體安全金鑰、裝置生物識別、PIN 鎖和通行金鑰。" + registerSecurityKey: "註冊安全金鑰或通行金鑰" securityKeyName: "輸入金鑰名稱" - tapSecurityKey: "按照瀏覽器的說明註冊安全金鑰或 Passkey。" + tapSecurityKey: "按照瀏覽器的說明註冊安全金鑰或通行金鑰。" removeKey: "刪除安全金鑰" removeKeyConfirm: "要刪除{name}嗎?" whyTOTPOnlyRenew: "如果註冊了安全金鑰,則無法解除驗證應用程式的設定。" @@ -2106,6 +2300,8 @@ _permissions: "read:clip-favorite": "查看摘錄的讚" "read:federation": "查看站台聯邦的相關資訊" "write:report-abuse": "檢舉違規行為" + "write:chat": "撰寫或刪除訊息" + "read:chat": "查看聊天訊息" _auth: shareAccessTitle: "應用程式的存取權限" shareAccess: "要授權「“{name}”」存取您的帳戶嗎?" @@ -2114,8 +2310,11 @@ _auth: permissionAsk: "此應用程式需要以下權限" pleaseGoBack: "請返回至應用程式" callback: "回到應用程式" + accepted: "已授予存取權限" denied: "拒絕訪問" + scopeUser: "以下列使用者身分操作" pleaseLogin: "必須登入以提供應用程式的存取權限。" + byClickingYouWillBeRedirectedToThisUrl: "如果授予存取權限,就會自動導向到以下的網址" _antennaSources: all: "全部貼文" homeTimeline: "來自已追隨使用者的貼文" @@ -2219,11 +2418,14 @@ _profile: metadataEdit: "編輯附加資訊" metadataDescription: "可以在個人資料中以表格形式顯示其他資訊。" metadataLabel: "標籤" - metadataContent: "内容" + metadataContent: "內容" changeAvatar: "更換大頭貼" changeBanner: "變更橫幅圖像" verifiedLinkDescription: "如果輸入包含您個人資料的網站 URL,欄位旁邊將出現驗證圖示。" avatarDecorationMax: "最多可以設置 {max} 個裝飾。" + followedMessage: "被追隨時的訊息" + followedMessageDescription: "可以設定被追隨時顯示給對方的訊息。" + followedMessageDescriptionForLockedAccount: "如果追隨需要核准的話,將在通過追隨請求之後顯示。" _exportOrImport: allNotes: "所有貼文" favoritedNotes: "「我的最愛」貼文" @@ -2286,9 +2488,6 @@ _pages: newPage: "建立頁面" editPage: "編輯頁面" readPage: "正在檢視原始碼" - created: "頁面已建立" - updated: "頁面已更新" - deleted: "頁面已被刪除" pageSetting: "頁面設定" nameAlreadyExists: "該頁面 URL 已存在" invalidNameTitle: "無效的頁面 URL" @@ -2332,7 +2531,7 @@ _pages: note: "嵌式貼文" _note: id: "貼文ID" - idDescription: "您也可以粘貼筆記 URL 並進行設置。 " + idDescription: "您也可以貼上貼文 URL 來進行設定。 " detailed: "顯示詳細內容" _relayStatus: requesting: "等待核准" @@ -2346,11 +2545,12 @@ _notification: youRenoted: "{name} 轉發了你的貼文" youWereFollowed: "您有新的追隨者" youReceivedFollowRequest: "您有新的追隨請求" - yourFollowRequestAccepted: "您的追隨請求已通過" + yourFollowRequestAccepted: "您的追隨請求已被核准" pollEnded: "問卷調查已產生結果" newNote: "新的貼文" unreadAntennaNote: "天線 {name}" roleAssigned: "已授予角色" + chatRoomInvitationReceived: "您被邀請加入聊天室" emptyPushNotificationMessage: "推送通知已更新" achievementEarned: "獲得成就" testNotification: "通知測試" @@ -2362,20 +2562,29 @@ _notification: renotedBySomeUsers: "{n}人做了轉發" followedBySomeUsers: "被{n}人追隨了" flushNotification: "重置通知歷史紀錄" + exportOfXCompleted: "{x} 的匯出已完成。" + login: "已登入" + createToken: "已產生存取權杖" + createTokenDescription: "如果您不知道,請透過「{text}」刪除存取權杖。" _types: all: "全部 " note: "使用者的最新貼文" follow: "追隨中" mention: "提及" reply: "回覆" - renote: "轉發貼文" + renote: "轉發" quote: "引用" reaction: "反應" pollEnded: "問卷調查結束" receiveFollowRequest: "已收到追隨請求" followRequestAccepted: "追隨請求已接受" roleAssigned: "已授予角色" + chatRoomInvitationReceived: "已被邀請加入聊天室" achievementEarned: "獲得成就" + exportCompleted: "已完成匯出。" + login: "登入" + createToken: "建立存取權杖" + test: "通知測試" app: "應用程式通知" _actions: followBack: "追隨回去" @@ -2402,6 +2611,7 @@ _deck: useSimpleUiForNonRootPages: "用簡易介面顯示非根頁面" usedAsMinWidthWhenFlexible: "如果啟用「自動調整寬度」,此為最小寬度" flexible: "自動調整寬度" + enableSyncBetweenDevicesForProfiles: "啟用裝置與裝置之間的設定檔資料同步化" _columns: main: "主列" widgets: "小工具" @@ -2441,7 +2651,10 @@ _webhookSettings: abuseReport: "當使用者檢舉時" abuseReportResolved: "當處理了使用者的檢舉時" userCreated: "使用者被新增時" + inactiveModeratorsWarning: "當審查員在一段時間內沒有活動時" + inactiveModeratorsInvitationOnlyChanged: "當審查員在一段時間內不活動時,系統會將模式變更為邀請制" deleteConfirm: "請問是否要刪除 Webhook?" + testRemarks: "按下切換開關右側的按鈕,就會將假資料發送至 Webhook。" _abuseReport: _notificationRecipient: createRecipient: "新增接收檢舉的通知對象" @@ -2454,7 +2667,7 @@ _abuseReport: mail: "寄送到擁有監察員權限的使用者電子郵件地址(僅在收到檢舉時)" webhook: "向指定的 SystemWebhook 發送通知(在收到檢舉和解決檢舉時發送)" keywords: "關鍵字" - notifiedUser: "被通知的使用者" + notifiedUser: "通知的使用者" notifiedWebhook: "使用的 Webhook" deleteConfirm: "確定要刪除通知對象嗎?" _moderationLogTypes: @@ -2485,6 +2698,8 @@ _moderationLogTypes: markSensitiveDriveFile: "標記為敏感檔案" unmarkSensitiveDriveFile: "撤銷標記為敏感檔案" resolveAbuseReport: "解決檢舉" + forwardAbuseReport: "轉發檢舉" + updateAbuseReportNote: "更新檢舉的審查備註" createInvitation: "建立邀請碼" createAd: "建立廣告" deleteAd: "刪除廣告" @@ -2504,6 +2719,8 @@ _moderationLogTypes: deletePage: "刪除頁面" deleteFlash: "刪除 Play" deleteGalleryPost: "刪除相簿的貼文" + deleteChatRoom: "刪除聊天室" + updateProxyAccountDescription: "更新代理帳戶的說明" _fileViewer: title: "檔案詳細資訊" type: "檔案類型 " @@ -2517,10 +2734,8 @@ _externalResourceInstaller: checkVendorBeforeInstall: "安裝前請確認提供者是可信賴的。" _plugin: title: "要安裝此外掛嘛?" - metaTitle: "外掛資訊" _theme: title: "要安裝此佈景主題嗎?" - metaTitle: "佈景主題資訊" _meta: base: "基本配色方案" _vendorInfo: @@ -2546,7 +2761,7 @@ _externalResourceInstaller: description: "已取得資料但解析 AiScript 時發生錯誤,導致無法載入。請聯絡外掛作者。請檢查 Javascript 控制台以取得錯誤詳細資訊。" _pluginInstallFailed: title: "外掛安裝失敗" - description: "安裝插件時出現問題。請再試一次。請參閱 Javascript 控制台以取得錯誤詳細資訊。" + description: "安裝外掛時出現問題。請再試一次。可參閱 Javascript 控制台以取得錯誤詳細資訊。" _themeParseFailed: title: "佈景主題解析錯誤" description: "已取得資料但解析佈景主題時發生錯誤,導致無法載入。請聯絡佈景主題的作者。請檢查 Javascript 控制台以取得錯誤詳細資訊。" @@ -2565,11 +2780,11 @@ _dataSaver: description: "將不再自動載入網址預覽縮圖。" _code: title: "程式碼突出顯示" - description: "如果使用了 MFM 的程式碼突顯標記,則在點擊之前不會載入。程式碼突顯要求加載每種程式語言的突顯定義檔案,但由於這些檔案不再自動載入,因此有望減少資料流量。" + description: "如果使用了程式碼突顯語法(如 MFM),則在點擊之前不會被載入。由於需要為對應的程式語言下載突顯定義檔案,因此關閉自動載入有助於減少資料流量。" _hemisphere: N: "北半球" S: "南半球" - caption: "在某些客戶端的設定中,用於判斷季節。" + caption: "某些客戶端的設定會用此來判斷季節。" _reversi: reversi: "黑白棋" gameSettings: "對弈設定" @@ -2640,3 +2855,135 @@ _contextMenu: app: "應用程式" appWithShift: "Shift 鍵應用程式" native: "瀏覽器的使用者介面" +_gridComponent: + _error: + requiredValue: "此值為必填欄位" + columnTypeNotSupport: "正規表達式驗證僅支援 type:text 的欄位。" + patternNotMatch: "此值不符合 {pattern} 中的樣式。" + notUnique: "此值必須是唯一的" +_roleSelectDialog: + notSelected: "未選擇" +_customEmojisManager: + _gridCommon: + copySelectionRows: "複製選取的行" + copySelectionRanges: "複製選取的範圍" + deleteSelectionRows: "刪除所選的行" + deleteSelectionRanges: "刪除選取範圍的行" + searchSettings: "搜尋設定" + searchSettingCaption: "詳細設定搜尋條件。" + searchLimit: "顯示的數量" + sortOrder: "排序" + registrationLogs: "登錄日誌" + registrationLogsCaption: "會顯示更新或刪除表情符號時的日誌。進行更新或刪除操作,或切換頁面、重新載入後,日誌將會消失。" + alertEmojisRegisterFailedDescription: "更新或刪除表情符號失敗。詳情請查看登錄日誌。" + _logs: + showSuccessLogSwitch: "顯示成功日誌" + failureLogNothing: "沒有失敗的日誌。" + logNothing: "沒有日誌。" + _remote: + selectionRowDetail: "選取行的詳細資訊" + importSelectionRows: "匯入選取的行" + importSelectionRangesRows: "匯入選取範圍的行" + importEmojisButton: "匯入勾選的表情符號" + confirmImportEmojisTitle: "匯入表情符號" + confirmImportEmojisDescription: "將從遠端接收的{count}個表情符號進行匯入。請務必注意表情符號的授權。是否執行此操作?" + _local: + tabTitleList: "已登錄的表情符號列表" + tabTitleRegister: "登錄表情符號" + _list: + emojisNothing: "沒有登錄的表情符號。" + markAsDeleteTargetRows: "將選取的行設為刪除對象" + markAsDeleteTargetRanges: "將選取範圍的行設為刪除對象\n" + alertUpdateEmojisNothingDescription: "沒有選取需要變更的表情符號。" + alertDeleteEmojisNothingDescription: "沒有選取需要刪除的表情符號。" + confirmMovePage: "要移動到其他頁面嗎?" + confirmChangeView: "要更改顯示方式嗎?" + confirmUpdateEmojisDescription: "將更新{count}個表情符號。是否執行此操作?" + confirmDeleteEmojisDescription: "將刪除勾選的{count}個表情符號。是否執行此操作?" + confirmResetDescription: "目前所做的所有變更都會重設。" + confirmMovePageDesciption: "此頁面的表情符號已被更改。 \n若未儲存就直接離開此頁面,則在此頁面進行的所有更改將會被捨棄。" + dialogSelectRoleTitle: "根據表情符號設定的角色進行搜尋" + _register: + uploadSettingTitle: "上傳設定" + uploadSettingDescription: "您可以在此畫面設定表情符號上傳時的操作。" + directoryToCategoryLabel: "在「類別」欄位中輸入目錄名稱" + directoryToCategoryCaption: "拖放目錄時,請在「類別」欄位中輸入目錄名稱。" + emojiInputAreaCaption: "以下列其中一種方式選擇您想要註冊的表情符號" + emojiInputAreaList1: "將圖片檔案或目錄拖放到此框中" + emojiInputAreaList2: "點擊此連結從電腦中選擇" + emojiInputAreaList3: "點擊此連結從雲端硬碟中選擇" + confirmRegisterEmojisDescription: "將列表中顯示的表情符號登錄為新的自定表情符號。是否確定?(為避免過高負荷,每次操作最多可登錄{count}個表情符號)" + confirmClearEmojisDescription: "放棄編輯內容並清除列表中顯示的表情符號。是否確定?" + confirmUploadEmojisDescription: "將拖放的{count}個檔案上傳到雲端硬碟。是否執行此操作?" +_embedCodeGen: + title: "自訂嵌入程式碼" + header: "檢視標頭 " + autoload: "自動繼續載入(不建議)" + maxHeight: "最大高度" + maxHeightDescription: "設定為 0 時代表沒有最大值。請指定某個值以避免小工具持續在縱向延伸。" + maxHeightWarn: "最大高度限制已停用(0)。如果這個變更不是您想要的,請將最大高度設定為某個值。" + previewIsNotActual: "由於超出了預覽畫面可顯示的範圍,因此顯示內容會與實際嵌入時有所不同。" + rounded: "圓角" + border: "給外框加上邊框" + applyToPreview: "反映在預覽中" + generateCode: "建立嵌入程式碼" + codeGenerated: "已產生程式碼" + codeGeneratedDescription: "請將產生的程式碼貼到您的網站上。" +_selfXssPrevention: + warning: "警告" + title: "「在此畫面貼上一些內容」完全是個騙局。" + description1: "如果您在此處貼上任何內容,惡意使用者可能會接管您的帳戶或竊取您的個人資訊。" + description2: "如果您不確切知道要貼上的內容,%c 請立即停止工作並關閉此視窗。" + description3: "細節請看這裡。{link}" +_followRequest: + recieved: "收到的請求" + sent: "送出的請求" +_remoteLookupErrors: + _federationNotAllowed: + title: "無法與這個伺服器通訊" + description: "與此伺服器的通訊可能被停用、或封鎖了該伺服器,或被該伺服器封鎖。\n請聯繫您的伺服器管理員。" + _uriInvalid: + title: "URI 不正確" + description: "輸入的 URI 有問題。請檢查是否輸入了 URI 中不能使用的字元。" + _requestFailed: + title: "請求失敗" + description: "與此伺服器的通訊失敗。可能是對方伺服器斷線。 此外,請檢查是否輸入了不正確或不存在的 URI。" + _responseInvalid: + title: "回應不正確" + description: "雖然能夠與這個伺服器通訊,但是取得的資料不正確。" + _noSuchObject: + title: "查無項目" + description: "無法找到所要求的資源,請再次檢查 URI。" +_captcha: + verify: "請通過 CAPTCHA 驗證" + testSiteKeyMessage: "可以輸入網站金鑰和秘密金鑰的測試值來檢查預覽。\n詳細資訊請參閱以下頁面。" + _error: + _requestFailed: + title: "CAPTCHA 請求失敗" + text: "請過一段時間後再執行,或再次檢查設定。" + _verificationFailed: + title: "CAPTCHA 驗證失敗" + text: "請再次檢查設定是否正確。" + _unknown: + title: "CAPTCHA 錯誤" + text: "發生了意外的錯誤。" +_bootErrors: + title: "載入失敗" + serverError: "如果稍等片刻並重新載入後問題仍然存在,請聯絡您的伺服器管理員並提供以下的錯誤 ID。" + solution: "執行以下操作或許可以解決問題。" + solution1: "將瀏覽器和作業系統更新至最新版本" + solution2: "停用廣告攔截器" + solution3: "清除瀏覽器的快取" + solution4: "(Tor 瀏覽器)將 dom.webaudio.enabled 設為 true" + otherOption: "其他選項" + otherOption1: "刪除用戶端設定和快取" + otherOption2: "啟動簡易用戶端" + otherOption3: "啟動修復工具" +_search: + searchScopeAll: "全部" + searchScopeLocal: "本地" + searchScopeServer: "指定伺服器" + searchScopeUser: "指定使用者" + pleaseEnterServerHost: "請輸入伺服器的主機名稱" + pleaseSelectUser: "請選擇使用者" + serverHostPlaceholder: "例:misskey.example.com" diff --git a/package.json b/package.json index 5c41a1d5bb..55d22d7007 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "misskey", - "version": "2024.9.0-alpha.2", + "version": "2025.3.2-beta.18", "codename": "nasubi", "repository": { "type": "git", "url": "https://github.com/misskey-dev/misskey.git" }, - "packageManager": "pnpm@9.6.0", + "packageManager": "pnpm@10.6.1", "workspaces": [ "packages/frontend-shared", "packages/frontend", @@ -24,8 +24,9 @@ "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", + "build-frontend-search-index": "pnpm --filter frontend build-search-index", "start": "pnpm check:connect && cd packages/backend && node ./built/boot/entry.js", - "start:test": "cd packages/backend && cross-env NODE_ENV=test 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", "init": "pnpm migrate", "migrate": "cd packages/backend && pnpm migrate", "revert": "cd packages/backend && pnpm revert", @@ -37,7 +38,7 @@ "cy:open": "pnpm cypress open --browser --e2e --config-file=cypress.config.ts", "cy:run": "pnpm cypress run", "e2e": "pnpm start-server-and-test start:test http://localhost:61812 cy:run", - "e2e-dev-container": "cp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run", + "e2e-dev-container": "ncp ./.config/cypress-devcontainer.yml ./.config/test.yml && pnpm start-server-and-test start:test http://localhost:61812 cy:run", "jest": "cd packages/backend && pnpm jest", "jest-and-coverage": "cd packages/backend && pnpm jest-and-coverage", "test": "pnpm -r test", @@ -47,35 +48,44 @@ "cleanall": "pnpm clean-all" }, "resolutions": { - "chokidar": "3.5.3", + "chokidar": "4.0.3", "lodash": "4.17.21" }, "dependencies": { - "cssnano": "6.1.2", - "execa": "8.0.1", - "fast-glob": "3.3.2", - "ignore-walk": "6.0.5", + "cssnano": "7.0.6", + "execa": "9.5.2", + "fast-glob": "3.3.3", + "ignore-walk": "7.0.0", "js-yaml": "4.1.0", - "postcss": "8.4.40", - "tar": "6.2.1", - "terser": "5.31.3", - "typescript": "5.5.4", - "esbuild": "0.23.0", - "glob": "11.0.0" + "postcss": "8.5.3", + "tar": "7.4.3", + "terser": "5.39.0", + "typescript": "5.8.2", + "esbuild": "0.25.0", + "glob": "11.0.1" }, "devDependencies": { - "@misskey-dev/eslint-plugin": "2.0.3", - "@types/node": "20.14.12", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", + "@misskey-dev/eslint-plugin": "2.1.0", + "@types/node": "22.13.10", + "@typescript-eslint/eslint-plugin": "8.26.0", + "@typescript-eslint/parser": "8.26.0", "cross-env": "7.0.3", - "cypress": "13.13.1", - "eslint": "9.8.0", - "globals": "15.8.0", + "cypress": "14.1.0", + "eslint": "9.22.0", + "globals": "16.0.0", "ncp": "2.0.0", - "start-server-and-test": "2.0.4" + "pnpm": "10.6.1", + "start-server-and-test": "2.0.10" }, "optionalDependencies": { - "@tensorflow/tfjs-core": "4.4.0" + "@tensorflow/tfjs-core": "4.22.0" + }, + "pnpm": { + "overrides": { + "@aiscript-dev/aiscript-languageserver": "-" + }, + "patchedDependencies": { + "re2": "scripts/dependency-patches/re2.patch" + } } } diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc index 845190b5f4..f4bf7a4d2a 100644 --- a/packages/backend/.swcrc +++ b/packages/backend/.swcrc @@ -1,5 +1,5 @@ { - "$schema": "https://json.schemastore.org/swcrc", + "$schema": "https://swc.rs/schema.json", "jsc": { "parser": { "syntax": "typescript", diff --git a/packages/backend/assets/tabler-badges/login-2.png b/packages/backend/assets/tabler-badges/login-2.png new file mode 100644 index 0000000000..f3ca8de3dd Binary files /dev/null and b/packages/backend/assets/tabler-badges/login-2.png differ diff --git a/packages/backend/eslint.config.js b/packages/backend/eslint.config.js index 4fd9f0cd51..ae7b2baf49 100644 --- a/packages/backend/eslint.config.js +++ b/packages/backend/eslint.config.js @@ -11,7 +11,7 @@ export default [ languageOptions: { parserOptions: { parser: tsParser, - project: ['./tsconfig.json', './test/tsconfig.json'], + project: ['./tsconfig.json', './test/tsconfig.json', './test-federation/tsconfig.json'], sourceType: 'module', tsconfigRootDir: import.meta.dirname, }, diff --git a/packages/backend/jest.config.fed.cjs b/packages/backend/jest.config.fed.cjs new file mode 100644 index 0000000000..fae187bc23 --- /dev/null +++ b/packages/backend/jest.config.fed.cjs @@ -0,0 +1,13 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/en/configuration.html + */ + +const base = require('./jest.config.cjs'); + +module.exports = { + ...base, + testMatch: [ + '/test-federation/test/**/*.test.ts', + ], +}; diff --git a/packages/backend/migration/1709126576000-optimize-emoji-index.js b/packages/backend/migration/1709126576000-optimize-emoji-index.js new file mode 100644 index 0000000000..e4184895d0 --- /dev/null +++ b/packages/backend/migration/1709126576000-optimize-emoji-index.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class OptimizeEmojiIndex1709126576000 { + name = 'OptimizeEmojiIndex1709126576000' + + async up(queryRunner) { + await queryRunner.query(`CREATE INDEX "IDX_EMOJI_ROLE_IDS" ON "emoji" using gin ("roleIdsThatCanBeUsedThisEmojiAsReaction")`) + await queryRunner.query(`CREATE INDEX "IDX_EMOJI_CATEGORY" ON "emoji" ("category")`) + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "IDX_EMOJI_CATEGORY"`) + await queryRunner.query(`DROP INDEX "IDX_EMOJI_ROLE_IDS"`) + } +} diff --git a/packages/backend/migration/1723944246767-followedMessage.js b/packages/backend/migration/1723944246767-followedMessage.js new file mode 100644 index 0000000000..fc9ad1cb85 --- /dev/null +++ b/packages/backend/migration/1723944246767-followedMessage.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class FollowedMessage1723944246767 { + name = 'FollowedMessage1723944246767'; + + async up(queryRunner) { + await queryRunner.query('ALTER TABLE "user_profile" ADD "followedMessage" character varying(256)'); + } + + async down(queryRunner) { + await queryRunner.query('ALTER TABLE "user_profile" DROP COLUMN "followedMessage"'); + } +} diff --git a/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js b/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js new file mode 100644 index 0000000000..4ff520172b --- /dev/null +++ b/packages/backend/migration/1727318020265-enableStatsForFederatedInstances.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class EnableStatsForFederatedInstances1727318020265 { + name = 'EnableStatsForFederatedInstances1727318020265' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableStatsForFederatedInstances" boolean NOT NULL DEFAULT true`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableStatsForFederatedInstances"`); + } +} diff --git a/packages/backend/migration/1727491883993-user-score.js b/packages/backend/migration/1727491883993-user-score.js new file mode 100644 index 0000000000..7292d5363c --- /dev/null +++ b/packages/backend/migration/1727491883993-user-score.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UserScore1727491883993 { + name = 'UserScore1727491883993' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "score" integer NOT NULL DEFAULT '0'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "score"`); + } +} diff --git a/packages/backend/migration/1727512908322-meta-federation.js b/packages/backend/migration/1727512908322-meta-federation.js new file mode 100644 index 0000000000..52c24df4f7 --- /dev/null +++ b/packages/backend/migration/1727512908322-meta-federation.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class MetaFederation1727512908322 { + name = 'MetaFederation1727512908322' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "federation" character varying(128) NOT NULL DEFAULT 'all'`); + await queryRunner.query(`ALTER TABLE "meta" ADD "federationHosts" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "federationHosts"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "federation"`); + } +} diff --git a/packages/backend/migration/1728085812127-refine-abuse-user-report.js b/packages/backend/migration/1728085812127-refine-abuse-user-report.js new file mode 100644 index 0000000000..57cbfdcf6d --- /dev/null +++ b/packages/backend/migration/1728085812127-refine-abuse-user-report.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class RefineAbuseUserReport1728085812127 { + name = 'RefineAbuseUserReport1728085812127' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "moderationNote" character varying(8192) NOT NULL DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD "resolvedAs" character varying(128)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "resolvedAs"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP COLUMN "moderationNote"`); + } +} diff --git a/packages/backend/migration/1728550878802-testcaptcha.js b/packages/backend/migration/1728550878802-testcaptcha.js new file mode 100644 index 0000000000..d8d987c0c1 --- /dev/null +++ b/packages/backend/migration/1728550878802-testcaptcha.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Testcaptcha1728550878802 { + name = 'Testcaptcha1728550878802' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "enableTestcaptcha" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableTestcaptcha"`); + } +} diff --git a/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js b/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js new file mode 100644 index 0000000000..36e698d120 --- /dev/null +++ b/packages/backend/migration/1728634286056-prohibitedWordsForNameOfUser.js @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class ProhibitedWordsForNameOfUser1728634286056 { + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "prohibitedWordsForNameOfUser" character varying(1024) array NOT NULL DEFAULT '{}'`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "prohibitedWordsForNameOfUser"`); + } +} diff --git a/packages/backend/migration/1729333924409-signinRequiredForShowContents.js b/packages/backend/migration/1729333924409-signinRequiredForShowContents.js new file mode 100644 index 0000000000..5d4d1fcce2 --- /dev/null +++ b/packages/backend/migration/1729333924409-signinRequiredForShowContents.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SigninRequiredForShowContents1729333924409 { + name = 'SigninRequiredForShowContents1729333924409' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "requireSigninToViewContents" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "requireSigninToViewContents"`); + } +} diff --git a/packages/backend/migration/1729486255072-makeNotesHiddenBefore.js b/packages/backend/migration/1729486255072-makeNotesHiddenBefore.js new file mode 100644 index 0000000000..5fe4886b04 --- /dev/null +++ b/packages/backend/migration/1729486255072-makeNotesHiddenBefore.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class MakeNotesHiddenBefore1729486255072 { + name = 'MakeNotesHiddenBefore1729486255072' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "makeNotesFollowersOnlyBefore" integer`); + await queryRunner.query(`ALTER TABLE "user" ADD "makeNotesHiddenBefore" integer`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "makeNotesHiddenBefore"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "makeNotesFollowersOnlyBefore"`); + } +} diff --git a/packages/backend/migration/1739006797620-GoogleAnalytics.js b/packages/backend/migration/1739006797620-GoogleAnalytics.js new file mode 100644 index 0000000000..5871bf098a --- /dev/null +++ b/packages/backend/migration/1739006797620-GoogleAnalytics.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class GoogleAnalytics1739006797620 { + name = 'GoogleAnalytics1739006797620' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "googleAnalyticsMeasurementId" character varying(64)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "googleAnalyticsMeasurementId"`); + } +} diff --git a/packages/backend/migration/1740121393164-system-accounts.js b/packages/backend/migration/1740121393164-system-accounts.js new file mode 100644 index 0000000000..9490cb2b64 --- /dev/null +++ b/packages/backend/migration/1740121393164-system-accounts.js @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts1740121393164 { + name = 'SystemAccounts1740121393164' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "system_account" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "type" character varying(256) NOT NULL, CONSTRAINT "PK_edb56f4aaf9ddd50ee556da97ba" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_41a3c87a37aea616ee459369e1" ON "system_account" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_c362033aee0ea51011386a5a7e" ON "system_account" ("type") `); + await queryRunner.query(`ALTER TABLE "system_account" ADD CONSTRAINT "FK_41a3c87a37aea616ee459369e12" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + + const instanceActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'instance.actor'`); + if (instanceActor.length > 0) { + await queryRunner.query(`INSERT INTO "system_account" ("id", "userId", "type") VALUES ('${instanceActor[0].id}', '${instanceActor[0].id}', 'actor')`); + } + + const relayActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'relay.actor'`); + if (relayActor.length > 0) { + await queryRunner.query(`INSERT INTO "system_account" ("id", "userId", "type") VALUES ('${relayActor[0].id}', '${relayActor[0].id}', 'relay')`); + } + + const meta = await queryRunner.query(`SELECT "proxyAccountId" FROM "meta" ORDER BY "id" DESC LIMIT 1`); + if (!meta && meta.length >= 1 && meta[0].proxyAccountId) { + await queryRunner.query(`INSERT INTO "system_account" ("id", "userId", "type") VALUES ('${meta[0].proxyAccountId}', '${meta[0].proxyAccountId}', 'proxy')`); + } + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "system_account" DROP CONSTRAINT "FK_41a3c87a37aea616ee459369e12"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c362033aee0ea51011386a5a7e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_41a3c87a37aea616ee459369e1"`); + await queryRunner.query(`DROP TABLE "system_account"`); + } +} diff --git a/packages/backend/migration/1740129169650-system-accounts-2.js b/packages/backend/migration/1740129169650-system-accounts-2.js new file mode 100644 index 0000000000..c03f0337ab --- /dev/null +++ b/packages/backend/migration/1740129169650-system-accounts-2.js @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts21740129169650 { + name = 'SystemAccounts21740129169650' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP CONSTRAINT "FK_ab1bc0c1e209daa77b8e8d212ad"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "proxyAccountId"`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "proxyAccountId" character varying(32)`); + const proxyAccountId = await queryRunner.query(`SELECT "userId" FROM "system_account" WHERE "type" = 'proxy' ORDER BY "id" DESC LIMIT 1`); + if (proxyAccountId && proxyAccountId.length >= 1) { + await queryRunner.query(`UPDATE "meta" SET "proxyAccountId" = '${proxyAccountId[0].userId}'`); + } + await queryRunner.query(`ALTER TABLE "meta" ADD CONSTRAINT "FK_ab1bc0c1e209daa77b8e8d212ad" FOREIGN KEY ("proxyAccountId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/migration/1740133121105-system-accounts-3.js b/packages/backend/migration/1740133121105-system-accounts-3.js new file mode 100644 index 0000000000..a1f8c996f5 --- /dev/null +++ b/packages/backend/migration/1740133121105-system-accounts-3.js @@ -0,0 +1,23 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts31740133121105 { + name = 'SystemAccounts31740133121105' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" ADD "rootUserId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "meta" ADD CONSTRAINT "FK_c80e4079d632f95eac06a9d28cc" FOREIGN KEY ("rootUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + + const users = await queryRunner.query(`SELECT "id" FROM "user" WHERE "isRoot" = true LIMIT 1`); + if (users.length > 0) { + await queryRunner.query(`UPDATE "meta" SET "rootUserId" = $1`, [users[0].id]); + } + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP CONSTRAINT "FK_c80e4079d632f95eac06a9d28cc"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "rootUserId"`); + } +} diff --git a/packages/backend/migration/1740993126937-system-accounts-4.js b/packages/backend/migration/1740993126937-system-accounts-4.js new file mode 100644 index 0000000000..83654aca80 --- /dev/null +++ b/packages/backend/migration/1740993126937-system-accounts-4.js @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts41740993126937 { + name = 'SystemAccounts41740993126937' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isRoot"`); + } + + async down(queryRunner) { + // down 実行時は isRoot = true のユーザーが存在しなくなるため手動で対応する必要あり + await queryRunner.query(`ALTER TABLE "user" ADD "isRoot" boolean NOT NULL DEFAULT false`); + } +} diff --git a/packages/backend/migration/1741279404074-system-accounts-fixup.js b/packages/backend/migration/1741279404074-system-accounts-fixup.js new file mode 100644 index 0000000000..31cab7f5ae --- /dev/null +++ b/packages/backend/migration/1741279404074-system-accounts-fixup.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SystemAccounts1741279404074 { + name = 'SystemAccounts1741279404074' + + async up(queryRunner) { + const instanceActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'instance.actor' AND "host" IS NULL AND "id" NOT IN (SELECT "userId" FROM "system_account" WHERE "type" = 'actor')`); + if (instanceActor.length > 0) { + console.warn('instance.actor was incorrect, updating...'); + await queryRunner.query(`UPDATE "system_account" SET "id" = '${instanceActor[0].id}', "userId" = '${instanceActor[0].id}' WHERE "type" = 'actor'`); + } + + const relayActor = await queryRunner.query(`SELECT "id" FROM "user" WHERE "username" = 'relay.actor' AND "host" IS NULL AND "id" NOT IN (SELECT "userId" FROM "system_account" WHERE "type" = 'relay')`); + if (relayActor.length > 0) { + console.warn('relay.actor was incorrect, updating...'); + await queryRunner.query(`UPDATE "system_account" SET "id" = '${relayActor[0].id}', "userId" = '${relayActor[0].id}' WHERE "type" = 'relay'`); + } + } + + async down(queryRunner) { + // fixup migration, no down migration + } +} diff --git a/packages/backend/migration/1741424411879-user-featured-fixup.js b/packages/backend/migration/1741424411879-user-featured-fixup.js new file mode 100644 index 0000000000..5643a328f0 --- /dev/null +++ b/packages/backend/migration/1741424411879-user-featured-fixup.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class UserFeaturedFixup1741424411879 { + name = 'UserFeaturedFixup1741424411879' + + async up(queryRunner) { + await queryRunner.query(`CREATE OR REPLACE FUNCTION pg_temp.extract_ap_id(text) RETURNS text AS $$ + SELECT + CASE + WHEN $1 ~ '^https?://' THEN $1 + WHEN $1 LIKE '{%' THEN COALESCE(jsonb_extract_path_text($1::jsonb, 'id'), null) + ELSE null + END; + $$ LANGUAGE sql IMMUTABLE;`); + + // "host" is NOT NULL is not needed but just in case add it to prevent overwriting irreplaceable data + await queryRunner.query(`UPDATE "user" SET "featured" = pg_temp.extract_ap_id("featured") WHERE "host" IS NOT NULL`); + } + + async down(queryRunner) { + // fixup migration, no down migration + } +} diff --git a/packages/backend/migration/1742203321812-chat.js b/packages/backend/migration/1742203321812-chat.js new file mode 100644 index 0000000000..3d8f7276b5 --- /dev/null +++ b/packages/backend/migration/1742203321812-chat.js @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat1742203321812 { + name = 'Chat1742203321812' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_room" ("id" character varying(32) NOT NULL, "name" character varying(256) NOT NULL, "ownerId" character varying(32) NOT NULL, CONSTRAINT "PK_8aa3a52cf74c96469f0ef9fbe3e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_f0d8ad64243fa2ca2800da0dfd" ON "chat_room" ("ownerId") `); + await queryRunner.query(`CREATE TABLE "chat_message" ("id" character varying(32) NOT NULL, "fromUserId" character varying(32) NOT NULL, "toUserId" character varying(32), "toRoomId" character varying(32), "text" character varying(4096), "uri" character varying(512), "reads" character varying(32) array NOT NULL DEFAULT '{}', "fileId" character varying(32), "reactions" character varying(1024) array NOT NULL DEFAULT '{}', CONSTRAINT "PK_3cc0d85193aade457d3077dd06b" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_79a26e7a4d9afa5e4fc05f134e" ON "chat_message" ("fromUserId") `); + await queryRunner.query(`CREATE INDEX "IDX_25e097b51d7622c249452c6f75" ON "chat_message" ("toUserId") `); + await queryRunner.query(`CREATE INDEX "IDX_f006b8a76efd1abf9f221c175c" ON "chat_message" ("toRoomId") `); + await queryRunner.query(`CREATE TABLE "chat_room_membership" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_2bd59c741e571b283c048beb69a" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_d99c5279460fb77ef58c596ce5" ON "chat_room_membership" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_c25143ebab714e930aeca1c0e8" ON "chat_room_membership" ("roomId") `); + await queryRunner.query(`ALTER TABLE "chat_room" ADD CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6" FOREIGN KEY ("ownerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed" FOREIGN KEY ("fromUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_25e097b51d7622c249452c6f757" FOREIGN KEY ("toUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce" FOREIGN KEY ("toRoomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_message" ADD CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_d99c5279460fb77ef58c596ce51" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_c25143ebab714e930aeca1c0e8d"`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP CONSTRAINT "FK_d99c5279460fb77ef58c596ce51"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_fd0f9a4879430239715ad4f8e2a"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_f006b8a76efd1abf9f221c175ce"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_25e097b51d7622c249452c6f757"`); + await queryRunner.query(`ALTER TABLE "chat_message" DROP CONSTRAINT "FK_79a26e7a4d9afa5e4fc05f134ed"`); + await queryRunner.query(`ALTER TABLE "chat_room" DROP CONSTRAINT "FK_f0d8ad64243fa2ca2800da0dfd6"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c25143ebab714e930aeca1c0e8"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d99c5279460fb77ef58c596ce5"`); + await queryRunner.query(`DROP TABLE "chat_room_membership"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f006b8a76efd1abf9f221c175c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_25e097b51d7622c249452c6f75"`); + await queryRunner.query(`DROP INDEX "public"."IDX_79a26e7a4d9afa5e4fc05f134e"`); + await queryRunner.query(`DROP TABLE "chat_message"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f0d8ad64243fa2ca2800da0dfd"`); + await queryRunner.query(`DROP TABLE "chat_room"`); + } +} diff --git a/packages/backend/migration/1742608337548-chat-2.js b/packages/backend/migration/1742608337548-chat-2.js new file mode 100644 index 0000000000..9f74a263d6 --- /dev/null +++ b/packages/backend/migration/1742608337548-chat-2.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat21742608337548 { + name = 'Chat21742608337548' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "chatScope" character varying(128) NOT NULL DEFAULT 'mutual'`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_185b6b5afa707b5d36d1ce3144" ON "chat_room_membership" ("userId", "roomId") `); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_185b6b5afa707b5d36d1ce3144"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "chatScope"`); + } +} diff --git a/packages/backend/migration/1742617546147-chat-3.js b/packages/backend/migration/1742617546147-chat-3.js new file mode 100644 index 0000000000..116b9a738b --- /dev/null +++ b/packages/backend/migration/1742617546147-chat-3.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat31742617546147 { + name = 'Chat31742617546147' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_approval" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "otherId" character varying(32) NOT NULL, CONSTRAINT "PK_fbbb95d60acf5c85388345b5f5d" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_530257863e1381a7f2f1d3282f" ON "chat_approval" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_b1d46037f23d170da5c05fdf75" ON "chat_approval" ("otherId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_12c4768a2f706fc267f2078903" ON "chat_approval" ("userId", "otherId") `); + await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_530257863e1381a7f2f1d3282fe" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_approval" ADD CONSTRAINT "FK_b1d46037f23d170da5c05fdf755" FOREIGN KEY ("otherId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_b1d46037f23d170da5c05fdf755"`); + await queryRunner.query(`ALTER TABLE "chat_approval" DROP CONSTRAINT "FK_530257863e1381a7f2f1d3282fe"`); + await queryRunner.query(`DROP INDEX "public"."IDX_12c4768a2f706fc267f2078903"`); + await queryRunner.query(`DROP INDEX "public"."IDX_b1d46037f23d170da5c05fdf75"`); + await queryRunner.query(`DROP INDEX "public"."IDX_530257863e1381a7f2f1d3282f"`); + await queryRunner.query(`DROP TABLE "chat_approval"`); + } +} diff --git a/packages/backend/migration/1742707840715-chat-4.js b/packages/backend/migration/1742707840715-chat-4.js new file mode 100644 index 0000000000..953a53d880 --- /dev/null +++ b/packages/backend/migration/1742707840715-chat-4.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat41742707840715 { + name = 'Chat41742707840715' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_room_invitation" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_9d489521a312dd28225672de2dc" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_8552bb38e7ed038c5bdd398a38" ON "chat_room_invitation" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_5f265075b215fc390a57523b12" ON "chat_room_invitation" ("roomId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_044f2a7962b8ee5bbfaa02e8a3" ON "chat_room_invitation" ("userId", "roomId") `); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_5f265075b215fc390a57523b12a" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_5f265075b215fc390a57523b12a"`); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384"`); + await queryRunner.query(`DROP INDEX "public"."IDX_044f2a7962b8ee5bbfaa02e8a3"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5f265075b215fc390a57523b12"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8552bb38e7ed038c5bdd398a38"`); + await queryRunner.query(`DROP TABLE "chat_room_invitation"`); + } +} diff --git a/packages/backend/migration/1742721896936-chat-5.js b/packages/backend/migration/1742721896936-chat-5.js new file mode 100644 index 0000000000..00db787cb7 --- /dev/null +++ b/packages/backend/migration/1742721896936-chat-5.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat51742721896936 { + name = 'Chat51742721896936' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD "ignored" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP COLUMN "ignored"`); + } +} diff --git a/packages/backend/migration/1742795111958-chat-6.js b/packages/backend/migration/1742795111958-chat-6.js new file mode 100644 index 0000000000..9a5dc3e32f --- /dev/null +++ b/packages/backend/migration/1742795111958-chat-6.js @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat61742795111958 { + name = 'Chat61742795111958' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room" ADD "description" character varying(2048) NOT NULL DEFAULT ''`); + await queryRunner.query(`ALTER TABLE "chat_room" ADD "isArchived" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "chat_room_membership" ADD "isMuted" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_membership" DROP COLUMN "isMuted"`); + await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "isArchived"`); + await queryRunner.query(`ALTER TABLE "chat_room" DROP COLUMN "description"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index aee3854ef3..bcaa6357ce 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -19,36 +19,38 @@ "watch": "node ./scripts/watch.mjs", "restart": "pnpm build && pnpm start", "dev": "node ./scripts/dev.mjs", - "typecheck": "tsc --noEmit && tsc -p test --noEmit", - "eslint": "eslint --quiet \"src/**/*.ts\"", + "typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit", + "eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"", "lint": "pnpm typecheck && pnpm eslint", "jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs", "jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs", + "jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs", "jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs", "jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs", "jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/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", "generate-api-json": "node ./scripts/generate_api_json.js" }, "optionalDependencies": { "@swc/core-android-arm64": "1.3.11", - "@swc/core-darwin-arm64": "1.3.56", - "@swc/core-darwin-x64": "1.3.56", + "@swc/core-darwin-arm64": "1.11.11", + "@swc/core-darwin-x64": "1.11.11", "@swc/core-freebsd-x64": "1.3.11", - "@swc/core-linux-arm-gnueabihf": "1.3.56", - "@swc/core-linux-arm64-gnu": "1.3.56", - "@swc/core-linux-arm64-musl": "1.3.56", - "@swc/core-linux-x64-gnu": "1.3.56", - "@swc/core-linux-x64-musl": "1.3.56", - "@swc/core-win32-arm64-msvc": "1.3.56", - "@swc/core-win32-ia32-msvc": "1.3.56", - "@swc/core-win32-x64-msvc": "1.3.56", - "@tensorflow/tfjs": "4.4.0", - "@tensorflow/tfjs-node": "4.4.0", - "bufferutil": "4.0.7", + "@swc/core-linux-arm-gnueabihf": "1.11.11", + "@swc/core-linux-arm64-gnu": "1.11.11", + "@swc/core-linux-arm64-musl": "1.11.11", + "@swc/core-linux-x64-gnu": "1.11.11", + "@swc/core-linux-x64-musl": "1.11.11", + "@swc/core-win32-arm64-msvc": "1.11.11", + "@swc/core-win32-ia32-msvc": "1.11.11", + "@swc/core-win32-x64-msvc": "1.11.11", + "@tensorflow/tfjs": "4.22.0", + "@tensorflow/tfjs-node": "4.22.0", + "bufferutil": "4.0.9", "slacc-android-arm-eabi": "0.0.10", "slacc-android-arm64": "0.0.10", "slacc-darwin-arm64": "0.0.10", @@ -62,37 +64,34 @@ "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.3" + "utf-8-validate": "6.0.5" }, "dependencies": { - "@aws-sdk/client-s3": "3.620.0", - "@aws-sdk/lib-storage": "3.620.0", - "@bull-board/api": "5.21.1", - "@bull-board/fastify": "5.21.1", - "@bull-board/ui": "5.21.1", - "@discordapp/twemoji": "15.0.3", - "@fastify/accepts": "4.3.0", - "@fastify/cookie": "9.3.1", - "@fastify/cors": "9.0.1", - "@fastify/express": "3.0.0", - "@fastify/http-proxy": "9.5.0", - "@fastify/multipart": "8.3.0", - "@fastify/static": "7.0.4", - "@fastify/view": "9.1.0", + "@aws-sdk/client-s3": "3.772.0", + "@aws-sdk/lib-storage": "3.772.0", + "@discordapp/twemoji": "15.1.0", + "@fastify/accepts": "5.0.2", + "@fastify/cookie": "11.0.2", + "@fastify/cors": "10.1.0", + "@fastify/express": "4.0.2", + "@fastify/http-proxy": "10.0.2", + "@fastify/multipart": "9.0.3", + "@fastify/static": "8.1.1", + "@fastify/view": "10.0.2", "@misskey-dev/sharp-read-bmp": "1.2.0", - "@misskey-dev/summaly": "5.1.0", - "@napi-rs/canvas": "^0.1.53", - "@nestjs/common": "10.3.10", - "@nestjs/core": "10.3.10", - "@nestjs/testing": "10.3.10", + "@misskey-dev/summaly": "5.2.0", + "@napi-rs/canvas": "0.1.68", + "@nestjs/common": "11.0.12", + "@nestjs/core": "11.0.12", + "@nestjs/testing": "11.0.12", "@peertube/http-signature": "1.7.0", - "@sentry/node": "8.20.0", - "@sentry/profiling-node": "8.20.0", - "@simplewebauthn/server": "10.0.1", - "@sinonjs/fake-timers": "11.2.2", + "@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.3.12", - "@swc/core": "1.6.6", + "@swc/cli": "0.6.0", + "@swc/core": "1.11.11", "@twemoji/parser": "15.1.1", "accepts": "1.3.8", "ajv": "8.17.1", @@ -101,10 +100,10 @@ "bcryptjs": "2.4.3", "blurhash": "2.0.5", "body-parser": "1.20.3", - "bullmq": "5.10.4", + "bullmq": "5.44.1", "cacheable-lookup": "7.0.0", "cbor": "9.0.2", - "chalk": "5.3.0", + "chalk": "5.4.1", "chalk-template": "1.1.0", "chokidar": "3.6.0", "cli-highlight": "2.1.11", @@ -112,132 +111,130 @@ "content-disposition": "0.5.4", "date-fns": "2.30.0", "deep-email-validator": "0.1.21", - "fastify": "4.28.1", - "fastify-raw-body": "4.3.0", + "fastify": "5.2.1", + "fastify-raw-body": "5.0.0", "feed": "4.2.2", - "file-type": "19.3.0", + "file-type": "19.6.0", "fluent-ffmpeg": "2.1.3", - "form-data": "4.0.0", - "got": "14.4.2", - "happy-dom": "15.6.1", + "form-data": "4.0.2", + "got": "14.4.6", + "happy-dom": "16.8.1", "hpagent": "1.2.0", "htmlescape": "1.1.1", "http-link-header": "1.1.3", - "ioredis": "5.4.1", - "ip-cidr": "4.0.1", + "ioredis": "5.6.0", + "ip-cidr": "4.0.2", "ipaddr.js": "2.2.0", - "is-svg": "5.0.1", + "is-svg": "5.1.0", "js-yaml": "4.1.0", - "jsdom": "24.1.1", + "jsdom": "26.0.0", "json5": "2.2.3", - "jsonld": "8.3.2", + "jsonld": "8.3.3", "jsrsasign": "11.1.0", - "juice": "11.0.0", - "meilisearch": "0.41.0", + "juice": "11.0.1", + "meilisearch": "0.49.0", "mfm-js": "0.24.0", "microformats-parser": "2.0.2", "mime-types": "2.1.35", "misskey-js": "workspace:*", "misskey-reversi": "workspace:*", "ms": "3.0.0-canary.1", - "nanoid": "5.0.7", + "nanoid": "5.1.5", "nested-property": "4.0.0", "node-fetch": "3.3.2", - "nodemailer": "6.9.14", - "nsfwjs": "2.4.2", - "oauth": "0.10.0", + "nodemailer": "6.10.0", + "nsfwjs": "4.2.0", + "oauth": "0.10.2", "oauth2orize": "1.12.0", "oauth2orize-pkce": "0.1.2", "os-utils": "0.0.14", - "otpauth": "9.3.1", - "parse5": "7.1.2", - "pg": "8.12.0", + "otpauth": "9.3.6", + "parse5": "7.2.1", + "pg": "8.14.1", "pkce-challenge": "4.1.0", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.3", - "punycode": "2.3.1", - "qrcode": "1.5.3", + "qrcode": "1.5.4", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.21.3", + "re2": "1.21.4", "redis-lock": "0.1.4", "reflect-metadata": "0.2.2", "rename": "1.0.4", "rss-parser": "3.13.0", - "rxjs": "7.8.1", - "sanitize-html": "2.13.0", - "secure-json-parse": "2.7.0", - "sharp": "0.33.4", + "rxjs": "7.8.2", + "sanitize-html": "2.15.0", + "secure-json-parse": "3.0.2", + "sharp": "0.33.5", "slacc": "0.0.10", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", - "systeminformation": "5.22.11", + "systeminformation": "5.25.11", "tinycolor2": "1.6.0", "tmp": "0.2.3", - "tsc-alias": "1.8.10", + "tsc-alias": "1.8.11", "tsconfig-paths": "4.2.0", - "typeorm": "0.3.20", - "typescript": "5.5.4", - "ulid": "2.3.0", + "typeorm": "0.3.21", + "typescript": "5.8.2", + "ulid": "2.4.0", "vary": "1.1.2", "web-push": "3.6.7", - "ws": "8.18.0", + "ws": "8.18.1", "xev": "3.0.2" }, "devDependencies": { "@jest/globals": "29.7.0", - "@nestjs/platform-express": "10.3.10", - "@simplewebauthn/types": "10.0.0", - "@swc/jest": "0.2.36", + "@nestjs/platform-express": "10.4.15", + "@simplewebauthn/types": "12.0.0", + "@swc/jest": "0.2.37", "@types/accepts": "1.3.7", - "@types/archiver": "6.0.2", + "@types/archiver": "6.0.3", "@types/bcryptjs": "2.4.6", "@types/body-parser": "1.19.5", - "@types/color-convert": "2.0.3", + "@types/color-convert": "2.0.4", "@types/content-disposition": "0.5.8", - "@types/fluent-ffmpeg": "2.1.24", + "@types/fluent-ffmpeg": "2.1.27", "@types/htmlescape": "1.1.3", "@types/http-link-header": "1.0.7", - "@types/jest": "29.5.12", + "@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.14", + "@types/jsrsasign": "10.5.15", "@types/mime-types": "2.1.4", "@types/ms": "0.7.34", - "@types/node": "20.14.12", - "@types/nodemailer": "6.4.15", - "@types/oauth": "0.9.5", + "@types/node": "22.13.10", + "@types/nodemailer": "6.4.17", + "@types/oauth": "0.9.6", "@types/oauth2orize": "1.11.5", "@types/oauth2orize-pkce": "0.1.2", - "@types/pg": "8.11.6", + "@types/pg": "8.11.11", "@types/pug": "2.0.10", - "@types/punycode": "2.1.4", "@types/qrcode": "1.5.5", "@types/random-seed": "0.3.5", "@types/ratelimiter": "3.4.6", "@types/rename": "1.0.7", - "@types/sanitize-html": "2.11.0", + "@types/sanitize-html": "2.13.0", "@types/semver": "7.5.8", "@types/simple-oauth2": "5.0.7", "@types/sinonjs__fake-timers": "8.1.5", "@types/tinycolor2": "1.4.6", "@types/tmp": "0.2.6", "@types/vary": "1.1.3", - "@types/web-push": "3.6.3", - "@types/ws": "8.5.11", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", - "aws-sdk-client-mock": "4.0.1", + "@types/web-push": "3.6.4", + "@types/ws": "8.18.0", + "@typescript-eslint/eslint-plugin": "8.27.0", + "@typescript-eslint/parser": "8.27.0", + "aws-sdk-client-mock": "4.1.0", "cross-env": "7.0.3", - "eslint-plugin-import": "2.29.1", - "execa": "9.3.0", + "eslint-plugin-import": "2.31.0", + "execa": "8.0.1", "fkill": "9.0.0", "jest": "29.7.0", "jest-mock": "29.7.0", - "nodemon": "3.1.4", - "pid-port": "1.0.0", + "nodemon": "3.1.9", + "pid-port": "1.0.2", "simple-oauth2": "5.1.0" } } diff --git a/packages/backend/scripts/check_connect.js b/packages/backend/scripts/check_connect.js index ba25fd416c..96c4549ccb 100644 --- a/packages/backend/scripts/check_connect.js +++ b/packages/backend/scripts/check_connect.js @@ -5,11 +5,52 @@ import Redis from 'ioredis'; import { loadConfig } from '../built/config.js'; +import { createPostgresDataSource } from '../built/postgres.js'; const config = loadConfig(); -const redis = new Redis(config.redis); -redis.on('connect', () => redis.disconnect()); -redis.on('error', (e) => { - throw e; -}); +async function connectToPostgres() { + const source = createPostgresDataSource(config); + await source.initialize(); + await source.destroy(); +} + +async function connectToRedis(redisOptions) { + return await new Promise(async (resolve, reject) => { + const 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); + } + }); +} + +// If not all of these are defined, the default one gets reused. +// so we use a Set to only try connecting once to each **uniq** redis. +const promises = Array + .from(new Set([ + config.redis, + config.redisForPubsub, + config.redisForJobQueue, + config.redisForTimelines, + config.redisForReactions, + ])) + .map(connectToRedis) + .concat([ + connectToPostgres() + ]); + +await Promise.all(promises); diff --git a/packages/backend/src/GlobalModule.ts b/packages/backend/src/GlobalModule.ts index 2ecc1f4742..5544eeeddd 100644 --- a/packages/backend/src/GlobalModule.ts +++ b/packages/backend/src/GlobalModule.ts @@ -7,11 +7,13 @@ import { Global, Inject, Module } from '@nestjs/common'; import * as Redis from 'ioredis'; import { DataSource } from 'typeorm'; import { MeiliSearch } from 'meilisearch'; +import { MiMeta } from '@/models/Meta.js'; import { DI } from './di-symbols.js'; import { Config, loadConfig } from './config.js'; import { createPostgresDataSource } from './postgres.js'; import { RepositoryModule } from './models/RepositoryModule.js'; import { allSettled } from './misc/promise-tracker.js'; +import { GlobalEvents } from './core/GlobalEventService.js'; import type { Provider, OnApplicationShutdown } from '@nestjs/common'; const $config: Provider = { @@ -31,7 +33,11 @@ const $db: Provider = { const $meilisearch: Provider = { provide: DI.meilisearch, useFactory: (config: Config) => { - if (config.meilisearch) { + if (config.fulltextSearch?.provider === 'meilisearch') { + if (!config.meilisearch) { + throw new Error('MeiliSearch is enabled but no configuration is provided'); + } + return new MeiliSearch({ host: `${config.meilisearch.ssl ? 'https' : 'http'}://${config.meilisearch.host}:${config.meilisearch.port}`, apiKey: config.meilisearch.apiKey, @@ -86,11 +92,68 @@ const $redisForReactions: Provider = { inject: [DI.config], }; +const $meta: Provider = { + provide: DI.meta, + useFactory: async (db: DataSource, redisForSub: Redis.Redis) => { + const meta = await db.transaction(async transactionalEntityManager => { + // 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する + const metas = await transactionalEntityManager.find(MiMeta, { + order: { + id: 'DESC', + }, + }); + + const meta = metas[0]; + + if (meta) { + return meta; + } else { + // metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う + const saved = await transactionalEntityManager + .upsert( + MiMeta, + { + id: 'x', + }, + ['id'], + ) + .then((x) => transactionalEntityManager.findOneByOrFail(MiMeta, x.identifiers[0])); + + return saved; + } + }); + + async function 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 'metaUpdated': { + for (const key in body.after) { + (meta as any)[key] = (body.after as any)[key]; + } + meta.rootUser = null; // joinなカラムは通常取ってこないので + break; + } + default: + break; + } + } + } + + redisForSub.on('message', onMessage); + + return meta; + }, + inject: [DI.db, DI.redisForSub], +}; + @Global() @Module({ imports: [RepositoryModule], - providers: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions], - exports: [$config, $db, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, RepositoryModule], + providers: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions], + exports: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, RepositoryModule], }) export class GlobalModule implements OnApplicationShutdown { constructor( diff --git a/packages/backend/src/boot/entry.ts b/packages/backend/src/boot/entry.ts index 25375c3015..da585ad68d 100644 --- a/packages/backend/src/boot/entry.ts +++ b/packages/backend/src/boot/entry.ts @@ -68,16 +68,22 @@ process.on('exit', code => { //#endregion -if (cluster.isPrimary || envOption.disableClustering) { - await masterMain(); - +if (!envOption.disableClustering) { if (cluster.isPrimary) { + logger.info(`Start main process... pid: ${process.pid}`); + await masterMain(); ev.mount(); + } else if (cluster.isWorker) { + logger.info(`Start worker process... pid: ${process.pid}`); + await workerMain(); + } else { + throw new Error('Unknown process type'); } -} - -if (cluster.isWorker || envOption.disableClustering) { - await workerMain(); +} else { + // 非clusterの場合はMasterのみが起動するため、Workerの処理は行わない(cluster.isWorker === trueの状態でこのブロックに来ることはない) + logger.info(`Start main process... pid: ${process.pid}`); + await masterMain(); + ev.mount(); } readyRef.value = true; diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts index 4bc5c799cf..d1fb3858db 100644 --- a/packages/backend/src/boot/master.ts +++ b/packages/backend/src/boot/master.ts @@ -91,25 +91,36 @@ export async function masterMain() { }); } - if (envOption.disableClustering) { + bootLogger.info( + `mode: [disableClustering: ${envOption.disableClustering}, onlyServer: ${envOption.onlyServer}, onlyQueue: ${envOption.onlyQueue}]`, + ); + + if (!envOption.disableClustering) { + // clusterモジュール有効時 + if (envOption.onlyServer) { - await server(); + // onlyServer かつ enableCluster な場合、メインプロセスはforkのみに制限する(listenしない)。 + // ワーカープロセス側でlistenすると、メインプロセスでポートへの着信を受け入れてワーカープロセスへの分配を行う動作をする。 + // そのため、メインプロセスでも直接listenするとポートの競合が発生して起動に失敗してしまう。 + // see: https://nodejs.org/api/cluster.html#cluster } else if (envOption.onlyQueue) { await jobQueue(); - } else { - await server(); - await jobQueue(); - } - } else { - if (envOption.onlyServer) { - // nop - } else if (envOption.onlyQueue) { - // nop } else { await server(); } await spawnWorkers(config.clusterLimit); + } else { + // clusterモジュール無効時 + + if (envOption.onlyServer) { + await server(); + } else if (envOption.onlyQueue) { + await jobQueue(); + } else { + await server(); + await jobQueue(); + } } if (envOption.onlyQueue) { diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 97ba79c574..32ea700748 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -50,6 +50,9 @@ type Source = { redisForJobQueue?: RedisOptionsSource; redisForTimelines?: RedisOptionsSource; redisForReactions?: RedisOptionsSource; + fulltextSearch?: { + provider?: FulltextSearchProvider; + }; meilisearch?: { host: string; port: string; @@ -63,11 +66,14 @@ type Source = { publishTarballInsteadOfProvideRepositoryUrl?: boolean; + setupPassword?: string; + proxy?: string; proxySmtp?: string; proxyBypassHosts?: string[]; allowedPrivateNetworks?: string[]; + disallowExternalApRedirect?: boolean; maxFileSize?: number; @@ -97,6 +103,13 @@ type Source = { perUserNotificationsMaxCount?: number; deactivateAntennaThreshold?: number; pidFile: string; + + logging?: { + sql?: { + disableQueryTruncation?: boolean, + enableQueryParamLogging?: boolean, + } + } }; export type Config = { @@ -122,6 +135,9 @@ export type Config = { user: string; pass: string; }[] | undefined; + fulltextSearch?: { + provider?: FulltextSearchProvider; + }; meilisearch: { host: string; port: string; @@ -134,6 +150,7 @@ export type Config = { proxySmtp: string | undefined; proxyBypassHosts: string[] | undefined; allowedPrivateNetworks: string[] | undefined; + disallowExternalApRedirect: boolean; maxFileSize: number; clusterLimit: number | undefined; id: string; @@ -149,9 +166,16 @@ export type Config = { inboxJobMaxAttempts: number | undefined; proxyRemoteFiles: boolean | undefined; signToActivityPubGet: boolean | undefined; + logging?: { + sql?: { + disableQueryTruncation?: boolean, + enableQueryParamLogging?: boolean, + } + } version: string; publishTarballInsteadOfProvideRepositoryUrl: boolean; + setupPassword: string | undefined; host: string; hostname: string; scheme: string; @@ -181,6 +205,8 @@ export type Config = { pidFile: string; }; +export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch'; + const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -232,6 +258,7 @@ export function loadConfig(): Config { return { version, publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl, + setupPassword: config.setupPassword, url: url.origin, port: config.port ?? parseInt(process.env.PORT ?? '', 10), socket: config.socket, @@ -248,6 +275,7 @@ export function loadConfig(): Config { db: { ...config.db, db: dbDb, user: dbUser, pass: dbPass }, dbReplications: config.dbReplications, dbSlaves: config.dbSlaves, + fulltextSearch: config.fulltextSearch, meilisearch: config.meilisearch, redis, redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis, @@ -261,6 +289,7 @@ export function loadConfig(): Config { proxySmtp: config.proxySmtp, proxyBypassHosts: config.proxyBypassHosts, allowedPrivateNetworks: config.allowedPrivateNetworks, + disallowExternalApRedirect: config.disallowExternalApRedirect ?? false, maxFileSize: config.maxFileSize ?? 262144000, clusterLimit: config.clusterLimit, outgoingAddress: config.outgoingAddress, @@ -289,6 +318,7 @@ export function loadConfig(): Config { perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 500, deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7), pidFile: config.pidFile, + logging: config.logging, }; } diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts index e3a61861f4..1ca0397206 100644 --- a/packages/backend/src/const.ts +++ b/packages/backend/src/const.ts @@ -26,6 +26,18 @@ export const DB_MAX_NOTE_TEXT_LENGTH = 8192; export const DB_MAX_IMAGE_COMMENT_LENGTH = 512; //#endregion +export const FILE_TYPE_IMAGE = [ + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/webp', + 'image/avif', + 'image/apng', + 'image/bmp', + 'image/tiff', + 'image/x-icon', +]; + // ブラウザで直接表示することを許可するファイルの種類のリスト // ここに含まれないものは application/octet-stream としてレスポンスされる // SVGはXSSを生むので許可しない diff --git a/packages/backend/src/core/AbuseReportNotificationService.ts b/packages/backend/src/core/AbuseReportNotificationService.ts index 7be5335885..9bca795479 100644 --- a/packages/backend/src/core/AbuseReportNotificationService.ts +++ b/packages/backend/src/core/AbuseReportNotificationService.ts @@ -14,30 +14,36 @@ import type { AbuseReportNotificationRecipientRepository, MiAbuseReportNotificationRecipient, MiAbuseUserReport, + MiMeta, MiUser, } from '@/models/_.js'; import { EmailService } from '@/core/EmailService.js'; -import { MetaService } from '@/core/MetaService.js'; import { RoleService } from '@/core/RoleService.js'; import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { IdService } from './IdService.js'; @Injectable() export class AbuseReportNotificationService implements OnApplicationShutdown { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.abuseReportNotificationRecipientRepository) private abuseReportNotificationRecipientRepository: AbuseReportNotificationRecipientRepository, + @Inject(DI.redisForSub) private redisForSub: Redis.Redis, + private idService: IdService, private roleService: RoleService, private systemWebhookService: SystemWebhookService, private emailService: EmailService, - private metaService: MetaService, private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, + private userEntityService: UserEntityService, ) { this.redisForSub.on('message', this.onMessage); } @@ -55,7 +61,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { return; } - const moderatorIds = await this.roleService.getModeratorIds(true, true); + const moderatorIds = await this.roleService.getModeratorIds({ + includeAdmins: true, + excludeExpire: true, + }); for (const moderatorId of moderatorIds) { for (const abuseReport of abuseReports) { @@ -93,10 +102,8 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .filter(x => x != null), ); - // 送信先の鮮度を保つため、毎回取得する - const meta = await this.metaService.fetch(true); recipientEMailAddresses.push( - ...(meta.email ? [meta.email] : []), + ...(this.meta.email ? [this.meta.email] : []), ); if (recipientEMailAddresses.length <= 0) { @@ -133,22 +140,42 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { return; } - const recipientWebhookIds = await this.fetchWebhookRecipients() - .then(it => it - .filter(it => it.isActive && it.systemWebhookId && it.method === 'webhook') - .map(it => it.systemWebhookId) - .filter(x => x != null)); - for (const webhookId of recipientWebhookIds) { - await Promise.all( - abuseReports.map(it => { - return this.systemWebhookService.enqueueSystemWebhook( - webhookId, - type, - it, - ); - }), - ); - } + const usersMap = await this.userEntityService.packMany( + [ + ...new Set([ + ...abuseReports.map(it => it.reporter ?? it.reporterId), + ...abuseReports.map(it => it.targetUser ?? it.targetUserId), + ...abuseReports.map(it => it.assignee ?? it.assigneeId), + ].filter(x => x != null)), + ], + null, + { schema: 'UserLite' }, + ).then(it => new Map(it.map(it => [it.id, it]))); + const convertedReports = abuseReports.map(it => { + return { + ...it, + reporter: usersMap.get(it.reporterId) ?? null, + targetUser: usersMap.get(it.targetUserId) ?? null, + assignee: it.assigneeId ? (usersMap.get(it.assigneeId) ?? null) : null, + }; + }); + + const inactiveRecipients = await this.fetchWebhookRecipients() + .then(it => it.filter(it => !it.isActive)); + const withoutWebhookIds = inactiveRecipients + .map(it => it.systemWebhookId) + .filter(x => x != null); + return Promise.all( + convertedReports.map(it => { + return this.systemWebhookService.enqueueSystemWebhook( + type, + it, + { + excludes: withoutWebhookIds, + }, + ); + }), + ); } /** @@ -261,8 +288,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .log(updater, 'createAbuseReportNotificationRecipient', { recipientId: id, recipient: created, - }) - .then(); + }); return created; } @@ -300,8 +326,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { recipientId: params.id, before: beforeEntity, after: afterEntity, - }) - .then(); + }); return afterEntity; } @@ -322,8 +347,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { .log(updater, 'deleteAbuseReportNotificationRecipient', { recipientId: id, recipient: entity, - }) - .then(); + }); } /** @@ -346,7 +370,10 @@ export class AbuseReportNotificationService implements OnApplicationShutdown { } // モデレータ権限の有無で通知先設定を振り分ける - const authorizedUserIds = await this.roleService.getModeratorIds(true, true); + const authorizedUserIds = await this.roleService.getModeratorIds({ + includeAdmins: true, + excludeExpire: true, + }); const authorizedUserRecipients = Array.of(); const unauthorizedUserRecipients = Array.of(); for (const recipient of userRecipients) { diff --git a/packages/backend/src/core/AbuseReportService.ts b/packages/backend/src/core/AbuseReportService.ts index 69c51509ba..846d2c8ebd 100644 --- a/packages/backend/src/core/AbuseReportService.ts +++ b/packages/backend/src/core/AbuseReportService.ts @@ -10,9 +10,9 @@ import { bindThis } from '@/decorators.js'; import type { AbuseUserReportsRepository, MiAbuseUserReport, MiUser, UsersRepository } from '@/models/_.js'; import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { QueueService } from '@/core/QueueService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; import { IdService } from './IdService.js'; @Injectable() @@ -20,12 +20,14 @@ export class AbuseReportService { constructor( @Inject(DI.abuseUserReportsRepository) private abuseUserReportsRepository: AbuseUserReportsRepository, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, + private idService: IdService, private abuseReportNotificationService: AbuseReportNotificationService, private queueService: QueueService, - private instanceActorService: InstanceActorService, + private systemAccountService: SystemAccountService, private apRendererService: ApRendererService, private moderationLogService: ModerationLogService, ) { @@ -77,16 +79,16 @@ export class AbuseReportService { * - SystemWebhook * * @param params 通報内容. もし複数件の通報に対応した時のために、あらかじめ複数件を処理できる前提で考える - * @param operator 通報を処理したユーザ + * @param moderator 通報を処理したユーザ * @see AbuseReportNotificationService.notify */ @bindThis public async resolve( params: { reportId: string; - forward: boolean; + resolvedAs: MiAbuseUserReport['resolvedAs']; }[], - operator: MiUser, + moderator: MiUser, ) { const paramsMap = new Map(params.map(it => [it.reportId, it])); const reports = await this.abuseUserReportsRepository.findBy({ @@ -99,30 +101,76 @@ export class AbuseReportService { await this.abuseUserReportsRepository.update(report.id, { resolved: true, - assigneeId: operator.id, - forwarded: ps.forward && report.targetUserHost !== null, + assigneeId: moderator.id, + resolvedAs: ps.resolvedAs, }); - if (ps.forward && report.targetUserHost != null) { - const actor = await this.instanceActorService.getInstanceActor(); - const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); - - // eslint-disable-next-line - const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment); - const contextAssignedFlag = this.apRendererService.addContext(flag); - this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false); - } - this.moderationLogService - .log(operator, 'resolveAbuseReport', { + .log(moderator, 'resolveAbuseReport', { reportId: report.id, report: report, - forwarded: ps.forward && report.targetUserHost !== null, - }) - .then(); + resolvedAs: ps.resolvedAs, + }); } return this.abuseUserReportsRepository.findBy({ id: In(reports.map(it => it.id)) }) .then(reports => this.abuseReportNotificationService.notifySystemWebhook(reports, 'abuseReportResolved')); } + + @bindThis + public async forward( + reportId: MiAbuseUserReport['id'], + moderator: MiUser, + ) { + const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId }); + + if (report.targetUserHost == null) { + throw new Error('The target user host is null.'); + } + + if (report.forwarded) { + throw new Error('The report has already been forwarded.'); + } + + await this.abuseUserReportsRepository.update(report.id, { + forwarded: true, + }); + + const actor = await this.systemAccountService.fetch('actor'); + const targetUser = await this.usersRepository.findOneByOrFail({ id: report.targetUserId }); + + const flag = this.apRendererService.renderFlag(actor, targetUser.uri!, report.comment); + const contextAssignedFlag = this.apRendererService.addContext(flag); + this.queueService.deliver(actor, contextAssignedFlag, targetUser.inbox, false); + + this.moderationLogService + .log(moderator, 'forwardAbuseReport', { + reportId: report.id, + report: report, + }); + } + + @bindThis + public async update( + reportId: MiAbuseUserReport['id'], + params: { + moderationNote?: MiAbuseUserReport['moderationNote']; + }, + moderator: MiUser, + ) { + const report = await this.abuseUserReportsRepository.findOneByOrFail({ id: reportId }); + + await this.abuseUserReportsRepository.update(report.id, { + moderationNote: params.moderationNote, + }); + + if (params.moderationNote != null && report.moderationNote !== params.moderationNote) { + this.moderationLogService.log(moderator, 'updateAbuseReportNote', { + reportId: report.id, + report: report, + before: report.moderationNote, + after: params.moderationNote, + }); + } + } } diff --git a/packages/backend/src/core/AccountMoveService.ts b/packages/backend/src/core/AccountMoveService.ts index b6b591d240..0fbb9bcd80 100644 --- a/packages/backend/src/core/AccountMoveService.ts +++ b/packages/backend/src/core/AccountMoveService.ts @@ -9,7 +9,7 @@ import { IsNull, In, MoreThan, Not } from 'typeorm'; import { bindThis } from '@/decorators.js'; import { DI } from '@/di-symbols.js'; import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js'; -import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; +import type { BlockingsRepository, FollowingsRepository, InstancesRepository, MiMeta, MutingsRepository, UserListMembershipsRepository, UsersRepository } from '@/models/_.js'; import type { RelationshipJobData, ThinUser } from '@/queue/types.js'; import { IdService } from '@/core/IdService.js'; @@ -20,15 +20,17 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; -import { MetaService } from '@/core/MetaService.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; @Injectable() export class AccountMoveService { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -53,13 +55,12 @@ export class AccountMoveService { private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, private globalEventService: GlobalEventService, - private proxyAccountService: ProxyAccountService, private perUserFollowingChart: PerUserFollowingChart, private federatedInstanceService: FederatedInstanceService, private instanceChart: InstanceChart, - private metaService: MetaService, private relayService: RelayService, private queueService: QueueService, + private systemAccountService: SystemAccountService, ) { } @@ -125,11 +126,11 @@ export class AccountMoveService { } // follow the new account - const proxy = await this.proxyAccountService.fetch(); + const proxy = await this.systemAccountService.fetch('proxy'); const followings = await this.followingsRepository.findBy({ followeeId: src.id, followerHost: IsNull(), // follower is local - followerId: proxy ? Not(proxy.id) : undefined, + followerId: Not(proxy.id), }); const followJobs = followings.map(following => ({ from: { id: following.followerId }, @@ -249,10 +250,8 @@ export class AccountMoveService { // Have the proxy account follow the new account in the same way as UserListService.push if (this.userEntityService.isRemoteUser(dst)) { - const proxy = await this.proxyAccountService.fetch(); - if (proxy) { - this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]); - } + const proxy = await this.systemAccountService.fetch('proxy'); + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: dst.id } }]); } } @@ -273,13 +272,15 @@ export class AccountMoveService { } // Update instance stats by decreasing remote followers count by the number of local followers who were following the old account. - if (this.userEntityService.isRemoteUser(oldAccount)) { - this.federatedInstanceService.fetch(oldAccount.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(oldAccount)) { + this.federatedInstanceService.fetchOrRegister(oldAccount.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', localFollowerIds.length); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } } // FIXME: expensive? diff --git a/packages/backend/src/core/AiService.ts b/packages/backend/src/core/AiService.ts index ad852fdd6e..248a9b8979 100644 --- a/packages/backend/src/core/AiService.ts +++ b/packages/backend/src/core/AiService.ts @@ -10,12 +10,13 @@ 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'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -const REQUIRED_CPU_FLAGS = ['avx2', 'fma']; +const REQUIRED_CPU_FLAGS_X64 = ['avx2', 'fma']; let isSupportedCpu: undefined | boolean = undefined; @Injectable() @@ -28,11 +29,10 @@ export class AiService { } @bindThis - public async detectSensitive(path: string): Promise { + public async detectSensitive(path: string): Promise { try { if (isSupportedCpu === undefined) { - const cpuFlags = await this.getCpuFlags(); - isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required)); + isSupportedCpu = await this.computeIsSupportedCpu(); } if (!isSupportedCpu) { @@ -41,6 +41,7 @@ export class AiService { } const tf = await import('@tensorflow/tfjs-node'); + tf.env().global.fetch = fetch; if (this.model == null) { await this.modelLoadMutex.runExclusive(async () => { @@ -64,6 +65,22 @@ export class AiService { } } + private async computeIsSupportedCpu(): Promise { + switch (process.arch) { + case 'x64': { + const cpuFlags = await this.getCpuFlags(); + return REQUIRED_CPU_FLAGS_X64.every(required => cpuFlags.includes(required)); + } + case 'arm64': { + // As far as I know, no required CPU flags for ARM64. + return true; + } + default: { + return false; + } + } + } + @bindThis private async getCpuFlags(): Promise { const str = await si.cpuFlags(); diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index 40a9db01c0..a9f6731977 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -72,7 +72,7 @@ export class AnnouncementService { updatedAt: null, title: values.title, text: values.text, - imageUrl: values.imageUrl, + imageUrl: values.imageUrl || null, icon: values.icon, display: values.display, forExistingUsers: values.forExistingUsers, @@ -209,6 +209,13 @@ export class AnnouncementService { return; } + const announcement = await this.announcementsRepository.findOneBy({ id: announcementId }); + if (announcement != null && announcement.userId === user.id) { + await this.announcementsRepository.update(announcementId, { + isActive: false, + }); + } + if ((await this.getUnreadAnnouncements(user)).length === 0) { this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements'); } diff --git a/packages/backend/src/core/CaptchaService.ts b/packages/backend/src/core/CaptchaService.ts index f6b7955cd2..ee081f29b0 100644 --- a/packages/backend/src/core/CaptchaService.ts +++ b/packages/backend/src/core/CaptchaService.ts @@ -6,6 +6,65 @@ import { Injectable } from '@nestjs/common'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiMeta } from '@/models/Meta.js'; +import Logger from '@/logger.js'; +import { LoggerService } from './LoggerService.js'; + +export const supportedCaptchaProviders = ['none', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile', 'testcaptcha'] as const; +export type CaptchaProvider = typeof supportedCaptchaProviders[number]; + +export const captchaErrorCodes = { + invalidProvider: Symbol('invalidProvider'), + invalidParameters: Symbol('invalidParameters'), + noResponseProvided: Symbol('noResponseProvided'), + requestFailed: Symbol('requestFailed'), + verificationFailed: Symbol('verificationFailed'), + unknown: Symbol('unknown'), +} as const; +export type CaptchaErrorCode = typeof captchaErrorCodes[keyof typeof captchaErrorCodes]; + +export type CaptchaSetting = { + provider: CaptchaProvider; + hcaptcha: { + siteKey: string | null; + secretKey: string | null; + } + mcaptcha: { + siteKey: string | null; + secretKey: string | null; + instanceUrl: string | null; + } + recaptcha: { + siteKey: string | null; + secretKey: string | null; + } + turnstile: { + siteKey: string | null; + secretKey: string | null; + } +}; + +export class CaptchaError extends Error { + public readonly code: CaptchaErrorCode; + public readonly cause?: unknown; + + constructor(code: CaptchaErrorCode, message: string, cause?: unknown) { + super(message); + this.code = code; + this.cause = cause; + this.name = 'CaptchaError'; + } +} + +export type CaptchaSaveSuccess = { + success: true; +}; +export type CaptchaSaveFailure = { + success: false; + error: CaptchaError; +}; +export type CaptchaSaveResult = CaptchaSaveSuccess | CaptchaSaveFailure; type CaptchaResponse = { success: boolean; @@ -14,9 +73,14 @@ type CaptchaResponse = { @Injectable() export class CaptchaService { + private readonly logger: Logger; + constructor( private httpRequestService: HttpRequestService, + private metaService: MetaService, + loggerService: LoggerService, ) { + this.logger = loggerService.getLogger('captcha'); } @bindThis @@ -44,32 +108,32 @@ export class CaptchaService { @bindThis public async verifyRecaptcha(secret: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('recaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'recaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(err => { - throw new Error(`recaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `recaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`recaptcha-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `recaptcha-failed: ${errorCodes}`); } } @bindThis public async verifyHcaptcha(secret: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('hcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'hcaptcha-failed: no response provided'); } const result = await this.getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(err => { - throw new Error(`hcaptcha-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `hcaptcha-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`hcaptcha-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `hcaptcha-failed: ${errorCodes}`); } } @@ -77,7 +141,7 @@ export class CaptchaService { @bindThis public async verifyMcaptcha(secret: string, siteKey: string, instanceHost: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('mcaptcha-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'mcaptcha-failed: no response provided'); } const endpointUrl = new URL('/api/v1/pow/siteverify', instanceHost); @@ -91,33 +155,251 @@ export class CaptchaService { headers: { 'Content-Type': 'application/json', }, - }); + }, { throwErrorWhenResponseNotOk: false }); if (result.status !== 200) { - throw new Error('mcaptcha-failed: mcaptcha didn\'t return 200 OK'); + throw new CaptchaError(captchaErrorCodes.requestFailed, 'mcaptcha-failed: mcaptcha didn\'t return 200 OK'); } const resp = (await result.json()) as { valid: boolean }; if (!resp.valid) { - throw new Error('mcaptcha-request-failed'); + throw new CaptchaError(captchaErrorCodes.verificationFailed, 'mcaptcha-request-failed'); } } @bindThis public async verifyTurnstile(secret: string, response: string | null | undefined): Promise { if (response == null) { - throw new Error('turnstile-failed: no response provided'); + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'turnstile-failed: no response provided'); } const result = await this.getCaptchaResponse('https://challenges.cloudflare.com/turnstile/v0/siteverify', secret, response).catch(err => { - throw new Error(`turnstile-request-failed: ${err}`); + throw new CaptchaError(captchaErrorCodes.requestFailed, `turnstile-request-failed: ${err}`); }); if (result.success !== true) { const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : ''; - throw new Error(`turnstile-failed: ${errorCodes}`); + throw new CaptchaError(captchaErrorCodes.verificationFailed, `turnstile-failed: ${errorCodes}`); } } + + @bindThis + public async verifyTestcaptcha(response: string | null | undefined): Promise { + if (response == null) { + throw new CaptchaError(captchaErrorCodes.noResponseProvided, 'testcaptcha-failed: no response provided'); + } + + const success = response === 'testcaptcha-passed'; + + if (!success) { + throw new CaptchaError(captchaErrorCodes.verificationFailed, 'testcaptcha-failed'); + } + } + + @bindThis + public async get(): Promise { + const meta = await this.metaService.fetch(true); + + let provider: CaptchaProvider; + switch (true) { + case meta.enableHcaptcha: { + provider = 'hcaptcha'; + break; + } + case meta.enableMcaptcha: { + provider = 'mcaptcha'; + break; + } + case meta.enableRecaptcha: { + provider = 'recaptcha'; + break; + } + case meta.enableTurnstile: { + provider = 'turnstile'; + break; + } + case meta.enableTestcaptcha: { + provider = 'testcaptcha'; + break; + } + default: { + provider = 'none'; + break; + } + } + + return { + provider: provider, + hcaptcha: { + siteKey: meta.hcaptchaSiteKey, + secretKey: meta.hcaptchaSecretKey, + }, + mcaptcha: { + siteKey: meta.mcaptchaSitekey, + secretKey: meta.mcaptchaSecretKey, + instanceUrl: meta.mcaptchaInstanceUrl, + }, + recaptcha: { + siteKey: meta.recaptchaSiteKey, + secretKey: meta.recaptchaSecretKey, + }, + turnstile: { + siteKey: meta.turnstileSiteKey, + secretKey: meta.turnstileSecretKey, + }, + }; + } + + /** + * captchaの設定を更新します. その際、フロントエンド側で受け取ったcaptchaからの戻り値を検証し、passした場合のみ設定を更新します. + * 実際の検証処理はサービス内で定義されている各captchaプロバイダの検証関数に委譲します. + * + * @param provider 検証するcaptchaのプロバイダ + * @param params + * @param params.sitekey hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsitekey. それ以外のプロバイダでは無視されます + * @param params.secret hcaptcha, recaptcha, turnstile, mcaptchaの場合に指定するsecret. それ以外のプロバイダでは無視されます + * @param params.instanceUrl mcaptchaの場合に指定するインスタンスのURL. それ以外のプロバイダでは無視されます + * @param params.captchaResult フロントエンド側で受け取ったcaptchaプロバイダからの戻り値. この値を使ってサーバサイドでの検証を行います + * @see verifyHcaptcha + * @see verifyMcaptcha + * @see verifyRecaptcha + * @see verifyTurnstile + * @see verifyTestcaptcha + */ + @bindThis + public async save( + provider: CaptchaProvider, + params?: { + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + captchaResult?: string | null; + }, + ): Promise { + if (!supportedCaptchaProviders.includes(provider)) { + return { + success: false, + error: new CaptchaError(captchaErrorCodes.invalidProvider, `Invalid captcha provider: ${provider}`), + }; + } + + const operation = { + none: async () => { + await this.updateMeta(provider, params); + }, + hcaptcha: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'hcaptcha-failed: secret and captureResult are required'); + } + + await this.verifyHcaptcha(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + mcaptcha: async () => { + if (!params?.secret || !params.sitekey || !params.instanceUrl || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'mcaptcha-failed: secret, sitekey, instanceUrl and captureResult are required'); + } + + await this.verifyMcaptcha(params.secret, params.sitekey, params.instanceUrl, params.captchaResult); + await this.updateMeta(provider, params); + }, + recaptcha: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'recaptcha-failed: secret and captureResult are required'); + } + + await this.verifyRecaptcha(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + turnstile: async () => { + if (!params?.secret || !params.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: secret and captureResult are required'); + } + + await this.verifyTurnstile(params.secret, params.captchaResult); + await this.updateMeta(provider, params); + }, + testcaptcha: async () => { + if (!params?.captchaResult) { + throw new CaptchaError(captchaErrorCodes.invalidParameters, 'turnstile-failed: captureResult are required'); + } + + await this.verifyTestcaptcha(params.captchaResult); + await this.updateMeta(provider, params); + }, + }[provider]; + + return operation() + .then(() => ({ success: true }) as CaptchaSaveSuccess) + .catch(err => { + this.logger.info(err); + const error = err instanceof CaptchaError + ? err + : new CaptchaError(captchaErrorCodes.unknown, `unknown error: ${err}`); + return { + success: false, + error, + }; + }); + } + + @bindThis + private async updateMeta( + provider: CaptchaProvider, + params?: { + sitekey?: string | null; + secret?: string | null; + instanceUrl?: string | null; + }, + ) { + const metaPartial: Partial< + Pick< + MiMeta, + ('enableHcaptcha' | 'hcaptchaSiteKey' | 'hcaptchaSecretKey') | + ('enableMcaptcha' | 'mcaptchaSitekey' | 'mcaptchaSecretKey' | 'mcaptchaInstanceUrl') | + ('enableRecaptcha' | 'recaptchaSiteKey' | 'recaptchaSecretKey') | + ('enableTurnstile' | 'turnstileSiteKey' | 'turnstileSecretKey') | + ('enableTestcaptcha') + > + > = { + enableHcaptcha: provider === 'hcaptcha', + enableMcaptcha: provider === 'mcaptcha', + enableRecaptcha: provider === 'recaptcha', + enableTurnstile: provider === 'turnstile', + enableTestcaptcha: provider === 'testcaptcha', + }; + + const updateIfNotUndefined = (key: K, value: typeof metaPartial[K]) => { + if (value !== undefined) { + metaPartial[key] = value; + } + }; + switch (provider) { + case 'hcaptcha': { + updateIfNotUndefined('hcaptchaSiteKey', params?.sitekey); + updateIfNotUndefined('hcaptchaSecretKey', params?.secret); + break; + } + case 'mcaptcha': { + updateIfNotUndefined('mcaptchaSitekey', params?.sitekey); + updateIfNotUndefined('mcaptchaSecretKey', params?.secret); + updateIfNotUndefined('mcaptchaInstanceUrl', params?.instanceUrl); + break; + } + case 'recaptcha': { + updateIfNotUndefined('recaptchaSiteKey', params?.sitekey); + updateIfNotUndefined('recaptchaSecretKey', params?.secret); + break; + } + case 'turnstile': { + updateIfNotUndefined('turnstileSiteKey', params?.sitekey); + updateIfNotUndefined('turnstileSecretKey', params?.secret); + break; + } + } + + await this.metaService.update(metaPartial); + } } diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts new file mode 100644 index 0000000000..df1c384b54 --- /dev/null +++ b/packages/backend/src/core/ChatService.ts @@ -0,0 +1,885 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { QueueService } from '@/core/QueueService.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; +import { bindThis } from '@/decorators.js'; +import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomInvitationsRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiChatRoomMembership, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js'; +import { UserBlockingService } from '@/core/UserBlockingService.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { UserFollowingService } from '@/core/UserFollowingService.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; +import { Packed } from '@/misc/json-schema.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { emojiRegex } from '@/misc/emoji-regex.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; + +const MAX_ROOM_MEMBERS = 30; +const MAX_REACTIONS_PER_MESSAGE = 100; +const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; + +// TODO: ReactionServiceのやつと共通化 +function normalizeEmojiString(x: string) { + const match = emojiRegex.exec(x); + if (match) { + // 合字を含む1つの絵文字 + const unicode = match[0]; + + // 異体字セレクタ除去 + return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, ''); + } else { + throw new Error('invalid emoji'); + } +} + +@Injectable() +export class ChatService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + @Inject(DI.chatApprovalsRepository) + private chatApprovalsRepository: ChatApprovalsRepository, + + @Inject(DI.chatRoomsRepository) + private chatRoomsRepository: ChatRoomsRepository, + + @Inject(DI.chatRoomInvitationsRepository) + private chatRoomInvitationsRepository: ChatRoomInvitationsRepository, + + @Inject(DI.chatRoomMembershipsRepository) + private chatRoomMembershipsRepository: ChatRoomMembershipsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userEntityService: UserEntityService, + private chatEntityService: ChatEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private pushNotificationService: PushNotificationService, + private notificationService: NotificationService, + private userBlockingService: UserBlockingService, + private queryService: QueryService, + private roleService: RoleService, + private userFollowingService: UserFollowingService, + private customEmojiService: CustomEmojiService, + private moderationLogService: ModerationLogService, + ) { + } + + @bindThis + public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }): Promise> { + if (fromUser.id === toUser.id) { + throw new Error('yourself'); + } + + const approvals = await this.chatApprovalsRepository.createQueryBuilder('approval') + .where(new Brackets(qb => { // 自分が相手を許可しているか + qb.where('approval.userId = :fromUserId', { fromUserId: fromUser.id }) + .andWhere('approval.otherId = :toUserId', { toUserId: toUser.id }); + })) + .orWhere(new Brackets(qb => { // 相手が自分を許可しているか + qb.where('approval.userId = :toUserId', { toUserId: toUser.id }) + .andWhere('approval.otherId = :fromUserId', { fromUserId: fromUser.id }); + })) + .take(2) + .getMany(); + + const otherApprovedMe = approvals.some(approval => approval.userId === toUser.id); + const iApprovedOther = approvals.some(approval => approval.userId === fromUser.id); + + if (!otherApprovedMe) { + if (toUser.chatScope === 'none') { + throw new Error('recipient is cannot chat (none)'); + } else if (toUser.chatScope === 'followers') { + const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id); + if (!isFollower) { + throw new Error('recipient is cannot chat (followers)'); + } + } else if (toUser.chatScope === 'following') { + const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id); + if (!isFollowing) { + throw new Error('recipient is cannot chat (following)'); + } + } else if (toUser.chatScope === 'mutual') { + const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id); + if (!isMutual) { + throw new Error('recipient is cannot chat (mutual)'); + } + } + } + + if (!(await this.roleService.getUserPolicies(toUser.id)).canChat) { + throw new Error('recipient is cannot chat (policy)'); + } + + const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id); + if (blocked) { + throw new Error('blocked'); + } + + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toUserId: toUser.id, + text: params.text ? params.text.trim() : null, + fileId: params.file ? params.file.id : null, + reads: [], + uri: params.uri ?? null, + } satisfies Partial; + + const inserted = await this.chatMessagesRepository.insertOne(message); + + // 相手を許可しておく + if (!iApprovedOther) { + this.chatApprovalsRepository.insertOne({ + id: this.idService.gen(), + userId: fromUser.id, + otherId: toUser.id, + }); + } + + const packedMessage = await this.chatEntityService.packMessageLiteFor1on1(inserted); + + if (this.userEntityService.isLocalUser(toUser)) { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.set(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`, message.id); + redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`); + redisPipeline.exec(); + } + + if (this.userEntityService.isLocalUser(fromUser)) { + // 自分のストリーム + this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage); + } + + if (this.userEntityService.isLocalUser(toUser)) { + // 相手のストリーム + this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage); + } + + // 3秒経っても既読にならなかったらイベント発行 + if (this.userEntityService.isLocalUser(toUser)) { + setTimeout(async () => { + const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`); + + if (marker == null) return; // 既読 + + const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser); + this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); + //this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); + }, 3000); + } + + return packedMessage; + } + + @bindThis + public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: { + text?: string | null; + file?: MiDriveFile | null; + uri?: string | null; + }): Promise> { + const memberships = await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id }); + + if (toRoom.ownerId !== fromUser.id && !memberships.some(member => member.userId === fromUser.id)) { + throw new Error('you are not a member of the room'); + } + + const membershipsOtherThanMe = memberships.filter(member => member.userId !== fromUser.id); + + const message = { + id: this.idService.gen(), + fromUserId: fromUser.id, + toRoomId: toRoom.id, + text: params.text ? params.text.trim() : null, + fileId: params.file ? params.file.id : null, + reads: [], + uri: params.uri ?? null, + } satisfies Partial; + + const inserted = await this.chatMessagesRepository.insertOne(message); + + const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted); + + this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage); + + const redisPipeline = this.redisClient.pipeline(); + for (const membership of membershipsOtherThanMe) { + if (membership.isMuted) continue; + + redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id); + redisPipeline.sadd(`newChatMessagesExists:${membership.userId}`, `room:${toRoom.id}`); + } + redisPipeline.exec(); + + // 3秒経っても既読にならなかったらイベント発行 + setTimeout(async () => { + const redisPipeline = this.redisClient.pipeline(); + for (const membership of membershipsOtherThanMe) { + redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`); + } + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + if (markers.every(marker => marker[1] == null)) return; + + const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted); + + for (let i = 0; i < membershipsOtherThanMe.length; i++) { + const marker = markers[i][1]; + if (marker == null) continue; + + this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); + //this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); + } + }, 3000); + + return packedMessage; + } + + @bindThis + public async readUserChatMessage( + readerId: MiUser['id'], + senderId: MiUser['id'], + ): Promise { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.del(`newUserChatMessageExists:${readerId}:${senderId}`); + redisPipeline.srem(`newChatMessagesExists:${readerId}`, `user:${senderId}`); + await redisPipeline.exec(); + } + + @bindThis + public async readRoomChatMessage( + readerId: MiUser['id'], + roomId: MiChatRoom['id'], + ): Promise { + const redisPipeline = this.redisClient.pipeline(); + redisPipeline.del(`newRoomChatMessageExists:${readerId}:${roomId}`); + redisPipeline.srem(`newChatMessagesExists:${readerId}`, `room:${roomId}`); + await redisPipeline.exec(); + } + + @bindThis + public findMessageById(messageId: MiChatMessage['id']) { + return this.chatMessagesRepository.findOneBy({ id: messageId }); + } + + @bindThis + public findMyMessageById(userId: MiUser['id'], messageId: MiChatMessage['id']) { + return this.chatMessagesRepository.findOneBy({ id: messageId, fromUserId: userId }); + } + + @bindThis + public async hasPermissionToViewRoomTimeline(meId: MiUser['id'], room: MiChatRoom) { + if (await this.isRoomMember(room, meId)) { + return true; + } else { + const iAmModerator = await this.roleService.isModerator({ id: meId }); + if (iAmModerator) { + return true; + } + + return false; + } + } + + @bindThis + public async deleteMessage(message: MiChatMessage) { + await this.chatMessagesRepository.delete(message.id); + + if (message.toUserId) { + const [fromUser, toUser] = await Promise.all([ + this.usersRepository.findOneByOrFail({ id: message.fromUserId }), + this.usersRepository.findOneByOrFail({ id: message.toUserId }), + ]); + + if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId, 'deleted', message.id); + if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatUserStream(message.toUserId, message.fromUserId, 'deleted', message.id); + + if (this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) { + //const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser)); + //this.queueService.deliver(fromUser, activity, toUser.inbox); + } + } else if (message.toRoomId) { + this.globalEventService.publishChatRoomStream(message.toRoomId, 'deleted', message.id); + } + } + + @bindThis + public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId) + .andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .andWhere('message.toUserId = :otherId'); + })) + .orWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :otherId') + .andWhere('message.toUserId = :meId'); + })); + })) + .setParameter('meId', meId) + .setParameter('otherId', otherId); + + const messages = await query.take(limit).getMany(); + + return messages; + } + + @bindThis + public async roomTimeline(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId) + .andWhere('message.toRoomId = :roomId', { roomId }) + .leftJoinAndSelect('message.file', 'file') + .leftJoinAndSelect('message.fromUser', 'fromUser'); + + const messages = await query.take(limit).getMany(); + + return messages; + } + + @bindThis + public async userHistory(meId: MiUser['id'], limit: number): Promise { + const history: MiChatMessage[] = []; + + const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: meId }); + + for (let i = 0; i < limit; i++) { + const found = history.map(m => (m.fromUserId === meId) ? m.toUserId! : m.fromUserId!); + + const query = this.chatMessagesRepository.createQueryBuilder('message') + .orderBy('message.id', 'DESC') + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId', { meId: meId }) + .orWhere('message.toUserId = :meId', { meId: meId }); + })) + .andWhere('message.toRoomId IS NULL') + .andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`) + .andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`); + + if (found.length > 0) { + query.andWhere('message.fromUserId NOT IN (:...found)', { found: found }); + query.andWhere('message.toUserId NOT IN (:...found)', { found: found }); + } + + query.setParameters(mutingQuery.getParameters()); + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return history; + } + + @bindThis + public async roomHistory(meId: MiUser['id'], limit: number): Promise { + // TODO: 一回のクエリにまとめられるかも + const [memberRoomIds, ownedRoomIds] = await Promise.all([ + this.chatRoomMembershipsRepository.findBy({ + userId: meId, + }).then(xs => xs.map(x => x.roomId)), + this.chatRoomsRepository.findBy({ + ownerId: meId, + }).then(xs => xs.map(x => x.id)), + ]); + + const roomIds = memberRoomIds.concat(ownedRoomIds); + + if (memberRoomIds.length === 0 && ownedRoomIds.length === 0) { + return []; + } + + const history: MiChatMessage[] = []; + + for (let i = 0; i < limit; i++) { + const found = history.map(m => m.toRoomId!); + + const query = this.chatMessagesRepository.createQueryBuilder('message') + .orderBy('message.id', 'DESC') + .where('message.toRoomId IN (:...roomIds)', { roomIds }); + + if (found.length > 0) { + query.andWhere('message.toRoomId NOT IN (:...found)', { found: found }); + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return history; + } + + @bindThis + public async getUserReadStateMap(userId: MiUser['id'], otherIds: MiUser['id'][]) { + const readStateMap: Record = {}; + + const redisPipeline = this.redisClient.pipeline(); + + for (const otherId of otherIds) { + redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`); + } + + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + for (let i = 0; i < otherIds.length; i++) { + const marker = markers[i][1]; + readStateMap[otherIds[i]] = marker == null; + } + + return readStateMap; + } + + @bindThis + public async getRoomReadStateMap(userId: MiUser['id'], roomIds: MiChatRoom['id'][]) { + const readStateMap: Record = {}; + + const redisPipeline = this.redisClient.pipeline(); + + for (const roomId of roomIds) { + redisPipeline.get(`newRoomChatMessageExists:${userId}:${roomId}`); + } + + const markers = await redisPipeline.exec(); + if (markers == null) throw new Error('redis error'); + + for (let i = 0; i < roomIds.length; i++) { + const marker = markers[i][1]; + readStateMap[roomIds[i]] = marker == null; + } + + return readStateMap; + } + + @bindThis + public async hasUnreadMessages(userId: MiUser['id']) { + const card = await this.redisClient.scard(`newChatMessagesExists:${userId}`); + return card > 0; + } + + @bindThis + public async createRoom(owner: MiUser, params: Partial<{ + name: string; + description: string; + }>) { + const room = { + id: this.idService.gen(), + name: params.name, + description: params.description, + ownerId: owner.id, + } satisfies Partial; + + const created = await this.chatRoomsRepository.insertOne(room); + + return created; + } + + @bindThis + public async hasPermissionToDeleteRoom(meId: MiUser['id'], room: MiChatRoom) { + if (room.ownerId === meId) { + return true; + } + + const iAmModerator = await this.roleService.isModerator({ id: meId }); + if (iAmModerator) { + return true; + } + + return false; + } + + @bindThis + public async deleteRoom(room: MiChatRoom, deleter?: MiUser) { + await this.chatRoomsRepository.delete(room.id); + + if (deleter) { + const deleterIsModerator = await this.roleService.isModerator(deleter); + + if (deleterIsModerator) { + this.moderationLogService.log(deleter, 'deleteChatRoom', { + roomId: room.id, + room: room, + }); + } + } + } + + @bindThis + public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) { + return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId }); + } + + @bindThis + public async findRoomById(roomId: MiChatRoom['id']) { + return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] }); + } + + @bindThis + public async isRoomMember(room: MiChatRoom, userId: MiUser['id']) { + if (room.ownerId === userId) return true; + const membership = await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId }); + return membership != null; + } + + @bindThis + public async createRoomInvitation(inviterId: MiUser['id'], roomId: MiChatRoom['id'], inviteeId: MiUser['id']) { + if (inviterId === inviteeId) { + throw new Error('yourself'); + } + + const room = await this.chatRoomsRepository.findOneByOrFail({ id: roomId, ownerId: inviterId }); + + if (await this.isRoomMember(room, inviteeId)) { + throw new Error('already member'); + } + + const existingInvitation = await this.chatRoomInvitationsRepository.findOneBy({ roomId, userId: inviteeId }); + if (existingInvitation) { + throw new Error('already invited'); + } + + const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId }); + if (membershipsCount >= MAX_ROOM_MEMBERS) { + throw new Error('room is full'); + } + + // TODO: cehck block + + const invitation = { + id: this.idService.gen(), + roomId: room.id, + userId: inviteeId, + } satisfies Partial; + + const created = await this.chatRoomInvitationsRepository.insertOne(invitation); + + this.notificationService.createNotification(inviteeId, 'chatRoomInvitationReceived', { + invitationId: invitation.id, + }, inviterId); + + return created; + } + + @bindThis + public async getSentRoomInvitationsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId) + .andWhere('invitation.roomId = :roomId', { roomId }); + + const invitations = await query.take(limit).getMany(); + + return invitations; + } + + @bindThis + public async getOwnedRoomsWithPagination(ownerId: MiUser['id'], limit: number, sinceId?: MiChatRoom['id'] | null, untilId?: MiChatRoom['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomsRepository.createQueryBuilder('room'), sinceId, untilId) + .andWhere('room.ownerId = :ownerId', { ownerId }); + + const rooms = await query.take(limit).getMany(); + + return rooms; + } + + @bindThis + public async getReceivedRoomInvitationsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId) + .andWhere('invitation.userId = :userId', { userId }) + .andWhere('invitation.ignored = FALSE'); + + const invitations = await query.take(limit).getMany(); + + return invitations; + } + + @bindThis + public async joinToRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId }); + + const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId }); + if (membershipsCount >= MAX_ROOM_MEMBERS) { + throw new Error('room is full'); + } + + const membership = { + id: this.idService.gen(), + roomId: roomId, + userId: userId, + } satisfies Partial; + + // TODO: transaction + await this.chatRoomMembershipsRepository.insertOne(membership); + await this.chatRoomInvitationsRepository.delete(invitation.id); + } + + @bindThis + public async ignoreRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomInvitationsRepository.update(invitation.id, { ignored: true }); + } + + @bindThis + public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) { + const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomMembershipsRepository.delete(membership.id); + } + + @bindThis + public async muteRoom(userId: MiUser['id'], roomId: MiChatRoom['id'], mute: boolean) { + const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId }); + await this.chatRoomMembershipsRepository.update(membership.id, { isMuted: mute }); + } + + @bindThis + public async updateRoom(room: MiChatRoom, params: { + name?: string; + description?: string; + }): Promise { + return this.chatRoomsRepository.createQueryBuilder().update() + .set(params) + .where('id = :id', { id: room.id }) + .returning('*') + .execute() + .then((response) => { + return response.raw[0]; + }); + } + + @bindThis + public async getRoomMembershipsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId) + .andWhere('membership.roomId = :roomId', { roomId }); + + const memberships = await query.take(limit).getMany(); + + return memberships; + } + + @bindThis + public async searchMessages(meId: MiUser['id'], query: string, limit: number, params: { + userId?: MiUser['id'] | null; + roomId?: MiChatRoom['id'] | null; + }) { + const q = this.chatMessagesRepository.createQueryBuilder('message'); + + if (params.userId) { + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .andWhere('message.toUserId = :otherId'); + })) + .orWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :otherId') + .andWhere('message.toUserId = :meId'); + })); + })) + .setParameter('meId', meId) + .setParameter('otherId', params.userId); + } else if (params.roomId) { + q.where('message.toRoomId = :roomId', { roomId: params.roomId }); + } else { + const membershipsQuery = this.chatRoomMembershipsRepository.createQueryBuilder('membership') + .select('membership.roomId') + .where('membership.userId = :meId', { meId: meId }); + + const ownedRoomsQuery = this.chatRoomsRepository.createQueryBuilder('room') + .select('room.id') + .where('room.ownerId = :meId', { meId }); + + q.andWhere(new Brackets(qb => { + qb + .where('message.fromUserId = :meId') + .orWhere('message.toUserId = :meId') + .orWhere(`message.toRoomId IN (${membershipsQuery.getQuery()})`) + .orWhere(`message.toRoomId IN (${ownedRoomsQuery.getQuery()})`); + })); + + q.setParameters(membershipsQuery.getParameters()); + q.setParameters(ownedRoomsQuery.getParameters()); + } + + q.andWhere('LOWER(message.text) LIKE :q', { q: `%${ sqlLikeEscape(query.toLowerCase()) }%` }); + + q.leftJoinAndSelect('message.file', 'file'); + q.leftJoinAndSelect('message.fromUser', 'fromUser'); + q.leftJoinAndSelect('message.toUser', 'toUser'); + q.leftJoinAndSelect('message.toRoom', 'toRoom'); + q.leftJoinAndSelect('toRoom.owner', 'toRoomOwner'); + + const messages = await q.orderBy('message.id', 'DESC').take(limit).getMany(); + + return messages; + } + + @bindThis + public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) { + let reaction; + + const custom = reaction_.match(isCustomEmojiRegexp); + + if (custom == null) { + reaction = normalizeEmojiString(reaction_); + } else { + const name = custom[1]; + const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name); + + if (emoji == null) { + throw new Error('no such emoji'); + } else { + reaction = `:${name}:`; + } + } + + const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId }); + + if (message.fromUserId === userId) { + throw new Error('cannot react to own message'); + } + + if (message.toRoomId === null && message.toUserId !== userId) { + throw new Error('cannot react to others message'); + } + + if (message.reactions.length >= MAX_REACTIONS_PER_MESSAGE) { + throw new Error('too many reactions'); + } + + const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null; + + if (room) { + if (!await this.isRoomMember(room, userId)) { + throw new Error('cannot react to others message'); + } + } + + await this.chatMessagesRepository.createQueryBuilder().update() + .set({ + reactions: () => `array_append("reactions", '${userId}/${reaction}')`, + }) + .where('id = :id', { id: message.id }) + .execute(); + + if (room) { + this.globalEventService.publishChatRoomStream(room.id, 'react', { + messageId: message.id, + user: await this.userEntityService.pack(userId), + reaction, + }); + } else { + this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'react', { + messageId: message.id, + reaction, + }); + this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'react', { + messageId: message.id, + reaction, + }); + } + } + + @bindThis + public async unreact(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) { + let reaction; + + const custom = reaction_.match(isCustomEmojiRegexp); + + if (custom == null) { + reaction = normalizeEmojiString(reaction_); + } else { // 削除されたカスタム絵文字のリアクションを削除したいかもしれないので絵文字の存在チェックはする必要なし + const name = custom[1]; + reaction = `:${name}:`; + } + + // NOTE: 自分のリアクションを(あれば)削除するだけなので諸々の権限チェックは必要なし + + const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId }); + + const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null; + + await this.chatMessagesRepository.createQueryBuilder().update() + .set({ + reactions: () => `array_remove("reactions", '${userId}/${reaction}')`, + }) + .where('id = :id', { id: message.id }) + .execute(); + + // TODO: 実際に削除が行われたときのみイベントを発行する + + if (room) { + this.globalEventService.publishChatRoomStream(room.id, 'unreact', { + messageId: message.id, + user: await this.userEntityService.pack(userId), + reaction, + }); + } else { + this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'unreact', { + messageId: message.id, + reaction, + }); + this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'unreact', { + messageId: message.id, + reaction, + }); + } + } + + @bindThis + public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) { + const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId) + .andWhere('membership.userId = :userId', { userId }); + + const memberships = await query.take(limit).getMany(); + + return memberships; + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 3b3c35f976..d8617e343c 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -14,6 +14,7 @@ import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationSe 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 { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -23,7 +24,6 @@ import { AppLockService } from './AppLockService.js'; import { AchievementService } from './AchievementService.js'; import { AvatarDecorationService } from './AvatarDecorationService.js'; import { CaptchaService } from './CaptchaService.js'; -import { CreateSystemUserService } from './CreateSystemUserService.js'; import { CustomEmojiService } from './CustomEmojiService.js'; import { DeleteAccountService } from './DeleteAccountService.js'; import { DownloadService } from './DownloadService.js'; @@ -36,7 +36,7 @@ import { HashtagService } from './HashtagService.js'; import { HttpRequestService } from './HttpRequestService.js'; import { IdService } from './IdService.js'; import { ImageProcessingService } from './ImageProcessingService.js'; -import { InstanceActorService } from './InstanceActorService.js'; +import { SystemAccountService } from './SystemAccountService.js'; import { InternalStorageService } from './InternalStorageService.js'; import { MetaService } from './MetaService.js'; import { MfmService } from './MfmService.js'; @@ -44,7 +44,6 @@ import { ModerationLogService } from './ModerationLogService.js'; import { NoteCreateService } from './NoteCreateService.js'; import { NoteDeleteService } from './NoteDeleteService.js'; import { NotePiningService } from './NotePiningService.js'; -import { NoteReadService } from './NoteReadService.js'; import { NotificationService } from './NotificationService.js'; import { PollService } from './PollService.js'; import { PushNotificationService } from './PushNotificationService.js'; @@ -68,7 +67,6 @@ import { UserSuspendService } from './UserSuspendService.js'; import { UserAuthService } from './UserAuthService.js'; import { VideoProcessingService } from './VideoProcessingService.js'; import { UserWebhookService } from './UserWebhookService.js'; -import { ProxyAccountService } from './ProxyAccountService.js'; import { UtilityService } from './UtilityService.js'; import { FileInfoService } from './FileInfoService.js'; import { SearchService } from './SearchService.js'; @@ -76,6 +74,7 @@ import { ClipService } from './ClipService.js'; import { FeaturedService } from './FeaturedService.js'; import { FanoutTimelineService } from './FanoutTimelineService.js'; import { ChannelFollowingService } from './ChannelFollowingService.js'; +import { ChatService } from './ChatService.js'; import { RegistryApiService } from './RegistryApiService.js'; import { ReversiService } from './ReversiService.js'; @@ -101,6 +100,7 @@ import { AppEntityService } from './entities/AppEntityService.js'; import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js'; import { BlockingEntityService } from './entities/BlockingEntityService.js'; import { ChannelEntityService } from './entities/ChannelEntityService.js'; +import { ChatEntityService } from './entities/ChatEntityService.js'; import { ClipEntityService } from './entities/ClipEntityService.js'; import { DriveFileEntityService } from './entities/DriveFileEntityService.js'; import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js'; @@ -166,7 +166,6 @@ const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppL const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService }; const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService }; const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService }; -const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useExisting: CreateSystemUserService }; const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useExisting: CustomEmojiService }; const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useExisting: DeleteAccountService }; const $DownloadService: Provider = { provide: 'DownloadService', useExisting: DownloadService }; @@ -179,7 +178,6 @@ const $HashtagService: Provider = { provide: 'HashtagService', useExisting: Hash const $HttpRequestService: Provider = { provide: 'HttpRequestService', useExisting: HttpRequestService }; const $IdService: Provider = { provide: 'IdService', useExisting: IdService }; const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService }; -const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService }; const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService }; const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService }; const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; @@ -187,10 +185,9 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; -const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; const $PollService: Provider = { provide: 'PollService', useExisting: PollService }; -const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useExisting: ProxyAccountService }; +const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService }; const $PushNotificationService: Provider = { provide: 'PushNotificationService', useExisting: PushNotificationService }; const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService }; const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService }; @@ -217,12 +214,14 @@ const $SystemWebhookService: Provider = { provide: 'SystemWebhookService', useEx const $WebhookTestService: Provider = { provide: 'WebhookTestService', useExisting: WebhookTestService }; const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService }; const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService }; +const $FlashService: Provider = { provide: 'FlashService', useExisting: FlashService }; const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService }; const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService }; const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: FeaturedService }; const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService }; const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; +const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; @@ -249,6 +248,7 @@ const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService }; const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService }; const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService }; +const $ChatEntityService: Provider = { provide: 'ChatEntityService', useExisting: ChatEntityService }; const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService }; const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService }; const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService }; @@ -316,7 +316,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AchievementService, AvatarDecorationService, CaptchaService, - CreateSystemUserService, CustomEmojiService, DeleteAccountService, DownloadService, @@ -329,7 +328,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting HttpRequestService, IdService, ImageProcessingService, - InstanceActorService, InternalStorageService, MetaService, MfmService, @@ -337,10 +335,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, - NoteReadService, NotificationService, PollService, - ProxyAccountService, + SystemAccountService, PushNotificationService, QueryService, ReactionService, @@ -367,12 +364,14 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebhookTestService, UtilityService, FileInfoService, + FlashService, SearchService, ClipService, FeaturedService, FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, @@ -399,6 +398,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -462,7 +462,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AchievementService, $AvatarDecorationService, $CaptchaService, - $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, $DownloadService, @@ -475,7 +474,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $HttpRequestService, $IdService, $ImageProcessingService, - $InstanceActorService, $InternalStorageService, $MetaService, $MfmService, @@ -483,10 +481,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, - $NoteReadService, $NotificationService, $PollService, - $ProxyAccountService, + $SystemAccountService, $PushNotificationService, $QueryService, $ReactionService, @@ -513,12 +510,14 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $WebhookTestService, $UtilityService, $FileInfoService, + $FlashService, $SearchService, $ClipService, $FeaturedService, $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, @@ -545,6 +544,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, @@ -609,7 +609,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AchievementService, AvatarDecorationService, CaptchaService, - CreateSystemUserService, CustomEmojiService, DeleteAccountService, DownloadService, @@ -622,7 +621,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting HttpRequestService, IdService, ImageProcessingService, - InstanceActorService, InternalStorageService, MetaService, MfmService, @@ -630,10 +628,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, - NoteReadService, NotificationService, PollService, - ProxyAccountService, + SystemAccountService, PushNotificationService, QueryService, ReactionService, @@ -660,12 +657,14 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting WebhookTestService, UtilityService, FileInfoService, + FlashService, SearchService, ClipService, FeaturedService, FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChatService, RegistryApiService, ReversiService, @@ -691,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -754,7 +754,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AchievementService, $AvatarDecorationService, $CaptchaService, - $CreateSystemUserService, $CustomEmojiService, $DeleteAccountService, $DownloadService, @@ -767,7 +766,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $HttpRequestService, $IdService, $ImageProcessingService, - $InstanceActorService, $InternalStorageService, $MetaService, $MfmService, @@ -775,10 +773,9 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, - $NoteReadService, $NotificationService, $PollService, - $ProxyAccountService, + $SystemAccountService, $PushNotificationService, $QueryService, $ReactionService, @@ -811,6 +808,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChatService, $RegistryApiService, $ReversiService, @@ -836,6 +834,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts deleted file mode 100644 index 6c5b0f6a36..0000000000 --- a/packages/backend/src/core/CreateSystemUserService.ts +++ /dev/null @@ -1,86 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { randomUUID } from 'node:crypto'; -import { Inject, Injectable } from '@nestjs/common'; -import bcrypt from 'bcryptjs'; -import { IsNull, DataSource } from 'typeorm'; -import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; -import { MiUser } from '@/models/User.js'; -import { MiUserProfile } from '@/models/UserProfile.js'; -import { IdService } from '@/core/IdService.js'; -import { MiUserKeypair } from '@/models/UserKeypair.js'; -import { MiUsedUsername } from '@/models/UsedUsername.js'; -import { DI } from '@/di-symbols.js'; -import generateNativeUserToken from '@/misc/generate-native-user-token.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class CreateSystemUserService { - constructor( - @Inject(DI.db) - private db: DataSource, - - private idService: IdService, - ) { - } - - @bindThis - public async createSystemUser(username: string): Promise { - const password = randomUUID(); - - // Generate hash of password - const salt = await bcrypt.genSalt(8); - const hash = await bcrypt.hash(password, salt); - - // Generate secret - const secret = generateNativeUserToken(); - - const keyPair = await genRsaKeyPair(); - - let account!: MiUser; - - // Start transaction - await this.db.transaction(async transactionalEntityManager => { - const exist = await transactionalEntityManager.findOneBy(MiUser, { - usernameLower: username.toLowerCase(), - host: IsNull(), - }); - - if (exist) throw new Error('the user is already exists'); - - account = await transactionalEntityManager.insert(MiUser, { - id: this.idService.gen(), - username: username, - usernameLower: username.toLowerCase(), - host: null, - token: secret, - isRoot: false, - isLocked: true, - isExplorable: false, - isBot: true, - }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); - - await transactionalEntityManager.insert(MiUserKeypair, { - publicKey: keyPair.publicKey, - privateKey: keyPair.privateKey, - userId: account.id, - }); - - await transactionalEntityManager.insert(MiUserProfile, { - userId: account.id, - autoAcceptFollowed: false, - password: hash, - }); - - await transactionalEntityManager.insert(MiUsedUsername, { - createdAt: new Date(), - username: username.toLowerCase(), - }); - }); - - return account; - } -} diff --git a/packages/backend/src/core/CustomEmojiService.ts b/packages/backend/src/core/CustomEmojiService.ts index 5db3c5b980..da71a5de6f 100644 --- a/packages/backend/src/core/CustomEmojiService.ts +++ b/packages/backend/src/core/CustomEmojiService.ts @@ -4,24 +4,59 @@ */ import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In, IsNull } from 'typeorm'; import * as Redis from 'ioredis'; -import { DI } from '@/di-symbols.js'; -import { IdService } from '@/core/IdService.js'; +import { In, IsNull } from 'typeorm'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiEmoji } from '@/models/Emoji.js'; -import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; -import { UtilityService } from '@/core/UtilityService.js'; -import { query } from '@/misc/prelude/url.js'; -import type { Serialized } from '@/types.js'; +import { IdService } from '@/core/IdService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; +import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js'; +import type { MiEmoji } from '@/models/Emoji.js'; +import type { Serialized } from '@/types.js'; const parseEmojiStrRegexp = /^([-\w]+)(?:@([\w.-]+))?$/; +export const fetchEmojisHostTypes = [ + 'local', + 'remote', + 'all', +] as const; +export type FetchEmojisHostTypes = typeof fetchEmojisHostTypes[number]; +export const fetchEmojisSortKeys = [ + '+id', + '-id', + '+updatedAt', + '-updatedAt', + '+name', + '-name', + '+host', + '-host', + '+uri', + '-uri', + '+publicUrl', + '-publicUrl', + '+type', + '-type', + '+aliases', + '-aliases', + '+category', + '-category', + '+license', + '-license', + '+isSensitive', + '-isSensitive', + '+localOnly', + '-localOnly', + '+roleIdsThatCanBeUsedThisEmojiAsReaction', + '-roleIdsThatCanBeUsedThisEmojiAsReaction', +] as const; +export type FetchEmojisSortKeys = typeof fetchEmojisSortKeys[number]; + @Injectable() export class CustomEmojiService implements OnApplicationShutdown { private emojisCache: MemoryKVCache; @@ -30,10 +65,8 @@ export class CustomEmojiService implements OnApplicationShutdown { constructor( @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, - private utilityService: UtilityService, private idService: IdService, private emojiEntityService: EmojiEntityService, @@ -58,7 +91,9 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis public async add(data: { - driveFile: MiDriveFile; + originalUrl: string; + publicUrl: string; + fileType: string; name: string; category: string | null; aliases: string[]; @@ -75,9 +110,9 @@ export class CustomEmojiService implements OnApplicationShutdown { category: data.category, host: data.host, aliases: data.aliases, - originalUrl: data.driveFile.url, - publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url, - type: data.driveFile.webpublicType ?? data.driveFile.type, + originalUrl: data.originalUrl, + publicUrl: data.publicUrl, + type: data.fileType, license: data.license, isSensitive: data.isSensitive, localOnly: data.localOnly, @@ -103,19 +138,35 @@ export class CustomEmojiService implements OnApplicationShutdown { } @bindThis - public async update(id: MiEmoji['id'], data: { - driveFile?: MiDriveFile; - name?: string; + public async update(data: ( + { id: MiEmoji['id'], name?: string; } | { name: string; id?: MiEmoji['id'], } + ) & { + originalUrl?: string; + publicUrl?: string; + fileType?: string; category?: string | null; aliases?: string[]; license?: string | null; isSensitive?: boolean; localOnly?: boolean; roleIdsThatCanBeUsedThisEmojiAsReaction?: MiRole['id'][]; - }, moderator?: MiUser): Promise { - const emoji = await this.emojisRepository.findOneByOrFail({ id: id }); - const sameNameEmoji = await this.emojisRepository.findOneBy({ name: data.name, host: IsNull() }); - if (sameNameEmoji != null && sameNameEmoji.id !== id) throw new Error('name already exists'); + }, moderator?: MiUser): Promise< + null + | 'NO_SUCH_EMOJI' + | 'SAME_NAME_EMOJI_EXISTS' + > { + const emoji = data.id + ? await this.getEmojiById(data.id) + : await this.getEmojiByName(data.name!); + if (emoji === null) return 'NO_SUCH_EMOJI'; + const id = emoji.id; + + // IDと絵文字名が両方指定されている場合は絵文字名の変更を行うため重複チェックが必要 + const doNameUpdate = data.id && data.name && (data.name !== emoji.name); + if (doNameUpdate) { + const isDuplicate = await this.checkDuplicate(data.name!); + if (isDuplicate) return 'SAME_NAME_EMOJI_EXISTS'; + } await this.emojisRepository.update(emoji.id, { updatedAt: new Date(), @@ -125,9 +176,9 @@ export class CustomEmojiService implements OnApplicationShutdown { license: data.license, isSensitive: data.isSensitive, localOnly: data.localOnly, - originalUrl: data.driveFile != null ? data.driveFile.url : undefined, - publicUrl: data.driveFile != null ? (data.driveFile.webpublicUrl ?? data.driveFile.url) : undefined, - type: data.driveFile != null ? (data.driveFile.webpublicType ?? data.driveFile.type) : undefined, + originalUrl: data.originalUrl, + publicUrl: data.publicUrl, + type: data.fileType, roleIdsThatCanBeUsedThisEmojiAsReaction: data.roleIdsThatCanBeUsedThisEmojiAsReaction ?? undefined, }); @@ -135,7 +186,7 @@ export class CustomEmojiService implements OnApplicationShutdown { const packed = await this.emojiEntityService.packDetailed(emoji.id); - if (emoji.name === data.name) { + if (!doNameUpdate) { this.globalEventService.publishBroadcastStream('emojiUpdated', { emojis: [packed], }); @@ -157,6 +208,7 @@ export class CustomEmojiService implements OnApplicationShutdown { after: updated, }); } + return null; } @bindThis @@ -293,7 +345,7 @@ export class CustomEmojiService implements OnApplicationShutdown { @bindThis private normalizeHost(src: string | undefined, noteUserHost: string | null): string | null { - // クエリに使うホスト + // クエリに使うホスト let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) : src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) : this.utilityService.isSelfHost(src) ? null // 自ホスト指定 @@ -399,6 +451,151 @@ export class CustomEmojiService implements OnApplicationShutdown { return this.emojisRepository.findOneBy({ name, host: IsNull() }); } + @bindThis + public async fetchEmojis( + params?: { + query?: { + updatedAtFrom?: string; + updatedAtTo?: string; + name?: string; + host?: string; + uri?: string; + publicUrl?: string; + type?: string; + aliases?: string; + category?: string; + license?: string; + isSensitive?: boolean; + localOnly?: boolean; + hostType?: FetchEmojisHostTypes; + roleIds?: string[]; + }, + sinceId?: string; + untilId?: string; + }, + opts?: { + limit?: number; + page?: number; + sortKeys?: FetchEmojisSortKeys[] + }, + ) { + function multipleWordsToQuery(words: string) { + return words.split(/\s/).filter(x => x.length > 0).map(x => `%${sqlLikeEscape(x)}%`); + } + + const builder = this.emojisRepository.createQueryBuilder('emoji'); + if (params?.query) { + const q = params.query; + if (q.updatedAtFrom) { + // noIndexScan + builder.andWhere('CAST(emoji.updatedAt AS DATE) >= :updateAtFrom', { updateAtFrom: q.updatedAtFrom }); + } + if (q.updatedAtTo) { + // noIndexScan + builder.andWhere('CAST(emoji.updatedAt AS DATE) <= :updateAtTo', { updateAtTo: q.updatedAtTo }); + } + if (q.name) { + builder.andWhere('emoji.name ~~ ANY(ARRAY[:...name])', { name: multipleWordsToQuery(q.name) }); + } + + switch (true) { + case q.hostType === 'local': { + builder.andWhere('emoji.host IS NULL'); + break; + } + case q.hostType === 'remote': { + if (q.host) { + // noIndexScan + builder.andWhere('emoji.host ~~ ANY(ARRAY[:...host])', { host: multipleWordsToQuery(q.host) }); + } else { + builder.andWhere('emoji.host IS NOT NULL'); + } + break; + } + } + + if (q.uri) { + // noIndexScan + builder.andWhere('emoji.uri ~~ ANY(ARRAY[:...uri])', { uri: multipleWordsToQuery(q.uri) }); + } + if (q.publicUrl) { + // noIndexScan + builder.andWhere('emoji.publicUrl ~~ ANY(ARRAY[:...publicUrl])', { publicUrl: multipleWordsToQuery(q.publicUrl) }); + } + if (q.type) { + // noIndexScan + builder.andWhere('emoji.type ~~ ANY(ARRAY[:...type])', { type: multipleWordsToQuery(q.type) }); + } + if (q.aliases) { + // noIndexScan + const subQueryBuilder = builder.subQuery() + .select('COUNT(0)', 'count') + .from( + sq2 => sq2 + .select('unnest(subEmoji.aliases)', 'alias') + .addSelect('subEmoji.id', 'id') + .from('emoji', 'subEmoji'), + 'aliasTable', + ) + .where('"emoji"."id" = "aliasTable"."id"') + .andWhere('"aliasTable"."alias" ~~ ANY(ARRAY[:...aliases])', { aliases: multipleWordsToQuery(q.aliases) }); + + builder.andWhere(`(${subQueryBuilder.getQuery()}) > 0`); + } + if (q.category) { + builder.andWhere('emoji.category ~~ ANY(ARRAY[:...category])', { category: multipleWordsToQuery(q.category) }); + } + if (q.license) { + // noIndexScan + builder.andWhere('emoji.license ~~ ANY(ARRAY[:...license])', { license: multipleWordsToQuery(q.license) }); + } + if (q.isSensitive != null) { + // noIndexScan + builder.andWhere('emoji.isSensitive = :isSensitive', { isSensitive: q.isSensitive }); + } + if (q.localOnly != null) { + // noIndexScan + builder.andWhere('emoji.localOnly = :localOnly', { localOnly: q.localOnly }); + } + if (q.roleIds && q.roleIds.length > 0) { + builder.andWhere('emoji.roleIdsThatCanBeUsedThisEmojiAsReaction && ARRAY[:...roleIds]::VARCHAR[]', { roleIds: q.roleIds }); + } + } + + if (params?.sinceId) { + builder.andWhere('emoji.id > :sinceId', { sinceId: params.sinceId }); + } + if (params?.untilId) { + builder.andWhere('emoji.id < :untilId', { untilId: params.untilId }); + } + + if (opts?.sortKeys && opts.sortKeys.length > 0) { + for (const sortKey of opts.sortKeys) { + const direction = sortKey.startsWith('-') ? 'DESC' : 'ASC'; + const key = sortKey.replace(/^[+-]/, ''); + builder.addOrderBy(`emoji.${key}`, direction); + } + } else { + builder.addOrderBy('emoji.id', 'DESC'); + } + + const limit = opts?.limit ?? 10; + if (opts?.page) { + builder.skip((opts.page - 1) * limit); + } + + builder.take(limit); + + const [emojis, count] = await builder.getManyAndCount(); + + return { + emojis, + count: (count > limit ? emojis.length : count), + allCount: count, + allPages: Math.ceil(count / limit), + }; + } + @bindThis public dispose(): void { this.emojisCache.dispose(); diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 7f1b8f3efb..483f14ce7f 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Not, IsNull } from 'typeorm'; -import type { FollowingsRepository, MiUser, UsersRepository } from '@/models/_.js'; +import type { FollowingsRepository, MiMeta, MiUser, UsersRepository } from '@/models/_.js'; import { QueueService } from '@/core/QueueService.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; @@ -13,10 +13,14 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; @Injectable() export class DeleteAccountService { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -28,6 +32,7 @@ export class DeleteAccountService { private queueService: QueueService, private globalEventService: GlobalEventService, private moderationLogService: ModerationLogService, + private systemAccountService: SystemAccountService, ) { } @@ -36,8 +41,13 @@ export class DeleteAccountService { id: string; host: string | null; }, moderator?: MiUser): Promise { + if (this.meta.rootUserId === user.id) throw new Error('cannot delete a root account'); + const _user = await this.usersRepository.findOneByOrFail({ id: user.id }); - if (_user.isRoot) throw new Error('cannot delete a root account'); + + if (user.host === null && _user.username.includes('.')) { + throw new Error('cannot delete a system account'); + } if (moderator != null) { this.moderationLogService.log(moderator, 'deleteAccount', { diff --git a/packages/backend/src/core/DownloadService.ts b/packages/backend/src/core/DownloadService.ts index 93f4a38246..a2b74d1ab2 100644 --- a/packages/backend/src/core/DownloadService.ts +++ b/packages/backend/src/core/DownloadService.ts @@ -6,7 +6,6 @@ import * as fs from 'node:fs'; import * as stream from 'node:stream/promises'; import { Inject, Injectable } from '@nestjs/common'; -import ipaddr from 'ipaddr.js'; import chalk from 'chalk'; import got, * as Got from 'got'; import { parse } from 'content-disposition'; @@ -61,8 +60,8 @@ export class DownloadService { request: operationTimeout, // whole operation timeout }, agent: { - http: this.httpRequestService.httpAgent, - https: this.httpRequestService.httpsAgent, + http: this.httpRequestService.getAgentForHttp(urlObj, true), + https: this.httpRequestService.getAgentForHttps(urlObj, true), }, http2: false, // default retry: { @@ -70,13 +69,6 @@ export class DownloadService { }, enableUnixSockets: false, }).on('response', (res: Got.Response) => { - if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) { - if (this.isPrivateIp(res.ip)) { - this.logger.warn(`Blocked address: ${res.ip}`); - req.destroy(); - } - } - const contentLength = res.headers['content-length']; if (contentLength != null) { const size = Number(contentLength); @@ -139,18 +131,4 @@ export class DownloadService { cleanup(); } } - - @bindThis - private isPrivateIp(ip: string): boolean { - const parsedIp = ipaddr.parse(ip); - - for (const net of this.config.allowedPrivateNetworks ?? []) { - const cidr = ipaddr.parseCIDR(net); - if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) { - return false; - } - } - - return parsedIp.range() !== 'unicast'; - } } diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index 8aa04b4da7..1550fe3d3c 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -11,11 +11,10 @@ import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import { IsNull } from 'typeorm'; import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; import type { Config } from '@/config.js'; import Logger from '@/logger.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiDriveFile } from '@/models/DriveFile.js'; import { IdService } from '@/core/IdService.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; @@ -99,6 +98,9 @@ export class DriveService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -115,7 +117,6 @@ export class DriveService { private userEntityService: UserEntityService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, - private metaService: MetaService, private downloadService: DownloadService, private internalStorageService: InternalStorageService, private s3Service: S3Service, @@ -149,9 +150,7 @@ export class DriveService { // thunbnail, webpublic を必要なら生成 const alts = await this.generateAlts(path, type, !file.uri); - const meta = await this.metaService.fetch(); - - if (meta.useObjectStorage) { + if (this.meta.useObjectStorage) { //#region ObjectStorage params let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']); @@ -170,11 +169,12 @@ export class DriveService { ext = ''; } - const baseUrl = meta.objectStorageBaseUrl - ?? `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`; + const baseUrl = this.meta.objectStorageBaseUrl + ?? `${ this.meta.objectStorageUseSSL ? 'https' : 'http' }://${ this.meta.objectStorageEndpoint }${ this.meta.objectStoragePort ? `:${this.meta.objectStoragePort}` : '' }/${ this.meta.objectStorageBucket }`; // for original - const key = `${meta.objectStoragePrefix}/${randomUUID()}${ext}`; + const prefix = this.meta.objectStoragePrefix ? `${this.meta.objectStoragePrefix}/` : ''; + const key = `${prefix}${randomUUID()}${ext}`; const url = `${ baseUrl }/${ key }`; // for alts @@ -191,7 +191,7 @@ export class DriveService { ]; if (alts.webpublic) { - webpublicKey = `${meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`; + webpublicKey = `${prefix}webpublic-${randomUUID()}.${alts.webpublic.ext}`; webpublicUrl = `${ baseUrl }/${ webpublicKey }`; this.registerLogger.info(`uploading webpublic: ${webpublicKey}`); @@ -199,7 +199,7 @@ export class DriveService { } if (alts.thumbnail) { - thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; + thumbnailKey = `${prefix}thumbnail-${randomUUID()}.${alts.thumbnail.ext}`; thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`; this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`); @@ -376,10 +376,8 @@ export class DriveService { if (type === 'image/apng') type = 'image/png'; if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream'; - const meta = await this.metaService.fetch(); - const params = { - Bucket: meta.objectStorageBucket, + Bucket: this.meta.objectStorageBucket, Key: key, Body: stream, ContentType: type, @@ -392,9 +390,9 @@ export class DriveService { // 許可されているファイル形式でしか拡張子をつけない ext ? correctFilename(filename, ext) : filename, ); - if (meta.objectStorageSetPublicRead) params.ACL = 'public-read'; + if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read'; - await this.s3Service.upload(meta, params) + await this.s3Service.upload(this.meta, params) .then( result => { if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput @@ -460,32 +458,31 @@ export class DriveService { ext = null, }: AddFileArgs): Promise { let skipNsfwCheck = false; - const instance = await this.metaService.fetch(); const userRoleNSFW = user && (await this.roleService.getUserPolicies(user.id)).alwaysMarkNsfw; if (user == null) { skipNsfwCheck = true; } else if (userRoleNSFW) { skipNsfwCheck = true; } - if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true; - if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true; - if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true; + if (this.meta.sensitiveMediaDetection === 'none') skipNsfwCheck = true; + if (user && this.meta.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true; + if (user && this.meta.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true; const info = await this.fileInfoService.getFileInfo(path, { skipSensitiveDetection: skipNsfwCheck, sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる - instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : - instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : - instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : - instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : + this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 : + this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 : + this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 : + this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 : 0.5, sensitiveThresholdForPorn: 0.75, - enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, + enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos, }); this.registerLogger.info(`${JSON.stringify(info)}`); // 現状 false positive が多すぎて実用に耐えない - //if (info.porn && instance.disallowUploadWhenPredictedAsPorn) { + //if (info.porn && this.meta.disallowUploadWhenPredictedAsPorn) { // throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.'); //} @@ -589,9 +586,9 @@ export class DriveService { sensitive ?? false : false; - if (user && this.utilityService.isMediaSilencedHost(instance.mediaSilencedHosts, user.host)) file.isSensitive = true; + if (user && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) file.isSensitive = true; if (info.sensitive && profile!.autoSensitive) file.isSensitive = true; - if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true; + if (info.sensitive && this.meta.setSensitiveFlagAutomatically) file.isSensitive = true; if (userRoleNSFW) file.isSensitive = true; if (url !== null) { @@ -652,7 +649,7 @@ export class DriveService { // ローカルユーザーのみ this.perUserDriveChart.update(file, true); } else { - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateDrive(file, true); } } @@ -798,7 +795,7 @@ export class DriveService { // ローカルユーザーのみ this.perUserDriveChart.update(file, false); } else { - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.updateDrive(file, false); } } @@ -820,14 +817,13 @@ export class DriveService { @bindThis public async deleteObjectStorageFile(key: string) { - const meta = await this.metaService.fetch(); try { const param = { - Bucket: meta.objectStorageBucket, + Bucket: this.meta.objectStorageBucket, Key: key, } as DeleteObjectCommandInput; - await this.s3Service.delete(meta, param); + await this.s3Service.delete(this.meta, param); } catch (err: any) { if (err.name === 'NoSuchKey') { this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error); diff --git a/packages/backend/src/core/EmailService.ts b/packages/backend/src/core/EmailService.ts index 37fa58bb65..45d7ea11e4 100644 --- a/packages/backend/src/core/EmailService.ts +++ b/packages/backend/src/core/EmailService.ts @@ -8,16 +8,14 @@ import * as nodemailer from 'nodemailer'; import juice from 'juice'; import { Inject, Injectable } from '@nestjs/common'; import { validate as validateEmail } from 'deep-email-validator'; -import { MetaService } from '@/core/MetaService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type Logger from '@/logger.js'; -import type { UserProfilesRepository } from '@/models/_.js'; +import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; -import { QueueService } from '@/core/QueueService.js'; @Injectable() export class EmailService { @@ -27,38 +25,37 @@ export class EmailService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private metaService: MetaService, private loggerService: LoggerService, private utilityService: UtilityService, private httpRequestService: HttpRequestService, - private queueService: QueueService, ) { this.logger = this.loggerService.getLogger('email'); } @bindThis public async sendEmail(to: string, subject: string, html: string, text: string) { - const meta = await this.metaService.fetch(true); - - if (!meta.enableEmail) return; + if (!this.meta.enableEmail) return; const iconUrl = `${this.config.url}/static-assets/mi-white.png`; const emailSettingUrl = `${this.config.url}/settings/email`; - const enableAuth = meta.smtpUser != null && meta.smtpUser !== ''; + const enableAuth = this.meta.smtpUser != null && this.meta.smtpUser !== ''; const transporter = nodemailer.createTransport({ - host: meta.smtpHost, - port: meta.smtpPort, - secure: meta.smtpSecure, + host: this.meta.smtpHost, + port: this.meta.smtpPort, + secure: this.meta.smtpSecure, ignoreTLS: !enableAuth, proxy: this.config.proxySmtp, auth: enableAuth ? { - user: meta.smtpUser, - pass: meta.smtpPass, + user: this.meta.smtpUser, + pass: this.meta.smtpPass, } : undefined, } as any); @@ -127,7 +124,7 @@ export class EmailService {
- +

${ subject }

@@ -148,7 +145,7 @@ export class EmailService { try { // TODO: htmlサニタイズ const info = await transporter.sendMail({ - from: meta.email!, + from: this.meta.email!, to: to, subject: subject, text: text, @@ -167,7 +164,12 @@ export class EmailService { available: boolean; reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp' | 'banned' | 'network' | 'blacklist'; }> { - const meta = await this.metaService.fetch(); + if (!this.utilityService.validateEmailFormat(emailAddress)) { + return { + available: false, + reason: 'format', + }; + } const exist = await this.userProfilesRepository.countBy({ emailVerified: true, @@ -186,11 +188,11 @@ export class EmailService { reason?: string | null, } = { valid: true, reason: null }; - if (meta.enableActiveEmailValidation) { - if (meta.enableVerifymailApi && meta.verifymailAuthKey != null) { - validated = await this.verifyMail(emailAddress, meta.verifymailAuthKey); - } else if (meta.enableTruemailApi && meta.truemailInstance && meta.truemailAuthKey != null) { - validated = await this.trueMail(meta.truemailInstance, emailAddress, meta.truemailAuthKey); + if (this.meta.enableActiveEmailValidation) { + if (this.meta.enableVerifymailApi && this.meta.verifymailAuthKey != null) { + validated = await this.verifyMail(emailAddress, this.meta.verifymailAuthKey); + } else if (this.meta.enableTruemailApi && this.meta.truemailInstance && this.meta.truemailAuthKey != null) { + validated = await this.trueMail(this.meta.truemailInstance, emailAddress, this.meta.truemailAuthKey); } else { validated = await validateEmail({ email: emailAddress, @@ -220,7 +222,7 @@ export class EmailService { } const emailDomain: string = emailAddress.split('@')[1]; - const isBanned = this.utilityService.isBlockedHost(meta.bannedEmailDomains, emailDomain); + const isBanned = this.utilityService.isBlockedHost(this.meta.bannedEmailDomains, emailDomain); if (isBanned) { return { @@ -317,6 +319,7 @@ export class EmailService { Accept: 'application/json', Authorization: truemailAuthKey, }, + isLocalAddressAllowed: true, }); const json = (await res.json()) as { diff --git a/packages/backend/src/core/FanoutTimelineService.ts b/packages/backend/src/core/FanoutTimelineService.ts index f6dabfadcd..24999bf4da 100644 --- a/packages/backend/src/core/FanoutTimelineService.ts +++ b/packages/backend/src/core/FanoutTimelineService.ts @@ -9,7 +9,7 @@ import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; -export type FanoutTimelineName = +export type FanoutTimelineName = ( // home timeline | `homeTimeline:${string}` | `homeTimelineWithFiles:${string}` // only notes with files are included @@ -37,6 +37,7 @@ export type FanoutTimelineName = // role timelines | `roleTimeline:${string}` // any notes are included +); @Injectable() export class FanoutTimelineService { diff --git a/packages/backend/src/core/FederatedInstanceService.ts b/packages/backend/src/core/FederatedInstanceService.ts index 7aeeb78178..73bbf03b26 100644 --- a/packages/backend/src/core/FederatedInstanceService.ts +++ b/packages/backend/src/core/FederatedInstanceService.ts @@ -47,7 +47,7 @@ export class FederatedInstanceService implements OnApplicationShutdown { } @bindThis - public async fetch(host: string): Promise { + public async fetchOrRegister(host: string): Promise { host = this.utilityService.toPuny(host); const cached = await this.federatedInstanceCache.get(host); @@ -70,6 +70,24 @@ export class FederatedInstanceService implements OnApplicationShutdown { } } + @bindThis + public async fetch(host: string): Promise { + host = this.utilityService.toPuny(host); + + const cached = await this.federatedInstanceCache.get(host); + if (cached !== undefined) return cached; + + const index = await this.instancesRepository.findOneBy({ host }); + + if (index == null) { + this.federatedInstanceCache.set(host, null); + return null; + } else { + this.federatedInstanceCache.set(host, index); + return index; + } + } + @bindThis public async update(id: MiInstance['id'], data: Partial): Promise { const result = await this.instancesRepository.createQueryBuilder().update() diff --git a/packages/backend/src/core/FetchInstanceMetadataService.ts b/packages/backend/src/core/FetchInstanceMetadataService.ts index aa16468ecb..ce3af7c774 100644 --- a/packages/backend/src/core/FetchInstanceMetadataService.ts +++ b/packages/backend/src/core/FetchInstanceMetadataService.ts @@ -82,7 +82,7 @@ export class FetchInstanceMetadataService { try { if (!force) { - const _instance = await this.federatedInstanceService.fetch(host); + const _instance = await this.federatedInstanceService.fetchOrRegister(host); const now = Date.now(); if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) { // unlock at the finally caluse @@ -181,7 +181,7 @@ 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; @@ -206,7 +206,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchFaviconUrl(instance: MiInstance, doc: DOMWindow['document'] | null): Promise { + private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise { const url = 'https://' + instance.host; if (doc) { @@ -232,7 +232,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async fetchIconUrl(instance: MiInstance, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + private async fetchIconUrl(instance: MiInstance, doc: Document | 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; @@ -261,7 +261,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + private async getThemeColor(info: NodeInfo | null, doc: Document | 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 +273,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record | null): Promise { if (info && info.metadata) { if (typeof info.metadata.nodeName === 'string') { return info.metadata.nodeName; @@ -298,7 +298,7 @@ export class FetchInstanceMetadataService { } @bindThis - private async getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record | null): Promise { + private async getDescription(info: NodeInfo | null, doc: Document | 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 6bd6cb8d9b..a295e81920 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -13,7 +13,6 @@ import * as fileType from 'file-type'; import FFmpeg from 'fluent-ffmpeg'; import isSvg from 'is-svg'; import probeImageSize from 'probe-image-size'; -import { type predictionType } from 'nsfwjs'; import { sharpBmp } from '@misskey-dev/sharp-read-bmp'; import * as blurhash from 'blurhash'; import { createTempDir } from '@/misc/create-temp.js'; @@ -21,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 type { PredictionType } from 'nsfwjs'; export type FileInfo = { size: number; @@ -170,7 +170,7 @@ export class FileInfoService { let sensitive = false; let porn = false; - function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] { + function judgePrediction(result: readonly PredictionType[]): [sensitive: boolean, porn: boolean] { let sensitive = false; let porn = false; @@ -268,7 +268,6 @@ export class FileInfoService { private async *asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator { const watcher = new FSWatcher({ cwd, - disableGlobbing: true, }); let finished = false; command.once('end', () => { diff --git a/packages/backend/src/core/FlashService.ts b/packages/backend/src/core/FlashService.ts new file mode 100644 index 0000000000..2a98225382 --- /dev/null +++ b/packages/backend/src/core/FlashService.ts @@ -0,0 +1,40 @@ +/* + * 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 FlashsRepository } from '@/models/_.js'; + +/** + * MisskeyPlay関係のService + */ +@Injectable() +export class FlashService { + constructor( + @Inject(DI.flashsRepository) + private flashRepository: FlashsRepository, + ) { + } + + /** + * 人気のあるPlay一覧を取得する. + */ + public async featured(opts?: { offset?: number, limit: number }) { + const builder = this.flashRepository.createQueryBuilder('flash') + .andWhere('flash.likedCount > 0') + .andWhere('flash.visibility = :visibility', { visibility: 'public' }) + .addOrderBy('flash.likedCount', 'DESC') + .addOrderBy('flash.updatedAt', 'DESC') + .addOrderBy('flash.id', 'DESC'); + + if (opts?.offset) { + builder.skip(opts.offset); + } + + builder.take(opts?.limit ?? 10); + + return await builder.getMany(); + } +} diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 87aa70713e..3215b41c8d 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; -import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; +import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -72,12 +72,8 @@ export interface MainEventTypes { readAllNotifications: undefined; notificationFlushed: undefined; unreadNotification: Packed<'Notification'>; - unreadMention: MiNote['id']; - readAllUnreadMentions: undefined; - unreadSpecifiedNote: MiNote['id']; - readAllUnreadSpecifiedNotes: undefined; - readAllAntennas: undefined; unreadAntenna: MiAntenna; + newChatMessage: Packed<'ChatMessage'>; readAllAnnouncements: undefined; myTokenRegenerated: undefined; signin: { @@ -163,6 +159,21 @@ export interface AdminEventTypes { }; } +export interface ChatEventTypes { + message: Packed<'ChatMessageLite'>; + deleted: Packed<'ChatMessageLite'>['id']; + react: { + reaction: string; + user?: Packed<'UserLite'>; + messageId: MiChatMessage['id']; + }; + unreact: { + reaction: string; + user?: Packed<'UserLite'>; + messageId: MiChatMessage['id']; + }; +} + export interface ReversiEventTypes { matched: { game: Packed<'ReversiGameDetailed'>; @@ -202,7 +213,7 @@ export interface ReversiGameEventTypes { type Events = { [K in keyof T]: { type: K; body: T[K]; } }; type EventUnionFromDictionary< T extends object, - U = Events + U = Events, > = U[keyof U]; type SerializedAll = { @@ -211,7 +222,7 @@ type SerializedAll = { type UndefinedAsNullAll = { [K in keyof T]: T[K] extends undefined ? null : T[K]; -} +}; export interface InternalEventTypes { userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; }; @@ -241,7 +252,7 @@ export interface InternalEventTypes { avatarDecorationCreated: MiAvatarDecoration; avatarDecorationDeleted: MiAvatarDecoration; avatarDecorationUpdated: MiAvatarDecoration; - metaUpdated: MiMeta; + metaUpdated: { before?: MiMeta; after: MiMeta; }; followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; updateUserProfile: MiUserProfile; @@ -295,6 +306,14 @@ export type GlobalEvents = { name: 'notesStream'; payload: Serialized>; }; + chatUser: { + name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`; + payload: EventTypesToEventPayload; + }; + chatRoom: { + name: `chatRoomStream:${MiChatRoom['id']}`; + payload: EventTypesToEventPayload; + }; reversi: { name: `reversiStream:${MiUser['id']}`; payload: EventTypesToEventPayload; @@ -393,6 +412,16 @@ export class GlobalEventService { this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishChatUserStream(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatUserStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value); + } + + @bindThis + public publishChatRoomStream(toRoomId: MiChatRoom['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishReversiStream(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void { this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value); diff --git a/packages/backend/src/core/HashtagService.ts b/packages/backend/src/core/HashtagService.ts index eb192ee6da..793bbeecb1 100644 --- a/packages/backend/src/core/HashtagService.ts +++ b/packages/backend/src/core/HashtagService.ts @@ -10,16 +10,18 @@ import type { MiUser } from '@/models/User.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { IdService } from '@/core/IdService.js'; import type { MiHashtag } from '@/models/Hashtag.js'; -import type { HashtagsRepository } from '@/models/_.js'; +import type { HashtagsRepository, MiMeta } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { FeaturedService } from '@/core/FeaturedService.js'; -import { MetaService } from '@/core/MetaService.js'; import { UtilityService } from '@/core/UtilityService.js'; @Injectable() export class HashtagService { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.redis) private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする @@ -29,7 +31,6 @@ export class HashtagService { private userEntityService: UserEntityService, private featuredService: FeaturedService, private idService: IdService, - private metaService: MetaService, private utilityService: UtilityService, ) { } @@ -160,10 +161,9 @@ export class HashtagService { @bindThis public async updateHashtagsRanking(hashtag: string, userId: MiUser['id']): Promise { - const instance = await this.metaService.fetch(); - const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t)); + const hiddenTags = this.meta.hiddenTags.map(t => normalizeForSearch(t)); if (hiddenTags.includes(hashtag)) return; - if (this.utilityService.isKeyWordIncluded(hashtag, instance.sensitiveWords)) return; + if (this.utilityService.isKeyWordIncluded(hashtag, this.meta.sensitiveWords)) return; // YYYYMMDDHHmm (10分間隔) const now = new Date(); diff --git a/packages/backend/src/core/HttpRequestService.ts b/packages/backend/src/core/HttpRequestService.ts index 7f3cac7c58..3ddfe52045 100644 --- a/packages/backend/src/core/HttpRequestService.ts +++ b/packages/backend/src/core/HttpRequestService.ts @@ -6,6 +6,7 @@ import * as http from 'node:http'; import * as https from 'node:https'; import * as net from 'node:net'; +import ipaddr from 'ipaddr.js'; import CacheableLookup from 'cacheable-lookup'; import fetch from 'node-fetch'; import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent'; @@ -15,6 +16,7 @@ import type { Config } from '@/config.js'; import { StatusError } from '@/misc/status-error.js'; import { bindThis } from '@/decorators.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; +import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; import type { IObject } from '@/core/activitypub/type.js'; import type { Response } from 'node-fetch'; import type { URL } from 'node:url'; @@ -24,27 +26,121 @@ export type HttpRequestSendOptions = { validators?: ((res: Response) => void)[]; }; +declare module 'node:http' { + interface Agent { + createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket; + } +} + +class HttpRequestServiceAgent extends http.Agent { + constructor( + private config: Config, + options?: http.AgentOptions, + ) { + super(options); + } + + @bindThis + public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket { + const socket = super.createConnection(options, callback) + .on('connect', () => { + const address = socket.remoteAddress; + if (process.env.NODE_ENV === 'production') { + if (address && ipaddr.isValid(address)) { + if (this.isPrivateIp(address)) { + socket.destroy(new Error(`Blocked address: ${address}`)); + } + } + } + }); + return socket; + } + + @bindThis + private isPrivateIp(ip: string): boolean { + const parsedIp = ipaddr.parse(ip); + + for (const net of this.config.allowedPrivateNetworks ?? []) { + const cidr = ipaddr.parseCIDR(net); + if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) { + return false; + } + } + + return parsedIp.range() !== 'unicast'; + } +} + +class HttpsRequestServiceAgent extends https.Agent { + constructor( + private config: Config, + options?: https.AgentOptions, + ) { + super(options); + } + + @bindThis + public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket { + const socket = super.createConnection(options, callback) + .on('connect', () => { + const address = socket.remoteAddress; + if (process.env.NODE_ENV === 'production') { + if (address && ipaddr.isValid(address)) { + if (this.isPrivateIp(address)) { + socket.destroy(new Error(`Blocked address: ${address}`)); + } + } + } + }); + return socket; + } + + @bindThis + private isPrivateIp(ip: string): boolean { + const parsedIp = ipaddr.parse(ip); + + for (const net of this.config.allowedPrivateNetworks ?? []) { + const cidr = ipaddr.parseCIDR(net); + if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) { + return false; + } + } + + return parsedIp.range() !== 'unicast'; + } +} + @Injectable() export class HttpRequestService { + /** + * Get http non-proxy agent (without local address filtering) + */ + private readonly httpNative: http.Agent; + + /** + * Get https non-proxy agent (without local address filtering) + */ + private readonly httpsNative: https.Agent; + /** * Get http non-proxy agent */ - private http: http.Agent; + private readonly http: http.Agent; /** * Get https non-proxy agent */ - private https: https.Agent; + private readonly https: https.Agent; /** * Get http proxy or non-proxy agent */ - public httpAgent: http.Agent; + public readonly httpAgent: http.Agent; /** * Get https proxy or non-proxy agent */ - public httpsAgent: https.Agent; + public readonly httpsAgent: https.Agent; constructor( @Inject(DI.config) @@ -56,19 +152,20 @@ export class HttpRequestService { lookup: false, // nativeのdns.lookupにfallbackしない }); - this.http = new http.Agent({ + const agentOption = { keepAlive: true, keepAliveMsecs: 30 * 1000, lookup: cache.lookup as unknown as net.LookupFunction, localAddress: config.outgoingAddress, - }); + }; - this.https = new https.Agent({ - keepAlive: true, - keepAliveMsecs: 30 * 1000, - lookup: cache.lookup as unknown as net.LookupFunction, - localAddress: config.outgoingAddress, - }); + this.httpNative = new http.Agent(agentOption); + + this.httpsNative = new https.Agent(agentOption); + + this.http = new HttpRequestServiceAgent(config, agentOption); + + this.https = new HttpsRequestServiceAgent(config, agentOption); const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128); @@ -100,19 +197,58 @@ export class HttpRequestService { /** * Get agent by URL * @param url URL - * @param bypassProxy Allways bypass proxy + * @param bypassProxy Always bypass proxy + * @param isLocalAddressAllowed */ @bindThis - public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent { + public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent { if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) { + if (isLocalAddressAllowed) { + return url.protocol === 'http:' ? this.httpNative : this.httpsNative; + } return url.protocol === 'http:' ? this.http : this.https; } else { + if (isLocalAddressAllowed && (!this.config.proxy)) { + return url.protocol === 'http:' ? this.httpNative : this.httpsNative; + } return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent; } } + /** + * Get agent for http by URL + * @param url URL + * @param isLocalAddressAllowed + */ @bindThis - public async getActivityJson(url: string): Promise { + public getAgentForHttp(url: URL, isLocalAddressAllowed = false): http.Agent { + if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) { + return isLocalAddressAllowed + ? this.httpNative + : this.http; + } else { + return this.httpAgent; + } + } + + /** + * Get agent for https by URL + * @param url URL + * @param isLocalAddressAllowed + */ + @bindThis + public getAgentForHttps(url: URL, isLocalAddressAllowed = false): https.Agent { + if ((this.config.proxyBypassHosts ?? []).includes(url.hostname)) { + return isLocalAddressAllowed + ? this.httpsNative + : this.https; + } else { + return this.httpsAgent; + } + } + + @bindThis + public async getActivityJson(url: string, isLocalAddressAllowed = false, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise { const res = await this.send(url, { method: 'GET', headers: { @@ -120,16 +256,22 @@ export class HttpRequestService { }, timeout: 5000, size: 1024 * 256, + isLocalAddressAllowed: isLocalAddressAllowed, }, { throwErrorWhenResponseNotOk: true, validators: [validateContentTypeSetAsActivityPub], }); - return await res.json() as IObject; + const finalUrl = res.url; // redirects may have been involved + const activity = await res.json() as IObject; + + assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail); + + return activity; } @bindThis - public async getJson(url: string, accept = 'application/json, */*', headers?: Record): Promise { + public async getJson(url: string, accept = 'application/json, */*', headers?: Record, isLocalAddressAllowed = false): Promise { const res = await this.send(url, { method: 'GET', headers: Object.assign({ @@ -137,19 +279,21 @@ export class HttpRequestService { }, headers ?? {}), timeout: 5000, size: 1024 * 256, + isLocalAddressAllowed: isLocalAddressAllowed, }); return await res.json() as T; } @bindThis - public async getHtml(url: string, accept = 'text/html, */*', headers?: Record): Promise { + public async getHtml(url: string, accept = 'text/html, */*', headers?: Record, isLocalAddressAllowed = false): Promise { const res = await this.send(url, { method: 'GET', headers: Object.assign({ Accept: accept, }, headers ?? {}), timeout: 5000, + isLocalAddressAllowed: isLocalAddressAllowed, }); return await res.text(); @@ -164,6 +308,7 @@ export class HttpRequestService { headers?: Record, timeout?: number, size?: number, + isLocalAddressAllowed?: boolean, } = {}, extra: HttpRequestSendOptions = { throwErrorWhenResponseNotOk: true, @@ -177,6 +322,8 @@ export class HttpRequestService { controller.abort(); }, timeout); + const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false; + const res = await fetch(url, { method: args.method ?? 'GET', headers: { @@ -185,7 +332,7 @@ export class HttpRequestService { }, body: args.body, size: args.size ?? 10 * 1024 * 1024, - agent: (url) => this.getAgentByUrl(url), + agent: (url) => this.getAgentByUrl(url, false, isLocalAddressAllowed), signal: controller.signal, }); diff --git a/packages/backend/src/core/InstanceActorService.ts b/packages/backend/src/core/InstanceActorService.ts deleted file mode 100644 index 22c47297a3..0000000000 --- a/packages/backend/src/core/InstanceActorService.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import { IsNull, Not } from 'typeorm'; -import type { MiLocalUser } from '@/models/User.js'; -import type { UsersRepository } from '@/models/_.js'; -import { MemorySingleCache } from '@/misc/cache.js'; -import { DI } from '@/di-symbols.js'; -import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; -import { bindThis } from '@/decorators.js'; - -const ACTOR_USERNAME = 'instance.actor' as const; - -@Injectable() -export class InstanceActorService { - private cache: MemorySingleCache; - - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private createSystemUserService: CreateSystemUserService, - ) { - this.cache = new MemorySingleCache(Infinity); - } - - @bindThis - public async realLocalUsersPresent(): Promise { - return await this.usersRepository.existsBy({ - host: IsNull(), - username: Not(ACTOR_USERNAME), - }); - } - - @bindThis - public async getInstanceActor(): Promise { - const cached = this.cache.get(); - if (cached) return cached; - - const user = await this.usersRepository.findOneBy({ - host: IsNull(), - username: ACTOR_USERNAME, - }) as MiLocalUser | undefined; - - if (user) { - this.cache.set(user); - return user; - } else { - const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as MiLocalUser; - this.cache.set(created); - return created; - } - } -} diff --git a/packages/backend/src/core/MetaService.ts b/packages/backend/src/core/MetaService.ts index ec630f804e..40e7439f5f 100644 --- a/packages/backend/src/core/MetaService.ts +++ b/packages/backend/src/core/MetaService.ts @@ -52,8 +52,8 @@ export class MetaService implements OnApplicationShutdown { switch (type) { case 'metaUpdated': { this.cache = { // TODO: このあたりのデシリアライズ処理は各modelファイル内に関数としてexportしたい - ...body, - proxyAccount: null, // joinなカラムは通常取ってこないので + ...(body.after), + rootUser: null, // joinなカラムは通常取ってこないので }; break; } @@ -113,17 +113,20 @@ export class MetaService implements OnApplicationShutdown { if (before) { await transactionalEntityManager.update(MiMeta, before.id, data); - - const metas = await transactionalEntityManager.find(MiMeta, { - order: { - id: 'DESC', - }, - }); - - return metas[0]; } else { - return await transactionalEntityManager.save(MiMeta, data); + await transactionalEntityManager.save(MiMeta, { + ...data, + id: 'x', + }); } + + const afters = await transactionalEntityManager.find(MiMeta, { + order: { + id: 'DESC', + }, + }); + + return afters[0]; }); if (data.hiddenTags) { @@ -141,7 +144,7 @@ export class MetaService implements OnApplicationShutdown { }); } - this.globalEventService.publishInternalEvent('metaUpdated', updated); + this.globalEventService.publishInternalEvent('metaUpdated', { before, after: updated }); return updated; } diff --git a/packages/backend/src/core/MfmService.ts b/packages/backend/src/core/MfmService.ts index 74536c68f5..00208927e2 100644 --- a/packages/backend/src/core/MfmService.ts +++ b/packages/backend/src/core/MfmService.ts @@ -171,6 +171,39 @@ export class MfmService { break; } + case 'ruby': { + let ruby: [string, string][] = []; + for (const child of node.childNodes) { + if (child.nodeName === 'rp') { + continue; + } + if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) { + ruby.push([child.value, '']); + continue; + } + if (child.nodeName === '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); + break; + } else { + ruby.at(-1)![1] = rt; + continue; + } + } + // If any other element is included in ruby, it is treated as a normal text + ruby = []; + appendChildren(node.childNodes); + break; + } + for (const [base, rt] of ruby) { + text += `$[ruby ${base} ${rt}]`; + } + break; + } + // block code (
)
 				case 'pre': {
 					if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
@@ -239,7 +272,7 @@ export class MfmService {
 			return null;
 		}
 
-		const { window } = new Window();
+		const { happyDOM, window } = new Window();
 
 		const doc = window.document;
 
@@ -406,8 +439,10 @@ export class MfmService {
 			mention: (node) => {
 				const a = doc.createElement('a');
 				const { username, host, acct } = node.props;
-				const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
-				a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`);
+				const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
+				a.setAttribute('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;
@@ -457,6 +492,11 @@ export class MfmService {
 
 		appendChildren(nodes, body);
 
-		return new XMLSerializer().serializeToString(body);
+		// Remove the unnecessary namespace
+		const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*

/, '

'); + + happyDOM.close().catch(err => {}); + + return serialized; } } diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 1d8d248322..8f416f398c 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -8,13 +8,12 @@ import * as mfm from 'mfm-js'; import { In, DataSource, IsNull, LessThan } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import RE2 from 're2'; import { extractMentions } from '@/misc/extract-mentions.js'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; 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, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, 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'; @@ -23,11 +22,8 @@ import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { IPoll } from '@/models/Poll.js'; import { MiPoll } from '@/models/Poll.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import { checkWordMute } from '@/misc/check-word-mute.js'; import type { MiChannel } from '@/models/Channel.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { MemorySingleCache } from '@/misc/cache.js'; -import type { MiUserProfile } from '@/models/UserProfile.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { DI } from '@/di-symbols.js'; @@ -46,12 +42,10 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { RoleService } from '@/core/RoleService.js'; -import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { FeaturedService } from '@/core/FeaturedService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; @@ -60,6 +54,8 @@ import { UserBlockingService } from '@/core/UserBlockingService.js'; import { isReply } from '@/misc/is-reply.js'; 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'; type NotificationType = 'reply' | 'renote' | 'quote' | 'mention'; @@ -151,11 +147,15 @@ type Option = { @Injectable() export class NoteCreateService implements OnApplicationShutdown { #shutdownController = new AbortController(); + private updateNotesCountQueue: CollapsedQueue; constructor( @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.db) private db: DataSource, @@ -198,7 +198,6 @@ export class NoteCreateService implements OnApplicationShutdown { private globalEventService: GlobalEventService, private queueService: QueueService, private fanoutTimelineService: FanoutTimelineService, - private noteReadService: NoteReadService, private notificationService: NotificationService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, @@ -210,7 +209,6 @@ export class NoteCreateService implements OnApplicationShutdown { private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, private roleService: RoleService, - private metaService: MetaService, private searchService: SearchService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, @@ -218,7 +216,10 @@ export class NoteCreateService implements OnApplicationShutdown { private instanceChart: InstanceChart, private utilityService: UtilityService, private userBlockingService: UserBlockingService, - ) { } + private cacheService: CacheService, + ) { + this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount); + } @bindThis public async create(user: { @@ -251,10 +252,8 @@ export class NoteCreateService implements OnApplicationShutdown { if (data.channel != null) data.visibleUsers = []; if (data.channel != null) data.localOnly = true; - const meta = await this.metaService.fetch(); - if (data.visibility === 'public' && data.channel == null) { - const sensitiveWords = meta.sensitiveWords; + const sensitiveWords = this.meta.sensitiveWords; if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) { data.visibility = 'home'; } else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) { @@ -262,17 +261,17 @@ export class NoteCreateService implements OnApplicationShutdown { } } - const hasProhibitedWords = await this.checkProhibitedWordsContain({ + const hasProhibitedWords = this.checkProhibitedWordsContain({ cw: data.cw, text: data.text, pollChoices: data.poll?.choices, - }, meta.prohibitedWords); + }, this.meta.prohibitedWords); if (hasProhibitedWords) { throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); } - const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host); + const inSilencedInstance = this.utilityService.isSilencedHost(this.meta.silencedHosts, user.host); if (data.visibility === 'public' && inSilencedInstance && user.host !== null) { data.visibility = 'home'; @@ -365,7 +364,7 @@ export class NoteCreateService implements OnApplicationShutdown { } // if the host is media-silenced, custom emojis are not allowed - if (this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, user.host)) emojis = []; + if (this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, user.host)) emojis = []; tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32); @@ -506,21 +505,21 @@ export class NoteCreateService implements OnApplicationShutdown { host: MiUser['host']; isBot: MiUser['isBot']; }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { - const meta = await this.metaService.fetch(); - this.notesChart.update(note, true); - if (note.visibility !== 'specified' && (meta.enableChartsForRemoteUser || (user.host == null))) { + if (note.visibility !== 'specified' && (this.meta.enableChartsForRemoteUser || (user.host == null))) { this.perUserNotesChart.update(user, note, true); } // Register host - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'notesCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, true); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { + this.updateNotesCountQueue.enqueue(i.id, 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, true); + } + }); + } } // ハッシュタグ更新 @@ -544,13 +543,21 @@ export class NoteCreateService implements OnApplicationShutdown { this.followingsRepository.findBy({ followeeId: user.id, notify: 'normal', - }).then(followings => { + }).then(async followings => { if (note.visibility !== 'specified') { + const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false; for (const following of followings) { // TODO: ワードミュート考慮 - this.notificationService.createNotification(following.followerId, 'note', { - noteId: note.id, - }, user.id); + let isRenoteMuted = false; + if (isPureRenote) { + const userIdsWhoMeMutingRenotes = await this.cacheService.renoteMutingsCache.fetch(following.followerId); + isRenoteMuted = userIdsWhoMeMutingRenotes.has(user.id); + } + if (!isRenoteMuted) { + this.notificationService.createNotification(following.followerId, 'note', { + noteId: note.id, + }, user.id); + } } } }); @@ -573,31 +580,6 @@ export class NoteCreateService implements OnApplicationShutdown { if (!silent) { if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user); - // 未読通知を作成 - if (data.visibility === 'specified') { - if (data.visibleUsers == null) throw new Error('invalid param'); - - for (const u of data.visibleUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: true, - isMentioned: false, - }); - } - } else { - for (const u of mentionedUsers) { - // ローカルユーザーのみ - if (!this.userEntityService.isLocalUser(u)) continue; - - this.noteReadService.insertNoteUnread(u.id, note, { - isSpecified: false, - isMentioned: true, - }); - } - } - // Pack the note const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true }); @@ -605,14 +587,7 @@ export class NoteCreateService implements OnApplicationShutdown { this.roleService.addNoteToRoleTimeline(noteObj); - this.webhookService.getActiveWebhooks().then(webhooks => { - webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'note', { - note: noteObj, - }); - } - }); + this.webhookService.enqueueUserWebhook(user.id, 'note', { note: noteObj }); const nm = new NotificationManager(this.mutingsRepository, this.notificationService, user, note); @@ -632,13 +607,7 @@ export class NoteCreateService implements OnApplicationShutdown { if (!isThreadMuted) { nm.push(data.reply.userId, 'reply'); this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'reply', { - note: noteObj, - }); - } + this.webhookService.enqueueUserWebhook(data.reply.userId, 'reply', { note: noteObj }); } } } @@ -655,20 +624,14 @@ export class NoteCreateService implements OnApplicationShutdown { // Publish event if ((user.id !== data.renote.userId) && data.renote.userHost === null) { this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'renote', { - note: noteObj, - }); - } + this.webhookService.enqueueUserWebhook(data.renote.userId, 'renote', { note: noteObj }); } } nm.notify(); //#region AP deliver - if (this.userEntityService.isLocalUser(user)) { + if (!data.localOnly && this.userEntityService.isLocalUser(user)) { (async () => { const noteActivity = await this.renderNoteOrRenoteActivity(data, note); const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity); @@ -787,13 +750,7 @@ export class NoteCreateService implements OnApplicationShutdown { }); this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'mention', { - note: detailPackedNote, - }); - } + this.webhookService.enqueueUserWebhook(u.id, 'mention', { note: detailPackedNote }); // Create notification nm.push(u.id, 'mention'); @@ -853,15 +810,14 @@ export class NoteCreateService implements OnApplicationShutdown { @bindThis private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) { - const meta = await this.metaService.fetch(); - if (!meta.enableFanoutTimeline) return; + if (!this.meta.enableFanoutTimeline) return; const r = this.redisForTimelines.pipeline(); if (note.channelId) { this.fanoutTimelineService.push(`channelTimeline:${note.channelId}`, note.id, this.config.perChannelMaxNoteCacheCount, r); - this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimelineWithChannel:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); const channelFollowings = await this.channelFollowingsRepository.find({ where: { @@ -871,9 +827,9 @@ export class NoteCreateService implements OnApplicationShutdown { }); for (const channelFollowing of channelFollowings) { - this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${channelFollowing.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); } } } else { @@ -911,9 +867,9 @@ export class NoteCreateService implements OnApplicationShutdown { if (!following.withReplies) continue; } - this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${following.followerId}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); } } @@ -930,25 +886,25 @@ export class NoteCreateService implements OnApplicationShutdown { if (!userListMembership.withReplies) continue; } - this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax, r); + this.fanoutTimelineService.push(`userListTimeline:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, meta.perUserListTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userListTimelineWithFiles:${userListMembership.userListId}`, note.id, this.meta.perUserListTimelineCacheMax / 2, r); } } // 自分自身のHTL if (note.userHost == null) { if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { - this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax, r); + this.fanoutTimelineService.push(`homeTimeline:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, meta.perUserHomeTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`homeTimelineWithFiles:${user.id}`, note.id, this.meta.perUserHomeTimelineCacheMax / 2, r); } } } // 自分自身以外への返信 if (isReply(note)) { - this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimelineWithReplies:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); if (note.visibility === 'public' && note.userHost == null) { this.fanoutTimelineService.push('localTimelineWithReplies', note.id, 300, r); @@ -957,9 +913,9 @@ export class NoteCreateService implements OnApplicationShutdown { } } } else { - this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax : meta.perRemoteUserUserTimelineCacheMax, r); + this.fanoutTimelineService.push(`userTimeline:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax : this.meta.perRemoteUserUserTimelineCacheMax, r); if (note.fileIds.length > 0) { - this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? meta.perLocalUserUserTimelineCacheMax / 2 : meta.perRemoteUserUserTimelineCacheMax / 2, r); + this.fanoutTimelineService.push(`userTimelineWithFiles:${user.id}`, note.id, note.userHost == null ? this.meta.perLocalUserUserTimelineCacheMax / 2 : this.meta.perRemoteUserUserTimelineCacheMax / 2, r); } if (note.visibility === 'public' && note.userHost == null) { @@ -1018,9 +974,9 @@ export class NoteCreateService implements OnApplicationShutdown { } } - public async checkProhibitedWordsContain(content: Parameters[0], prohibitedWords?: string[]) { + public checkProhibitedWordsContain(content: Parameters[0], prohibitedWords?: string[]) { if (prohibitedWords == null) { - prohibitedWords = (await this.metaService.fetch()).prohibitedWords; + prohibitedWords = this.meta.prohibitedWords; } if ( @@ -1036,12 +992,23 @@ export class NoteCreateService implements OnApplicationShutdown { } @bindThis - public dispose(): void { - this.#shutdownController.abort(); + private collapseNotesCount(oldValue: number, newValue: number) { + return oldValue + newValue; } @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); + private async performUpdateNotesCount(id: MiNote['id'], incrBy: number) { + await this.instancesRepository.increment({ id: id }, 'notesCount', incrBy); + } + + @bindThis + public async dispose(): Promise { + this.#shutdownController.abort(); + await this.updateNotesCountQueue.performAllNow(); + } + + @bindThis + public async onApplicationShutdown(signal?: string | undefined): Promise { + await this.dispose(); } } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index b7c01c64c8..e394506a44 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -3,11 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets, In } from 'typeorm'; +import { Brackets, In, IsNull, Not } from 'typeorm'; import { Injectable, Inject } from '@nestjs/common'; import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js'; import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js'; -import type { InstancesRepository, NotesRepository, UsersRepository } from '@/models/_.js'; +import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js'; import { RelayService } from '@/core/RelayService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { DI } from '@/di-symbols.js'; @@ -19,9 +19,7 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; import { SearchService } from '@/core/SearchService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; @@ -32,6 +30,9 @@ export class NoteDeleteService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -42,13 +43,11 @@ export class NoteDeleteService { private instancesRepository: InstancesRepository, private userEntityService: UserEntityService, - private noteEntityService: NoteEntityService, private globalEventService: GlobalEventService, private relayService: RelayService, private federatedInstanceService: FederatedInstanceService, private apRendererService: ApRendererService, private apDeliverManagerService: ApDeliverManagerService, - private metaService: MetaService, private searchService: SearchService, private moderationLogService: ModerationLogService, private notesChart: NotesChart, @@ -102,20 +101,20 @@ export class NoteDeleteService { } //#endregion - const meta = await this.metaService.fetch(); - this.notesChart.update(note, false); - if (meta.enableChartsForRemoteUser || (user.host == null)) { + if (this.meta.enableChartsForRemoteUser || (user.host == null)) { this.perUserNotesChart.update(user, note, false); } - if (this.userEntityService.isRemoteUser(user)) { - this.federatedInstanceService.fetch(user.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateNote(i.host, note, false); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(user)) { + this.federatedInstanceService.fetchOrRegister(user.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateNote(i.host, note, false); + } + }); + } } } @@ -190,13 +189,27 @@ export class NoteDeleteService { }) as MiRemoteUser[]; } + @bindThis + private async getRenotedOrRepliedRemoteUsers(note: MiNote) { + const query = this.notesRepository.createQueryBuilder('note') + .leftJoinAndSelect('note.user', 'user') + .where(new Brackets(qb => { + qb.orWhere('note.renoteId = :renoteId', { renoteId: note.id }); + qb.orWhere('note.replyId = :replyId', { replyId: note.id }); + })) + .andWhere({ userHost: Not(IsNull()) }); + const notes = await query.getMany() as (MiNote & { user: MiRemoteUser })[]; + const remoteUsers = notes.map(({ user }) => user); + return remoteUsers; + } + @bindThis private async deliverToConcerned(user: { id: MiLocalUser['id']; host: null; }, note: MiNote, content: any) { this.apDeliverManagerService.deliverToFollowers(user, content); this.relayService.deliverToRelays(user, content); - const remoteUsers = await this.getMentionedRemoteUsers(note); - for (const remoteUser of remoteUsers) { - this.apDeliverManagerService.deliverToUser(user, content, remoteUser); - } + this.apDeliverManagerService.deliverToUsers(user, content, [ + ...await this.getMentionedRemoteUsers(note), + ...await this.getRenotedOrRepliedRemoteUsers(note), + ]); } } diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts deleted file mode 100644 index 181c9f7649..0000000000 --- a/packages/backend/src/core/NoteReadService.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { setTimeout } from 'node:timers/promises'; -import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; -import { In } from 'typeorm'; -import { DI } from '@/di-symbols.js'; -import type { MiUser } from '@/models/User.js'; -import type { Packed } from '@/misc/json-schema.js'; -import type { MiNote } from '@/models/Note.js'; -import { IdService } from '@/core/IdService.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js'; -import { bindThis } from '@/decorators.js'; -import { trackPromise } from '@/misc/promise-tracker.js'; - -@Injectable() -export class NoteReadService implements OnApplicationShutdown { - #shutdownController = new AbortController(); - - constructor( - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - - @Inject(DI.mutingsRepository) - private mutingsRepository: MutingsRepository, - - @Inject(DI.noteThreadMutingsRepository) - private noteThreadMutingsRepository: NoteThreadMutingsRepository, - - private idService: IdService, - private globalEventService: GlobalEventService, - ) { - } - - @bindThis - public async insertNoteUnread(userId: MiUser['id'], note: MiNote, params: { - // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse - isSpecified: boolean; - isMentioned: boolean; - }): Promise { - //#region ミュートしているなら無視 - const mute = await this.mutingsRepository.findBy({ - muterId: userId, - }); - if (mute.map(m => m.muteeId).includes(note.userId)) return; - //#endregion - - // スレッドミュート - const isThreadMuted = await this.noteThreadMutingsRepository.exists({ - where: { - userId: userId, - threadId: note.threadId ?? note.id, - }, - }); - if (isThreadMuted) return; - - const unread = { - id: this.idService.gen(), - noteId: note.id, - userId: userId, - isSpecified: params.isSpecified, - isMentioned: params.isMentioned, - noteUserId: note.userId, - }; - - await this.noteUnreadsRepository.insert(unread); - - // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する - setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { - const exist = await this.noteUnreadsRepository.exists({ where: { id: unread.id } }); - - if (!exist) return; - - if (params.isMentioned) { - this.globalEventService.publishMainStream(userId, 'unreadMention', note.id); - } - if (params.isSpecified) { - this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id); - } - }, () => { /* aborted, ignore it */ }); - } - - @bindThis - public async read( - userId: MiUser['id'], - notes: (MiNote | Packed<'Note'>)[], - ): Promise { - if (notes.length === 0) return; - - const noteIds = new Set(); - - for (const note of notes) { - if (note.mentions && note.mentions.includes(userId)) { - noteIds.add(note.id); - } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) { - noteIds.add(note.id); - } - } - - if (noteIds.size === 0) return; - - // Remove the record - await this.noteUnreadsRepository.delete({ - userId: userId, - noteId: In(Array.from(noteIds)), - }); - - // TODO: ↓まとめてクエリしたい - - trackPromise(this.noteUnreadsRepository.countBy({ - userId: userId, - isMentioned: true, - }).then(mentionsCount => { - if (mentionsCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions'); - } - })); - - trackPromise(this.noteUnreadsRepository.countBy({ - userId: userId, - isSpecified: true, - }).then(specifiedCount => { - if (specifiedCount === 0) { - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); - } - })); - } - - @bindThis - public dispose(): void { - this.#shutdownController.abort(); - } - - @bindThis - public onApplicationShutdown(signal?: string | undefined): void { - this.dispose(); - } -} diff --git a/packages/backend/src/core/ProxyAccountService.ts b/packages/backend/src/core/ProxyAccountService.ts deleted file mode 100644 index 71d663bf90..0000000000 --- a/packages/backend/src/core/ProxyAccountService.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; -import type { MiLocalUser } from '@/models/User.js'; -import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; -import { bindThis } from '@/decorators.js'; - -@Injectable() -export class ProxyAccountService { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private metaService: MetaService, - ) { - } - - @bindThis - public async fetch(): Promise { - const meta = await this.metaService.fetch(); - if (meta.proxyAccountId == null) return null; - return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as MiLocalUser; - } -} diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index 6a845b951d..1479bb00d9 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -10,8 +10,7 @@ import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { Packed } from '@/misc/json-schema.js'; import { getNoteSummary } from '@/misc/get-note-summary.js'; -import type { MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js'; -import { MetaService } from '@/core/MetaService.js'; +import type { MiMeta, MiSwSubscription, SwSubscriptionsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RedisKVCache } from '@/misc/cache.js'; @@ -54,13 +53,14 @@ export class PushNotificationService implements OnApplicationShutdown { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.redis) private redisClient: Redis.Redis, @Inject(DI.swSubscriptionsRepository) private swSubscriptionsRepository: SwSubscriptionsRepository, - - private metaService: MetaService, ) { this.subscriptionsCache = new RedisKVCache(this.redisClient, 'userSwSubscriptions', { lifetime: 1000 * 60 * 60 * 1, // 1h @@ -73,14 +73,12 @@ export class PushNotificationService implements OnApplicationShutdown { @bindThis public async pushNotification(userId: string, type: T, body: PushNotificationsTypes[T]) { - const meta = await this.metaService.fetch(); - - if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return; + if (!this.meta.enableServiceWorker || this.meta.swPublicKey == null || this.meta.swPrivateKey == null) return; // アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録 push.setVapidDetails(this.config.url, - meta.swPublicKey, - meta.swPrivateKey); + this.meta.swPublicKey, + this.meta.swPrivateKey); const subscriptions = await this.subscriptionsCache.fetch(userId); diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index c4feeaf971..412ab33b3f 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -69,7 +69,7 @@ export class QueryService { // ここでいうBlockedは被Blockedの意 @bindThis - public generateBlockedUserQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { + public generateBlockedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking') .select('blocking.blockerId') .where('blocking.blockeeId = :blockeeId', { blockeeId: me.id }); @@ -127,7 +127,7 @@ export class QueryService { } @bindThis - public generateMutedUserQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { + public generateMutedUserQueryForNotes(q: SelectQueryBuilder, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void { const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: me.id }); diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index f35e456556..da76dd1284 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -7,13 +7,15 @@ import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import type { MiWebhook, webhookEventTypes } from '@/models/Webhook.js'; +import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; +import { type SystemWebhookPayload } from '@/core/SystemWebhookService.js'; +import { type UserWebhookPayload } from './UserWebhookService.js'; import type { DbJobData, DeliverJobData, @@ -30,8 +32,8 @@ import type { ObjectStorageQueue, RelationshipQueue, SystemQueue, - UserWebhookDeliverQueue, SystemWebhookDeliverQueue, + UserWebhookDeliverQueue, } from './QueueModule.js'; import type httpSignature from '@peertube/http-signature'; import type * as Bull from 'bullmq'; @@ -93,6 +95,13 @@ export class QueueService { repeat: { pattern: '0 0 * * *' }, removeOnComplete: true, }); + + this.systemQueue.add('checkModeratorsActivity', { + }, { + // 毎時30分に起動 + repeat: { pattern: '30 * * * *' }, + removeOnComplete: true, + }); } @bindThis @@ -461,10 +470,10 @@ export class QueueService { * @see UserWebhookDeliverProcessorService */ @bindThis - public userWebhookDeliver( + public userWebhookDeliver( webhook: MiWebhook, - type: typeof webhookEventTypes[number], - content: unknown, + type: T, + content: UserWebhookPayload, opts?: { attempts?: number }, ) { const data: UserWebhookDeliverJobData = { @@ -493,10 +502,10 @@ export class QueueService { * @see SystemWebhookDeliverProcessorService */ @bindThis - public systemWebhookDeliver( + public systemWebhookDeliver( webhook: MiSystemWebhook, - type: SystemWebhookEventType, - content: unknown, + type: T, + content: SystemWebhookPayload, opts?: { attempts?: number }, ) { const data: SystemWebhookDeliverJobData = { diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts index db8fe1a838..6f9fe53937 100644 --- a/packages/backend/src/core/ReactionService.ts +++ b/packages/backend/src/core/ReactionService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/_.js'; +import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, MiMeta } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { MiRemoteUser, MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; @@ -20,7 +20,6 @@ import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerServ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; @@ -71,6 +70,9 @@ const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; @Injectable() export class ReactionService { constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -84,7 +86,6 @@ export class ReactionService { private emojisRepository: EmojisRepository, private utilityService: UtilityService, - private metaService: MetaService, private customEmojiService: CustomEmojiService, private roleService: RoleService, private userEntityService: UserEntityService, @@ -103,8 +104,6 @@ export class ReactionService { @bindThis public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) { - const meta = await this.metaService.fetch(); - // Check blocking if (note.userId !== user.id) { const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); @@ -150,7 +149,7 @@ export class ReactionService { } // for media silenced host, custom emoji reactions are not allowed - if (reacterHost != null && this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, reacterHost)) { + if (reacterHost != null && this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, reacterHost)) { reaction = FALLBACK; } } else { @@ -195,7 +194,7 @@ export class ReactionService { } // Increment reactions count - if (meta.enableReactionsBuffering) { + if (this.meta.enableReactionsBuffering) { await this.reactionsBufferingService.create(note.id, user.id, reaction, note.reactionAndUserPairCache); } else { const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`; @@ -228,7 +227,7 @@ export class ReactionService { } } - if (meta.enableChartsForRemoteUser || (user.host == null)) { + if (this.meta.enableChartsForRemoteUser || (user.host == null)) { this.perUserReactionsChart.update(user, note); } @@ -305,10 +304,8 @@ export class ReactionService { throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted'); } - const meta = await this.metaService.fetch(); - // Decrement reactions count - if (meta.enableReactionsBuffering) { + if (this.meta.enableReactionsBuffering) { await this.reactionsBufferingService.delete(note.id, user.id, exist.reaction); } else { const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`; @@ -341,8 +338,21 @@ export class ReactionService { } /** - * 文字列タイプのレガシーな形式のリアクションを現在の形式に変換しつつ、 - * データベース上には存在する「0個のリアクションがついている」という情報を削除する。 + * - 文字列タイプのレガシーな形式のリアクションを現在の形式に変換する + * - ローカルのリアクションのホストを `@.` にする(`decodeReaction()`の効果) + */ + @bindThis + public convertLegacyReaction(reaction: string): string { + reaction = this.decodeReaction(reaction).reaction; + if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; + return reaction; + } + + // TODO: 廃止 + /** + * - 文字列タイプのレガシーな形式のリアクションを現在の形式に変換する + * - ローカルのリアクションのホストを `@.` にする(`decodeReaction()`の効果) + * - データベース上には存在する「0個のリアクションがついている」という情報を削除する */ @bindThis public convertLegacyReactions(reactions: MiNote['reactions']): MiNote['reactions'] { @@ -355,10 +365,7 @@ export class ReactionService { return count > 0; }) .map(([reaction, count]) => { - // unchecked indexed access - const convertedReaction = legacies[reaction] as string | undefined; - - const key = this.decodeReaction(convertedReaction ?? reaction).reaction; + const key = this.convertLegacyReaction(reaction); return [key, count] as const; }) @@ -413,11 +420,4 @@ export class ReactionService { host: undefined, }; } - - @bindThis - public convertLegacyReaction(reaction: string): string { - reaction = this.decodeReaction(reaction).reaction; - if (Object.keys(legacies).includes(reaction)) return legacies[reaction]; - return reaction; - } } diff --git a/packages/backend/src/core/ReactionsBufferingService.ts b/packages/backend/src/core/ReactionsBufferingService.ts index b1a197feeb..b4207c5106 100644 --- a/packages/backend/src/core/ReactionsBufferingService.ts +++ b/packages/backend/src/core/ReactionsBufferingService.ts @@ -11,22 +11,48 @@ import { bindThis } from '@/decorators.js'; import type { MiUser, NotesRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import { PER_NOTE_REACTION_USER_PAIR_CACHE_MAX } from '@/const.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; const REDIS_DELTA_PREFIX = 'reactionsBufferDeltas'; const REDIS_PAIR_PREFIX = 'reactionsBufferPairs'; @Injectable() -export class ReactionsBufferingService { +export class ReactionsBufferingService implements OnApplicationShutdown { constructor( @Inject(DI.config) private config: Config, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.redisForReactions) private redisForReactions: Redis.Redis, // TODO: 専用のRedisインスタンスにする @Inject(DI.notesRepository) private notesRepository: NotesRepository, ) { + this.redisForSub.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string) { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'metaUpdated': { + // リアクションバッファリングが有効→無効になったら即bake + if (body.before != null && body.before.enableReactionsBuffering && !body.after.enableReactionsBuffering) { + this.bake(); + } + break; + } + default: + break; + } + } } @bindThis @@ -159,4 +185,27 @@ export class ReactionsBufferingService { .execute(); } } + + @bindThis + public mergeReactions(src: MiNote['reactions'], delta: Record): MiNote['reactions'] { + const reactions = { ...src }; + for (const [name, count] of Object.entries(delta)) { + if (reactions[name] != null) { + reactions[name] += count; + } else { + reactions[name] = count; + } + } + return reactions; + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } } diff --git a/packages/backend/src/core/RelayService.ts b/packages/backend/src/core/RelayService.ts index db32114346..9120de1f9f 100644 --- a/packages/backend/src/core/RelayService.ts +++ b/packages/backend/src/core/RelayService.ts @@ -4,53 +4,34 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; -import type { MiLocalUser, MiUser } from '@/models/User.js'; -import type { RelaysRepository, UsersRepository } from '@/models/_.js'; +import type { MiUser } from '@/models/User.js'; +import type { RelaysRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { MemorySingleCache } from '@/misc/cache.js'; import type { MiRelay } from '@/models/Relay.js'; import { QueueService } from '@/core/QueueService.js'; -import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { DI } from '@/di-symbols.js'; import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; - -const ACTOR_USERNAME = 'relay.actor' as const; +import { SystemAccountService } from '@/core/SystemAccountService.js'; @Injectable() export class RelayService { private relaysCache: MemorySingleCache; constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - @Inject(DI.relaysRepository) private relaysRepository: RelaysRepository, private idService: IdService, private queueService: QueueService, - private createSystemUserService: CreateSystemUserService, + private systemAccountService: SystemAccountService, private apRendererService: ApRendererService, ) { this.relaysCache = new MemorySingleCache(1000 * 60 * 10); // 10m } - @bindThis - private async getRelayActor(): Promise { - const user = await this.usersRepository.findOneBy({ - host: IsNull(), - username: ACTOR_USERNAME, - }); - - if (user) return user as MiLocalUser; - - const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME); - return created as MiLocalUser; - } - @bindThis public async addRelay(inbox: string): Promise { const relay = await this.relaysRepository.insertOne({ @@ -59,8 +40,8 @@ export class RelayService { status: 'requesting', }); - const relayActor = await this.getRelayActor(); - const follow = await this.apRendererService.renderFollowRelay(relay, relayActor); + const relayActor = await this.systemAccountService.fetch('relay'); + const follow = this.apRendererService.renderFollowRelay(relay, relayActor); const activity = this.apRendererService.addContext(follow); this.queueService.deliver(relayActor, activity, relay.inbox, false); @@ -77,7 +58,7 @@ export class RelayService { throw new Error('relay not found'); } - const relayActor = await this.getRelayActor(); + const relayActor = await this.systemAccountService.fetch('relay'); const follow = this.apRendererService.renderFollowRelay(relay, relayActor); const undo = this.apRendererService.renderUndo(follow, relayActor); const activity = this.apRendererService.addContext(undo); diff --git a/packages/backend/src/core/RemoteUserResolveService.ts b/packages/backend/src/core/RemoteUserResolveService.ts index f5a55eb8bc..a2f1b73cdb 100644 --- a/packages/backend/src/core/RemoteUserResolveService.ts +++ b/packages/backend/src/core/RemoteUserResolveService.ts @@ -56,7 +56,7 @@ export class RemoteUserResolveService { host = this.utilityService.toPuny(host); - if (this.config.host === host) { + if (host === this.utilityService.toPuny(this.config.host)) { this.logger.info(`return local user: ${usernameLower}`); return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => { if (u == null) { @@ -74,7 +74,7 @@ export class RemoteUserResolveService { if (user == null) { const self = await this.resolveSelf(acctLower); - if (self.href.startsWith(this.config.url)) { + if (this.utilityService.isUriLocal(self.href)) { const local = this.apDbResolverService.parseUri(self.href); if (local.local && local.type === 'users') { // the LR points to local diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 24752edcf6..86f8a5caa1 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -8,6 +8,7 @@ import * as Redis from 'ioredis'; import { In } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import type { + MiMeta, MiRole, MiRoleAssignment, RoleAssignmentsRepository, @@ -18,7 +19,6 @@ import { MemoryKVCache, MemorySingleCache } from '@/misc/cache.js'; import type { MiUser } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; import type { RoleCondFormulaValue } from '@/models/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -63,6 +63,7 @@ export type RolePolicies = { canImportFollowing: boolean; canImportMuting: boolean; canImportUserLists: boolean; + canChat: boolean; }; export const DEFAULT_POLICIES: RolePolicies = { @@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = { canImportFollowing: true, canImportMuting: true, canImportUserLists: true, + canChat: true, }; @Injectable() @@ -111,8 +113,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { constructor( private moduleRef: ModuleRef, - @Inject(DI.redis) - private redisClient: Redis.Redis, + @Inject(DI.meta) + private meta: MiMeta, @Inject(DI.redisForTimelines) private redisForTimelines: Redis.Redis, @@ -129,7 +131,6 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @Inject(DI.roleAssignmentsRepository) private roleAssignmentsRepository: RoleAssignmentsRepository, - private metaService: MetaService, private cacheService: CacheService, private userEntityService: UserEntityService, private globalEventService: GlobalEventService, @@ -349,8 +350,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async getUserPolicies(userId: MiUser['id'] | null): Promise { - const meta = await this.metaService.fetch(); - const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies }; + const basePolicies = { ...DEFAULT_POLICIES, ...this.meta.policies }; if (userId == null) return basePolicies; @@ -402,65 +402,87 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)), canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)), canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)), + canChat: calc('canChat', vs => vs.some(v => v === true)), }; } @bindThis - public async isModerator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise { + public async isModerator(user: { id: MiUser['id'] } | null): Promise { if (user == null) return false; - return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator); + return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator); } @bindThis - public async isAdministrator(user: { id: MiUser['id']; isRoot: MiUser['isRoot'] } | null): Promise { + public async isAdministrator(user: { id: MiUser['id'] } | null): Promise { if (user == null) return false; - return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isAdministrator); + return (this.meta.rootUserId === user.id) || (await this.getUserRoles(user.id)).some(r => r.isAdministrator); } @bindThis - public async isExplorable(role: { id: MiRole['id']} | null): Promise { + public async isExplorable(role: { id: MiRole['id'] } | null): Promise { if (role == null) return false; const check = await this.rolesRepository.findOneBy({ id: role.id }); if (check == null) return false; return check.isExplorable; } + /** + * モデレーター権限のロールが割り当てられているユーザID一覧を取得する. + * + * @param opts.includeAdmins 管理者権限も含めるか(デフォルト: true) + * @param opts.includeRoot rootユーザも含めるか(デフォルト: false) + * @param opts.excludeExpire 期限切れのロールを除外するか(デフォルト: false) + */ @bindThis - public async getModeratorIds(includeAdmins = true, excludeExpire = false): Promise { + public async getModeratorIds(opts?: { + includeAdmins?: boolean, + includeRoot?: boolean, + excludeExpire?: boolean, + }): Promise { + const includeAdmins = opts?.includeAdmins ?? true; + const includeRoot = opts?.includeRoot ?? false; + const excludeExpire = opts?.excludeExpire ?? false; + const roles = await this.rolesCache.fetch(() => this.rolesRepository.findBy({})); const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); - // TODO: isRootなアカウントも含める const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ roleId: In(moderatorRoles.map(r => r.id)) }) : []; + // Setを経由して重複を除去(ユーザIDは重複する可能性があるので) const now = Date.now(); - const result = [ - // Setを経由して重複を除去(ユーザIDは重複する可能性があるので) - ...new Set( - assigns - .filter(it => - (excludeExpire) - ? (it.expiresAt == null || it.expiresAt.getTime() > now) - : true, - ) - .map(a => a.userId), - ), - ]; + const resultSet = new Set( + assigns + .filter(it => + (excludeExpire) + ? (it.expiresAt == null || it.expiresAt.getTime() > now) + : true, + ) + .map(a => a.userId), + ); - return result.sort((x, y) => x.localeCompare(y)); + if (includeRoot && this.meta.rootUserId) { + resultSet.add(this.meta.rootUserId); + } + + return [...resultSet].sort((x, y) => x.localeCompare(y)); } @bindThis - public async getModerators(includeAdmins = true): Promise { - const ids = await this.getModeratorIds(includeAdmins); - const users = ids.length > 0 ? await this.usersRepository.findBy({ - id: In(ids), - }) : []; - return users; + public async getModerators(opts?: { + includeAdmins?: boolean, + includeRoot?: boolean, + excludeExpire?: boolean, + }): Promise { + const ids = await this.getModeratorIds(opts); + return ids.length > 0 + ? await this.usersRepository.findBy({ + id: In(ids), + }) + : []; } @bindThis diff --git a/packages/backend/src/core/S3Service.ts b/packages/backend/src/core/S3Service.ts index bb2a463354..968a5dcc0b 100644 --- a/packages/backend/src/core/S3Service.ts +++ b/packages/backend/src/core/S3Service.ts @@ -28,7 +28,7 @@ export class S3Service { ? `${meta.objectStorageUseSSL ? 'https' : 'http'}://${meta.objectStorageEndpoint}` : `${meta.objectStorageUseSSL ? 'https' : 'http'}://example.net`; // dummy url to select http(s) agent - const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy); + const agent = this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy, true); const handlerOption: NodeHttpHandlerOptions = {}; if (meta.objectStorageUseSSL) { handlerOption.httpsAgent = agent as https.Agent; @@ -46,6 +46,8 @@ export class S3Service { tls: meta.objectStorageUseSSL, forcePathStyle: meta.objectStorageEndpoint ? meta.objectStorageS3ForcePathStyle : false, // AWS with endPoint omitted requestHandler: new NodeHttpHandler(handlerOption), + requestChecksumCalculation: 'WHEN_REQUIRED', + responseChecksumValidation: 'WHEN_REQUIRED', }); } diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index edfc470375..aa787c93de 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -6,16 +6,17 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { Config } from '@/config.js'; +import { type Config, FulltextSearchProvider } from '@/config.js'; import { bindThis } from '@/decorators.js'; import { MiNote } from '@/models/Note.js'; -import { MiUser } from '@/models/_.js'; import type { NotesRepository } from '@/models/_.js'; +import { MiUser } from '@/models/_.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { CacheService } from '@/core/CacheService.js'; import { QueryService } from '@/core/QueryService.js'; import { IdService } from '@/core/IdService.js'; +import { LoggerService } from '@/core/LoggerService.js'; import type { Index, MeiliSearch } from 'meilisearch'; type K = string; @@ -27,12 +28,24 @@ type Q = { op: '<', k: K, v: number } | { op: '>=', k: K, v: number } | { op: '<=', k: K, v: number } | - { op: 'is null', k: K} | - { op: 'is not null', k: K} | + { op: 'is null', k: K } | + { op: 'is not null', k: K } | { op: 'and', qs: Q[] } | { op: 'or', qs: Q[] } | { op: 'not', q: Q }; +export type SearchOpts = { + userId?: MiNote['userId'] | null; + channelId?: MiNote['channelId'] | null; + host?: string | null; +}; + +export type SearchPagination = { + untilId?: MiNote['id']; + sinceId?: MiNote['id']; + limit: number; +}; + function compileValue(value: V): string { if (typeof value === 'string') { return `'${value}'`; // TODO: escape @@ -64,7 +77,8 @@ function compileQuery(q: Q): string { @Injectable() export class SearchService { private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local'; - private meilisearchNoteIndex: Index | null = null; + private readonly meilisearchNoteIndex: Index | null = null; + private readonly provider: FulltextSearchProvider; constructor( @Inject(DI.config) @@ -79,6 +93,7 @@ export class SearchService { private cacheService: CacheService, private queryService: QueryService, private idService: IdService, + private loggerService: LoggerService, ) { if (meilisearch) { this.meilisearchNoteIndex = meilisearch.index(`${config.meilisearch!.index}---notes`); @@ -109,132 +124,185 @@ export class SearchService { if (config.meilisearch?.scope) { this.meilisearchIndexScope = config.meilisearch.scope; } + + this.provider = config.fulltextSearch?.provider ?? 'sqlLike'; + this.loggerService.getLogger('SearchService').info(`-- Provider: ${this.provider}`); } @bindThis public async indexNote(note: MiNote): Promise { + if (!this.meilisearch) return; if (note.text == null && note.cw == null) return; if (!['home', 'public'].includes(note.visibility)) return; - if (this.meilisearch) { - switch (this.meilisearchIndexScope) { - case 'global': - break; + switch (this.meilisearchIndexScope) { + case 'global': + break; - case 'local': - if (note.userHost == null) break; - return; + case 'local': + if (note.userHost == null) break; + return; - default: { - if (note.userHost == null) break; - if (this.meilisearchIndexScope.includes(note.userHost)) break; - return; - } + default: { + if (note.userHost == null) break; + if (this.meilisearchIndexScope.includes(note.userHost)) break; + return; } - - await this.meilisearchNoteIndex?.addDocuments([{ - id: note.id, - createdAt: this.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 this.meilisearchNoteIndex?.addDocuments([{ + id: note.id, + createdAt: this.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', + }); } @bindThis public async unindexNote(note: MiNote): Promise { + if (!this.meilisearch) return; if (!['home', 'public'].includes(note.visibility)) return; - if (this.meilisearch) { - this.meilisearchNoteIndex!.deleteDocument(note.id); + await this.meilisearchNoteIndex?.deleteDocument(note.id); + } + + @bindThis + public async searchNote( + q: string, + me: MiUser | null, + opts: SearchOpts, + pagination: SearchPagination, + ): Promise { + switch (this.provider) { + case 'sqlLike': + case 'sqlPgroonga': { + // ほとんど内容に差がないのでsqlLikeとsqlPgroongaを同じ処理にしている. + // 今後の拡張で差が出る用であれば関数を分ける. + return this.searchNoteByLike(q, me, opts, pagination); + } + case 'meilisearch': { + return this.searchNoteByMeiliSearch(q, me, opts, pagination); + } + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const typeCheck: never = this.provider; + return []; + } } } @bindThis - public async searchNote(q: string, me: MiUser | null, opts: { - userId?: MiNote['userId'] | null; - channelId?: MiNote['channelId'] | null; - host?: string | null; - }, pagination: { - untilId?: MiNote['id']; - sinceId?: MiNote['id']; - limit?: number; - }): Promise { - if (this.meilisearch) { - const filter: Q = { - op: 'and', - qs: [], - }; - if (pagination.untilId) filter.qs.push({ op: '<', k: 'createdAt', v: this.idService.parse(pagination.untilId).date.getTime() }); - if (pagination.sinceId) filter.qs.push({ op: '>', k: 'createdAt', v: this.idService.parse(pagination.sinceId).date.getTime() }); - if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); - if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); - if (opts.host) { - if (opts.host === '.') { - filter.qs.push({ op: 'is null', k: 'userHost' }); - } else { - filter.qs.push({ op: '=', k: 'userHost', v: opts.host }); - } + private async searchNoteByLike( + q: string, + me: MiUser | null, + opts: SearchOpts, + pagination: SearchPagination, + ): Promise { + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); + + if (opts.userId) { + query.andWhere('note.userId = :userId', { userId: opts.userId }); + } else if (opts.channelId) { + query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); + } + + query + .innerJoinAndSelect('note.user', 'user') + .leftJoinAndSelect('note.reply', 'reply') + .leftJoinAndSelect('note.renote', 'renote') + .leftJoinAndSelect('reply.user', 'replyUser') + .leftJoinAndSelect('renote.user', 'renoteUser'); + + if (this.config.fulltextSearch?.provider === 'sqlPgroonga') { + query.andWhere('note.text &@~ :q', { q }); + } else { + query.andWhere('LOWER(note.text) LIKE :q', { q: `%${ sqlLikeEscape(q.toLowerCase()) }%` }); + } + + if (opts.host) { + if (opts.host === '.') { + query.andWhere('user.host IS NULL'); + } else { + query.andWhere('user.host = :host', { host: opts.host }); } - const res = await this.meilisearchNoteIndex!.search(q, { - sort: ['createdAt:desc'], - matchingStrategy: 'all', - attributesToRetrieve: ['id', 'createdAt'], - filter: compileQuery(filter), - limit: pagination.limit, - }); - if (res.hits.length === 0) return []; - const [ - userIdsWhoMeMuting, - userIdsWhoBlockingMe, - ] = me ? await Promise.all([ + } + + this.queryService.generateVisibilityQuery(query, me); + if (me) this.queryService.generateMutedUserQueryForNotes(query, me); + if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + + return query.limit(pagination.limit).getMany(); + } + + @bindThis + private async searchNoteByMeiliSearch( + q: string, + me: MiUser | null, + opts: SearchOpts, + pagination: SearchPagination, + ): Promise { + if (!this.meilisearch || !this.meilisearchNoteIndex) { + throw new Error('MeiliSearch is not available'); + } + + const filter: Q = { + op: 'and', + qs: [], + }; + if (pagination.untilId) filter.qs.push({ + op: '<', + k: 'createdAt', + v: this.idService.parse(pagination.untilId).date.getTime(), + }); + if (pagination.sinceId) filter.qs.push({ + op: '>', + k: 'createdAt', + v: this.idService.parse(pagination.sinceId).date.getTime(), + }); + if (opts.userId) filter.qs.push({ op: '=', k: 'userId', v: opts.userId }); + if (opts.channelId) filter.qs.push({ op: '=', k: 'channelId', v: opts.channelId }); + if (opts.host) { + if (opts.host === '.') { + filter.qs.push({ op: 'is null', k: 'userHost' }); + } else { + filter.qs.push({ op: '=', k: 'userHost', v: opts.host }); + } + } + + const res = await this.meilisearchNoteIndex.search(q, { + sort: ['createdAt:desc'], + matchingStrategy: 'all', + attributesToRetrieve: ['id', 'createdAt'], + filter: compileQuery(filter), + limit: pagination.limit, + }); + if (res.hits.length === 0) { + return []; + } + + const [ + userIdsWhoMeMuting, + userIdsWhoBlockingMe, + ] = me + ? await Promise.all([ this.cacheService.userMutingsCache.fetch(me.id), this.cacheService.userBlockedCache.fetch(me.id), - ]) : [new Set(), new Set()]; - const notes = (await this.notesRepository.findBy({ - id: In(res.hits.map(x => x.id)), - })).filter(note => { - if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; - if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; - return true; - }); - return notes.sort((a, b) => a.id > b.id ? -1 : 1); - } else { - const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), pagination.sinceId, pagination.untilId); + ]) + : [new Set(), new Set()]; + const notes = (await this.notesRepository.findBy({ + id: In(res.hits.map(x => x.id)), + })).filter(note => { + if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false; + if (me && isUserRelated(note, userIdsWhoMeMuting)) return false; + return true; + }); - if (opts.userId) { - query.andWhere('note.userId = :userId', { userId: opts.userId }); - } else if (opts.channelId) { - query.andWhere('note.channelId = :channelId', { channelId: opts.channelId }); - } - - query - .andWhere('note.text ILIKE :q', { q: `%${ sqlLikeEscape(q) }%` }) - .innerJoinAndSelect('note.user', 'user') - .leftJoinAndSelect('note.reply', 'reply') - .leftJoinAndSelect('note.renote', 'renote') - .leftJoinAndSelect('reply.user', 'replyUser') - .leftJoinAndSelect('renote.user', 'renoteUser'); - - if (opts.host) { - if (opts.host === '.') { - query.andWhere('user.host IS NULL'); - } else { - query.andWhere('user.host = :host', { host: opts.host }); - } - } - - this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - - return await query.limit(pagination.limit).getMany(); - } + return notes.sort((a, b) => a.id > b.id ? -1 : 1); } } diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index de45898328..5462cb0b13 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -8,20 +8,20 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { DataSource, IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; import { MiUser } from '@/models/User.js'; import { MiUserProfile } from '@/models/UserProfile.js'; import { IdService } from '@/core/IdService.js'; import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUsedUsername } from '@/models/UsedUsername.js'; -import generateUserToken from '@/misc/generate-native-user-token.js'; +import { generateNativeUserToken } from '@/misc/token.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; import { bindThis } from '@/decorators.js'; import UsersChart from '@/core/chart/charts/users.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { MetaService } from '@/core/MetaService.js'; import { UserService } from '@/core/UserService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { MetaService } from '@/core/MetaService.js'; @Injectable() export class SignupService { @@ -29,6 +29,9 @@ export class SignupService { @Inject(DI.db) private db: DataSource, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -39,8 +42,8 @@ export class SignupService { private userService: UserService, private userEntityService: UserEntityService, private idService: IdService, + private systemAccountService: SystemAccountService, private metaService: MetaService, - private instanceActorService: InstanceActorService, private usersChart: UsersChart, ) { } @@ -73,7 +76,7 @@ export class SignupService { } // Generate secret - const secret = generateUserToken(); + const secret = generateNativeUserToken(); // Check username duplication if (await this.usersRepository.exists({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { @@ -85,11 +88,8 @@ export class SignupService { throw new Error('USED_USERNAME'); } - const isTheFirstUser = !await this.instanceActorService.realLocalUsersPresent(); - - if (!opts.ignorePreservedUsernames && !isTheFirstUser) { - const instance = await this.metaService.fetch(true); - const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + if (!opts.ignorePreservedUsernames && this.meta.rootUserId != null) { + const isPreserved = this.meta.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); if (isPreserved) { throw new Error('USED_USERNAME'); } @@ -129,7 +129,6 @@ export class SignupService { usernameLower: username.toLowerCase(), host: this.utilityService.toPunyNullable(host), token: secret, - isRoot: isTheFirstUser, })); await transactionalEntityManager.save(new MiUserKeypair({ @@ -150,8 +149,12 @@ export class SignupService { })); }); - this.usersChart.update(account, true).then(); - this.userService.notifySystemWebhook(account, 'userCreated').then(); + this.usersChart.update(account, true); + this.userService.notifySystemWebhook(account, 'userCreated'); + + if (this.meta.rootUserId == null) { + await this.metaService.update({ rootUserId: account.id }); + } return { account, secret }; } diff --git a/packages/backend/src/core/SystemAccountService.ts b/packages/backend/src/core/SystemAccountService.ts new file mode 100644 index 0000000000..1e050c3054 --- /dev/null +++ b/packages/backend/src/core/SystemAccountService.ts @@ -0,0 +1,172 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { DataSource, IsNull } from 'typeorm'; +import bcrypt from 'bcryptjs'; +import { MiLocalUser, MiUser } from '@/models/User.js'; +import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js'; +import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; +import { MemoryKVCache } from '@/misc/cache.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { generateNativeUserToken } from '@/misc/token.js'; +import { IdService } from '@/core/IdService.js'; +import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; + +export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const; + +@Injectable() +export class SystemAccountService { + private cache: MemoryKVCache; + + constructor( + @Inject(DI.db) + private db: DataSource, + + @Inject(DI.meta) + private meta: MiMeta, + + @Inject(DI.systemAccountsRepository) + private systemAccountsRepository: SystemAccountsRepository, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + private idService: IdService, + ) { + this.cache = new MemoryKVCache(1000 * 60 * 10); // 10m + } + + @bindThis + public async list(): Promise { + const accounts = await this.systemAccountsRepository.findBy({}); + + return accounts; + } + + @bindThis + public async fetch(type: typeof SYSTEM_ACCOUNT_TYPES[number]): Promise { + const cached = this.cache.get(type); + if (cached) return cached; + + const systemAccount = await this.systemAccountsRepository.findOne({ + where: { type: type }, + relations: ['user'], + }); + + if (systemAccount) { + this.cache.set(type, systemAccount.user as MiLocalUser); + return systemAccount.user as MiLocalUser; + } else { + const created = await this.createCorrespondingUser(type, { + username: `system.${type}`, // NOTE: (できれば避けたいが) . が含まれるかどうかでシステムアカウントかどうかを判定している処理もあるので変えないように + name: this.meta.name, + }); + this.cache.set(type, created); + return created; + } + } + + @bindThis + private async createCorrespondingUser(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: { + username: MiUser['username']; + name?: MiUser['name']; + }): Promise { + const password = randomUUID(); + + // Generate hash of password + const salt = await bcrypt.genSalt(8); + const hash = await bcrypt.hash(password, salt); + + // Generate secret + const secret = generateNativeUserToken(); + + const keyPair = await genRsaKeyPair(); + + let account!: MiUser; + + // Start transaction + await this.db.transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOneBy(MiUser, { + usernameLower: extra.username.toLowerCase(), + host: IsNull(), + }); + + if (exist) { + account = exist; + return; + } + + account = await transactionalEntityManager.insert(MiUser, { + id: this.idService.gen(), + username: extra.username, + usernameLower: extra.username.toLowerCase(), + host: null, + token: secret, + isLocked: true, + isExplorable: false, + isBot: true, + name: extra.name, + }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); + + await transactionalEntityManager.insert(MiUserKeypair, { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + userId: account.id, + }); + + await transactionalEntityManager.insert(MiUserProfile, { + userId: account.id, + autoAcceptFollowed: false, + password: hash, + }); + + await transactionalEntityManager.insert(MiUsedUsername, { + createdAt: new Date(), + username: extra.username.toLowerCase(), + }); + + await transactionalEntityManager.insert(MiSystemAccount, { + id: this.idService.gen(), + userId: account.id, + type: type, + }); + }); + + return account as MiLocalUser; + } + + @bindThis + public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: { + name?: string; + description?: MiUserProfile['description']; + }): Promise { + const user = await this.fetch(type); + + const updates = {} as Partial; + if (extra.name !== undefined) updates.name = extra.name; + + if (Object.keys(updates).length > 0) { + await this.usersRepository.update(user.id, updates); + } + + const profileUpdates = {} as Partial; + if (extra.description !== undefined) profileUpdates.description = extra.description; + + if (Object.keys(profileUpdates).length > 0) { + await this.userProfilesRepository.update(user.id, profileUpdates); + } + + const updated = await this.usersRepository.findOneByOrFail({ id: user.id }) as MiLocalUser; + this.cache.set(type, updated); + + return updated; + } +} diff --git a/packages/backend/src/core/SystemWebhookService.ts b/packages/backend/src/core/SystemWebhookService.ts index bb7c6b8c0e..8239490adc 100644 --- a/packages/backend/src/core/SystemWebhookService.ts +++ b/packages/backend/src/core/SystemWebhookService.ts @@ -15,11 +15,41 @@ import { QueueService } from '@/core/QueueService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; import { LoggerService } from '@/core/LoggerService.js'; import Logger from '@/logger.js'; +import { Packed } from '@/misc/json-schema.js'; +import { AbuseReportResolveType } from '@/models/AbuseUserReport.js'; +import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; +export type AbuseReportPayload = { + id: string; + targetUserId: string; + targetUser: Packed<'UserLite'> | null; + targetUserHost: string | null; + reporterId: string; + reporter: Packed<'UserLite'> | null; + reporterHost: string | null; + assigneeId: string | null; + assignee: Packed<'UserLite'> | null; + resolved: boolean; + forwarded: boolean; + comment: string; + moderationNote: string; + resolvedAs: AbuseReportResolveType | null; +}; + +export type InactiveModeratorsWarningPayload = { + remainingTime: ModeratorInactivityRemainingTime; +}; + +export type SystemWebhookPayload = + T extends 'abuseReport' | 'abuseReportResolved' ? AbuseReportPayload : + T extends 'userCreated' ? Packed<'UserLite'> : + T extends 'inactiveModeratorsWarning' ? InactiveModeratorsWarningPayload : + T extends 'inactiveModeratorsInvitationOnlyChanged' ? Record : + never; + @Injectable() export class SystemWebhookService implements OnApplicationShutdown { - private logger: Logger; private activeSystemWebhooksFetched = false; private activeSystemWebhooks: MiSystemWebhook[] = []; @@ -31,11 +61,9 @@ export class SystemWebhookService implements OnApplicationShutdown { private idService: IdService, private queueService: QueueService, private moderationLogService: ModerationLogService, - private loggerService: LoggerService, private globalEventService: GlobalEventService, ) { this.redisForSub.on('message', this.onMessage); - this.logger = this.loggerService.getLogger('webhook'); } @bindThis @@ -101,8 +129,7 @@ export class SystemWebhookService implements OnApplicationShutdown { .log(updater, 'createSystemWebhook', { systemWebhookId: webhook.id, webhook: webhook, - }) - .then(); + }); return webhook; } @@ -139,8 +166,7 @@ export class SystemWebhookService implements OnApplicationShutdown { systemWebhookId: beforeEntity.id, before: beforeEntity, after: afterEntity, - }) - .then(); + }); return afterEntity; } @@ -158,35 +184,30 @@ export class SystemWebhookService implements OnApplicationShutdown { .log(updater, 'deleteSystemWebhook', { systemWebhookId: webhook.id, webhook, - }) - .then(); + }); } /** * SystemWebhook をWebhook配送キューに追加する * @see QueueService.systemWebhookDeliver - * // TODO: contentの型を厳格化する */ @bindThis public async enqueueSystemWebhook( - webhook: MiSystemWebhook | MiSystemWebhook['id'], type: T, - content: unknown, + content: SystemWebhookPayload, + opts?: { + excludes?: MiSystemWebhook['id'][]; + }, ) { - const webhookEntity = typeof webhook === 'string' - ? (await this.fetchActiveSystemWebhooks()).find(a => a.id === webhook) - : webhook; - if (!webhookEntity || !webhookEntity.isActive) { - this.logger.info(`SystemWebhook is not active or not found : ${webhook}`); - return; - } - - if (!webhookEntity.on.includes(type)) { - this.logger.info(`SystemWebhook ${webhookEntity.id} is not listening to ${type}`); - return; - } - - return this.queueService.systemWebhookDeliver(webhookEntity, type, content); + const webhooks = await this.fetchActiveSystemWebhooks() + .then(webhooks => { + return webhooks.filter(webhook => !opts?.excludes?.includes(webhook.id) && webhook.on.includes(type)); + }); + return Promise.all( + webhooks.map(webhook => { + return this.queueService.systemWebhookDeliver(webhook, type, content); + }), + ); } @bindThis diff --git a/packages/backend/src/core/UserBlockingService.ts b/packages/backend/src/core/UserBlockingService.ts index 2f1310b8ef..8da1bb2092 100644 --- a/packages/backend/src/core/UserBlockingService.ts +++ b/packages/backend/src/core/UserBlockingService.ts @@ -118,13 +118,7 @@ export class UserBlockingService implements OnModuleInit { schema: 'UserDetailedNotMe', }).then(async packed => { this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } + this.webhookService.enqueueUserWebhook(follower.id, 'unfollow', { user: packed }); }); } diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 6aab8fde70..e7a6be99fb 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; -import { IsNull } from 'typeorm'; +import { Brackets, IsNull } from 'typeorm'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { QueueService } from '@/core/QueueService.js'; @@ -13,23 +13,20 @@ import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { IdService } from '@/core/IdService.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import type { Packed } from '@/misc/json-schema.js'; import InstanceChart from '@/core/chart/charts/instance.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { UserWebhookService } from '@/core/UserWebhookService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { bindThis } from '@/decorators.js'; import { UserBlockingService } from '@/core/UserBlockingService.js'; -import { MetaService } from '@/core/MetaService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { AccountMoveService } from '@/core/AccountMoveService.js'; import { UtilityService } from '@/core/UtilityService.js'; -import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import type { ThinUser } from '@/queue/types.js'; import Logger from '../logger.js'; @@ -58,6 +55,9 @@ export class UserFollowingService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -79,13 +79,11 @@ export class UserFollowingService implements OnModuleInit { private idService: IdService, private queueService: QueueService, private globalEventService: GlobalEventService, - private metaService: MetaService, private notificationService: NotificationService, private federatedInstanceService: FederatedInstanceService, private webhookService: UserWebhookService, private apRendererService: ApRendererService, private accountMoveService: AccountMoveService, - private fanoutTimelineService: FanoutTimelineService, private perUserFollowingChart: PerUserFollowingChart, private instanceChart: InstanceChart, ) { @@ -172,7 +170,7 @@ export class UserFollowingService implements OnModuleInit { followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee) && process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING !== 'true') || - (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && this.utilityService.isSilencedHost((await this.metaService.fetch()).silencedHosts, follower.host)) + (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower) && this.utilityService.isSilencedHost(this.meta.silencedHosts, follower.host)) ) { let autoAccept = false; @@ -277,16 +275,19 @@ export class UserFollowingService implements OnModuleInit { followeeId: followee.id, followerId: follower.id, }); - - // 通知を作成 - if (follower.host === null) { - this.notificationService.createNotification(follower.id, 'followRequestAccepted', { - }, followee.id); - } } if (alreadyFollowed) return; + // 通知を作成 + if (follower.host === null) { + const profile = await this.cacheService.userProfileCache.fetch(followee.id); + + this.notificationService.createNotification(follower.id, 'followRequestAccepted', { + message: profile.followedMessage, + }, followee.id); + } + this.globalEventService.publishInternalEvent('follow', { followerId: follower.id, followeeId: followee.id }); const [followeeUser, followerUser] = await Promise.all([ @@ -304,20 +305,22 @@ export class UserFollowingService implements OnModuleInit { //#endregion //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, true); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, true); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followingCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, true); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => { + this.instancesRepository.increment({ id: i.id }, 'followersCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, true); + } + }); + } } //#endregion @@ -330,13 +333,7 @@ export class UserFollowingService implements OnModuleInit { schema: 'UserDetailedNotMe', }).then(async packed => { this.globalEventService.publishMainStream(follower.id, 'follow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'follow', { - user: packed, - }); - } + this.webhookService.enqueueUserWebhook(follower.id, 'follow', { user: packed }); }); } @@ -344,13 +341,7 @@ export class UserFollowingService implements OnModuleInit { if (this.userEntityService.isLocalUser(followee)) { this.userEntityService.pack(follower.id, followee).then(async packed => { this.globalEventService.publishMainStream(followee.id, 'followed', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'followed', { - user: packed, - }); - } + this.webhookService.enqueueUserWebhook(followee.id, 'followed', { user: packed }); }); // 通知を作成 @@ -397,13 +388,7 @@ export class UserFollowingService implements OnModuleInit { schema: 'UserDetailedNotMe', }).then(async packed => { this.globalEventService.publishMainStream(follower.id, 'unfollow', packed); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'unfollow', { - user: packed, - }); - } + this.webhookService.enqueueUserWebhook(follower.id, 'unfollow', { user: packed }); }); } @@ -436,20 +421,22 @@ export class UserFollowingService implements OnModuleInit { //#endregion //#region Update instance stats - if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { - this.federatedInstanceService.fetch(follower.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowing(i.host, false); - } - }); - } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { - this.federatedInstanceService.fetch(followee.host).then(async i => { - this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.updateFollowers(i.host, false); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) { + this.federatedInstanceService.fetchOrRegister(follower.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowing(i.host, false); + } + }); + } else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { + this.federatedInstanceService.fetchOrRegister(followee.host).then(async i => { + this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.updateFollowers(i.host, false); + } + }); + } } //#endregion @@ -739,13 +726,7 @@ export class UserFollowingService implements OnModuleInit { }); this.globalEventService.publishMainStream(follower.id, 'unfollow', packedFollowee); - - const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); - for (const webhook of webhooks) { - this.queueService.userWebhookDeliver(webhook, 'unfollow', { - user: packedFollowee, - }); - } + this.webhookService.enqueueUserWebhook(follower.id, 'unfollow', { user: packedFollowee }); } @bindThis @@ -755,4 +736,30 @@ export class UserFollowingService implements OnModuleInit { .where('following.followerId = :followerId', { followerId: userId }) .getMany(); } + + @bindThis + public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) { + return this.followingsRepository.exists({ + where: { + followerId, + followeeId, + }, + }); + } + + @bindThis + public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) { + const count = await this.followingsRepository.createQueryBuilder('following') + .where(new Brackets(qb => { + qb.where('following.followerId = :aUserId', { aUserId }) + .andWhere('following.followeeId = :bUserId', { bUserId }); + })) + .orWhere(new Brackets(qb => { + qb.where('following.followerId = :bUserId', { bUserId }) + .andWhere('following.followeeId = :aUserId', { aUserId }); + })) + .getCount(); + + return count === 2; + } } diff --git a/packages/backend/src/core/UserListService.ts b/packages/backend/src/core/UserListService.ts index 6333356fe9..f0a8768c8f 100644 --- a/packages/backend/src/core/UserListService.ts +++ b/packages/backend/src/core/UserListService.ts @@ -15,11 +15,11 @@ import type { GlobalEvents } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { ProxyAccountService } from '@/core/ProxyAccountService.js'; import { bindThis } from '@/decorators.js'; import { QueueService } from '@/core/QueueService.js'; import { RedisKVCache } from '@/misc/cache.js'; import { RoleService } from '@/core/RoleService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; @Injectable() export class UserListService implements OnApplicationShutdown, OnModuleInit { @@ -43,8 +43,8 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { private userEntityService: UserEntityService, private idService: IdService, private globalEventService: GlobalEventService, - private proxyAccountService: ProxyAccountService, private queueService: QueueService, + private systemAccountService: SystemAccountService, ) { this.membersCache = new RedisKVCache>(this.redisClient, 'userListMembers', { lifetime: 1000 * 60 * 30, // 30m @@ -111,10 +111,8 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit { // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする if (this.userEntityService.isRemoteUser(target)) { - const proxy = await this.proxyAccountService.fetch(); - if (proxy) { - this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]); - } + const proxy = await this.systemAccountService.fetch('proxy'); + this.queueService.createFollowJob([{ from: { id: proxy.id }, to: { id: target.id } }]); } } diff --git a/packages/backend/src/core/UserSearchService.ts b/packages/backend/src/core/UserSearchService.ts index 0d03cf6ee0..4be7bd9bdb 100644 --- a/packages/backend/src/core/UserSearchService.ts +++ b/packages/backend/src/core/UserSearchService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, SelectQueryBuilder } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import { type FollowingsRepository, MiUser, type UsersRepository } from '@/models/_.js'; +import { type FollowingsRepository, MiUser, type MutingsRepository, type UserProfilesRepository, type UsersRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; import type { Config } from '@/config.js'; @@ -22,10 +22,19 @@ export class UserSearchService { constructor( @Inject(DI.config) private config: Config, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + private userEntityService: UserEntityService, ) { } @@ -58,7 +67,7 @@ export class UserSearchService { * @see {@link UserSearchService#buildSearchUserNoLoginQueries} */ @bindThis - public async search( + public async searchByUsernameAndHost( params: { username?: string | null, host?: string | null, @@ -202,4 +211,91 @@ export class UserSearchService { return userQuery; } + + @bindThis + public async search(query: string, meId: MiUser['id'] | null, options: Partial<{ + limit: number; + offset: number; + origin: 'local' | 'remote' | 'combined'; + }> = {}) { + const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 + + const isUsername = query.startsWith('@') && !query.includes(' ') && query.indexOf('@', 1) === -1; + + let users: MiUser[] = []; + + const mutingQuery = meId == null ? null : this.mutingsRepository.createQueryBuilder('muting') + .select('muting.muteeId') + .where('muting.muterId = :muterId', { muterId: meId }); + + const nameQuery = this.usersRepository.createQueryBuilder('user') + .where(new Brackets(qb => { + qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' }); + + if (isUsername) { + qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(query.replace('@', '').toLowerCase()) + '%' }); + } else if (this.userEntityService.validateLocalUsername(query)) { // Also search username if it qualifies as username + qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(query.toLowerCase()) + '%' }); + } + })) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE'); + + if (mutingQuery) { + nameQuery.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`); + nameQuery.setParameters(mutingQuery.getParameters()); + } + + if (options.origin === 'local') { + nameQuery.andWhere('user.host IS NULL'); + } else if (options.origin === 'remote') { + nameQuery.andWhere('user.host IS NOT NULL'); + } + + users = await nameQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .limit(options.limit) + .offset(options.offset) + .getMany(); + + if (users.length < (options.limit ?? 30)) { + const profQuery = this.userProfilesRepository.createQueryBuilder('prof') + .select('prof.userId') + .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(query) + '%' }); + + if (mutingQuery) { + profQuery.andWhere(`prof.userId NOT IN (${mutingQuery.getQuery()})`); + profQuery.setParameters(mutingQuery.getParameters()); + } + + if (options.origin === 'local') { + profQuery.andWhere('prof.userHost IS NULL'); + } else if (options.origin === 'remote') { + profQuery.andWhere('prof.userHost IS NOT NULL'); + } + + const userQuery = this.usersRepository.createQueryBuilder('user') + .where(`user.id IN (${ profQuery.getQuery() })`) + .andWhere(new Brackets(qb => { + qb + .where('user.updatedAt IS NULL') + .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); + })) + .andWhere('user.isSuspended = FALSE') + .setParameters(profQuery.getParameters()); + + users = users.concat(await userQuery + .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') + .limit(options.limit) + .offset(options.offset) + .getMany(), + ); + } + + return users; + } } diff --git a/packages/backend/src/core/UserService.ts b/packages/backend/src/core/UserService.ts index 9b1961c631..1f471513f3 100644 --- a/packages/backend/src/core/UserService.ts +++ b/packages/backend/src/core/UserService.ts @@ -63,13 +63,6 @@ export class UserService { @bindThis public async notifySystemWebhook(user: MiUser, type: 'userCreated') { const packedUser = await this.userEntityService.pack(user, null, { schema: 'UserLite' }); - const recipientWebhookIds = await this.systemWebhookService.fetchSystemWebhooks({ isActive: true, on: [type] }); - for (const webhookId of recipientWebhookIds) { - await this.systemWebhookService.enqueueSystemWebhook( - webhookId, - type, - packedUser, - ); - } + return this.systemWebhookService.enqueueSystemWebhook(type, packedUser); } } diff --git a/packages/backend/src/core/UserWebhookService.ts b/packages/backend/src/core/UserWebhookService.ts index 8a40a53688..9b0a598a1b 100644 --- a/packages/backend/src/core/UserWebhookService.ts +++ b/packages/backend/src/core/UserWebhookService.ts @@ -5,13 +5,26 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; -import { type WebhooksRepository } from '@/models/_.js'; -import { MiWebhook } from '@/models/Webhook.js'; +import { MiUser, type WebhooksRepository } from '@/models/_.js'; +import { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; import { DI } from '@/di-symbols.js'; import { bindThis } from '@/decorators.js'; import { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { Packed } from '@/misc/json-schema.js'; +import { QueueService } from '@/core/QueueService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; +export type UserWebhookPayload = + T extends 'note' | 'reply' | 'renote' | 'mention' ? { + note: Packed<'Note'>, + } : + T extends 'follow' | 'unfollow' ? { + user: Packed<'UserDetailedNotMe'>, + } : + T extends 'followed' ? { + user: Packed<'UserLite'>, + } : never; + @Injectable() export class UserWebhookService implements OnApplicationShutdown { private activeWebhooksFetched = false; @@ -22,6 +35,7 @@ export class UserWebhookService implements OnApplicationShutdown { private redisForSub: Redis.Redis, @Inject(DI.webhooksRepository) private webhooksRepository: WebhooksRepository, + private queueService: QueueService, ) { this.redisForSub.on('message', this.onMessage); } @@ -63,6 +77,25 @@ export class UserWebhookService implements OnApplicationShutdown { return query.getMany(); } + /** + * UserWebhook をWebhook配送キューに追加する + * @see QueueService.userWebhookDeliver + */ + @bindThis + public async enqueueUserWebhook( + userId: MiUser['id'], + type: T, + content: UserWebhookPayload, + ) { + const webhooks = await this.getActiveWebhooks() + .then(webhooks => webhooks.filter(webhook => webhook.userId === userId && webhook.on.includes(type))); + return Promise.all( + webhooks.map(webhook => { + return this.queueService.userWebhookDeliver(webhook, type, content); + }), + ); + } + @bindThis private async onMessage(_: string, data: string): Promise { const obj = JSON.parse(data); diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts index 94729250a6..23fb928ac9 100644 --- a/packages/backend/src/core/UtilityService.ts +++ b/packages/backend/src/core/UtilityService.ts @@ -3,19 +3,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { URL } from 'node:url'; -import { toASCII } from 'punycode'; +import { URL, domainToASCII } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; import RE2 from 're2'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; +import { MiMeta } from '@/models/Meta.js'; @Injectable() export class UtilityService { constructor( @Inject(DI.config) private config: Config, + + @Inject(DI.meta) + private meta: MiMeta, ) { } @@ -30,6 +33,19 @@ export class UtilityService { return this.toPuny(this.config.host) === this.toPuny(host); } + @bindThis + public isUriLocal(uri: string): boolean { + return this.punyHost(uri) === this.toPuny(this.config.host); + } + + // メールアドレスのバリデーションを行う + // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address + @bindThis + public validateEmailFormat(email: string): boolean { + const regexp = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + return regexp.test(email); + } + @bindThis public isBlockedHost(blockedHosts: string[], host: string | null): boolean { if (host == null) return false; @@ -92,17 +108,39 @@ export class UtilityService { @bindThis public extractDbHost(uri: string): string { const url = new URL(uri); - return this.toPuny(url.hostname); + return this.toPuny(url.host); } @bindThis public toPuny(host: string): string { - return toASCII(host.toLowerCase()); + return domainToASCII(host.toLowerCase()); } @bindThis public toPunyNullable(host: string | null | undefined): string | null { if (host == null) return null; - return toASCII(host.toLowerCase()); + return domainToASCII(host.toLowerCase()); + } + + @bindThis + public punyHost(url: string): string { + const urlObj = new URL(url); + const host = `${this.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`; + return host; + } + + @bindThis + public isFederationAllowedHost(host: string): boolean { + 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; + + return true; + } + + @bindThis + public isFederationAllowedUri(uri: string): boolean { + const host = this.extractDbHost(uri); + return this.isFederationAllowedHost(host); } } diff --git a/packages/backend/src/core/WebAuthnService.ts b/packages/backend/src/core/WebAuthnService.ts index ec9f4484a4..372e1e2ab7 100644 --- a/packages/backend/src/core/WebAuthnService.ts +++ b/packages/backend/src/core/WebAuthnService.ts @@ -12,10 +12,9 @@ import { } from '@simplewebauthn/server'; import { AttestationFormat, isoCBOR, isoUint8Array } from '@simplewebauthn/server/helpers'; import { DI } from '@/di-symbols.js'; -import type { UserSecurityKeysRepository } from '@/models/_.js'; +import type { MiMeta, UserSecurityKeysRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiUser } from '@/models/_.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import type { @@ -23,7 +22,6 @@ import type { AuthenticatorTransportFuture, CredentialDeviceType, PublicKeyCredentialCreationOptionsJSON, - PublicKeyCredentialDescriptorFuture, PublicKeyCredentialRequestOptionsJSON, RegistrationResponseJSON, } from '@simplewebauthn/types'; @@ -31,33 +29,33 @@ import type { @Injectable() export class WebAuthnService { constructor( - @Inject(DI.redis) - private redisClient: Redis.Redis, - @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.userSecurityKeysRepository) private userSecurityKeysRepository: UserSecurityKeysRepository, - - private metaService: MetaService, ) { } @bindThis - public async getRelyingParty(): Promise<{ origin: string; rpId: string; rpName: string; rpIcon?: string; }> { - const instance = await this.metaService.fetch(); + public getRelyingParty(): { origin: string; rpId: string; rpName: string; rpIcon?: string; } { return { origin: this.config.url, rpId: this.config.hostname, - rpName: instance.name ?? this.config.host, - rpIcon: instance.iconUrl ?? undefined, + rpName: this.meta.name ?? this.config.host, + rpIcon: this.meta.iconUrl ?? undefined, }; } @bindThis public async initiateRegistration(userId: MiUser['id'], userName: string, userDisplayName?: string): Promise { - const relyingParty = await this.getRelyingParty(); + const relyingParty = this.getRelyingParty(); const keys = await this.userSecurityKeysRepository.findBy({ userId: userId, }); @@ -104,7 +102,7 @@ export class WebAuthnService { await this.redisClient.del(`webauthn:challenge:${userId}`); - const relyingParty = await this.getRelyingParty(); + const relyingParty = this.getRelyingParty(); let verification; try { @@ -129,11 +127,11 @@ export class WebAuthnService { const { registrationInfo } = verification; return { - credentialID: registrationInfo.credentialID, - credentialPublicKey: registrationInfo.credentialPublicKey, + credentialID: registrationInfo.credential.id, + credentialPublicKey: registrationInfo.credential.publicKey, attestationObject: registrationInfo.attestationObject, fmt: registrationInfo.fmt, - counter: registrationInfo.counter, + counter: registrationInfo.credential.counter, userVerified: registrationInfo.userVerified, credentialDeviceType: registrationInfo.credentialDeviceType, credentialBackedUp: registrationInfo.credentialBackedUp, @@ -143,7 +141,7 @@ export class WebAuthnService { @bindThis public async initiateAuthentication(userId: MiUser['id']): Promise { - const relyingParty = await this.getRelyingParty(); + const relyingParty = this.getRelyingParty(); const keys = await this.userSecurityKeysRepository.findBy({ userId: userId, }); @@ -166,16 +164,92 @@ export class WebAuthnService { return authenticationOptions; } + /** + * Initiate Passkey Auth (Without specifying user) + * @returns authenticationOptions + */ + @bindThis + public async initiateSignInWithPasskeyAuthentication(context: string): Promise { + const relyingParty = await this.getRelyingParty(); + + const authenticationOptions = await generateAuthenticationOptions({ + rpID: relyingParty.rpId, + userVerification: 'preferred', + }); + + await this.redisClient.setex(`webauthn:challenge:${context}`, 90, authenticationOptions.challenge); + + return authenticationOptions; + } + + /** + * Verify Webauthn AuthenticationCredential + * @throws IdentifiableError + * @returns If the challenge is successful, return the user ID. Otherwise, return null. + */ + @bindThis + public async verifySignInWithPasskeyAuthentication(context: string, response: AuthenticationResponseJSON): Promise { + const challenge = await this.redisClient.getdel(`webauthn:challenge:${context}`); + + if (!challenge) { + throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', `challenge '${context}' not found`); + } + + const key = await this.userSecurityKeysRepository.findOneBy({ + id: response.id, + }); + + if (!key) { + throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'Unknown Webauthn key'); + } + + const relyingParty = await this.getRelyingParty(); + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: response, + expectedChallenge: challenge, + expectedOrigin: relyingParty.origin, + expectedRPID: relyingParty.rpId, + credential: { + id: key.id, + publicKey: Buffer.from(key.publicKey, 'base64url'), + counter: key.counter, + transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined, + }, + requireUserVerification: true, + }); + } catch (error) { + throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', `verification failed: ${error}`); + } + + const { verified, authenticationInfo } = verification; + + if (!verified) { + return null; + } + + await this.userSecurityKeysRepository.update({ + id: response.id, + }, { + lastUsed: new Date(), + counter: authenticationInfo.newCounter, + credentialDeviceType: authenticationInfo.credentialDeviceType, + credentialBackedUp: authenticationInfo.credentialBackedUp, + }); + + return key.userId; + } + @bindThis public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise { - const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`); + const challenge = await this.redisClient.getdel(`webauthn:challenge:${userId}`); if (!challenge) { throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found'); } - await this.redisClient.del(`webauthn:challenge:${userId}`); - const key = await this.userSecurityKeysRepository.findOneBy({ id: response.id, userId: userId, @@ -209,7 +283,7 @@ export class WebAuthnService { } } - const relyingParty = await this.getRelyingParty(); + const relyingParty = this.getRelyingParty(); let verification; try { @@ -218,9 +292,9 @@ export class WebAuthnService { expectedChallenge: challenge, expectedOrigin: relyingParty.origin, expectedRPID: relyingParty.rpId, - authenticator: { - credentialID: key.id, - credentialPublicKey: Buffer.from(key.publicKey, 'base64url'), + credential: { + id: key.id, + publicKey: Buffer.from(key.publicKey, 'base64url'), counter: key.counter, transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined, }, diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 0b4e107d21..222153fd2a 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -7,32 +7,16 @@ import { Injectable } from '@nestjs/common'; import { MiAbuseUserReport, MiNote, MiUser, MiWebhook } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { MiSystemWebhook, type SystemWebhookEventType } from '@/models/SystemWebhook.js'; -import { SystemWebhookService } from '@/core/SystemWebhookService.js'; -import { Packed } from '@/misc/json-schema.js'; +import { type AbuseReportPayload, SystemWebhookPayload, SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { type Packed } from '@/misc/json-schema.js'; import { type WebhookEventTypes } from '@/models/Webhook.js'; -import { UserWebhookService } from '@/core/UserWebhookService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js'; import { QueueService } from '@/core/QueueService.js'; +import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; const oneDayMillis = 24 * 60 * 60 * 1000; -function generateAbuseReport(override?: Partial): MiAbuseUserReport { - return { - id: 'dummy-abuse-report1', - targetUserId: 'dummy-target-user', - targetUser: null, - reporterId: 'dummy-reporter-user', - reporter: null, - assigneeId: null, - assignee: null, - resolved: false, - forwarded: false, - comment: 'This is a dummy report for testing purposes.', - targetUserHost: null, - reporterHost: null, - ...override, - }; -} - function generateDummyUser(override?: Partial): MiUser { return { id: 'dummy-user-1', @@ -63,11 +47,15 @@ function generateDummyUser(override?: Partial): MiUser { isLocked: false, isBot: false, isCat: true, - isRoot: false, isExplorable: true, isHibernated: false, isDeleted: false, + requireSigninToViewContents: false, + makeNotesFollowersOnlyBefore: null, + makeNotesHiddenBefore: null, + chatScope: 'mutual', emojis: [], + score: 0, host: null, inbox: null, sharedInbox: null, @@ -121,124 +109,6 @@ function generateDummyNote(override?: Partial): MiNote { }; } -function toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Packed<'Note'> { - return { - id: note.id, - createdAt: new Date().toISOString(), - deletedAt: null, - text: note.text, - cw: note.cw, - userId: note.userId, - user: toPackedUserLite(note.user ?? generateDummyUser()), - replyId: note.replyId, - renoteId: note.renoteId, - isHidden: false, - visibility: note.visibility, - mentions: note.mentions, - visibleUserIds: note.visibleUserIds, - fileIds: note.fileIds, - files: [], - tags: note.tags, - poll: null, - emojis: note.emojis, - channelId: note.channelId, - channel: note.channel, - localOnly: note.localOnly, - reactionAcceptance: note.reactionAcceptance, - reactionEmojis: {}, - reactions: {}, - reactionCount: 0, - renoteCount: note.renoteCount, - repliesCount: note.repliesCount, - uri: note.uri ?? undefined, - url: note.url ?? undefined, - reactionAndUserPairCache: note.reactionAndUserPairCache, - ...(detail ? { - clippedCount: note.clippedCount, - reply: note.reply ? toPackedNote(note.reply, false) : null, - renote: note.renote ? toPackedNote(note.renote, true) : null, - myReaction: null, - } : {}), - ...override, - }; -} - -function toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Packed<'UserLite'> { - return { - id: user.id, - name: user.name, - username: user.username, - host: user.host, - avatarUrl: user.avatarUrl, - avatarBlurhash: user.avatarBlurhash, - avatarDecorations: user.avatarDecorations.map(it => ({ - id: it.id, - angle: it.angle, - flipH: it.flipH, - url: 'https://example.com/dummy-image001.png', - offsetX: it.offsetX, - offsetY: it.offsetY, - })), - isBot: user.isBot, - isCat: user.isCat, - emojis: user.emojis, - onlineStatus: 'active', - badgeRoles: [], - ...override, - }; -} - -function toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Packed<'UserDetailedNotMe'> { - return { - ...toPackedUserLite(user), - url: null, - uri: null, - movedTo: null, - alsoKnownAs: [], - createdAt: new Date().toISOString(), - updatedAt: user.updatedAt?.toISOString() ?? null, - lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, - bannerUrl: user.bannerUrl, - bannerBlurhash: user.bannerBlurhash, - isLocked: user.isLocked, - isSilenced: false, - isSuspended: user.isSuspended, - description: null, - location: null, - birthday: null, - lang: null, - fields: [], - verifiedLinks: [], - followersCount: user.followersCount, - followingCount: user.followingCount, - notesCount: user.notesCount, - pinnedNoteIds: [], - pinnedNotes: [], - pinnedPageId: null, - pinnedPage: null, - publicReactions: true, - followersVisibility: 'public', - followingVisibility: 'public', - twoFactorEnabled: false, - usePasswordLessLogin: false, - securityKeys: false, - roles: [], - memo: null, - moderationNote: undefined, - isFollowing: false, - isFollowed: false, - hasPendingFollowRequestFromYou: false, - hasPendingFollowRequestToYou: false, - isBlocking: false, - isBlocked: false, - isMuted: false, - isRenoteMuted: false, - notify: 'none', - withReplies: true, - ...override, - }; -} - const dummyUser1 = generateDummyUser(); const dummyUser2 = generateDummyUser({ id: 'dummy-user-2', @@ -267,9 +137,11 @@ const dummyUser3 = generateDummyUser({ @Injectable() export class WebhookTestService { - public static NoSuchWebhookError = class extends Error {}; + public static NoSuchWebhookError = class extends Error { + }; constructor( + private customEmojiService: CustomEmojiService, private userWebhookService: UserWebhookService, private systemWebhookService: SystemWebhookService, private queueService: QueueService, @@ -285,10 +157,10 @@ export class WebhookTestService { * - 送信対象イベント(on)に関する設定 */ @bindThis - public async testUserWebhook( + public async testUserWebhook( params: { webhookId: MiWebhook['id'], - type: WebhookEventTypes, + type: T, override?: Partial>, }, sender: MiUser | null, @@ -300,7 +172,7 @@ export class WebhookTestService { } const webhook = webhooks[0]; - const send = (contents: unknown) => { + const send = (type: U, contents: UserWebhookPayload) => { const merged = { ...webhook, ...params.override, @@ -308,7 +180,7 @@ export class WebhookTestService { // テスト目的なのでUserWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図). // また、Jobの試行回数も1回だけ. - this.queueService.userWebhookDeliver(merged, params.type, contents, { attempts: 1 }); + this.queueService.userWebhookDeliver(merged, type, contents, { attempts: 1 }); }; const dummyNote1 = generateDummyNote({ @@ -340,33 +212,41 @@ export class WebhookTestService { switch (params.type) { case 'note': { - send(toPackedNote(dummyNote1)); + send('note', { note: await this.toPackedNote(dummyNote1) }); break; } case 'reply': { - send(toPackedNote(dummyReply1)); + send('reply', { note: await this.toPackedNote(dummyReply1) }); break; } case 'renote': { - send(toPackedNote(dummyRenote1)); + send('renote', { note: await this.toPackedNote(dummyRenote1) }); break; } case 'mention': { - send(toPackedNote(dummyMention1)); + send('mention', { note: await this.toPackedNote(dummyMention1) }); break; } case 'follow': { - send(toPackedUserDetailedNotMe(dummyUser1)); + send('follow', { user: await this.toPackedUserDetailedNotMe(dummyUser1) }); break; } case 'followed': { - send(toPackedUserLite(dummyUser2)); + send('followed', { user: await this.toPackedUserLite(dummyUser2) }); break; } case 'unfollow': { - send(toPackedUserDetailedNotMe(dummyUser3)); + send('unfollow', { user: await this.toPackedUserDetailedNotMe(dummyUser3) }); break; } + // まだ実装されていない (#9485) + case 'reaction': + return; + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _exhaustiveAssertion: never = params.type; + return; + } } } @@ -379,10 +259,10 @@ export class WebhookTestService { * - 送信対象イベント(on)に関する設定 */ @bindThis - public async testSystemWebhook( + public async testSystemWebhook( params: { webhookId: MiSystemWebhook['id'], - type: SystemWebhookEventType, + type: T, override?: Partial>, }, ) { @@ -392,7 +272,7 @@ export class WebhookTestService { } const webhook = webhooks[0]; - const send = (contents: unknown) => { + const send = (type: U, contents: SystemWebhookPayload) => { const merged = { ...webhook, ...params.override, @@ -400,12 +280,12 @@ export class WebhookTestService { // テスト目的なのでSystemWebhookServiceの機能を経由せず直接キューに追加する(チェック処理などをスキップする意図). // また、Jobの試行回数も1回だけ. - this.queueService.systemWebhookDeliver(merged, params.type, contents, { attempts: 1 }); + this.queueService.systemWebhookDeliver(merged, type, contents, { attempts: 1 }); }; switch (params.type) { case 'abuseReport': { - send(generateAbuseReport({ + send('abuseReport', await this.generateAbuseReport({ targetUserId: dummyUser1.id, targetUser: dummyUser1, reporterId: dummyUser2.id, @@ -414,7 +294,7 @@ export class WebhookTestService { break; } case 'abuseReportResolved': { - send(generateAbuseReport({ + send('abuseReportResolved', await this.generateAbuseReport({ targetUserId: dummyUser1.id, targetUser: dummyUser1, reporterId: dummyUser2.id, @@ -426,9 +306,181 @@ export class WebhookTestService { break; } case 'userCreated': { - send(toPackedUserLite(dummyUser1)); + send('userCreated', await this.toPackedUserLite(dummyUser1)); break; } + case 'inactiveModeratorsWarning': { + const dummyTime: ModeratorInactivityRemainingTime = { + time: 100000, + asDays: 1, + asHours: 24, + }; + + send('inactiveModeratorsWarning', { + remainingTime: dummyTime, + }); + break; + } + case 'inactiveModeratorsInvitationOnlyChanged': { + send('inactiveModeratorsInvitationOnlyChanged', {}); + break; + } + default: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _exhaustiveAssertion: never = params.type; + return; + } } } + + @bindThis + private async generateAbuseReport(override?: Partial): Promise { + const result: MiAbuseUserReport = { + id: 'dummy-abuse-report1', + targetUserId: 'dummy-target-user', + targetUser: null, + reporterId: 'dummy-reporter-user', + reporter: null, + assigneeId: null, + assignee: null, + resolved: false, + forwarded: false, + comment: 'This is a dummy report for testing purposes.', + targetUserHost: null, + reporterHost: null, + resolvedAs: null, + moderationNote: 'foo', + ...override, + }; + + return { + ...result, + targetUser: result.targetUser ? await this.toPackedUserLite(result.targetUser) : null, + reporter: result.reporter ? await this.toPackedUserLite(result.reporter) : null, + assignee: result.assignee ? await this.toPackedUserLite(result.assignee) : null, + }; + } + + @bindThis + private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise> { + return { + id: note.id, + createdAt: new Date().toISOString(), + deletedAt: null, + text: note.text, + cw: note.cw, + userId: note.userId, + user: await this.toPackedUserLite(note.user ?? generateDummyUser()), + replyId: note.replyId, + renoteId: note.renoteId, + isHidden: false, + visibility: note.visibility, + mentions: note.mentions, + visibleUserIds: note.visibleUserIds, + fileIds: note.fileIds, + files: [], + tags: note.tags, + poll: null, + emojis: await this.customEmojiService.populateEmojis(note.emojis, note.userHost), + channelId: note.channelId, + channel: note.channel, + localOnly: note.localOnly, + reactionAcceptance: note.reactionAcceptance, + reactionEmojis: {}, + reactions: {}, + reactionCount: 0, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + uri: note.uri ?? undefined, + url: note.url ?? undefined, + reactionAndUserPairCache: note.reactionAndUserPairCache, + ...(detail ? { + clippedCount: note.clippedCount, + reply: note.reply ? await this.toPackedNote(note.reply, false) : null, + renote: note.renote ? await this.toPackedNote(note.renote, true) : null, + myReaction: null, + } : {}), + ...override, + }; + } + + @bindThis + private async toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Promise> { + return { + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: user.avatarUrl, + avatarBlurhash: user.avatarBlurhash, + avatarDecorations: user.avatarDecorations.map(it => ({ + id: it.id, + angle: it.angle, + flipH: it.flipH, + url: 'https://example.com/dummy-image001.png', + offsetX: it.offsetX, + offsetY: it.offsetY, + })), + isBot: user.isBot, + isCat: user.isCat, + emojis: await this.customEmojiService.populateEmojis(user.emojis, user.host), + onlineStatus: 'active', + badgeRoles: [], + ...override, + }; + } + + @bindThis + private async toPackedUserDetailedNotMe(user: MiUser, override?: Packed<'UserDetailedNotMe'>): Promise> { + return { + ...await this.toPackedUserLite(user), + url: null, + uri: null, + movedTo: null, + alsoKnownAs: [], + createdAt: new Date().toISOString(), + updatedAt: user.updatedAt?.toISOString() ?? null, + lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, + bannerUrl: user.bannerUrl, + bannerBlurhash: user.bannerBlurhash, + isLocked: user.isLocked, + isSilenced: false, + isSuspended: user.isSuspended, + description: null, + location: null, + birthday: null, + lang: null, + fields: [], + verifiedLinks: [], + followersCount: user.followersCount, + followingCount: user.followingCount, + notesCount: user.notesCount, + pinnedNoteIds: [], + pinnedNotes: [], + pinnedPageId: null, + pinnedPage: null, + publicReactions: true, + followersVisibility: 'public', + followingVisibility: 'public', + chatScope: 'mutual', + canChat: true, + twoFactorEnabled: false, + usePasswordLessLogin: false, + securityKeys: false, + roles: [], + memo: null, + moderationNote: undefined, + isFollowing: false, + isFollowed: false, + hasPendingFollowRequestFromYou: false, + hasPendingFollowRequestToYou: false, + isBlocking: false, + isBlocked: false, + isMuted: false, + isRenoteMuted: false, + notify: 'none', + withReplies: true, + ...override, + }; + } } diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index 4192e8659a..5c16744a77 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -10,6 +10,7 @@ import type { Config } from '@/config.js'; import { MemoryKVCache } from '@/misc/cache.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js'; import { CacheService } from '@/core/CacheService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import type { MiNote } from '@/models/Note.js'; import { bindThis } from '@/decorators.js'; import { MiLocalUser, MiRemoteUser } from '@/models/User.js'; @@ -53,6 +54,7 @@ export class ApDbResolverService implements OnApplicationShutdown { private cacheService: CacheService, private apPersonService: ApPersonService, + private utilityService: UtilityService, ) { this.publicKeyCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h this.publicKeyByUserIdCache = new MemoryKVCache(1000 * 60 * 60 * 12); // 12h @@ -63,7 +65,9 @@ export class ApDbResolverService implements OnApplicationShutdown { const separator = '/'; const uri = new URL(getApId(value)); - if (uri.origin !== this.config.url) return { local: false, uri: uri.href }; + if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) { + return { local: false, uri: uri.href }; + } const [, type, id, ...rest] = uri.pathname.split(separator); return { diff --git a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts index 5d07cd8e8f..0140ce9fd6 100644 --- a/packages/backend/src/core/activitypub/ApDeliverManagerService.ts +++ b/packages/backend/src/core/activitypub/ApDeliverManagerService.ts @@ -196,6 +196,25 @@ export class ApDeliverManagerService { await manager.execute(); } + /** + * Deliver activity to users + * @param actor + * @param activity Activity + * @param targets Target users + */ + @bindThis + public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise { + const manager = new DeliverManager( + this.userEntityService, + this.followingsRepository, + this.queueService, + actor, + activity, + ); + for (const to of targets) manager.addDirectRecipe(to); + await manager.execute(); + } + @bindThis public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager { return new DeliverManager( diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index e2164fec1d..e88f60b806 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -17,18 +17,18 @@ import { NoteCreateService } from '@/core/NoteCreateService.js'; import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js'; import { AppLockService } from '@/core/AppLockService.js'; import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; import { IdService } from '@/core/IdService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import type { MiRemoteUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { AbuseReportService } from '@/core/AbuseReportService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; import { getApHrefNullable, getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; import { ApNoteService } from './models/ApNoteService.js'; import { ApLoggerService } from './ApLoggerService.js'; @@ -48,6 +48,9 @@ export class ApInboxService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -64,7 +67,6 @@ export class ApInboxService { private noteEntityService: NoteEntityService, private utilityService: UtilityService, private idService: IdService, - private metaService: MetaService, private abuseReportService: AbuseReportService, private userFollowingService: UserFollowingService, private apAudienceService: ApAudienceService, @@ -88,15 +90,26 @@ export class ApInboxService { } @bindThis - public async performActivity(actor: MiRemoteUser, activity: IObject): Promise { + public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise { let result = undefined as string | void; if (isCollectionOrOrderedCollection(activity)) { const results = [] as [string, string | void][]; - const resolver = this.apResolverService.createResolver(); - for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) { + // eslint-disable-next-line no-param-reassign + resolver ??= this.apResolverService.createResolver(); + + const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems); + if (items.length >= resolver.getRecursionLimit()) { + throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`); + } + + for (const item of items) { const act = await resolver.resolve(item); + if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) { + this.logger.debug('skipping activity: activity id is null or mismatching'); + continue; + } try { - results.push([getApId(item), await this.performOneActivity(actor, act)]); + results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]); } catch (err) { if (err instanceof Error || typeof err === 'string') { this.logger.error(err); @@ -111,13 +124,14 @@ export class ApInboxService { result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n'); } } else { - result = await this.performOneActivity(actor, activity); + result = await this.performOneActivity(actor, activity, resolver); } // ついでにリモートユーザーの情報が古かったら更新しておく if (actor.uri) { if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) { setImmediate(() => { + // 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない this.apPersonService.updatePerson(actor.uri); }); } @@ -126,37 +140,37 @@ export class ApInboxService { } @bindThis - public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise { + public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise { if (actor.isSuspended) return; if (isCreate(activity)) { - return await this.create(actor, activity); + return await this.create(actor, activity, resolver); } else if (isDelete(activity)) { return await this.delete(actor, activity); } else if (isUpdate(activity)) { - return await this.update(actor, activity); + return await this.update(actor, activity, resolver); } else if (isFollow(activity)) { return await this.follow(actor, activity); } else if (isAccept(activity)) { - return await this.accept(actor, activity); + return await this.accept(actor, activity, resolver); } else if (isReject(activity)) { - return await this.reject(actor, activity); + return await this.reject(actor, activity, resolver); } else if (isAdd(activity)) { - return await this.add(actor, activity); + return await this.add(actor, activity, resolver); } else if (isRemove(activity)) { - return await this.remove(actor, activity); + return await this.remove(actor, activity, resolver); } else if (isAnnounce(activity)) { - return await this.announce(actor, activity); + return await this.announce(actor, activity, resolver); } else if (isLike(activity)) { return await this.like(actor, activity); } else if (isUndo(activity)) { - return await this.undo(actor, activity); + return await this.undo(actor, activity, resolver); } else if (isBlock(activity)) { return await this.block(actor, activity); } else if (isFlag(activity)) { return await this.flag(actor, activity); } else if (isMove(activity)) { - return await this.move(actor, activity); + return await this.move(actor, activity, resolver); } else { return `unrecognized activity type: ${activity.type}`; } @@ -188,22 +202,26 @@ export class ApInboxService { await this.apNoteService.extractEmojis(activity.tag ?? [], actor.host).catch(() => null); - return await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name).catch(err => { - if (err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { + try { + await this.reactionService.create(actor, note, activity._misskey_reaction ?? activity.content ?? activity.name); + return 'ok'; + } catch (err) { + if (err instanceof IdentifiableError && err.id === '51c42bb4-931a-456b-bff7-e5a8a70dd298') { return 'skip: already reacted'; } else { throw err; } - }).then(() => 'ok'); + } } @bindThis - private async accept(actor: MiRemoteUser, activity: IAccept): Promise { + private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise { const uri = activity.id ?? activity; this.logger.info(`Accept: ${uri}`); - const resolver = this.apResolverService.createResolver(); + // eslint-disable-next-line no-param-reassign + resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(err => { this.logger.error(`Resolution failed: ${err}`); @@ -240,7 +258,7 @@ export class ApInboxService { } @bindThis - private async add(actor: MiRemoteUser, activity: IAdd): Promise { + private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise { if (actor.uri !== activity.actor) { return 'invalid actor'; } @@ -250,7 +268,7 @@ export class ApInboxService { } if (activity.target === actor.featured) { - const note = await this.apNoteService.resolveNote(activity.object); + const note = await this.apNoteService.resolveNote(activity.object, { resolver }); if (note == null) return 'note not found'; await this.notePiningService.addPinned(actor, note.id); return; @@ -260,12 +278,13 @@ export class ApInboxService { } @bindThis - private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise { + private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise { const uri = getApId(activity); this.logger.info(`Announce: ${uri}`); - const resolver = this.apResolverService.createResolver(); + // eslint-disable-next-line no-param-reassign + resolver ??= this.apResolverService.createResolver(); if (!activity.object) return 'skip: activity has no object property'; const targetUri = getApId(activity.object); @@ -273,7 +292,7 @@ export class ApInboxService { const target = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); - return e; + throw e; }); if (isPost(target)) return await this.announceNote(actor, activity, target); @@ -282,16 +301,15 @@ export class ApInboxService { } @bindThis - private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise { + private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise { const uri = getApId(activity); if (actor.isSuspended) { return; } - // アナウンス先をブロックしてたら中断 - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) return; + // アナウンス先が許可されているかチェック + if (!this.utilityService.isFederationAllowedUri(uri)) return; const unlock = await this.appLockService.getApLock(uri); @@ -305,7 +323,7 @@ export class ApInboxService { // Announce対象をresolve let renote; try { - renote = await this.apNoteService.resolveNote(target); + renote = await this.apNoteService.resolveNote(target, { resolver }); if (renote == null) return 'announce target is null'; } catch (err) { // 対象が4xxならスキップ @@ -324,7 +342,7 @@ export class ApInboxService { this.logger.info(`Creating the (Re)Note: ${uri}`); - const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc); + const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver); const createdAt = activity.published ? new Date(activity.published) : null; if (createdAt && createdAt < this.idService.parse(renote.id).date) { @@ -362,7 +380,7 @@ export class ApInboxService { } @bindThis - private async create(actor: MiRemoteUser, activity: ICreate): Promise { + private async create(actor: MiRemoteUser, activity: ICreate, resolver?: Resolver): Promise { const uri = getApId(activity); this.logger.info(`Create: ${uri}`); @@ -387,7 +405,8 @@ export class ApInboxService { activity.object.attributedTo = activity.actor; } - const resolver = this.apResolverService.createResolver(); + // eslint-disable-next-line no-param-reassign + resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -414,6 +433,8 @@ export class ApInboxService { if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) { return 'skip: host in actor.uri !== note.id'; } + } else { + return 'skip: note.id is not a string'; } } @@ -423,7 +444,7 @@ export class ApInboxService { const exist = await this.apNoteService.fetchNote(note); if (exist) return 'skip: note exists'; - await this.apNoteService.createNote(note, resolver, silent); + await this.apNoteService.createNote(note, actor, resolver, silent); return 'ok'; } catch (err) { if (err instanceof StatusError && !err.isRetryable) { @@ -486,19 +507,12 @@ export class ApInboxService { return `skip: delete actor ${actor.uri} !== ${uri}`; } - const user = await this.usersRepository.findOneBy({ id: actor.id }); - if (user == null) { - return 'skip: actor not found'; - } else if (user.isDeleted) { - return 'skip: already deleted'; + if (!(await this.usersRepository.update({ id: actor.id, isDeleted: false }, { isDeleted: true })).affected) { + return 'skip: already deleted or actor not found'; } const job = await this.queueService.createDeleteAccountJob(actor); - await this.usersRepository.update(actor.id, { - isDeleted: true, - }); - this.globalEventService.publishInternalEvent('remoteUserUpdated', { id: actor.id }); return `ok: queued ${job.name} ${job.id}`; @@ -555,12 +569,13 @@ export class ApInboxService { } @bindThis - private async reject(actor: MiRemoteUser, activity: IReject): Promise { + private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise { const uri = activity.id ?? activity; this.logger.info(`Reject: ${uri}`); - const resolver = this.apResolverService.createResolver(); + // eslint-disable-next-line no-param-reassign + resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -597,7 +612,7 @@ export class ApInboxService { } @bindThis - private async remove(actor: MiRemoteUser, activity: IRemove): Promise { + private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise { if (actor.uri !== activity.actor) { return 'invalid actor'; } @@ -607,7 +622,7 @@ export class ApInboxService { } if (activity.target === actor.featured) { - const note = await this.apNoteService.resolveNote(activity.object); + const note = await this.apNoteService.resolveNote(activity.object, { resolver }); if (note == null) return 'note not found'; await this.notePiningService.removePinned(actor, note.id); return; @@ -617,7 +632,7 @@ export class ApInboxService { } @bindThis - private async undo(actor: MiRemoteUser, activity: IUndo): Promise { + private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise { if (actor.uri !== activity.actor) { return 'invalid actor'; } @@ -626,11 +641,12 @@ export class ApInboxService { this.logger.info(`Undo: ${uri}`); - const resolver = this.apResolverService.createResolver(); + // eslint-disable-next-line no-param-reassign + resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); - return e; + throw e; }); // don't queue because the sender may attempt again when timeout @@ -750,14 +766,15 @@ export class ApInboxService { } @bindThis - private async update(actor: MiRemoteUser, activity: IUpdate): Promise { + private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise { if (actor.uri !== activity.actor) { return 'skip: invalid actor'; } this.logger.debug('Update'); - const resolver = this.apResolverService.createResolver(); + // eslint-disable-next-line no-param-reassign + resolver ??= this.apResolverService.createResolver(); const object = await resolver.resolve(activity.object).catch(e => { this.logger.error(`Resolution failed: ${e}`); @@ -768,7 +785,7 @@ export class ApInboxService { await this.apPersonService.updatePerson(actor.uri, resolver, object); return 'ok: Person updated'; } else if (getApType(object) === 'Question') { - await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err)); + await this.apQuestionService.updateQuestion(object, actor, resolver).catch(err => console.error(err)); return 'ok: Question updated'; } else { return `skip: Unknown type: ${getApType(object)}`; @@ -776,11 +793,11 @@ export class ApInboxService { } @bindThis - private async move(actor: MiRemoteUser, activity: IMove): Promise { + private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise { // fetch the new and old accounts const targetUri = getApHrefNullable(activity.target); if (!targetUri) return 'skip: invalid activity target'; - return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do'; + return await this.apPersonService.updatePerson(actor.uri, resolver) ?? 'skip: nothing to do'; } } diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index 98e944f347..f01874952f 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -23,10 +23,11 @@ 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'; -import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository } from '@/models/_.js'; +import type { UsersRepository, UserProfilesRepository, NotesRepository, DriveFilesRepository, PollsRepository, MiMeta } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { IdService } from '@/core/IdService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { JsonLdService } from './JsonLdService.js'; import { ApMfmService } from './ApMfmService.js'; import { CONTEXT } from './misc/contexts.js'; @@ -38,6 +39,9 @@ export class ApRendererService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -61,6 +65,7 @@ export class ApRendererService { private apMfmService: ApMfmService, private mfmService: MfmService, private idService: IdService, + private utilityService: UtilityService, ) { } @@ -183,6 +188,9 @@ export class ApRendererService { // || emoji.originalUrl してるのは後方互換性のため(publicUrlはstringなので??はだめ) url: emoji.publicUrl || emoji.originalUrl, }, + _misskey_license: { + freeText: emoji.license, + }, }; } @@ -250,6 +258,38 @@ export class ApRendererService { }; } + @bindThis + public renderIdenticon(user: MiLocalUser): IApImage { + return { + type: 'Image', + url: this.userEntityService.getIdenticonUrl(user), + sensitive: false, + name: null, + }; + } + + @bindThis + public renderSystemAvatar(user: MiLocalUser): IApImage { + if (this.meta.iconUrl == null) return this.renderIdenticon(user); + return { + type: 'Image', + url: this.meta.iconUrl, + sensitive: false, + name: null, + }; + } + + @bindThis + public renderSystemBanner(): IApImage | null { + if (this.meta.bannerUrl == null) return null; + return { + type: 'Image', + url: this.meta.bannerUrl, + sensitive: false, + name: null, + }; + } + @bindThis public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey { return { @@ -459,11 +499,28 @@ export class ApRendererService { this.userProfilesRepository.findOneByOrFail({ userId: user.id }), ]); + const tryRewriteUrl = (maybeUrl: string) => { + const urlSafeRegex = /^(?:http[s]?:\/\/.)?(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,6}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/; + try { + const match = maybeUrl.match(urlSafeRegex); + if (!match) { + return maybeUrl; + } + const urlPart = match[0]; + const urlPartParsed = new URL(urlPart); + const restPart = maybeUrl.slice(match[0].length); + + return `${urlPart}${restPart}`; + } catch (e) { + return maybeUrl; + } + }; + const attachment = profile.fields.map(field => ({ type: 'PropertyValue', name: field.name, value: (field.value.startsWith('http://') || field.value.startsWith('https://')) - ? `${new URL(field.value).href}` + ? tryRewriteUrl(field.value) : field.value, })); @@ -494,8 +551,12 @@ export class ApRendererService { name: user.name, summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null, _misskey_summary: profile.description, - icon: avatar ? this.renderImage(avatar) : null, - image: banner ? this.renderImage(banner) : null, + _misskey_followedMessage: profile.followedMessage, + _misskey_requireSigninToViewContents: user.requireSigninToViewContents, + _misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore, + _misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore, + icon: avatar ? this.renderImage(avatar) : isSystem ? this.renderSystemAvatar(user) : this.renderIdenticon(user), + image: banner ? this.renderImage(banner) : isSystem ? this.renderSystemBanner() : null, tag, manuallyApprovesFollowers: user.isLocked, discoverable: user.isExplorable, @@ -570,7 +631,7 @@ export class ApRendererService { @bindThis public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo { - const id = typeof object !== 'string' && typeof object.id === 'string' && object.id.startsWith(this.config.url) ? `${object.id}/undo` : undefined; + const id = typeof object !== 'string' && typeof object.id === 'string' && this.utilityService.isUriLocal(object.id) ? `${object.id}/undo` : undefined; return { type: 'Undo', diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index 805280db36..61d328ccac 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -11,11 +11,14 @@ import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; import type { MiUser } from '@/models/User.js'; import { UserKeypairService } from '@/core/UserKeypairService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; import type Logger from '@/logger.js'; import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js'; +import { assertActivityMatchesUrl, FetchAllowSoftFailMask as FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; +import type { IObject } from './type.js'; type Request = { url: string; @@ -145,6 +148,7 @@ export class ApRequestService { private userKeypairService: UserKeypairService, private httpRequestService: HttpRequestService, private loggerService: LoggerService, + private utilityService: UtilityService, ) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる @@ -181,7 +185,7 @@ export class ApRequestService { * @param url URL to fetch */ @bindThis - public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise { + public async signedGet(url: string, user: { id: MiUser['id'] }, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict, followAlternate?: boolean): Promise { const _followAlternate = followAlternate ?? true; const keypair = await this.userKeypairService.getUserKeypair(user.id); @@ -205,9 +209,13 @@ export class ApRequestService { //#region リクエスト先がhtmlかつactivity+jsonへのalternate linkタグがあるとき const contentType = res.headers.get('content-type'); - if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) { + if ( + res.ok && + (contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && + _followAlternate === true + ) { const html = await res.text(); - const window = new Window({ + const { window, happyDOM } = new Window({ settings: { disableJavaScriptEvaluation: true, disableJavaScriptFileLoading: true, @@ -234,20 +242,24 @@ export class ApRequestService { const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]'); if (alternate) { const href = alternate.getAttribute('href'); - if (href) { - return await this.signedGet(href, user, false); + if (href && this.utilityService.punyHost(url) === this.utilityService.punyHost(href)) { + return await this.signedGet(href, user, allowSoftfail, false); } } } catch (e) { // something went wrong parsing the HTML, ignore the whole thing } finally { - window.close(); + happyDOM.close().catch(err => {}); } } //#endregion validateContentTypeSetAsActivityPub(res); + const finalUrl = res.url; // redirects may have been involved + const activity = await res.json() as IObject; - return await res.json(); + assertActivityMatchesUrl(url, activity, finalUrl, allowSoftfail); + + return activity; } } diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts index bb3c40f093..2534899ad1 100644 --- a/packages/backend/src/core/activitypub/ApResolverService.ts +++ b/packages/backend/src/core/activitypub/ApResolverService.ts @@ -6,20 +6,21 @@ import { Inject, Injectable } from '@nestjs/common'; import { IsNull, Not } from 'typeorm'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; -import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; +import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { DI } from '@/di-symbols.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; 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 { 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'; export class Resolver { @@ -29,20 +30,20 @@ export class Resolver { constructor( private config: Config, + private meta: MiMeta, private usersRepository: UsersRepository, private notesRepository: NotesRepository, private pollsRepository: PollsRepository, private noteReactionsRepository: NoteReactionsRepository, private followRequestsRepository: FollowRequestsRepository, private utilityService: UtilityService, - private instanceActorService: InstanceActorService, - private metaService: MetaService, + private systemAccountService: SystemAccountService, private apRequestService: ApRequestService, private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, private apDbResolverService: ApDbResolverService, private loggerService: LoggerService, - private recursionLimit = 100, + private recursionLimit = 256, ) { this.history = new Set(); this.logger = this.loggerService.getLogger('ap-resolve'); @@ -53,6 +54,11 @@ export class Resolver { return Array.from(this.history); } + @bindThis + public getRecursionLimit(): number { + return this.recursionLimit; + } + @bindThis public async resolveCollection(value: string | IObject): Promise { const collection = typeof value === 'string' @@ -62,12 +68,12 @@ export class Resolver { if (isCollectionOrOrderedCollection(collection)) { return collection; } else { - throw new Error(`unrecognized collection type: ${collection.type}`); + throw new IdentifiableError('f100eccf-f347-43fb-9b45-96a0831fb635', `unrecognized collection type: ${collection.type}`); } } @bindThis - public async resolve(value: string | IObject): Promise { + public async resolve(value: string | IObject, allowSoftfail: FetchAllowSoftFailMask = FetchAllowSoftFailMask.Strict): Promise { if (typeof value !== 'string') { return value; } @@ -76,15 +82,15 @@ export class Resolver { // URLs with fragment parts cannot be resolved correctly because // the fragment part does not get transmitted over HTTP(S). // Avoid strange behaviour by not trying to resolve these at all. - throw new Error(`cannot resolve URL with fragment: ${value}`); + throw new IdentifiableError('b94fd5b1-0e3b-4678-9df2-dad4cd515ab2', `cannot resolve URL with fragment: ${value}`); } if (this.history.has(value)) { - throw new Error('cannot resolve already resolved one'); + throw new IdentifiableError('0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5', 'cannot resolve already resolved one'); } if (this.history.size > this.recursionLimit) { - throw new Error(`hit recursion limit: ${this.utilityService.extractDbHost(value)}`); + throw new IdentifiableError('d592da9f-822f-4d91-83d7-4ceefabcf3d2', `hit recursion limit: ${this.utilityService.extractDbHost(value)}`); } this.history.add(value); @@ -94,25 +100,24 @@ export class Resolver { return await this.resolveLocal(value); } - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, host)) { - throw new Error('Instance is blocked'); + if (!this.utilityService.isFederationAllowedHost(host)) { + throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', 'Instance is blocked'); } if (this.config.signToActivityPubGet && !this.user) { - this.user = await this.instanceActorService.getInstanceActor(); + this.user = await this.systemAccountService.fetch('actor'); } const object = (this.user - ? await this.apRequestService.signedGet(value, this.user) as IObject - : await this.httpRequestService.getActivityJson(value)) as IObject; + ? await this.apRequestService.signedGet(value, this.user, allowSoftfail) as IObject + : await this.httpRequestService.getActivityJson(value, undefined, allowSoftfail)) as IObject; if ( Array.isArray(object['@context']) ? !(object['@context'] as unknown[]).includes('https://www.w3.org/ns/activitystreams') : object['@context'] !== 'https://www.w3.org/ns/activitystreams' ) { - throw new Error('invalid response'); + throw new IdentifiableError('72180409-793c-4973-868e-5a118eb5519b', 'invalid response'); } return object; @@ -121,7 +126,7 @@ export class Resolver { @bindThis private resolveLocal(url: string): Promise { const parsed = this.apDbResolverService.parseUri(url); - if (!parsed.local) throw new Error('resolveLocal: not local'); + if (!parsed.local) throw new IdentifiableError('02b40cd0-fa92-4b0c-acc9-fb2ada952ab8', 'resolveLocal: not local'); switch (parsed.type) { case 'notes': @@ -150,7 +155,7 @@ export class Resolver { case 'follows': return this.followRequestsRepository.findOneBy({ id: parsed.id }) .then(async followRequest => { - if (followRequest == null) throw new Error('resolveLocal: invalid follow request ID'); + if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', 'resolveLocal: invalid follow request ID'); const [follower, followee] = await Promise.all([ this.usersRepository.findOneBy({ id: followRequest.followerId, @@ -162,12 +167,12 @@ export class Resolver { }), ]); if (follower == null || followee == null) { - throw new Error('resolveLocal: follower or followee does not exist'); + throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', 'resolveLocal: follower or followee does not exist'); } return this.apRendererService.addContext(this.apRendererService.renderFollow(follower as MiLocalUser | MiRemoteUser, followee as MiLocalUser | MiRemoteUser, url)); }); default: - throw new Error(`resolveLocal: type ${parsed.type} unhandled`); + throw new IdentifiableError('7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0', `resolveLocal: type ${parsed.type} unhandled`); } } } @@ -178,6 +183,9 @@ export class ApResolverService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -194,8 +202,7 @@ export class ApResolverService { private followRequestsRepository: FollowRequestsRepository, private utilityService: UtilityService, - private instanceActorService: InstanceActorService, - private metaService: MetaService, + private systemAccountService: SystemAccountService, private apRequestService: ApRequestService, private httpRequestService: HttpRequestService, private apRendererService: ApRendererService, @@ -208,14 +215,14 @@ export class ApResolverService { public createResolver(): Resolver { return new Resolver( this.config, + this.meta, this.usersRepository, this.notesRepository, this.pollsRepository, this.noteReactionsRepository, this.followRequestsRepository, this.utilityService, - this.instanceActorService, - this.metaService, + this.systemAccountService, this.apRequestService, this.httpRequestService, this.apRendererService, diff --git a/packages/backend/src/core/activitypub/misc/check-against-url.ts b/packages/backend/src/core/activitypub/misc/check-against-url.ts new file mode 100644 index 0000000000..bbfe57f9fa --- /dev/null +++ b/packages/backend/src/core/activitypub/misc/check-against-url.ts @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: dakkar and sharkey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { IObject } from '../type.js'; + +export enum FetchAllowSoftFailMask { + // Allow no softfail flags + Strict = 0, + // The values in tuple (requestUrl, finalUrl, objectId) are not all identical + // + // This condition is common for user-initiated lookups but should not be allowed in federation loop + // + // Allow variations: + // good example: https://alice.example.com/@user -> https://alice.example.com/user/:userId + // problematic example: https://alice.example.com/redirect?url=https://bad.example.com/ -> https://bad.example.com/ -> https://alice.example.com/somethingElse + NonCanonicalId = 1 << 0, + // Allow the final object to be at most one subdomain deeper than the request URL, similar to SPF relaxed alignment + // + // Currently no code path allows this flag to be set, but is kept in case of future use as some niche deployments do this, and we provide a pre-reviewed mechanism to opt-in. + // + // Allow variations: + // good example: https://example.com/@user -> https://activitypub.example.com/@user { id: 'https://activitypub.example.com/@user' } + // problematic example: https://example.com/@user -> https://untrusted.example.com/@user { id: 'https://untrusted.example.com/@user' } + MisalignedOrigin = 1 << 1, + // The requested URL has a different host than the returned object ID, although the final URL is still consistent with the object ID + // + // This condition is common for user-initiated lookups using an intermediate host but should not be allowed in federation loops + // + // Allow variations: + // good example: https://alice.example.com/@user@bob.example.com -> https://bob.example.com/@user { id: 'https://bob.example.com/@user' } + // problematic example: https://alice.example.com/definitelyAlice -> https://bob.example.com/@somebodyElse { id: 'https://bob.example.com/@somebodyElse' } + CrossOrigin = 1 << 2 | MisalignedOrigin, + // Allow all softfail flags + // + // do not use this flag on released code + Any = ~0, +} + +/** + * Fuzz match on whether the candidate host has authority over the request host + * + * @param requestHost The host of the requested resources + * @param candidateHost The host of final response + * @returns Whether the candidate host has authority over the request host, or if a soft fail is required for a match + */ +function hostFuzzyMatch(requestHost: string, candidateHost: string): FetchAllowSoftFailMask { + const requestFqdn = requestHost.endsWith('.') ? requestHost : `${requestHost}.`; + const candidateFqdn = candidateHost.endsWith('.') ? candidateHost : `${candidateHost}.`; + + if (requestFqdn === candidateFqdn) { + return FetchAllowSoftFailMask.Strict; + } + + // allow only one case where candidateHost is a first-level subdomain of requestHost + const requestDnsDepth = requestFqdn.split('.').length; + const candidateDnsDepth = candidateFqdn.split('.').length; + + if ((candidateDnsDepth - requestDnsDepth) !== 1) { + return FetchAllowSoftFailMask.CrossOrigin; + } + + if (`.${candidateHost}`.endsWith(`.${requestHost}`)) { + return FetchAllowSoftFailMask.MisalignedOrigin; + } + + return FetchAllowSoftFailMask.CrossOrigin; +} + +// normalize host names by removing www. prefix +function normalizeSynonymousSubdomain(url: URL | string): URL { + const urlParsed = url instanceof URL ? url : new URL(url); + const host = urlParsed.host; + const normalizedHost = host.replace(/^www\./, ''); + return new URL(urlParsed.toString().replace(host, normalizedHost)); +} + +export function assertActivityMatchesUrl(requestUrl: string | URL, activity: IObject, finalUrl: string | URL, allowSoftfail: FetchAllowSoftFailMask): FetchAllowSoftFailMask { + // must have a unique identifier to verify authority + if (!activity.id) { + throw new Error('bad Activity: missing id field'); + } + + let softfail = 0; + + // if the flag is allowed, set the flag on return otherwise throw + const requireSoftfail = (needed: FetchAllowSoftFailMask, message: string) => { + if ((allowSoftfail & needed) !== needed) { + throw new Error(message); + } + + softfail |= needed; + }; + + const requestUrlParsed = normalizeSynonymousSubdomain(requestUrl); + const idParsed = normalizeSynonymousSubdomain(activity.id); + + const finalUrlParsed = normalizeSynonymousSubdomain(finalUrl); + + // mastodon sends activities with hash in the URL + // currently it only happens with likes, deletes etc. + // but object ID never has hash + requestUrlParsed.hash = ''; + finalUrlParsed.hash = ''; + + const requestUrlSecure = requestUrlParsed.protocol === 'https:'; + const finalUrlSecure = finalUrlParsed.protocol === 'https:'; + if (requestUrlSecure && !finalUrlSecure) { + throw new Error(`bad Activity: id(${activity.id}) is not allowed to have http:// in the url`); + } + + // Compare final URL to the ID + if (finalUrlParsed.href !== idParsed.href) { + requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match response url(${finalUrlParsed.toString()})`); + + // at lease host need to match exactly (ActivityPub requirement) + if (idParsed.host !== finalUrlParsed.host) { + throw new Error(`bad Activity: id(${activity.id}) does not match response host(${finalUrlParsed.host})`); + } + } + + // Compare request URL to the ID + if (requestUrlParsed.href !== idParsed.href) { + requireSoftfail(FetchAllowSoftFailMask.NonCanonicalId, `bad Activity: id(${activity.id}) does not match request url(${requestUrlParsed.toString()})`); + + // if cross-origin lookup is allowed, we can accept some variation between the original request URL to the final object ID (but not between the final URL and the object ID) + const hostResult = hostFuzzyMatch(requestUrlParsed.host, idParsed.host); + + requireSoftfail(hostResult, `bad Activity: id(${activity.id}) is valid but is not the same origin as request url(${requestUrlParsed.toString()})`); + } + + return softfail; +} diff --git a/packages/backend/src/core/activitypub/misc/contexts.ts b/packages/backend/src/core/activitypub/misc/contexts.ts index feb8c42c56..6611e4b7f9 100644 --- a/packages/backend/src/core/activitypub/misc/contexts.ts +++ b/packages/backend/src/core/activitypub/misc/contexts.ts @@ -554,6 +554,15 @@ const extension_context_definition = { '_misskey_reaction': 'misskey:_misskey_reaction', '_misskey_votes': 'misskey:_misskey_votes', '_misskey_summary': 'misskey:_misskey_summary', + '_misskey_followedMessage': 'misskey:_misskey_followedMessage', + '_misskey_requireSigninToViewContents': 'misskey:_misskey_requireSigninToViewContents', + '_misskey_makeNotesFollowersOnlyBefore': 'misskey:_misskey_makeNotesFollowersOnlyBefore', + '_misskey_makeNotesHiddenBefore': 'misskey:_misskey_makeNotesHiddenBefore', + '_misskey_license': 'misskey:_misskey_license', + 'freeText': { + '@id': 'misskey:freeText', + '@type': 'schema:text', + }, 'isCat': 'misskey:isCat', // vcard vcard: 'http://www.w3.org/2006/vcard/ns#', diff --git a/packages/backend/src/core/activitypub/models/ApImageService.ts b/packages/backend/src/core/activitypub/models/ApImageService.ts index 3691967270..e7ece87b01 100644 --- a/packages/backend/src/core/activitypub/models/ApImageService.ts +++ b/packages/backend/src/core/activitypub/models/ApImageService.ts @@ -5,10 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, MiMeta } from '@/models/_.js'; import type { MiRemoteUser } from '@/models/User.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; -import { MetaService } from '@/core/MetaService.js'; import { truncate } from '@/misc/truncate.js'; import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { DriveService } from '@/core/DriveService.js'; @@ -24,10 +23,12 @@ export class ApImageService { private logger: Logger; constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private metaService: MetaService, private apResolverService: ApResolverService, private driveService: DriveService, private apLoggerService: ApLoggerService, @@ -63,12 +64,10 @@ export class ApImageService { this.logger.info(`Creating the Image: ${image.url}`); - const instance = await this.metaService.fetch(); - // Cache if remote file cache is on AND either // 1. remote sensitive file is also on // 2. or the image is not sensitive - const shouldBeCached = instance.cacheRemoteFiles && (instance.cacheRemoteSensitiveFiles || !image.sensitive); + const shouldBeCached = this.meta.cacheRemoteFiles && (this.meta.cacheRemoteSensitiveFiles || !image.sensitive); const file = await this.driveService.uploadFromUrl({ url: image.url, diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 5b75da22a0..8abacd293f 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -6,13 +6,12 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { PollsRepository, EmojisRepository } from '@/models/_.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 { toArray, toSingle, unique } from '@/misc/prelude/array.js'; import type { MiEmoji } from '@/models/Emoji.js'; -import { MetaService } from '@/core/MetaService.js'; import { AppLockService } from '@/core/AppLockService.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import { NoteCreateService } from '@/core/NoteCreateService.js'; @@ -46,6 +45,9 @@ export class ApNoteService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.pollsRepository) private pollsRepository: PollsRepository, @@ -65,7 +67,6 @@ export class ApNoteService { private apMentionService: ApMentionService, private apImageService: ApImageService, private apQuestionService: ApQuestionService, - private metaService: MetaService, private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, @@ -76,7 +77,7 @@ export class ApNoteService { } @bindThis - public validateNote(object: IObject, uri: string): Error | null { + public validateNote(object: IObject, uri: string, actor?: MiRemoteUser): Error | null { const expectHost = this.utilityService.extractDbHost(uri); const apType = getApType(object); @@ -97,6 +98,14 @@ export class ApNoteService { return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed'); } + if (actor) { + const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri; + + if (attribution !== actor.uri) { + return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`); + } + } + return null; } @@ -114,14 +123,14 @@ export class ApNoteService { * Noteを作成します。 */ @bindThis - public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise { + 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(); const object = await resolver.resolve(value); const entryUri = getApId(value); - const err = this.validateNote(object, entryUri); + const err = this.validateNote(object, entryUri, actor); if (err) { this.logger.error(err.message, { resolver: { history: resolver.getHistory() }, @@ -135,7 +144,11 @@ export class ApNoteService { this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); - if (note.id && !checkHttps(note.id)) { + if (note.id == null) { + throw new Error('Refusing to create note without id'); + } + + if (!checkHttps(note.id)) { throw new Error('unexpected schema of note.id: ' + note.id); } @@ -155,8 +168,9 @@ export class ApNoteService { const uri = getOneApId(note.attributedTo); // ローカルで投稿者を検索し、もし凍結されていたらスキップ - const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser; - if (cachedActor && cachedActor.isSuspended) { + // eslint-disable-next-line no-param-reassign + actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined; + if (actor && actor.isSuspended) { throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended'); } @@ -182,13 +196,14 @@ export class ApNoteService { /** * 禁止ワードチェック */ - const hasProhibitedWords = await this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices }); + const hasProhibitedWords = this.noteCreateService.checkProhibitedWordsContain({ cw, text, pollChoices: poll?.choices }); if (hasProhibitedWords) { throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words'); } //#endregion - const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser; + // eslint-disable-next-line no-param-reassign + actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser; // 解決した投稿者が凍結されていたらスキップ if (actor.isSuspended) { @@ -335,9 +350,7 @@ export class ApNoteService { public async resolveNote(value: string | IObject, options: { sentFrom?: URL, resolver?: Resolver } = {}): Promise { const uri = getApId(value); - // ブロックしていたら中断 - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.extractDbHost(uri))) { + if (!this.utilityService.isFederationAllowedUri(uri)) { throw new StatusError('blocked host', 451); } @@ -349,7 +362,7 @@ export class ApNoteService { if (exist) return exist; //#endregion - if (uri.startsWith(this.config.url)) { + if (this.utilityService.isUriLocal(uri)) { throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note'); } @@ -357,7 +370,7 @@ export class ApNoteService { // ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが // 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。 const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri; - return await this.createNote(createFrom, options.resolver, true); + return await this.createNote(createFrom, undefined, options.resolver, true); } finally { unlock(); } @@ -395,6 +408,8 @@ export class ApNoteService { originalUrl: tag.icon.url, publicUrl: tag.icon.url, updatedAt: new Date(), + // _misskey_license が存在しなければ `null` + license: (tag._misskey_license?.freeText ?? null) }); const emoji = await this.emojisRepository.findOneBy({ host, name }); @@ -416,6 +431,8 @@ export class ApNoteService { publicUrl: tag.icon.url, updatedAt: new Date(), aliases: [], + // _misskey_license が存在しなければ `null` + license: (tag._misskey_license?.freeText ?? null) }); })); } diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index f3ddf3952c..e52078ed0f 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -8,7 +8,7 @@ import promiseLimit from 'promise-limit'; import { DataSource } from 'typeorm'; import { ModuleRef } from '@nestjs/core'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, InstancesRepository, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; +import type { FollowingsRepository, InstancesRepository, MiMeta, UserProfilesRepository, UserPublickeysRepository, UsersRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { MiLocalUser, MiRemoteUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js'; @@ -35,7 +35,6 @@ import type { UtilityService } from '@/core/UtilityService.js'; import type { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; -import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import type { AccountMoveService } from '@/core/AccountMoveService.js'; import { checkHttps } from '@/misc/check-https.js'; @@ -46,7 +45,7 @@ import type { ApNoteService } from './ApNoteService.js'; import type { ApMfmService } from '../ApMfmService.js'; import type { ApResolverService, Resolver } from '../ApResolverService.js'; import type { ApLoggerService } from '../ApLoggerService.js'; -// eslint-disable-next-line @typescript-eslint/consistent-type-imports + import type { ApImageService } from './ApImageService.js'; import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js'; @@ -62,7 +61,6 @@ export class ApPersonService implements OnModuleInit { private driveFileEntityService: DriveFileEntityService; private idService: IdService; private globalEventService: GlobalEventService; - private metaService: MetaService; private federatedInstanceService: FederatedInstanceService; private fetchInstanceMetadataService: FetchInstanceMetadataService; private cacheService: CacheService; @@ -84,6 +82,9 @@ export class ApPersonService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.db) private db: DataSource, @@ -112,7 +113,6 @@ export class ApPersonService implements OnModuleInit { this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService'); this.idService = this.moduleRef.get('IdService'); this.globalEventService = this.moduleRef.get('GlobalEventService'); - this.metaService = this.moduleRef.get('MetaService'); this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.fetchInstanceMetadataService = this.moduleRef.get('FetchInstanceMetadataService'); this.cacheService = this.moduleRef.get('CacheService'); @@ -129,12 +129,6 @@ export class ApPersonService implements OnModuleInit { this.logger = this.apLoggerService.logger; } - private punyHost(url: string): string { - const urlObj = new URL(url); - const host = `${this.utilityService.toPuny(urlObj.hostname)}${urlObj.port.length > 0 ? ':' + urlObj.port : ''}`; - return host; - } - /** * Validate and convert to actor object * @param x Fetched object @@ -142,7 +136,7 @@ export class ApPersonService implements OnModuleInit { */ @bindThis private validateActor(x: IObject, uri: string): IActor { - const expectHost = this.punyHost(uri); + const expectHost = this.utilityService.punyHost(uri); if (!isActor(x)) { throw new Error(`invalid Actor type '${x.type}'`); @@ -156,6 +150,36 @@ export class ApPersonService implements OnModuleInit { throw new Error('invalid Actor: wrong inbox'); } + if (this.utilityService.punyHost(x.inbox) !== expectHost) { + throw new Error('invalid Actor: inbox has different host'); + } + + const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined); + if (sharedInboxObject != null) { + const sharedInbox = getApId(sharedInboxObject); + if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && new URL(sharedInbox).host === expectHost)) { + this.logger.warn(`invalid Actor: skipping wrong shared inbox, expected host: ${expectHost}, actual URL: ${sharedInbox}`); + x.sharedInbox = undefined; + if (x.endpoints?.sharedInbox) { + x.endpoints.sharedInbox = undefined; + } + } + } + + for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) { + const xCollection = (x as IActor)[collection]; + if (xCollection != null) { + const collectionUri = getApId(xCollection); + if (typeof collectionUri === 'string' && collectionUri.length > 0) { + if (this.utilityService.punyHost(collectionUri) !== expectHost) { + throw new Error(`invalid Actor: ${collection} has different host`); + } + } else if (collectionUri != null) { + throw new Error(`invalid Actor: wrong ${collection}`); + } + } + } + if (!(typeof x.preferredUsername === 'string' && x.preferredUsername.length > 0 && x.preferredUsername.length <= 128 && /^\w([\w-.]*\w)?$/.test(x.preferredUsername))) { throw new Error('invalid Actor: wrong username'); } @@ -179,7 +203,7 @@ export class ApPersonService implements OnModuleInit { x.summary = truncate(x.summary, summaryLength); } - const idHost = this.punyHost(x.id); + const idHost = this.utilityService.punyHost(x.id); if (idHost !== expectHost) { throw new Error('invalid Actor: id has different host'); } @@ -189,7 +213,7 @@ export class ApPersonService implements OnModuleInit { throw new Error('invalid Actor: publicKey.id is not a string'); } - const publicKeyIdHost = this.punyHost(x.publicKey.id); + const publicKeyIdHost = this.utilityService.punyHost(x.publicKey.id); if (publicKeyIdHost !== expectHost) { throw new Error('invalid Actor: publicKey.id has different host'); } @@ -232,6 +256,12 @@ export class ApPersonService implements OnModuleInit { if (user == null) throw new Error('failed to create user: user is null'); const [avatar, banner] = await Promise.all([icon, image].map(img => { + // icon and image may be arrays + // see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-icon + if (Array.isArray(img)) { + img = img.find(item => item && item.url) ?? null; + } + // if we have an explicitly missing image, return an // explicitly-null set of values if ((img == null) || (typeof img === 'object' && img.url == null)) { @@ -274,7 +304,8 @@ export class ApPersonService implements OnModuleInit { public async createPerson(uri: string, resolver?: Resolver): Promise { if (typeof uri !== 'string') throw new Error('uri is not string'); - if (uri.startsWith(this.config.url)) { + const host = this.utilityService.punyHost(uri); + if (host === this.utilityService.toPuny(this.config.host)) { throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user'); } @@ -288,8 +319,6 @@ export class ApPersonService implements OnModuleInit { this.logger.info(`Creating the Person: ${person.id}`); - const host = this.punyHost(object.id); - const fields = this.analyzeAttachments(person.attachment ?? []); const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32); @@ -307,14 +336,18 @@ export class ApPersonService implements OnModuleInit { this.logger.error('error occurred while fetching following/followers collection', { stack: err }); } return 'private'; - }) - ) + }), + ), ); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const url = getOneApHrefNullable(person.url); + if (person.id == null) { + throw new Error('Refusing to create person without id'); + } + if (url && !checkHttps(url)) { throw new Error('unexpected schema of person url: ' + url); } @@ -349,13 +382,16 @@ export class ApPersonService implements OnModuleInit { usernameLower: person.preferredUsername?.toLowerCase(), host, inbox: person.inbox, - sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, + sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, followersUri: person.followers ? getApId(person.followers) : undefined, featured: person.featured ? getApId(person.featured) : undefined, uri: person.id, tags, isBot, isCat: (person as any).isCat === true, + requireSigninToViewContents: (person as any).requireSigninToViewContents === true, + makeNotesFollowersOnlyBefore: (person as any).makeNotesFollowersOnlyBefore ?? null, + makeNotesHiddenBefore: (person as any).makeNotesHiddenBefore ?? null, emojis, })) as MiRemoteUser; @@ -370,6 +406,7 @@ export class ApPersonService implements OnModuleInit { await transactionalEntityManager.save(new MiUserProfile({ userId: user.id, description: _description, + followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, url, fields, followingVisibility, @@ -407,13 +444,15 @@ export class ApPersonService implements OnModuleInit { this.cacheService.uriPersonCache.set(user.uri, user); // Register host - this.federatedInstanceService.fetch(host).then(async i => { - this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - if ((await this.metaService.fetch()).enableChartsForFederatedInstances) { - this.instanceChart.newUser(i.host); - } - }); + if (this.meta.enableStatsForFederatedInstances) { + this.federatedInstanceService.fetchOrRegister(host).then(i => { + this.instancesRepository.increment({ id: i.id }, 'usersCount', 1); + if (this.meta.enableChartsForFederatedInstances) { + this.instanceChart.newUser(i.host); + } + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + }); + } this.usersChart.update(user, true); @@ -453,7 +492,7 @@ export class ApPersonService implements OnModuleInit { if (typeof uri !== 'string') throw new Error('uri is not string'); // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(`${this.config.url}/`)) return; + if (this.utilityService.isUriLocal(uri)) return; //#region このサーバーに既に登録されているか const exist = await this.fetchPerson(uri) as MiRemoteUser | null; @@ -494,24 +533,34 @@ export class ApPersonService implements OnModuleInit { return undefined; } return 'private'; - }) - ) + }), + ), ); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const url = getOneApHrefNullable(person.url); - if (url && !checkHttps(url)) { - throw new Error('unexpected schema of person url: ' + url); + if (person.id == null) { + throw new Error('Refusing to update person without id'); + } + + if (url != null) { + if (!checkHttps(url)) { + throw new Error('unexpected schema of person url: ' + url); + } + + if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(person.id)) { + throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`); + } } const updates = { lastFetchedAt: new Date(), inbox: person.inbox, - sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox, + sharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null, followersUri: person.followers ? getApId(person.followers) : undefined, - featured: person.featured, + featured: person.featured ? getApId(person.featured) : undefined, emojis: emojiNames, name: truncate(person.name, nameLength), tags, @@ -545,7 +594,9 @@ export class ApPersonService implements OnModuleInit { if (moving) updates.movedAt = new Date(); // Update user - await this.usersRepository.update(exist.id, updates); + if (!(await this.usersRepository.update({ id: exist.id, isDeleted: false }, updates)).affected) { + return 'skip'; + } if (person.publicKey) { await this.userPublickeysRepository.update({ userId: exist.id }, { @@ -566,6 +617,7 @@ export class ApPersonService implements OnModuleInit { url, fields, description: _description, + followedMessage: person._misskey_followedMessage != null ? truncate(person._misskey_followedMessage, 256) : null, followingVisibility, followersVisibility, birthday: bday?.[0] ?? null, @@ -580,7 +632,7 @@ export class ApPersonService implements OnModuleInit { // 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする await this.followingsRepository.update( { followerId: exist.id }, - { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox }, + { followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null }, ); await this.updateFeatured(exist.id, resolver).catch(err => this.logger.error(err)); @@ -649,7 +701,7 @@ export class ApPersonService implements OnModuleInit { @bindThis public async updateFeatured(userId: MiUser['id'], resolver?: Resolver): Promise { - const user = await this.usersRepository.findOneByOrFail({ id: userId }); + const user = await this.usersRepository.findOneByOrFail({ id: userId, isDeleted: false }); if (!this.userEntityService.isRemoteUser(user)) return; if (!user.featured) return; @@ -715,7 +767,7 @@ export class ApPersonService implements OnModuleInit { await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]); dst = await this.fetchPerson(src.movedToUri) ?? dst; } else { - if (src.movedToUri.startsWith(`${this.config.url}/`)) { + if (this.utilityService.isUriLocal(src.movedToUri)) { // ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている return 'failed: movedTo is local but not found'; } diff --git a/packages/backend/src/core/activitypub/models/ApQuestionService.ts b/packages/backend/src/core/activitypub/models/ApQuestionService.ts index 73004d10b0..a2cdaf02ca 100644 --- a/packages/backend/src/core/activitypub/models/ApQuestionService.ts +++ b/packages/backend/src/core/activitypub/models/ApQuestionService.ts @@ -5,16 +5,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { NotesRepository, PollsRepository } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, PollsRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import type { IPoll } from '@/models/Poll.js'; +import type { MiRemoteUser } from '@/models/User.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; -import { isQuestion } from '../type.js'; +import { getOneApId, isQuestion } from '../type.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { ApLoggerService } from '../ApLoggerService.js'; import { ApResolverService } from '../ApResolverService.js'; import type { Resolver } from '../ApResolverService.js'; -import type { IObject, IQuestion } from '../type.js'; +import type { IObject } from '../type.js'; @Injectable() export class ApQuestionService { @@ -24,6 +26,9 @@ export class ApQuestionService { @Inject(DI.config) private config: Config, + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -32,6 +37,7 @@ export class ApQuestionService { private apResolverService: ApResolverService, private apLoggerService: ApLoggerService, + private utilityService: UtilityService, ) { this.logger = this.apLoggerService.logger; } @@ -65,12 +71,12 @@ export class ApQuestionService { * @returns true if updated */ @bindThis - public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise { + public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise { const uri = typeof value === 'string' ? value : value.id; if (uri == null) throw new Error('uri is null'); // URIがこのサーバーを指しているならスキップ - if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local'); + if (this.utilityService.isUriLocal(uri)) throw new Error('uri points local'); //#region このサーバーに既に登録されているか const note = await this.notesRepository.findOneBy({ uri }); @@ -78,15 +84,26 @@ export class ApQuestionService { const poll = await this.pollsRepository.findOneBy({ noteId: note.id }); if (poll == null) throw new Error('Question is not registered'); + + const user = await this.usersRepository.findOneBy({ id: poll.userId }); + if (user == null) throw new Error('Question is not registered'); //#endregion // resolve new Question object // eslint-disable-next-line no-param-reassign if (resolver == null) resolver = this.apResolverService.createResolver(); - const question = await resolver.resolve(value) as IQuestion; + const question = await resolver.resolve(value); this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); - if (question.type !== 'Question') throw new Error('object is not a Question'); + if (!isQuestion(question)) throw new Error('object is not a Question'); + + const attribution = (question.attributedTo) ? getOneApId(question.attributedTo) : user.uri; + const attributionMatchesExisting = attribution === user.uri; + const actorMatchesAttribution = (actor) ? attribution === actor.uri : true; + + if (!attributionMatchesExisting || !actorMatchesAttribution) { + throw new Error('Refusing to ingest update for poll by different user'); + } const apChoices = question.oneOf ?? question.anyOf; if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices); @@ -96,7 +113,7 @@ export class ApQuestionService { for (const choice of poll.choices) { const oldCount = poll.votes[poll.choices.indexOf(choice)]; const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems; - if (newCount == null) throw new Error('invalid newCount: ' + newCount); + if (newCount == null || !(Number.isInteger(newCount) && newCount >= 0)) throw new Error('invalid newCount: ' + newCount); if (oldCount !== newCount) { changed = true; diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 16812b7a4d..72732b01df 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -13,6 +13,10 @@ export interface IObject { name?: string | null; summary?: string; _misskey_summary?: string; + _misskey_followedMessage?: string | null; + _misskey_requireSigninToViewContents?: boolean; + _misskey_makeNotesFollowersOnlyBefore?: number | null; + _misskey_makeNotesHiddenBefore?: number | null; published?: string; cc?: ApObject; to?: ApObject; @@ -238,6 +242,11 @@ export interface IApEmoji extends IObject { type: 'Emoji'; name: string; updated: string; + // Misskey拡張。後方互換性のためにoptional。 + // 将来の拡張性を考慮してobjectにしている + _misskey_license?: { + freeText: string | null; + }; } export const isEmoji = (object: IObject): object is IApEmoji => diff --git a/packages/backend/src/core/chart/ChartManagementService.ts b/packages/backend/src/core/chart/ChartManagementService.ts index 79681370a1..f04c561063 100644 --- a/packages/backend/src/core/chart/ChartManagementService.ts +++ b/packages/backend/src/core/chart/ChartManagementService.ts @@ -58,9 +58,9 @@ export class ChartManagementService implements OnApplicationShutdown { @bindThis public async start() { // 20分おきにメモリ情報をDBに書き込み - this.saveIntervalId = setInterval(() => { + this.saveIntervalId = setInterval(async () => { for (const chart of this.charts) { - chart.save(); + await chart.save(); } }, 1000 * 60 * 20); } @@ -69,9 +69,9 @@ export class ChartManagementService implements OnApplicationShutdown { public async dispose(): Promise { clearInterval(this.saveIntervalId); if (process.env.NODE_ENV !== 'test') { - await Promise.all( - this.charts.map(chart => chart.save()), - ); + for (const chart of this.charts) { + await chart.save(); + } } } diff --git a/packages/backend/src/core/chart/charts/federation.ts b/packages/backend/src/core/chart/charts/federation.ts index f40a26495d..c9b43cc66d 100644 --- a/packages/backend/src/core/chart/charts/federation.ts +++ b/packages/backend/src/core/chart/charts/federation.ts @@ -5,10 +5,9 @@ import { Injectable, Inject } from '@nestjs/common'; import { DataSource } from 'typeorm'; -import type { FollowingsRepository, InstancesRepository } from '@/models/_.js'; +import type { FollowingsRepository, InstancesRepository, MiMeta } from '@/models/_.js'; import { AppLockService } from '@/core/AppLockService.js'; import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import Chart from '../core.js'; import { ChartLoggerService } from '../ChartLoggerService.js'; @@ -24,13 +23,15 @@ export default class FederationChart extends Chart { // eslint-di @Inject(DI.db) private db: DataSource, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, - private metaService: MetaService, private appLockService: AppLockService, private chartLoggerService: ChartLoggerService, ) { @@ -43,8 +44,6 @@ export default class FederationChart extends Chart { // eslint-di } protected async tickMinor(): Promise>> { - const meta = await this.metaService.fetch(); - const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance') .select('instance.host') .where('instance.suspensionState != \'none\''); @@ -65,21 +64,21 @@ export default class FederationChart extends Chart { // eslint-di this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followerHost)') .where('following.followerHost IS NOT NULL') - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .getRawOne() .then(x => parseInt(x.count, 10)), this.followingsRepository.createQueryBuilder('following') .select('COUNT(DISTINCT following.followeeHost)') .where('following.followeeHost IS NOT NULL') - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`) .andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`) .setParameters(pubsubSubQuery.getParameters()) @@ -88,7 +87,7 @@ export default class FederationChart extends Chart { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ subInstancesQuery.getQuery() })`) - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() @@ -96,7 +95,7 @@ export default class FederationChart extends Chart { // eslint-di this.instancesRepository.createQueryBuilder('instance') .select('COUNT(instance.id)') .where(`instance.host IN (${ pubInstancesQuery.getQuery() })`) - .andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) + .andWhere(this.meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT ILIKE ALL(ARRAY[:...blocked])', { blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) }) .andWhere('instance.suspensionState = \'none\'') .andWhere('instance.isNotResponding = false') .getRawOne() diff --git a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts index a13c244c19..70ead890ab 100644 --- a/packages/backend/src/core/entities/AbuseUserReportEntityService.ts +++ b/packages/backend/src/core/entities/AbuseUserReportEntityService.ts @@ -53,6 +53,8 @@ export class AbuseUserReportEntityService { schema: 'UserDetailedNotMe', }) : null, forwarded: report.forwarded, + resolvedAs: report.resolvedAs, + moderationNote: report.moderationNote, }); } diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts new file mode 100644 index 0000000000..099a9e3ad2 --- /dev/null +++ b/packages/backend/src/core/entities/ChatEntityService.ts @@ -0,0 +1,376 @@ +/* + * 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 { MiUser, ChatMessagesRepository, MiChatMessage, ChatRoomsRepository, MiChatRoom, MiChatRoomInvitation, ChatRoomInvitationsRepository, MiChatRoomMembership, ChatRoomMembershipsRepository } from '@/models/_.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/json-schema.js'; +import type { } from '@/models/Blocking.js'; +import { bindThis } from '@/decorators.js'; +import { IdService } from '@/core/IdService.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { In } from 'typeorm'; + +@Injectable() +export class ChatEntityService { + constructor( + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + @Inject(DI.chatRoomsRepository) + private chatRoomsRepository: ChatRoomsRepository, + + @Inject(DI.chatRoomInvitationsRepository) + private chatRoomInvitationsRepository: ChatRoomInvitationsRepository, + + @Inject(DI.chatRoomMembershipsRepository) + private chatRoomMembershipsRepository: ChatRoomMembershipsRepository, + + private userEntityService: UserEntityService, + private driveFileEntityService: DriveFileEntityService, + private idService: IdService, + ) { + } + + @bindThis + public async packMessageDetailed( + src: MiChatMessage['id'] | MiChatMessage, + me?: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedFiles?: Map | null>; + packedUsers?: Map>; + packedRooms?: Map | null>; + }; + }, + ): Promise> { + const packedUsers = options?._hint_?.packedUsers; + const packedFiles = options?._hint_?.packedFiles; + const packedRooms = options?._hint_?.packedRooms; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me), + toUserId: message.toUserId, + toUser: message.toUserId ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined, + toRoomId: message.toRoomId, + 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, + }; + } + + @bindThis + public async packMessagesDetailed( + messages: MiChatMessage[], + me: { id: MiUser['id'] }, + ) { + if (messages.length === 0) return []; + + const excludeMe = (x: MiUser | string) => { + if (typeof x === 'string') { + return x !== me.id; + } else { + return x.id !== me.id; + } + }; + + const users = [ + ...messages.map((m) => m.fromUser ?? m.fromUserId).filter(excludeMe), + ...messages.map((m) => m.toUser ?? m.toUserId).filter(x => x != null).filter(excludeMe), + ]; + + const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0])); + + for (const reactedUserId of reactedUserIds) { + if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) { + users.push(reactedUserId); + } + } + + const [packedUsers, packedFiles, packedRooms] = await Promise.all([ + this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + this.packRooms(messages.map(m => m.toRoom ?? m.toRoomId).filter(x => x != null), me) + .then(rooms => new Map(rooms.map(r => [r.id, r]))), + ]); + + return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } }))); + } + + @bindThis + public async packMessageLiteFor1on1( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map | null>; + }; + }, + ): Promise> { + const packedFiles = options?._hint_?.packedFiles; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + toUserId: message.toUserId, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesLiteFor1on1( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const [packedFiles] = await Promise.all([ + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + ]); + + return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } }))); + } + + @bindThis + public async packMessageLiteForRoom( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map | null>; + packedUsers: Map>; + }; + }, + ): Promise> { + const packedFiles = options?._hint_?.packedFiles; + const packedUsers = options?._hint_?.packedUsers; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = []; + + for (const record of message.reactions) { + const [userId, reaction] = record.split('/'); + reactions.push({ + user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId), + reaction, + }); + } + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId), + toRoomId: message.toRoomId, + fileId: message.fileId, + file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null, + reactions, + }; + } + + @bindThis + public async packMessagesLiteForRoom( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const users = messages.map(x => x.fromUser ?? x.fromUserId); + const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0])); + + for (const reactedUserId of reactedUserIds) { + if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) { + users.push(reactedUserId); + } + } + + const [packedUsers, packedFiles] = await Promise.all([ + this.userEntityService.packMany(users) + .then(users => new Map(users.map(u => [u.id, u]))), + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)) + .then(files => new Map(files.map(f => [f.id, f]))), + ]); + + return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } }))); + } + + @bindThis + public async packRoom( + src: MiChatRoom['id'] | MiChatRoom, + me?: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedOwners: Map>; + memberships?: Map; + }; + }, + ): Promise> { + const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src }); + + const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null; + + return { + id: room.id, + createdAt: this.idService.parse(room.id).date.toISOString(), + name: room.name, + description: room.description, + ownerId: room.ownerId, + owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me), + isMuted: membership != null ? membership.isMuted : false, + }; + } + + @bindThis + public async packRooms( + rooms: (MiChatRoom | MiChatRoom['id'])[], + me: { id: MiUser['id'] }, + ) { + if (rooms.length === 0) return []; + + const _rooms = rooms.filter((room): room is MiChatRoom => typeof room !== 'string'); + if (_rooms.length !== rooms.length) { + _rooms.push( + ...await this.chatRoomsRepository.find({ + where: { + id: In(rooms.filter((room): room is string => typeof room === 'string')), + }, + relations: ['owner'], + }), + ); + } + + const owners = _rooms.map(x => x.owner ?? x.ownerId); + + const [packedOwners, memberships] = await Promise.all([ + this.userEntityService.packMany(owners, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.chatRoomMembershipsRepository.find({ + where: { + roomId: In(_rooms.map(x => x.id)), + userId: me.id, + }, + }).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))), + ]); + + return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } }))); + } + + @bindThis + public async packRoomInvitation( + src: MiChatRoomInvitation['id'] | MiChatRoomInvitation, + me: { id: MiUser['id'] }, + options?: { + _hint_?: { + packedRooms: Map>; + packedUsers: Map>; + }; + }, + ): Promise> { + const invitation = typeof src === 'object' ? src : await this.chatRoomInvitationsRepository.findOneByOrFail({ id: src }); + + return { + id: invitation.id, + createdAt: this.idService.parse(invitation.id).date.toISOString(), + roomId: invitation.roomId, + room: options?._hint_?.packedRooms.get(invitation.roomId) ?? await this.packRoom(invitation.room ?? invitation.roomId, me), + userId: invitation.userId, + user: options?._hint_?.packedUsers.get(invitation.userId) ?? await this.userEntityService.pack(invitation.user ?? invitation.userId, me), + }; + } + + @bindThis + public async packRoomInvitations( + invitations: MiChatRoomInvitation[], + me: { id: MiUser['id'] }, + ) { + if (invitations.length === 0) return []; + + return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me))); + } + + @bindThis + public async packRoomMembership( + src: MiChatRoomMembership['id'] | MiChatRoomMembership, + me: { id: MiUser['id'] }, + options?: { + populateUser?: boolean; + populateRoom?: boolean; + _hint_?: { + packedRooms: Map>; + packedUsers: Map>; + }; + }, + ): Promise> { + const membership = typeof src === 'object' ? src : await this.chatRoomMembershipsRepository.findOneByOrFail({ id: src }); + + return { + id: membership.id, + createdAt: this.idService.parse(membership.id).date.toISOString(), + userId: membership.userId, + user: options?.populateUser ? (options._hint_?.packedUsers.get(membership.userId) ?? await this.userEntityService.pack(membership.user ?? membership.userId, me)) : undefined, + roomId: membership.roomId, + room: options?.populateRoom ? (options._hint_?.packedRooms.get(membership.roomId) ?? await this.packRoom(membership.room ?? membership.roomId, me)) : undefined, + }; + } + + @bindThis + public async packRoomMemberships( + memberships: MiChatRoomMembership[], + me: { id: MiUser['id'] }, + options: { + populateUser?: boolean; + populateRoom?: boolean; + } = {}, + ) { + if (memberships.length === 0) return []; + + const users = memberships.map(x => x.user ?? x.userId); + const rooms = memberships.map(x => x.room ?? x.roomId); + + const [packedUsers, packedRooms] = await Promise.all([ + this.userEntityService.packMany(users, me) + .then(users => new Map(users.map(u => [u.id, u]))), + this.packRooms(rooms, me) + .then(rooms => new Map(rooms.map(r => [r.id, r]))), + ]); + + return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } }))); + } +} diff --git a/packages/backend/src/core/entities/EmojiEntityService.ts b/packages/backend/src/core/entities/EmojiEntityService.ts index 841bd731c0..490d3f2511 100644 --- a/packages/backend/src/core/entities/EmojiEntityService.ts +++ b/packages/backend/src/core/entities/EmojiEntityService.ts @@ -4,10 +4,10 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { EmojisRepository } from '@/models/_.js'; +import type { EmojisRepository, MiRole, RolesRepository } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; import type { MiEmoji } from '@/models/Emoji.js'; import { bindThis } from '@/decorators.js'; @@ -16,6 +16,8 @@ export class EmojiEntityService { constructor( @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, ) { } @@ -68,8 +70,90 @@ export class EmojiEntityService { @bindThis public packDetailedMany( emojis: any[], - ) { + ): Promise[]> { return Promise.all(emojis.map(x => this.packDetailed(x))); } + + @bindThis + public async packDetailedAdmin( + src: MiEmoji['id'] | MiEmoji, + hint?: { + roles?: Map + }, + ): Promise> { + const emoji = typeof src === 'object' ? src : await this.emojisRepository.findOneByOrFail({ id: src }); + + const roles = Array.of(); + if (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length > 0) { + if (hint?.roles) { + const hintRoles = hint.roles; + roles.push( + ...emoji.roleIdsThatCanBeUsedThisEmojiAsReaction + .filter(x => hintRoles.has(x)) + .map(x => hintRoles.get(x)!), + ); + } else { + roles.push( + ...await this.rolesRepository.findBy({ id: In(emoji.roleIdsThatCanBeUsedThisEmojiAsReaction) }), + ); + } + + roles.sort((a, b) => { + if (a.displayOrder !== b.displayOrder) { + return b.displayOrder - a.displayOrder; + } + + return a.id.localeCompare(b.id); + }); + } + + return { + id: emoji.id, + updatedAt: emoji.updatedAt?.toISOString() ?? null, + name: emoji.name, + host: emoji.host, + uri: emoji.uri, + type: emoji.type, + aliases: emoji.aliases, + category: emoji.category, + publicUrl: emoji.publicUrl, + originalUrl: emoji.originalUrl, + license: emoji.license, + localOnly: emoji.localOnly, + isSensitive: emoji.isSensitive, + roleIdsThatCanBeUsedThisEmojiAsReaction: roles.map(it => ({ id: it.id, name: it.name })), + }; + } + + @bindThis + public async packDetailedAdminMany( + emojis: MiEmoji['id'][] | MiEmoji[], + hint?: { + roles?: Map + }, + ): Promise[]> { + // IDのみの要素をピックアップし、DBからレコードを取り出して他の値を補完する + const emojiEntities = emojis.filter(x => typeof x === 'object') as MiEmoji[]; + const emojiIdOnlyList = emojis.filter(x => typeof x === 'string') as string[]; + if (emojiIdOnlyList.length > 0) { + emojiEntities.push(...await this.emojisRepository.findBy({ id: In(emojiIdOnlyList) })); + } + + // 特定ロール専用の絵文字である場合、そのロール情報をあらかじめまとめて取得しておく(pack側で都度取得も出来るが負荷が高いので) + let hintRoles: Map; + if (hint?.roles) { + hintRoles = hint.roles; + } else { + const roles = Array.of(); + const roleIds = [...new Set(emojiEntities.flatMap(x => x.roleIdsThatCanBeUsedThisEmojiAsReaction))]; + if (roleIds.length > 0) { + roles.push(...await this.rolesRepository.findBy({ id: In(roleIds) })); + } + + hintRoles = new Map(roles.map(x => [x.id, x])); + } + + return Promise.all(emojis.map(x => this.packDetailedAdmin(x, { roles: hintRoles }))); + } } diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index 4aa7104c1e..7b0150f5b6 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -5,10 +5,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import type { FlashsRepository, FlashLikesRepository } from '@/models/_.js'; -import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { FlashLikesRepository, FlashsRepository } 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 { MiFlash } from '@/models/Flash.js'; import { bindThis } from '@/decorators.js'; @@ -20,10 +18,8 @@ export class FlashEntityService { constructor( @Inject(DI.flashsRepository) private flashsRepository: FlashsRepository, - @Inject(DI.flashLikesRepository) private flashLikesRepository: FlashLikesRepository, - private userEntityService: UserEntityService, private idService: IdService, ) { @@ -34,25 +30,36 @@ export class FlashEntityService { src: MiFlash['id'] | MiFlash, me?: { id: MiUser['id'] } | null | undefined, hint?: { - packedUser?: Packed<'UserLite'> + packedUser?: Packed<'UserLite'>, + likedFlashIds?: MiFlash['id'][], }, ): Promise> { const meId = me ? me.id : null; const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src }); - return await awaitAll({ + // { schema: 'UserDetailed' } すると無限ループするので注意 + const user = hint?.packedUser ?? await this.userEntityService.pack(flash.user ?? flash.userId, me); + + let isLiked = undefined; + if (meId) { + isLiked = hint?.likedFlashIds + ? hint.likedFlashIds.includes(flash.id) + : await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }); + } + + return { id: flash.id, createdAt: this.idService.parse(flash.id).date.toISOString(), updatedAt: flash.updatedAt.toISOString(), userId: flash.userId, - user: hint?.packedUser ?? this.userEntityService.pack(flash.user ?? flash.userId, me), // { schema: 'UserDetailed' } すると無限ループするので注意 + user: user, title: flash.title, summary: flash.summary, script: flash.script, visibility: flash.visibility, likedCount: flash.likedCount, - isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined, - }); + isLiked: isLiked, + }; } @bindThis @@ -63,7 +70,19 @@ export class FlashEntityService { const _users = flashes.map(({ user, userId }) => user ?? userId); const _userMap = await this.userEntityService.packMany(_users, me) .then(users => new Map(users.map(u => [u.id, u]))); - return Promise.all(flashes.map(flash => this.pack(flash, me, { packedUser: _userMap.get(flash.userId) }))); + const _likedFlashIds = me + ? await this.flashLikesRepository.createQueryBuilder('flashLike') + .select('flashLike.flashId') + .where('flashLike.userId = :userId', { userId: me.id }) + .getRawMany<{ flashLike_flashId: string }>() + .then(likes => [...new Set(likes.map(like => like.flashLike_flashId))]) + : []; + return Promise.all( + flashes.map(flash => this.pack(flash, me, { + packedUser: _userMap.get(flash.userId), + likedFlashIds: _likedFlashIds, + })), + ); } } diff --git a/packages/backend/src/core/entities/InstanceEntityService.ts b/packages/backend/src/core/entities/InstanceEntityService.ts index 4956bc22ce..284537b986 100644 --- a/packages/backend/src/core/entities/InstanceEntityService.ts +++ b/packages/backend/src/core/entities/InstanceEntityService.ts @@ -3,19 +3,22 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import type { Packed } from '@/misc/json-schema.js'; import type { MiInstance } from '@/models/Instance.js'; -import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; import { UtilityService } from '@/core/UtilityService.js'; import { RoleService } from '@/core/RoleService.js'; import { MiUser } from '@/models/User.js'; +import { DI } from '@/di-symbols.js'; +import { MiMeta } from '@/models/_.js'; @Injectable() export class InstanceEntityService { constructor( - private metaService: MetaService, + @Inject(DI.meta) + private meta: MiMeta, + private roleService: RoleService, private utilityService: UtilityService, @@ -27,7 +30,6 @@ export class InstanceEntityService { instance: MiInstance, me?: { id: MiUser['id']; } | null | undefined, ): Promise> { - const meta = await this.metaService.fetch(); const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false; return { @@ -41,7 +43,7 @@ export class InstanceEntityService { isNotResponding: instance.isNotResponding, isSuspended: instance.suspensionState !== 'none', suspensionState: instance.suspensionState, - isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host), + isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host), softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, openRegistrations: instance.openRegistrations, @@ -49,8 +51,8 @@ export class InstanceEntityService { description: instance.description, maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, - isSilenced: this.utilityService.isSilencedHost(meta.silencedHosts, instance.host), - isMediaSilenced: this.utilityService.isMediaSilencedHost(meta.mediaSilencedHosts, instance.host), + isSilenced: this.utilityService.isSilencedHost(this.meta.silencedHosts, instance.host), + isMediaSilenced: this.utilityService.isMediaSilencedHost(this.meta.mediaSilencedHosts, instance.host), iconUrl: instance.iconUrl, faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, diff --git a/packages/backend/src/core/entities/MetaEntityService.ts b/packages/backend/src/core/entities/MetaEntityService.ts index f4b1e302d0..08717bd066 100644 --- a/packages/backend/src/core/entities/MetaEntityService.ts +++ b/packages/backend/src/core/entities/MetaEntityService.ts @@ -10,10 +10,8 @@ import type { Packed } from '@/misc/json-schema.js'; import type { MiMeta } from '@/models/Meta.js'; import type { AdsRepository } from '@/models/_.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; -import { MetaService } from '@/core/MetaService.js'; import { bindThis } from '@/decorators.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; @@ -24,12 +22,13 @@ export class MetaEntityService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.adsRepository) private adsRepository: AdsRepository, - private userEntityService: UserEntityService, - private metaService: MetaService, - private instanceActorService: InstanceActorService, + private systemAccountService: SystemAccountService, ) { } @bindThis @@ -37,7 +36,7 @@ export class MetaEntityService { let instance = meta; if (!instance) { - instance = await this.metaService.fetch(); + instance = this.meta; } const ads = await this.adsRepository.createQueryBuilder('ads') @@ -95,6 +94,8 @@ export class MetaEntityService { recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, + googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl ?? '/assets/ai.png', @@ -130,6 +131,7 @@ export class MetaEntityService { enableUrlPreview: instance.urlPreviewEnabled, noteSearchableScope: (this.config.meilisearch == null || this.config.meilisearch.scope !== 'local') ? 'global' : 'local', maxFileSize: this.config.maxFileSize, + federation: this.meta.federation, }; return packed; @@ -140,19 +142,19 @@ export class MetaEntityService { let instance = meta; if (!instance) { - instance = await this.metaService.fetch(); + instance = this.meta; } const packed = await this.pack(instance); - const proxyAccount = instance.proxyAccountId ? await this.userEntityService.pack(instance.proxyAccountId).catch(() => null) : null; + const proxyAccount = await this.systemAccountService.fetch('proxy'); const packDetailed: Packed<'MetaDetailed'> = { ...packed, cacheRemoteFiles: instance.cacheRemoteFiles, cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles, - requireSetup: !await this.instanceActorService.realLocalUsersPresent(), - proxyAccountName: proxyAccount ? proxyAccount.username : null, + requireSetup: this.meta.rootUserId == null, + proxyAccountName: proxyAccount.username, features: { localTimeline: instance.policies.ltlAvailable, globalTimeline: instance.policies.gtlAvailable, diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 0d0b80765a..97f1c3d739 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -11,28 +11,39 @@ import type { Packed } from '@/misc/json-schema.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; import type { MiUser } from '@/models/User.js'; import type { MiNote } from '@/models/Note.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository } from '@/models/_.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; -import { MetaService } from '@/core/MetaService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; -function mergeReactions(src: Record, delta: Record) { - const reactions = { ...src }; - for (const [name, count] of Object.entries(delta)) { - if (reactions[name] != null) { - reactions[name] += count; +// is-renote.tsとよしなにリンク +function isPureRenote(note: MiNote): note is MiNote & { renoteId: MiNote['id']; renote: MiNote } { + return ( + note.renote != null && + note.reply == null && + note.text == null && + note.cw == null && + (note.fileIds == null || note.fileIds.length === 0) && + !note.hasPoll + ); +} + +function getAppearNoteIds(notes: MiNote[]): Set { + const appearNoteIds = new Set(); + for (const note of notes) { + if (isPureRenote(note)) { + appearNoteIds.add(note.renoteId); } else { - reactions[name] = count; + appearNoteIds.add(note.id); } } - return reactions; + return appearNoteIds; } @Injectable() @@ -43,12 +54,14 @@ export class NoteEntityService implements OnModuleInit { private reactionService: ReactionService; private reactionsBufferingService: ReactionsBufferingService; private idService: IdService; - private metaService: MetaService; private noteLoader = new DebounceLoader(this.findNoteOrFail); constructor( private moduleRef: ModuleRef, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -76,7 +89,6 @@ export class NoteEntityService implements OnModuleInit { //private reactionService: ReactionService, //private reactionsBufferingService: ReactionsBufferingService, //private idService: IdService, - //private metaService: MetaService, ) { } @@ -87,54 +99,86 @@ export class NoteEntityService implements OnModuleInit { this.reactionService = this.moduleRef.get('ReactionService'); this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService'); this.idService = this.moduleRef.get('IdService'); - this.metaService = this.moduleRef.get('MetaService'); } @bindThis - private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null) { + 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)) + ) + ) { + packedNote.visibility = 'followers'; + } + } + return packedNote.visibility; + } + + @bindThis + private async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null): Promise { + if (meId === packedNote.userId) return; + // TODO: isVisibleForMe を使うようにしても良さそう(型違うけど) let hide = false; - // visibility が specified かつ自分が指定されていなかったら非表示 - if (packedNote.visibility === 'specified') { - if (meId == null) { - hide = true; - } else if (meId === packedNote.userId) { - hide = false; - } else { - // 指定されているかどうか - const specified = packedNote.visibleUserIds!.some((id: any) => meId === id); + if (packedNote.user.requireSigninToViewContents && meId == null) { + hide = true; + } - if (specified) { - hide = false; - } else { + 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)) + ) + ) { + hide = true; + } + } + + // visibility が specified かつ自分が指定されていなかったら非表示 + if (!hide) { + if (packedNote.visibility === 'specified') { + if (meId == null) { hide = true; + } else { + // 指定されているかどうか + const specified = packedNote.visibleUserIds!.some(id => meId === id); + + if (!specified) { + hide = true; + } } } } // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 - if (packedNote.visibility === 'followers') { - if (meId == null) { - hide = true; - } else if (meId === packedNote.userId) { - hide = false; - } else if (packedNote.reply && (meId === packedNote.reply.userId)) { - // 自分の投稿に対するリプライ - hide = false; - } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { - // 自分へのメンション - hide = false; - } else { - // フォロワーかどうか - const isFollowing = await this.followingsRepository.exists({ - where: { - followeeId: packedNote.userId, - followerId: meId, - }, - }); + if (!hide) { + if (packedNote.visibility === 'followers') { + if (meId == null) { + hide = true; + } else if (packedNote.reply && (meId === packedNote.reply.userId)) { + // 自分の投稿に対するリプライ + hide = false; + } else if (packedNote.mentions && packedNote.mentions.some(id => meId === id)) { + // 自分へのメンション + hide = false; + } else { + // フォロワーかどうか + // TODO: 当関数呼び出しごとにクエリが走るのは重そうだからなんとかする + const isFollowing = await this.followingsRepository.exists({ + where: { + followeeId: packedNote.userId, + followerId: meId, + }, + }); - hide = !isFollowing; + hide = !isFollowing; + } } } @@ -146,6 +190,7 @@ export class NoteEntityService implements OnModuleInit { packedNote.poll = undefined; packedNote.cw = null; packedNote.isHidden = true; + // TODO: hiddenReason みたいなのを提供しても良さそう } } @@ -240,7 +285,7 @@ export class NoteEntityService implements OnModuleInit { return true; } else { // 指定されているかどうか - return note.visibleUserIds.some((id: any) => meId === id); + return note.visibleUserIds.some(id => meId === id); } } @@ -307,7 +352,7 @@ export class NoteEntityService implements OnModuleInit { skipHide?: boolean; withReactionAndUserPairCache?: boolean; _hint_?: { - bufferdReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; + bufferedReactions: Map; pairs: ([MiUser['id'], string])[] }> | null; myReactions: Map; packedFiles: Map | null>; packedUsers: Map> @@ -324,21 +369,14 @@ export class NoteEntityService implements OnModuleInit { const note = typeof src === 'object' ? src : await this.noteLoader.load(src); const host = note.userHost; - const meta = await this.metaService.fetch(); - - const bufferdReactions = opts._hint_?.bufferdReactions != null - ? (opts._hint_.bufferdReactions.get(note.id) ?? { deltas: {}, pairs: [] }) - : meta.enableReactionsBuffering + const bufferedReactions = opts._hint_?.bufferedReactions != null + ? (opts._hint_.bufferedReactions.get(note.id) ?? { deltas: {}, pairs: [] }) + : this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.get(note.id) : { deltas: {}, pairs: [] }; - const reactions = mergeReactions(note.reactions, bufferdReactions.deltas ?? {}); - for (const [name, count] of Object.entries(reactions)) { - if (count <= 0) { - delete reactions[name]; - } - } + const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions.deltas ?? {})); - const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferdReactions.pairs.map(x => x.join('/'))); + const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/'))); let text = note.text; @@ -423,6 +461,8 @@ export class NoteEntityService implements OnModuleInit { } : {}), }); + this.treatVisibility(packed); + if (!opts.skipHide) { await this.hideNote(packed, meId); } @@ -441,9 +481,7 @@ export class NoteEntityService implements OnModuleInit { ) { if (notes.length === 0) return []; - const meta = await this.metaService.fetch(); - - const bufferdReactions = meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(notes.map(x => x.id)) : null; + const bufferedReactions = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany([...getAppearNoteIds(notes)]) : null; const meId = me ? me.id : null; const myReactionsMap = new Map(); @@ -454,12 +492,12 @@ export class NoteEntityService implements OnModuleInit { const oldId = this.idService.gen(Date.now() - 2000); for (const note of notes) { - if (note.renote && (note.text == null && note.fileIds.length === 0)) { // pure renote - const reactionsCount = Object.values(mergeReactions(note.renote.reactions, bufferdReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); + if (isPureRenote(note)) { + const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.renote.reactions, bufferedReactions?.get(note.renote.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { myReactionsMap.set(note.renote.id, null); - } else if (reactionsCount <= note.renote.reactionAndUserPairCache.length + (bufferdReactions?.get(note.renote.id)?.pairs.length ?? 0)) { - const pairInBuffer = bufferdReactions?.get(note.renote.id)?.pairs.find(p => p[0] === meId); + } else if (reactionsCount <= note.renote.reactionAndUserPairCache.length + (bufferedReactions?.get(note.renote.id)?.pairs.length ?? 0)) { + const pairInBuffer = bufferedReactions?.get(note.renote.id)?.pairs.find(p => p[0] === meId); if (pairInBuffer) { myReactionsMap.set(note.renote.id, pairInBuffer[1]); } else { @@ -471,11 +509,11 @@ export class NoteEntityService implements OnModuleInit { } } else { if (note.id < oldId) { - const reactionsCount = Object.values(mergeReactions(note.reactions, bufferdReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); + const reactionsCount = Object.values(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.get(note.id)?.deltas ?? {})).reduce((a, b) => a + b, 0); if (reactionsCount === 0) { myReactionsMap.set(note.id, null); - } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferdReactions?.get(note.id)?.pairs.length ?? 0)) { - const pairInBuffer = bufferdReactions?.get(note.id)?.pairs.find(p => p[0] === meId); + } else if (reactionsCount <= note.reactionAndUserPairCache.length + (bufferedReactions?.get(note.id)?.pairs.length ?? 0)) { + const pairInBuffer = bufferedReactions?.get(note.id)?.pairs.find(p => p[0] === meId); if (pairInBuffer) { myReactionsMap.set(note.id, pairInBuffer[1]); } else { @@ -516,7 +554,7 @@ export class NoteEntityService implements OnModuleInit { return await Promise.all(notes.map(n => this.pack(n, me, { ...options, _hint_: { - bufferdReactions, + bufferedReactions, myReactions: myReactionsMap, packedFiles, packedUsers, diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index f393513510..e91fb9eb51 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -16,6 +16,7 @@ import { bindThis } from '@/decorators.js'; import { FilterUnionByProperty, groupedNotificationTypes } from '@/types.js'; import { CacheService } from '@/core/CacheService.js'; import { RoleEntityService } from './RoleEntityService.js'; +import { ChatEntityService } from './ChatEntityService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; @@ -27,6 +28,7 @@ export class NotificationEntityService implements OnModuleInit { private userEntityService: UserEntityService; private noteEntityService: NoteEntityService; private roleEntityService: RoleEntityService; + private chatEntityService: ChatEntityService; constructor( private moduleRef: ModuleRef, @@ -41,9 +43,6 @@ export class NotificationEntityService implements OnModuleInit { private followRequestsRepository: FollowRequestsRepository, private cacheService: CacheService, - - //private userEntityService: UserEntityService, - //private noteEntityService: NoteEntityService, ) { } @@ -51,6 +50,7 @@ export class NotificationEntityService implements OnModuleInit { this.userEntityService = this.moduleRef.get('UserEntityService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); this.roleEntityService = this.moduleRef.get('RoleEntityService'); + this.chatEntityService = this.moduleRef.get('ChatEntityService'); } /** @@ -59,7 +59,6 @@ export class NotificationEntityService implements OnModuleInit { async #packInternal ( src: T, meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types options: { checkValidNotifier?: boolean; }, @@ -92,7 +91,7 @@ export class NotificationEntityService implements OnModuleInit { // if the user has been deleted, don't show this notification if (needsUser && !userIfNeed) return null; - // #region Grouped notifications + //#region Grouped notifications if (notification.type === 'reaction:grouped') { const reactions = (await Promise.all(notification.reactions.map(async reaction => { const user = hint?.packedUsers != null @@ -137,7 +136,7 @@ export class NotificationEntityService implements OnModuleInit { users, }); } - // #endregion + //#endregion const needsRole = notification.type === 'roleAssigned'; const role = needsRole ? await this.roleEntityService.pack(notification.roleId) : undefined; @@ -146,6 +145,13 @@ export class NotificationEntityService implements OnModuleInit { return null; } + const needsChatRoomInvitation = notification.type === 'chatRoomInvitationReceived'; + const chatRoomInvitation = needsChatRoomInvitation ? await this.chatEntityService.packRoomInvitation(notification.invitationId, { id: meId }).catch(() => null) : undefined; + // if the invitation has been deleted, don't show this notification + if (needsChatRoomInvitation && !chatRoomInvitation) { + return null; + } + return await awaitAll({ id: notification.id, createdAt: new Date(notification.createdAt).toISOString(), @@ -159,9 +165,19 @@ export class NotificationEntityService implements OnModuleInit { ...(notification.type === 'roleAssigned' ? { role: role, } : {}), + ...(notification.type === 'chatRoomInvitationReceived' ? { + invitation: chatRoomInvitation, + } : {}), + ...(notification.type === 'followRequestAccepted' ? { + message: notification.message, + } : {}), ...(notification.type === 'achievementEarned' ? { achievement: notification.achievement, } : {}), + ...(notification.type === 'exportCompleted' ? { + exportedEntity: notification.exportedEntity, + fileId: notification.fileId, + } : {}), ...(notification.type === 'app' ? { body: notification.customBody, header: notification.customHeader, @@ -229,7 +245,7 @@ export class NotificationEntityService implements OnModuleInit { public async pack( src: MiNotification | MiGroupedNotification, meId: MiUser['id'], - // eslint-disable-next-line @typescript-eslint/ban-types + options: { checkValidNotifier?: boolean; }, diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 9bf568bc90..ad8052711c 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -28,10 +28,10 @@ import type { FollowingsRepository, FollowRequestsRepository, MiFollowing, + MiMeta, MiUserNotePining, MiUserProfile, MutingsRepository, - NoteUnreadsRepository, RenoteMutingsRepository, UserMemoRepository, UserNotePiningsRepository, @@ -47,9 +47,9 @@ import { IdService } from '@/core/IdService.js'; import type { AnnouncementService } from '@/core/AnnouncementService.js'; import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { ChatService } from '@/core/ChatService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { NoteEntityService } from './NoteEntityService.js'; -import type { DriveFileEntityService } from './DriveFileEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; const Ajv = _Ajv.default; @@ -57,12 +57,14 @@ const ajv = new Ajv(); function isLocalUser(user: MiUser): user is MiLocalUser; function isLocalUser(user: T): user is (T & { host: null; }); + function isLocalUser(user: MiUser | { host: MiUser['host'] }): boolean { return user.host == null; } function isRemoteUser(user: MiUser): user is MiRemoteUser; function isRemoteUser(user: T): user is (T & { host: string; }); + function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean { return !isLocalUser(user); } @@ -78,7 +80,7 @@ export type UserRelation = { isBlocked: boolean isMuted: boolean isRenoteMuted: boolean -} +}; @Injectable() export class UserEntityService implements OnModuleInit { @@ -91,6 +93,7 @@ export class UserEntityService implements OnModuleInit { private federatedInstanceService: FederatedInstanceService; private idService: IdService; private avatarDecorationService: AvatarDecorationService; + private chatService: ChatService; constructor( private moduleRef: ModuleRef, @@ -98,6 +101,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.redis) private redisClient: Redis.Redis, @@ -122,9 +128,6 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.renoteMutingsRepository) private renoteMutingsRepository: RenoteMutingsRepository, - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - @Inject(DI.userNotePiningsRepository) private userNotePiningsRepository: UserNotePiningsRepository, @@ -146,6 +149,7 @@ export class UserEntityService implements OnModuleInit { this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService'); this.idService = this.moduleRef.get('IdService'); this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService'); + this.chatService = this.moduleRef.get('ChatService'); } //#region Validators @@ -379,7 +383,11 @@ export class UserEntityService implements OnModuleInit { @bindThis public getIdenticonUrl(user: MiUser): string { - return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; + if ((user.host == null || user.host === this.config.host) && user.username.includes('.') && this.meta.iconUrl) { // ローカルのシステムアカウントの場合 + return this.meta.iconUrl; + } else { + return `${this.config.url}/identicon/${user.username.toLowerCase()}@${user.host ?? this.config.host}`; + } } @bindThis @@ -490,6 +498,9 @@ export class UserEntityService implements OnModuleInit { }))) : [], isBot: user.isBot, isCat: user.isCat, + requireSigninToViewContents: user.requireSigninToViewContents === false ? undefined : true, + makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore ?? undefined, + makeNotesHiddenBefore: user.makeNotesHiddenBefore ?? undefined, instance: user.host ? this.federatedInstanceService.federatedInstanceCache.fetch(user.host).then(instance => instance ? { name: instance.name, softwareName: instance.softwareName, @@ -508,7 +519,7 @@ export class UserEntityService implements OnModuleInit { name: r.name, iconUrl: r.iconUrl, displayOrder: r.displayOrder, - })) + })), ) : undefined, ...(isDetailed ? { @@ -545,11 +556,8 @@ export class UserEntityService implements OnModuleInit { publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964 followersVisibility: profile!.followersVisibility, followingVisibility: profile!.followingVisibility, - twoFactorEnabled: profile!.twoFactorEnabled, - usePasswordLessLogin: profile!.usePasswordLessLogin, - securityKeys: profile!.twoFactorEnabled - ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) - : false, + chatScope: user.chatScope, + canChat: this.roleService.getUserPolicies(user.id).then(r => r.canChat), roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({ id: role.id, name: role.name, @@ -564,9 +572,18 @@ export class UserEntityService implements OnModuleInit { moderationNote: iAmModerator ? (profile!.moderationNote ?? '') : undefined, } : {}), + ...(isDetailed && (isMe || iAmModerator) ? { + twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, + securityKeys: profile!.twoFactorEnabled + ? this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1) + : false, + } : {}), + ...(isDetailed && isMe ? { avatarId: user.avatarId, bannerId: user.bannerId, + followedMessage: profile!.followedMessage, isModerator: isModerator, isAdmin: isAdmin, injectFeaturedNote: profile!.injectFeaturedNote, @@ -581,14 +598,9 @@ export class UserEntityService implements OnModuleInit { isDeleted: user.isDeleted, twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none', hideOnlineStatus: user.hideOnlineStatus, - hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({ - where: { userId: user.id, isSpecified: true }, - take: 1, - }).then(count => count > 0), - hasUnreadMentions: this.noteUnreadsRepository.count({ - where: { userId: user.id, isMentioned: true }, - take: 1, - }).then(count => count > 0), + hasUnreadSpecifiedNotes: false, // 後方互換性のため + hasUnreadMentions: false, // 後方互換性のため + hasUnreadChatMessages: this.chatService.hasUnreadMessages(user.id), hasUnreadAnnouncement: unreadAnnouncements!.length > 0, unreadAnnouncements, hasUnreadAntenna: this.getHasUnreadAntenna(user.id), @@ -635,6 +647,7 @@ export class UserEntityService implements OnModuleInit { isRenoteMuted: relation.isRenoteMuted, notify: relation.following?.notify ?? 'none', withReplies: relation.following?.withReplies ?? false, + followedMessage: relation.isFollowing ? profile!.followedMessage : undefined, } : {}), } as Promiseable>; diff --git a/packages/backend/src/daemons/ServerStatsService.ts b/packages/backend/src/daemons/ServerStatsService.ts index 2c70344c94..d229efb123 100644 --- a/packages/backend/src/daemons/ServerStatsService.ts +++ b/packages/backend/src/daemons/ServerStatsService.ts @@ -3,13 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +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 { MetaService } from '@/core/MetaService.js'; import type { OnApplicationShutdown } from '@nestjs/common'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; const ev = new Xev(); @@ -23,7 +24,8 @@ export class ServerStatsService implements OnApplicationShutdown { private intervalId: NodeJS.Timeout | null = null; constructor( - private metaService: MetaService, + @Inject(DI.meta) + private meta: MiMeta, ) { } @@ -32,7 +34,7 @@ export class ServerStatsService implements OnApplicationShutdown { */ @bindThis public async start(): Promise { - if (!(await this.metaService.fetch(true)).enableServerMachineStats) return; + if (!this.meta.enableServerMachineStats) return; const log = [] as any[]; diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index b6f003c2e6..77d2838e09 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -6,6 +6,7 @@ export const DI = { config: Symbol('config'), db: Symbol('db'), + meta: Symbol('meta'), meilisearch: Symbol('meilisearch'), redis: Symbol('redis'), redisForPub: Symbol('redisForPub'), @@ -23,7 +24,6 @@ export const DI = { noteFavoritesRepository: Symbol('noteFavoritesRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), - noteUnreadsRepository: Symbol('noteUnreadsRepository'), pollsRepository: Symbol('pollsRepository'), pollVotesRepository: Symbol('pollVotesRepository'), userProfilesRepository: Symbol('userProfilesRepository'), @@ -73,6 +73,7 @@ export const DI = { registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), systemWebhooksRepository: Symbol('systemWebhooksRepository'), + systemAccountsRepository: Symbol('systemAccountsRepository'), adsRepository: Symbol('adsRepository'), passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'), retentionAggregationsRepository: Symbol('retentionAggregationsRepository'), @@ -81,6 +82,11 @@ export const DI = { flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), userMemosRepository: Symbol('userMemosRepository'), + chatMessagesRepository: Symbol('chatMessagesRepository'), + chatApprovalsRepository: Symbol('chatApprovalsRepository'), + chatRoomsRepository: Symbol('chatRoomsRepository'), + chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'), + chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), //#endregion diff --git a/packages/backend/src/misc/FileWriterStream.ts b/packages/backend/src/misc/FileWriterStream.ts index 367a8eb560..27c67cb5df 100644 --- a/packages/backend/src/misc/FileWriterStream.ts +++ b/packages/backend/src/misc/FileWriterStream.ts @@ -4,6 +4,7 @@ */ import * as fs from 'node:fs/promises'; +import { WritableStream } from 'node:stream/web'; import type { PathLike } from 'node:fs'; /** diff --git a/packages/backend/src/misc/collapsed-queue.ts b/packages/backend/src/misc/collapsed-queue.ts new file mode 100644 index 0000000000..5bc20a78ae --- /dev/null +++ b/packages/backend/src/misc/collapsed-queue.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type Job = { + value: V; + timer: NodeJS.Timeout; +}; + +// TODO: redis使えるようにする +export class CollapsedQueue { + private jobs: Map> = new Map(); + + constructor( + private timeout: number, + private collapse: (oldValue: V, newValue: V) => V, + private perform: (key: K, value: V) => Promise, + ) {} + + enqueue(key: K, value: V) { + if (this.jobs.has(key)) { + const old = this.jobs.get(key)!; + const merged = this.collapse(old.value, value); + this.jobs.set(key, { ...old, value: merged }); + } else { + const timer = setTimeout(() => { + const job = this.jobs.get(key)!; + this.jobs.delete(key); + this.perform(key, job.value); + }, this.timeout); + this.jobs.set(key, { value, timer }); + } + } + + async performAllNow() { + const entries = [...this.jobs.entries()]; + this.jobs.clear(); + for (const [_key, job] of entries) { + clearTimeout(job.timer); + } + await Promise.allSettled(entries.map(([key, job]) => this.perform(key, job.value))); + } +} diff --git a/packages/backend/src/misc/fastify-hook-handlers.ts b/packages/backend/src/misc/fastify-hook-handlers.ts index 3e1c099e00..fa3ef0a267 100644 --- a/packages/backend/src/misc/fastify-hook-handlers.ts +++ b/packages/backend/src/misc/fastify-hook-handlers.ts @@ -8,7 +8,7 @@ import type { onRequestHookHandler } from 'fastify'; export const handleRequestRedirectToOmitSearch: onRequestHookHandler = (request, reply, done) => { const index = request.url.indexOf('?'); if (~index) { - reply.redirect(301, request.url.slice(0, index)); + reply.redirect(request.url.slice(0, index), 301); } done(); }; diff --git a/packages/backend/src/misc/is-native-token.ts b/packages/backend/src/misc/is-native-token.ts deleted file mode 100644 index 300c4c05b3..0000000000 --- a/packages/backend/src/misc/is-native-token.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -// eslint-disable-next-line import/no-default-export -export default (token: string) => token.length === 16; diff --git a/packages/backend/src/misc/is-renote.ts b/packages/backend/src/misc/is-renote.ts index 48f821806c..f4bb329d80 100644 --- a/packages/backend/src/misc/is-renote.ts +++ b/packages/backend/src/misc/is-renote.ts @@ -6,6 +6,8 @@ import type { MiNote } from '@/models/Note.js'; import type { Packed } from '@/misc/json-schema.js'; +// NoteEntityService.isPureRenote とよしなにリンク + type Renote = MiNote & { renoteId: NonNullable diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts index 040e36228c..bc9308ca9b 100644 --- a/packages/backend/src/misc/json-schema.ts +++ b/packages/backend/src/misc/json-schema.ts @@ -33,7 +33,11 @@ import { packedClipSchema } from '@/models/json-schema/clip.js'; import { packedFederationInstanceSchema } from '@/models/json-schema/federation-instance.js'; import { packedQueueCountSchema } from '@/models/json-schema/queue.js'; import { packedGalleryPostSchema } from '@/models/json-schema/gallery-post.js'; -import { packedEmojiDetailedSchema, packedEmojiSimpleSchema } from '@/models/json-schema/emoji.js'; +import { + packedEmojiDetailedAdminSchema, + packedEmojiDetailedSchema, + packedEmojiSimpleSchema, +} from '@/models/json-schema/emoji.js'; import { packedFlashSchema } from '@/models/json-schema/flash.js'; import { packedAnnouncementSchema } from '@/models/json-schema/announcement.js'; import { packedSigninSchema } from '@/models/json-schema/signin.js'; @@ -59,6 +63,10 @@ import { } from '@/models/json-schema/meta.js'; import { packedSystemWebhookSchema } from '@/models/json-schema/system-webhook.js'; import { packedAbuseReportNotificationRecipientSchema } from '@/models/json-schema/abuse-report-notification-recipient.js'; +import { packedChatMessageSchema, packedChatMessageLiteSchema } from '@/models/json-schema/chat-message.js'; +import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js'; +import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js'; +import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js'; export const refs = { UserLite: packedUserLiteSchema, @@ -95,6 +103,7 @@ export const refs = { GalleryPost: packedGalleryPostSchema, EmojiSimple: packedEmojiSimpleSchema, EmojiDetailed: packedEmojiDetailedSchema, + EmojiDetailedAdmin: packedEmojiDetailedAdminSchema, Flash: packedFlashSchema, Signin: packedSigninSchema, RoleCondFormulaLogics: packedRoleCondFormulaLogicsSchema, @@ -115,6 +124,11 @@ export const refs = { MetaDetailed: packedMetaDetailedSchema, SystemWebhook: packedSystemWebhookSchema, AbuseReportNotificationRecipient: packedAbuseReportNotificationRecipientSchema, + ChatMessage: packedChatMessageSchema, + ChatMessageLite: packedChatMessageLiteSchema, + ChatRoom: packedChatRoomSchema, + ChatRoomInvitation: packedChatRoomInvitationSchema, + ChatRoomMembership: packedChatRoomMembershipSchema, }; export type Packed = SchemaType; @@ -138,7 +152,7 @@ type OfSchema = { readonly anyOf?: ReadonlyArray; readonly oneOf?: ReadonlyArray; readonly allOf?: ReadonlyArray; -} +}; export interface Schema extends OfSchema { readonly type?: TypeStringef; @@ -161,15 +175,16 @@ export interface Schema extends OfSchema { readonly maximum?: number; readonly minimum?: number; readonly pattern?: string; + readonly additionalProperties?: Schema | boolean; } type RequiredPropertyNames = { [K in keyof s]: - // K is not optional - s[K]['optional'] extends false ? K : - // K has default value - s[K]['default'] extends null | string | number | boolean | Record ? K : - never + // K is not optional + s[K]['optional'] extends false ? K : + // K has default value + s[K]['default'] extends null | string | number | boolean | Record ? K : + never }[keyof s]; export type Obj = Record; @@ -208,11 +223,18 @@ type ObjectSchemaTypeDef

= p['anyOf'] extends ReadonlyArray ? p['anyOf'][number]['required'] extends ReadonlyArray ? UnionObjType> & ObjType> : never - : ObjType> - : - p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md - p['allOf'] extends ReadonlyArray ? UnionToIntersection> : - any + : ObjType> + : + p['anyOf'] extends ReadonlyArray ? never : // see CONTRIBUTING.md + p['allOf'] extends ReadonlyArray ? UnionToIntersection> : + p['additionalProperties'] extends true ? Record : + p['additionalProperties'] extends Schema ? + p['additionalProperties'] extends infer AdditionalProperties ? + AdditionalProperties extends Schema ? + Record> : + never : + never : + any; type ObjectSchemaType

= NullOrUndefined>; @@ -222,30 +244,30 @@ export type SchemaTypeDef

= p['type'] extends 'number' ? number : p['type'] extends 'string' ? ( p['enum'] extends readonly (string | null)[] ? - p['enum'][number] : - p['format'] extends 'date-time' ? string : // Dateにする?? - string + p['enum'][number] : + p['format'] extends 'date-time' ? string : // Dateにする?? + string ) : - p['type'] extends 'boolean' ? boolean : - p['type'] extends 'object' ? ObjectSchemaTypeDef

: - p['type'] extends 'array' ? ( - p['items'] extends OfSchema ? ( - p['items']['anyOf'] extends ReadonlyArray ? UnionSchemaType>[] : - p['items']['oneOf'] extends ReadonlyArray ? ArrayUnion>> : - p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : - never + p['type'] extends 'boolean' ? boolean : + p['type'] extends 'object' ? ObjectSchemaTypeDef

: + p['type'] extends 'array' ? ( + p['items'] extends OfSchema ? ( + p['items']['anyOf'] extends ReadonlyArray ? UnionSchemaType>[] : + p['items']['oneOf'] extends ReadonlyArray ? ArrayUnion>> : + p['items']['allOf'] extends ReadonlyArray ? UnionToIntersection>>[] : + never + ) : + p['prefixItems'] extends ReadonlyArray ? ( + p['items'] extends NonNullable ? [...ArrayToTuple, ...SchemaType[]] : + p['items'] extends false ? ArrayToTuple : + p['unevaluatedItems'] extends false ? ArrayToTuple : + [...ArrayToTuple, ...unknown[]] + ) : + p['items'] extends NonNullable ? SchemaType[] : + any[] ) : - p['prefixItems'] extends ReadonlyArray ? ( - p['items'] extends NonNullable ? [...ArrayToTuple, ...SchemaType[]] : - p['items'] extends false ? ArrayToTuple : - p['unevaluatedItems'] extends false ? ArrayToTuple : - [...ArrayToTuple, ...unknown[]] - ) : - p['items'] extends NonNullable ? SchemaType[] : - any[] - ) : - p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : - p['oneOf'] extends ReadonlyArray ? UnionSchemaType : - any; + p['anyOf'] extends ReadonlyArray ? UnionSchemaType & PartialIntersection> : + p['oneOf'] extends ReadonlyArray ? UnionSchemaType : + any; export type SchemaType

= NullOrUndefined>; diff --git a/packages/backend/src/misc/json-value.ts b/packages/backend/src/misc/json-value.ts index bd7fe12058..195f7c4d47 100644 --- a/packages/backend/src/misc/json-value.ts +++ b/packages/backend/src/misc/json-value.ts @@ -4,7 +4,7 @@ */ export type JsonValue = JsonArray | JsonObject | string | number | boolean | null; -export type JsonObject = {[K in string]?: JsonValue}; +export type JsonObject = { [K in string]?: JsonValue }; export type JsonArray = JsonValue[]; export function isJsonObject(value: JsonValue | undefined): value is JsonObject { diff --git a/packages/backend/src/misc/sql-like-escape.ts b/packages/backend/src/misc/sql-like-escape.ts index 0c05255674..6b4f51b00e 100644 --- a/packages/backend/src/misc/sql-like-escape.ts +++ b/packages/backend/src/misc/sql-like-escape.ts @@ -4,5 +4,5 @@ */ export function sqlLikeEscape(s: string) { - return s.replace(/([%_])/g, '\\$1'); + return s.replace(/([\\%_])/g, '\\$1'); } diff --git a/packages/backend/src/misc/generate-native-user-token.ts b/packages/backend/src/misc/token.ts similarity index 54% rename from packages/backend/src/misc/generate-native-user-token.ts rename to packages/backend/src/misc/token.ts index 85fb383ba2..5d37cba26d 100644 --- a/packages/backend/src/misc/generate-native-user-token.ts +++ b/packages/backend/src/misc/token.ts @@ -5,5 +5,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js'; -// eslint-disable-next-line import/no-default-export -export default () => secureRndstr(16); +export const generateNativeUserToken = () => secureRndstr(16); + +export const isNativeUserToken = (token: string) => token.length === 16; diff --git a/packages/backend/src/models/AbuseUserReport.ts b/packages/backend/src/models/AbuseUserReport.ts index 0615fd7eb5..d43ebf9342 100644 --- a/packages/backend/src/models/AbuseUserReport.ts +++ b/packages/backend/src/models/AbuseUserReport.ts @@ -7,6 +7,8 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ import { id } from './util/id.js'; import { MiUser } from './User.js'; +export type AbuseReportResolveType = 'accept' | 'reject'; + @Entity('abuse_user_report') export class MiAbuseUserReport { @PrimaryColumn(id()) @@ -50,6 +52,9 @@ export class MiAbuseUserReport { }) public resolved: boolean; + /** + * リモートサーバーに転送したかどうか + */ @Column('boolean', { default: false, }) @@ -60,6 +65,21 @@ export class MiAbuseUserReport { }) public comment: string; + @Column('varchar', { + length: 8192, default: '', + }) + public moderationNote: string; + + /** + * accept 是認 ... 通報内容が正当であり、肯定的に対応された + * reject 否認 ... 通報内容が正当でなく、否定的に対応された + * null ... その他 + */ + @Column('varchar', { + length: 128, nullable: true, + }) + public resolvedAs: AbuseReportResolveType | null; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/packages/backend/src/models/ChatApproval.ts b/packages/backend/src/models/ChatApproval.ts new file mode 100644 index 0000000000..55c9f07e9a --- /dev/null +++ b/packages/backend/src/models/ChatApproval.ts @@ -0,0 +1,39 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('chat_approval') +@Index(['userId', 'otherId'], { unique: true }) +export class MiChatApproval { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Index() + @Column({ + ...id(), + }) + public otherId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public other: MiUser | null; +} diff --git a/packages/backend/src/models/ChatMessage.ts b/packages/backend/src/models/ChatMessage.ts new file mode 100644 index 0000000000..3d2b64268e --- /dev/null +++ b/packages/backend/src/models/ChatMessage.ts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiDriveFile } from './DriveFile.js'; +import { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_message') +export class MiChatMessage { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public fromUserId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public fromUser: MiUser | null; + + @Index() + @Column({ + ...id(), nullable: true, + }) + public toUserId: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public toUser: MiUser | null; + + @Index() + @Column({ + ...id(), nullable: true, + }) + public toRoomId: MiChatRoom['id'] | null; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public toRoom: MiChatRoom | null; + + @Column('varchar', { + length: 4096, nullable: true, + }) + public text: string | null; + + @Column('varchar', { + length: 512, nullable: true, + }) + public uri: string | null; + + @Column({ + ...id(), + array: true, default: '{}', + }) + public reads: MiUser['id'][]; + + @Column({ + ...id(), + nullable: true, + }) + public fileId: MiDriveFile['id'] | null; + + @ManyToOne(type => MiDriveFile, { + onDelete: 'SET NULL', + }) + @JoinColumn() + public file: MiDriveFile | null; + + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public reactions: string[]; +} diff --git a/packages/backend/src/models/ChatRoom.ts b/packages/backend/src/models/ChatRoom.ts new file mode 100644 index 0000000000..ad2a910b78 --- /dev/null +++ b/packages/backend/src/models/ChatRoom.ts @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('chat_room') +export class MiChatRoom { + @PrimaryColumn(id()) + public id: string; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Index() + @Column({ + ...id(), + }) + public ownerId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public owner: MiUser | null; + + @Column('varchar', { + length: 2048, default: '', + }) + public description: string; + + @Column('boolean', { + default: false, + }) + public isArchived: boolean; +} diff --git a/packages/backend/src/models/ChatRoomInvitation.ts b/packages/backend/src/models/ChatRoomInvitation.ts new file mode 100644 index 0000000000..36ce12bc92 --- /dev/null +++ b/packages/backend/src/models/ChatRoomInvitation.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_room_invitation') +@Index(['userId', 'roomId'], { unique: true }) +export class MiChatRoomInvitation { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Index() + @Column({ + ...id(), + }) + public roomId: MiChatRoom['id']; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public room: MiChatRoom | null; + + @Column('boolean', { + default: false, + }) + public ignored: boolean; +} diff --git a/packages/backend/src/models/ChatRoomMembership.ts b/packages/backend/src/models/ChatRoomMembership.ts new file mode 100644 index 0000000000..3cb5524859 --- /dev/null +++ b/packages/backend/src/models/ChatRoomMembership.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_room_membership') +@Index(['userId', 'roomId'], { unique: true }) +export class MiChatRoomMembership { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Index() + @Column({ + ...id(), + }) + public roomId: MiChatRoom['id']; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public room: MiChatRoom | null; + + @Column('boolean', { + default: false, + }) + public isMuted: boolean; +} diff --git a/packages/backend/src/models/Flash.ts b/packages/backend/src/models/Flash.ts index a1469a0d94..5db7dca992 100644 --- a/packages/backend/src/models/Flash.ts +++ b/packages/backend/src/models/Flash.ts @@ -7,6 +7,9 @@ import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typ import { id } from './util/id.js'; import { MiUser } from './User.js'; +export const flashVisibility = ['public', 'private'] as const; +export type FlashVisibility = typeof flashVisibility[number]; + @Entity('flash') export class MiFlash { @PrimaryColumn(id()) @@ -63,5 +66,5 @@ export class MiFlash { @Column('varchar', { length: 512, default: 'public', }) - public visibility: 'public' | 'private'; + public visibility: FlashVisibility; } diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts index 9ab76d373f..1fbf5371bc 100644 --- a/packages/backend/src/models/Meta.ts +++ b/packages/backend/src/models/Meta.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Entity, Column, PrimaryColumn, ManyToOne } from 'typeorm'; import { id } from './util/id.js'; import { MiUser } from './User.js'; @@ -15,6 +15,18 @@ export class MiMeta { }) public id: string; + @Column({ + ...id(), + nullable: true, + }) + public rootUserId: MiUser['id'] | null; + + @ManyToOne(type => MiUser, { + onDelete: 'SET NULL', + nullable: true, + }) + public rootUser: MiUser | null; + @Column('varchar', { length: 1024, nullable: true, }) @@ -81,6 +93,11 @@ export class MiMeta { }) public prohibitedWords: string[]; + @Column('varchar', { + length: 1024, array: true, default: '{}', + }) + public prohibitedWordsForNameOfUser: string[]; + @Column('varchar', { length: 1024, array: true, default: '{}', }) @@ -167,18 +184,6 @@ export class MiMeta { }) public cacheRemoteSensitiveFiles: boolean; - @Column({ - ...id(), - nullable: true, - }) - public proxyAccountId: MiUser['id'] | null; - - @ManyToOne(type => MiUser, { - onDelete: 'SET NULL', - }) - @JoinColumn() - public proxyAccount: MiUser | null; - @Column('boolean', { default: false, }) @@ -258,6 +263,11 @@ export class MiMeta { }) public turnstileSecretKey: string | null; + @Column('boolean', { + default: false, + }) + public enableTestcaptcha: boolean; + // chaptcha系を追加した際にはnodeinfoのレスポンスに追加するのを忘れないようにすること @Column('enum', { @@ -519,6 +529,11 @@ export class MiMeta { }) public enableChartsForFederatedInstances: boolean; + @Column('boolean', { + default: true, + }) + public enableStatsForFederatedInstances: boolean; + @Column('boolean', { default: false, }) @@ -630,4 +645,23 @@ export class MiMeta { nullable: true, }) public urlPreviewUserAgent: string | null; + + @Column('varchar', { + length: 128, + default: 'all', + }) + public federation: 'all' | 'specified' | 'none'; + + @Column('varchar', { + length: 1024, + array: true, + default: '{}', + }) + public federationHosts: string[]; + + @Column('varchar', { + length: 64, + nullable: true, + }) + public googleAnalyticsMeasurementId: string | null; } diff --git a/packages/backend/src/models/NoteUnread.ts b/packages/backend/src/models/NoteUnread.ts deleted file mode 100644 index c759181117..0000000000 --- a/packages/backend/src/models/NoteUnread.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; -import { id } from './util/id.js'; -import { MiUser } from './User.js'; -import { MiNote } from './Note.js'; -import type { MiChannel } from './Channel.js'; - -@Entity('note_unread') -@Index(['userId', 'noteId'], { unique: true }) -export class MiNoteUnread { - @PrimaryColumn(id()) - public id: string; - - @Index() - @Column(id()) - public userId: MiUser['id']; - - @ManyToOne(type => MiUser, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public user: MiUser | null; - - @Index() - @Column(id()) - public noteId: MiNote['id']; - - @ManyToOne(type => MiNote, { - onDelete: 'CASCADE', - }) - @JoinColumn() - public note: MiNote | null; - - /** - * メンションか否か - */ - @Index() - @Column('boolean') - public isMentioned: boolean; - - /** - * ダイレクト投稿か否か - */ - @Index() - @Column('boolean') - public isSpecified: boolean; - - //#region Denormalized fields - @Index() - @Column({ - ...id(), - comment: '[Denormalized]', - }) - public noteUserId: MiUser['id']; - - @Index() - @Column({ - ...id(), - nullable: true, - comment: '[Denormalized]', - }) - public noteChannelId: MiChannel['id'] | null; - //#endregion -} diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index 87d8c16cb3..5764a307b0 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -3,10 +3,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { userExportableEntities } from '@/types.js'; import { MiUser } from './User.js'; import { MiNote } from './Note.js'; import { MiAccessToken } from './AccessToken.js'; import { MiRole } from './Role.js'; +import { MiDriveFile } from './DriveFile.js'; export type MiNotification = { type: 'note'; @@ -67,16 +69,37 @@ export type MiNotification = { id: string; createdAt: string; notifierId: MiUser['id']; + message: string | null; } | { type: 'roleAssigned'; id: string; createdAt: string; roleId: MiRole['id']; +} | { + type: 'chatRoomInvitationReceived'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + invitationId: string; } | { type: 'achievementEarned'; id: string; createdAt: string; achievement: string; +} | { + type: 'exportCompleted'; + id: string; + createdAt: string; + exportedEntity: typeof userExportableEntities[number]; + fileId: MiDriveFile['id']; +} | { + type: 'login'; + id: string; + createdAt: string; +} | { + type: 'createToken'; + id: string; + createdAt: string; } | { type: 'app'; id: string; diff --git a/packages/backend/src/models/Page.ts b/packages/backend/src/models/Page.ts index 1695bf570e..0b59e7a92c 100644 --- a/packages/backend/src/models/Page.ts +++ b/packages/backend/src/models/Page.ts @@ -118,3 +118,5 @@ export class MiPage { } } } + +export const pageNameSchema = { type: 'string', pattern: /^[^\s:\/?#\[\]@!$&'()*+,;=\\%\x00-\x20]{1,256}$/.source } as const; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index ea0f88baba..b7142d91bf 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { Provider } from '@nestjs/common'; import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; import { @@ -43,7 +42,6 @@ import { MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, - MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, @@ -63,6 +61,7 @@ import { MiRoleAssignment, MiSignin, MiSwSubscription, + MiSystemAccount, MiSystemWebhook, MiUsedUsername, MiUser, @@ -77,8 +76,14 @@ import { MiUserProfile, MiUserPublickey, MiUserSecurityKey, - MiWebhook + MiWebhook, + MiChatMessage, + MiChatRoom, + MiChatRoomMembership, + MiChatRoomInvitation, + MiChatApproval, } from './_.js'; +import type { Provider } from '@nestjs/common'; import type { DataSource } from 'typeorm'; const $usersRepository: Provider = { @@ -135,12 +140,6 @@ const $noteReactionsRepository: Provider = { inject: [DI.db], }; -const $noteUnreadsRepository: Provider = { - provide: DI.noteUnreadsRepository, - useFactory: (db: DataSource) => db.getRepository(MiNoteUnread).extend(miRepository as MiRepository), - inject: [DI.db], -}; - const $pollsRepository: Provider = { provide: DI.pollsRepository, useFactory: (db: DataSource) => db.getRepository(MiPoll).extend(miRepository as MiRepository), @@ -285,6 +284,12 @@ const $swSubscriptionsRepository: Provider = { inject: [DI.db], }; +const $systemAccountsRepository: Provider = { + provide: DI.systemAccountsRepository, + useFactory: (db: DataSource) => db.getRepository(MiSystemAccount).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $hashtagsRepository: Provider = { provide: DI.hashtagsRepository, useFactory: (db: DataSource) => db.getRepository(MiHashtag).extend(miRepository as MiRepository), @@ -299,7 +304,7 @@ const $abuseUserReportsRepository: Provider = { const $abuseReportNotificationRecipientRepository: Provider = { provide: DI.abuseReportNotificationRecipientRepository, - useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient), + useFactory: (db: DataSource) => db.getRepository(MiAbuseReportNotificationRecipient).extend(miRepository as MiRepository), inject: [DI.db], }; @@ -431,7 +436,7 @@ const $webhooksRepository: Provider = { const $systemWebhooksRepository: Provider = { provide: DI.systemWebhooksRepository, - useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook), + useFactory: (db: DataSource) => db.getRepository(MiSystemWebhook).extend(miRepository as MiRepository), inject: [DI.db], }; @@ -483,6 +488,36 @@ const $userMemosRepository: Provider = { inject: [DI.db], }; +const $chatMessagesRepository: Provider = { + provide: DI.chatMessagesRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatMessage).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatRoomsRepository: Provider = { + provide: DI.chatRoomsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoom).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatRoomMembershipsRepository: Provider = { + provide: DI.chatRoomMembershipsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoomMembership).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatRoomInvitationsRepository: Provider = { + provide: DI.chatRoomInvitationsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoomInvitation).extend(miRepository as MiRepository), + inject: [DI.db], +}; + +const $chatApprovalsRepository: Provider = { + provide: DI.chatApprovalsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatApproval).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $bubbleGameRecordsRepository: Provider = { provide: DI.bubbleGameRecordsRepository, useFactory: (db: DataSource) => db.getRepository(MiBubbleGameRecord).extend(miRepository as MiRepository), @@ -507,7 +542,6 @@ const $reversiGamesRepository: Provider = { $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, - $noteUnreadsRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, @@ -532,6 +566,7 @@ const $reversiGamesRepository: Provider = { $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, + $systemAccountsRepository, $hashtagsRepository, $abuseUserReportsRepository, $abuseReportNotificationRecipientRepository, @@ -565,6 +600,11 @@ const $reversiGamesRepository: Provider = { $flashsRepository, $flashLikesRepository, $userMemosRepository, + $chatMessagesRepository, + $chatRoomsRepository, + $chatRoomMembershipsRepository, + $chatRoomInvitationsRepository, + $chatApprovalsRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, ], @@ -578,7 +618,6 @@ const $reversiGamesRepository: Provider = { $noteFavoritesRepository, $noteThreadMutingsRepository, $noteReactionsRepository, - $noteUnreadsRepository, $pollsRepository, $pollVotesRepository, $userProfilesRepository, @@ -603,6 +642,7 @@ const $reversiGamesRepository: Provider = { $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, + $systemAccountsRepository, $hashtagsRepository, $abuseUserReportsRepository, $abuseReportNotificationRecipientRepository, @@ -636,6 +676,11 @@ const $reversiGamesRepository: Provider = { $flashsRepository, $flashLikesRepository, $userMemosRepository, + $chatMessagesRepository, + $chatRoomsRepository, + $chatRoomMembershipsRepository, + $chatRoomInvitationsRepository, + $chatApprovalsRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, ], diff --git a/packages/backend/src/models/SystemAccount.ts b/packages/backend/src/models/SystemAccount.ts new file mode 100644 index 0000000000..f32880b81d --- /dev/null +++ b/packages/backend/src/models/SystemAccount.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Serialized } from '@/types.js'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; + +@Entity('system_account') +@Index(['type'], { unique: true }) +export class MiSystemAccount { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Column('varchar', { + length: 256, + }) + public type: string; +} diff --git a/packages/backend/src/models/SystemWebhook.ts b/packages/backend/src/models/SystemWebhook.ts index d6c27eae51..1a7ce4962b 100644 --- a/packages/backend/src/models/SystemWebhook.ts +++ b/packages/backend/src/models/SystemWebhook.ts @@ -14,6 +14,10 @@ export const systemWebhookEventTypes = [ 'abuseReportResolved', // ユーザが作成された時 'userCreated', + // モデレータが一定期間不在である警告 + 'inactiveModeratorsWarning', + // モデレータが一定期間不在のためシステムにより招待制へと変更された + 'inactiveModeratorsInvitationOnlyChanged', ] as const; export type SystemWebhookEventType = typeof systemWebhookEventTypes[number]; diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts index 9e2d7a3444..bc652cea62 100644 --- a/packages/backend/src/models/User.ts +++ b/packages/backend/src/models/User.ts @@ -155,6 +155,11 @@ export class MiUser { }) public tags: string[]; + @Column('integer', { + default: 0, + }) + public score: number; + @Column('boolean', { default: false, comment: 'Whether the User is suspended.', @@ -179,12 +184,6 @@ export class MiUser { }) public isCat: boolean; - @Column('boolean', { - default: false, - comment: 'Whether the User is the root.', - }) - public isRoot: boolean; - @Index() @Column('boolean', { default: true, @@ -197,6 +196,23 @@ export class MiUser { }) public isHibernated: boolean; + @Column('boolean', { + default: false, + }) + public requireSigninToViewContents: boolean; + + // in sec, マイナスで相対時間 + @Column('integer', { + nullable: true, + }) + public makeNotesFollowersOnlyBefore: number | null; + + // in sec, マイナスで相対時間 + @Column('integer', { + nullable: true, + }) + public makeNotesHiddenBefore: number | null; + // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ @Column('boolean', { default: false, @@ -209,6 +225,17 @@ export class MiUser { }) public emojis: string[]; + // チャットを許可する相手 + // everyone: 誰からでも + // followers: フォロワーのみ + // following: フォローしているユーザーのみ + // mutual: 相互フォローのみ + // none: 誰からも受け付けない + @Column('varchar', { + length: 128, default: 'mutual', + }) + public chatScope: 'everyone' | 'followers' | 'following' | 'mutual' | 'none'; + @Index() @Column('varchar', { length: 128, nullable: true, @@ -266,28 +293,29 @@ export class MiUser { export type MiLocalUser = MiUser & { host: null; uri: null; -} +}; export type MiPartialLocalUser = Partial & { id: MiUser['id']; host: null; uri: null; -} +}; export type MiRemoteUser = MiUser & { host: string; uri: string; -} +}; export type MiPartialRemoteUser = Partial & { id: MiUser['id']; host: string; uri: string; -} +}; export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; export const passwordSchema = { type: 'string', minLength: 1 } as const; export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const; +export const followedMessageSchema = { type: 'string', minLength: 1, maxLength: 256 } as const; export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; export const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const; diff --git a/packages/backend/src/models/UserProfile.ts b/packages/backend/src/models/UserProfile.ts index 7dbe0b3717..5544555296 100644 --- a/packages/backend/src/models/UserProfile.ts +++ b/packages/backend/src/models/UserProfile.ts @@ -42,6 +42,14 @@ export class MiUserProfile { }) public description: string | null; + // フォローされた際のメッセージ + @Column('varchar', { + length: 256, nullable: true, + }) + public followedMessage: string | null; + + // TODO: 鍵アカウントの場合の、フォローリクエスト受信時のメッセージも設定できるようにする + @Column('jsonb', { default: [], }) diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index c72bdaa727..e852b302f3 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -3,13 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm'; -import { DriverUtils } from 'typeorm/driver/DriverUtils.js'; +import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; -import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; -import { OrmUtils } from 'typeorm/util/OrmUtils.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; @@ -43,7 +40,6 @@ import { MiNote } from '@/models/Note.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; -import { MiNoteUnread } from '@/models/NoteUnread.js'; import { MiPage } from '@/models/Page.js'; import { MiPageLike } from '@/models/PageLike.js'; import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js'; @@ -56,6 +52,7 @@ import { MiRegistryItem } from '@/models/RegistryItem.js'; import { MiRelay } from '@/models/Relay.js'; import { MiSignin } from '@/models/Signin.js'; import { MiSwSubscription } from '@/models/SwSubscription.js'; +import { MiSystemAccount } from '@/models/SystemAccount.js'; import { MiUsedUsername } from '@/models/UsedUsername.js'; import { MiUser } from '@/models/User.js'; import { MiUserIp } from '@/models/UserIp.js'; @@ -77,6 +74,11 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserListFavorite } from '@/models/UserListFavorite.js'; +import { MiChatMessage } from '@/models/ChatMessage.js'; +import { MiChatRoom } from '@/models/ChatRoom.js'; +import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; +import { MiChatApproval } from '@/models/ChatApproval.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; @@ -158,7 +160,6 @@ export { MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, - MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, @@ -171,6 +172,7 @@ export { MiRelay, MiSignin, MiSwSubscription, + MiSystemAccount, MiUsedUsername, MiUser, MiUserIp, @@ -192,6 +194,11 @@ export { MiFlash, MiFlashLike, MiUserMemo, + MiChatMessage, + MiChatRoom, + MiChatRoomMembership, + MiChatRoomInvitation, + MiChatApproval, MiBubbleGameRecord, MiReversiGame, }; @@ -229,7 +236,6 @@ export type NotesRepository = Repository & MiRepository; export type NoteFavoritesRepository = Repository & MiRepository; export type NoteReactionsRepository = Repository & MiRepository; export type NoteThreadMutingsRepository = Repository & MiRepository; -export type NoteUnreadsRepository = Repository & MiRepository; export type PagesRepository = Repository & MiRepository; export type PageLikesRepository = Repository & MiRepository; export type PasswordResetRequestsRepository = Repository & MiRepository; @@ -242,6 +248,7 @@ export type RegistryItemsRepository = Repository & MiRepository< export type RelaysRepository = Repository & MiRepository; export type SigninsRepository = Repository & MiRepository; export type SwSubscriptionsRepository = Repository & MiRepository; +export type SystemAccountsRepository = Repository & MiRepository; export type UsedUsernamesRepository = Repository & MiRepository; export type UsersRepository = Repository & MiRepository; export type UserIpsRepository = Repository & MiRepository; @@ -263,5 +270,10 @@ export type RoleAssignmentsRepository = Repository & MiReposit export type FlashsRepository = Repository & MiRepository; export type FlashLikesRepository = Repository & MiRepository; export type UserMemoRepository = Repository & MiRepository; +export type ChatMessagesRepository = Repository & MiRepository; +export type ChatRoomsRepository = Repository & MiRepository; +export type ChatRoomMembershipsRepository = Repository & MiRepository; +export type ChatRoomInvitationsRepository = Repository & MiRepository; +export type ChatApprovalsRepository = Repository & MiRepository; export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/chat-message.ts b/packages/backend/src/models/json-schema/chat-message.ts new file mode 100644 index 0000000000..44b7298702 --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-message.ts @@ -0,0 +1,146 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatMessageSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + toUserId: { + type: 'string', + optional: true, nullable: true, + }, + toUser: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + toRoomId: { + type: 'string', + optional: true, nullable: true, + }, + toRoom: { + type: 'object', + optional: true, nullable: true, + ref: 'ChatRoom', + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + isRead: { + type: 'boolean', + optional: true, nullable: false, + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + }, + }, + }, + }, +} as const; + +export const packedChatMessageLiteSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + fromUserId: { + type: 'string', + optional: false, nullable: false, + }, + fromUser: { + type: 'object', + optional: true, nullable: false, + ref: 'UserLite', + }, + toUserId: { + type: 'string', + optional: true, nullable: true, + }, + toRoomId: { + type: 'string', + optional: true, nullable: true, + }, + text: { + type: 'string', + optional: true, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + reactions: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + reaction: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/chat-room-invitation.ts b/packages/backend/src/models/json-schema/chat-room-invitation.ts new file mode 100644 index 0000000000..204c959b2c --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-room-invitation.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatRoomInvitationSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + roomId: { + type: 'string', + optional: false, nullable: false, + }, + room: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/chat-room-membership.ts b/packages/backend/src/models/json-schema/chat-room-membership.ts new file mode 100644 index 0000000000..adb73f9dde --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-room-membership.ts @@ -0,0 +1,37 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatRoomMembershipSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + }, + user: { + type: 'object', + optional: true, nullable: false, + ref: 'UserLite', + }, + roomId: { + type: 'string', + optional: false, nullable: false, + }, + room: { + type: 'object', + optional: true, nullable: false, + ref: 'ChatRoom', + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/chat-room.ts b/packages/backend/src/models/json-schema/chat-room.ts new file mode 100644 index 0000000000..e97556e378 --- /dev/null +++ b/packages/backend/src/models/json-schema/chat-room.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const packedChatRoomSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + }, + createdAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: false, + }, + ownerId: { + type: 'string', + optional: false, nullable: false, + }, + owner: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + isMuted: { + type: 'boolean', + optional: true, nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/emoji.ts b/packages/backend/src/models/json-schema/emoji.ts index 62686ad5ae..3cd263fa37 100644 --- a/packages/backend/src/models/json-schema/emoji.ts +++ b/packages/backend/src/models/json-schema/emoji.ts @@ -104,3 +104,86 @@ export const packedEmojiDetailedSchema = { }, }, } as const; + +export const packedEmojiDetailedAdminSchema = { + type: 'object', + properties: { + id: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + updatedAt: { + type: 'string', + format: 'date-time', + optional: false, nullable: true, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + host: { + type: 'string', + optional: false, nullable: true, + description: 'The local host is represented with `null`.', + }, + publicUrl: { + type: 'string', + optional: false, nullable: false, + }, + originalUrl: { + type: 'string', + optional: false, nullable: false, + }, + uri: { + type: 'string', + optional: false, nullable: true, + }, + type: { + type: 'string', + optional: false, nullable: true, + }, + aliases: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + format: 'id', + optional: false, nullable: false, + }, + }, + category: { + type: 'string', + optional: false, nullable: true, + }, + license: { + type: 'string', + optional: false, nullable: true, + }, + localOnly: { + type: 'boolean', + optional: false, nullable: false, + }, + isSensitive: { + type: 'boolean', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + format: 'misskey:id', + optional: false, nullable: false, + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + }, + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/json-schema/meta.ts b/packages/backend/src/models/json-schema/meta.ts index 99feeaa7d7..1e25c355ca 100644 --- a/packages/backend/src/models/json-schema/meta.ts +++ b/packages/backend/src/models/json-schema/meta.ts @@ -115,6 +115,14 @@ export const packedMetaLiteSchema = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, + googleAnalyticsMeasurementId: { + type: 'string', + optional: false, nullable: true, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -257,6 +265,11 @@ export const packedMetaLiteSchema = { type: 'number', optional: false, nullable: false, }, + federation: { + type: 'string', + enum: ['all', 'specified', 'none'], + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index b05ec8b762..7f23d2d6a1 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -4,7 +4,7 @@ */ import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js'; -import { notificationTypes } from '@/types.js'; +import { notificationTypes, userExportableEntities } from '@/types.js'; const baseSchema = { type: 'object', @@ -267,6 +267,10 @@ export const packedNotificationSchema = { optional: false, nullable: false, format: 'id', }, + message: { + type: 'string', + optional: false, nullable: true, + }, }, }, { type: 'object', @@ -283,6 +287,21 @@ export const packedNotificationSchema = { optional: false, nullable: false, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['chatRoomInvitationReceived'], + }, + invitation: { + type: 'object', + ref: 'ChatRoomInvitation', + optional: false, nullable: false, + }, + }, }, { type: 'object', properties: { @@ -298,6 +317,46 @@ export const packedNotificationSchema = { enum: ACHIEVEMENT_TYPES, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['exportCompleted'], + }, + exportedEntity: { + type: 'string', + optional: false, nullable: false, + enum: userExportableEntities, + }, + fileId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['login'], + }, + }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['createToken'], + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index 3537de94c8..1685a806c9 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -292,6 +292,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canChat: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 947a9317d7..e475296702 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -115,6 +115,18 @@ export const packedUserLiteSchema = { type: 'boolean', nullable: false, optional: true, }, + requireSigninToViewContents: { + type: 'boolean', + nullable: false, optional: true, + }, + makeNotesFollowersOnlyBefore: { + type: 'number', + nullable: true, optional: true, + }, + makeNotesHiddenBefore: { + type: 'number', + nullable: true, optional: true, + }, instance: { type: 'object', nullable: false, optional: true, @@ -346,20 +358,14 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: false, enum: ['public', 'followers', 'private'], }, - twoFactorEnabled: { - type: 'boolean', + chatScope: { + type: 'string', nullable: false, optional: false, - default: false, + enum: ['everyone', 'following', 'followers', 'mutual', 'none'], }, - usePasswordLessLogin: { + canChat: { type: 'boolean', nullable: false, optional: false, - default: false, - }, - securityKeys: { - type: 'boolean', - nullable: false, optional: false, - default: false, }, roles: { type: 'array', @@ -370,6 +376,10 @@ export const packedUserDetailedNotMeOnlySchema = { ref: 'RoleLite', }, }, + followedMessage: { + type: 'string', + nullable: true, optional: true, + }, memo: { type: 'string', nullable: true, optional: false, @@ -378,6 +388,18 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'string', nullable: false, optional: true, }, + twoFactorEnabled: { + type: 'boolean', + nullable: false, optional: true, + }, + usePasswordLessLogin: { + type: 'boolean', + nullable: false, optional: true, + }, + securityKeys: { + type: 'boolean', + nullable: false, optional: true, + }, //#region relations isFollowing: { type: 'boolean', @@ -437,6 +459,10 @@ export const packedMeDetailedOnlySchema = { nullable: true, optional: false, format: 'id', }, + followedMessage: { + type: 'string', + nullable: true, optional: false, + }, isModerator: { type: 'boolean', nullable: true, optional: false, @@ -523,6 +549,10 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, + hasUnreadChatMessages: { + type: 'boolean', + nullable: false, optional: false, + }, hasUnreadNotification: { type: 'boolean', nullable: false, optional: false, @@ -582,6 +612,7 @@ export const packedMeDetailedOnlySchema = { receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig }, + chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig }, achievementEarned: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, @@ -622,6 +653,21 @@ export const packedMeDetailedOnlySchema = { nullable: false, optional: false, ref: 'RolePolicies', }, + twoFactorEnabled: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, + usePasswordLessLogin: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, + securityKeys: { + type: 'boolean', + nullable: false, optional: false, + default: false, + }, //#region secrets email: { type: 'string', diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 251a03c303..4694e7003d 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -8,6 +8,9 @@ import pg from 'pg'; import { DataSource, Logger } 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'; +import { bindThis } from '@/decorators.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; @@ -42,7 +45,6 @@ import { MiNote } from '@/models/Note.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; -import { MiNoteUnread } from '@/models/NoteUnread.js'; import { MiPage } from '@/models/Page.js'; import { MiPageLike } from '@/models/PageLike.js'; import { MiPasswordResetRequest } from '@/models/PasswordResetRequest.js'; @@ -76,12 +78,14 @@ import { MiRoleAssignment } from '@/models/RoleAssignment.js'; import { MiFlash } from '@/models/Flash.js'; import { MiFlashLike } from '@/models/FlashLike.js'; import { MiUserMemo } from '@/models/UserMemo.js'; +import { MiChatMessage } from '@/models/ChatMessage.js'; +import { MiChatRoom } from '@/models/ChatRoom.js'; +import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; - -import { Config } from '@/config.js'; -import MisskeyLogger from '@/logger.js'; -import { bindThis } from '@/decorators.js'; +import { MiChatApproval } from '@/models/ChatApproval.js'; +import { MiSystemAccount } from '@/models/SystemAccount.js'; pg.types.setTypeParser(20, Number); @@ -89,27 +93,65 @@ export const dbLogger = new MisskeyLogger('db'); const sqlLogger = dbLogger.createSubLogger('sql', 'gray'); +export type LoggerProps = { + disableQueryTruncation?: boolean; + enableQueryParamLogging?: 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; +} + +function stringifyParameter(param: any) { + if (param instanceof Date) { + return param.toISOString(); + } else { + return param; + } +} + class MyCustomLogger implements Logger { + constructor(private props: LoggerProps = {}) { + } + @bindThis - private highlight(sql: string) { - return highlight.highlight(sql, { - language: 'sql', ignoreIllegals: true, - }); + private transformQueryLog(sql: string) { + let modded = sql; + if (!this.props.disableQueryTruncation) { + modded = truncateSql(modded); + } + + return highlightSql(modded); + } + + @bindThis + private transformParameters(parameters?: any[]) { + if (this.props.enableQueryParamLogging && parameters && parameters.length > 0) { + return parameters.map(stringifyParameter); + } + + return undefined; } @bindThis public logQuery(query: string, parameters?: any[]) { - sqlLogger.info(this.highlight(query).substring(0, 100)); + sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters)); } @bindThis public logQueryError(error: string, query: string, parameters?: any[]) { - sqlLogger.error(this.highlight(query)); + sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters)); } @bindThis public logQuerySlow(time: number, query: string, parameters?: any[]) { - sqlLogger.warn(this.highlight(query)); + sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters)); } @bindThis @@ -156,7 +198,6 @@ export const entities = [ MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, - MiNoteUnread, MiPage, MiPageLike, MiGalleryPost, @@ -168,6 +209,7 @@ export const entities = [ MiEmoji, MiHashtag, MiSwSubscription, + MiSystemAccount, MiAbuseUserReport, MiAbuseReportNotificationRecipient, MiRegistrationTicket, @@ -196,6 +238,11 @@ export const entities = [ MiFlash, MiFlashLike, MiUserMemo, + MiChatMessage, + MiChatRoom, + MiChatRoomMembership, + MiChatRoomInvitation, + MiChatApproval, MiBubbleGameRecord, MiReversiGame, ...charts, @@ -247,7 +294,12 @@ export function createPostgresDataSource(config: Config) { }, } : false, logging: log, - logger: log ? new MyCustomLogger() : undefined, + logger: log + ? new MyCustomLogger({ + disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, + enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, + }) + : undefined, maxQueryExecutionTime: 300, entities: entities, migrations: ['../../migration/*.js'], diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index 0027b5ef3d..9044285bf6 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -6,6 +6,7 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; import { GlobalModule } from '@/GlobalModule.js'; +import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { QueueLoggerService } from './QueueLoggerService.js'; import { QueueProcessorService } from './QueueProcessorService.js'; import { DeliverProcessorService } from './processors/DeliverProcessorService.js'; @@ -80,6 +81,8 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor DeliverProcessorService, InboxProcessorService, AggregateRetentionProcessorService, + CheckExpiredMutingsProcessorService, + CheckModeratorsActivityProcessorService, QueueProcessorService, ], exports: [ diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index e9e1c45224..6940e1c188 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -10,6 +10,7 @@ import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; +import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js'; @@ -66,7 +67,7 @@ function getJobInfo(job: Bull.Job | undefined, increment = false): string { // onActiveとかonCompletedのattemptsMadeがなぜか0始まりなのでインクリメントする const currentAttempts = job.attemptsMade + (increment ? 1 : 0); - const maxAttempts = job.opts ? job.opts.attempts : 0; + const maxAttempts = job.opts.attempts ?? 0; return `id=${job.id} attempts=${currentAttempts}/${maxAttempts} age=${formated}`; } @@ -120,24 +121,35 @@ export class QueueProcessorService implements OnApplicationShutdown { private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, + private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService, private cleanProcessorService: CleanProcessorService, ) { this.logger = this.queueLoggerService.logger; - function renderError(e: Error): any { - if (e) { // 何故かeがundefinedで来ることがある - return { - stack: e.stack, - message: e.message, - name: e.name, - }; - } else { - return { - stack: '?', - message: '?', - name: '?', - }; + function renderError(e?: Error) { + // 何故かeがundefinedで来ることがある + if (!e) return '?'; + + if (e instanceof Bull.UnrecoverableError || e.name === 'AbortError') { + return `${e.name}: ${e.message}`; } + + return { + stack: e.stack, + message: e.message, + name: e.name, + }; + } + + function renderJob(job?: Bull.Job) { + if (!job) return '?'; + + return { + name: job.name || undefined, + info: getJobInfo(job), + failedReason: job.failedReason || undefined, + data: job.data, + }; } //#region system @@ -150,6 +162,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process(); + case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process(); case 'clean': return this.cleanProcessorService.process(); default: throw new Error(`unrecognized job type ${job.name} for system`); } @@ -172,15 +185,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err: Error) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: System: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -229,15 +242,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: DB: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -269,15 +282,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: Deliver: ${err.message}`, { + Sentry.captureMessage(`Queue: Deliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -309,15 +322,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} activity=${job ? (job.data.activity ? job.data.activity.id : 'none') : '-'}`, { job, e: renderError(err) }); + 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) { - Sentry.captureMessage(`Queue: Inbox: ${err.message}`, { + Sentry.captureMessage(`Queue: Inbox: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -349,15 +362,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.message}`, { + Sentry.captureMessage(`Queue: UserWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -389,15 +402,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active ${getJobInfo(job, true)} to=${job.data.to}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) ${getJobInfo(job, true)} to=${job.data.to}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); + logger.error(`failed(${err.name}: ${err.message}) ${getJobInfo(job)} to=${job ? job.data.to : '-'}`); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.message}`, { + Sentry.captureMessage(`Queue: SystemWebhookDeliver: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -436,15 +449,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: Relationship: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion @@ -477,15 +490,15 @@ export class QueueProcessorService implements OnApplicationShutdown { .on('active', (job) => logger.debug(`active id=${job.id}`)) .on('completed', (job, result) => logger.debug(`completed(${result}) id=${job.id}`)) .on('failed', (job, err) => { - logger.error(`failed(${err.stack}) id=${job ? job.id : '-'}`, { job, e: renderError(err) }); + logger.error(`failed(${err.name}: ${err.message}) id=${job?.id ?? '?'}`, { job: renderJob(job), e: renderError(err) }); if (config.sentryForBackend) { - Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.message}`, { + Sentry.captureMessage(`Queue: ObjectStorage: ${job?.name ?? '?'}: ${err.name}: ${err.message}`, { level: 'error', extra: { job, err }, }); } }) - .on('error', (err: Error) => logger.error(`error ${err.stack}`, { e: renderError(err) })) + .on('error', (err: Error) => logger.error(`error ${err.name}: ${err.message}`, { e: renderError(err) })) .on('stalled', (jobId) => logger.warn(`stalled id=${jobId}`)); } //#endregion diff --git a/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts b/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts index cd56ba9837..d49c99f694 100644 --- a/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts +++ b/packages/backend/src/queue/processors/BakeBufferedReactionsProcessorService.ts @@ -7,17 +7,20 @@ import { Inject, Injectable } from '@nestjs/common'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; -import { MetaService } from '@/core/MetaService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; @Injectable() export class BakeBufferedReactionsProcessorService { private logger: Logger; constructor( + @Inject(DI.meta) + private meta: MiMeta, + private reactionsBufferingService: ReactionsBufferingService, - private metaService: MetaService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('bake-buffered-reactions'); @@ -25,8 +28,7 @@ export class BakeBufferedReactionsProcessorService { @bindThis public async process(): Promise { - const meta = await this.metaService.fetch(); - if (!meta.enableReactionsBuffering) { + if (!this.meta.enableReactionsBuffering) { this.logger.info('Reactions buffering is disabled. Skipping...'); return; } diff --git a/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts new file mode 100644 index 0000000000..c9fe4fca73 --- /dev/null +++ b/packages/backend/src/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -0,0 +1,282 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { MiUser, type UserProfilesRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; + +// モデレーターが不在と判断する日付の閾値 +const MODERATOR_INACTIVITY_LIMIT_DAYS = 7; +// 警告通知やログ出力を行う残日数の閾値 +const MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS = 2; +// 期限から6時間ごとに通知を行う +const MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS = 6; +const ONE_HOUR_MILLI_SEC = 1000 * 60 * 60; +const ONE_DAY_MILLI_SEC = ONE_HOUR_MILLI_SEC * 24; + +export type ModeratorInactivityEvaluationResult = { + isModeratorsInactive: boolean; + inactiveModerators: MiUser[]; + remainingTime: ModeratorInactivityRemainingTime; +}; + +export type ModeratorInactivityRemainingTime = { + time: number; + asHours: number; + asDays: number; +}; + +function generateModeratorInactivityMail(remainingTime: ModeratorInactivityRemainingTime) { + const subject = 'Moderator Inactivity Warning / モデレーター不在の通知'; + + const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`; + const timeVariantJa = remainingTime.asDays === 0 ? `${remainingTime.asHours} 時間` : `${remainingTime.asDays} 日間`; + const message = [ + 'To Moderators,', + '', + `A moderator has been inactive for a period of time. If there are ${timeVariant} of inactivity left, it will switch to invitation only.`, + 'If you do not wish to move to invitation only, you must log into Misskey and update your last active date and time.', + '', + '---------------', + '', + 'To モデレーター各位', + '', + `モデレーターが一定期間活動していないようです。あと${timeVariantJa}活動していない状態が続くと招待制に切り替わります。`, + '招待制に切り替わることを望まない場合は、Misskeyにログインして最終アクティブ日時を更新してください。', + '', + ]; + + const html = message.join('
'); + const text = message.join('\n'); + + return { + subject, + html, + text, + }; +} + +function generateInvitationOnlyChangedMail() { + const subject = 'Change to Invitation-Only / 招待制に変更されました'; + + const message = [ + 'To Moderators,', + '', + `Changed to invitation only because no moderator activity was detected for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days.`, + 'To cancel the invitation only, you need to access the control panel.', + '', + '---------------', + '', + 'To モデレーター各位', + '', + `モデレーターの活動が${MODERATOR_INACTIVITY_LIMIT_DAYS}日間検出されなかったため、招待制に変更されました。`, + '招待制を解除するには、コントロールパネルにアクセスする必要があります。', + '', + ]; + + const html = message.join('
'); + const text = message.join('\n'); + + return { + subject, + html, + text, + }; +} + +@Injectable() +export class CheckModeratorsActivityProcessorService { + private logger: Logger; + + constructor( + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private metaService: MetaService, + private roleService: RoleService, + private emailService: EmailService, + private announcementService: AnnouncementService, + private systemWebhookService: SystemWebhookService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('check-moderators-activity'); + } + + @bindThis + public async process(): Promise { + this.logger.info('start.'); + + const meta = await this.metaService.fetch(false); + if (!meta.disableRegistration) { + await this.processImpl(); + } else { + this.logger.info('is already invitation only.'); + } + + this.logger.succ('finish.'); + } + + @bindThis + private async processImpl() { + const evaluateResult = await this.evaluateModeratorsInactiveDays(); + if (evaluateResult.isModeratorsInactive) { + this.logger.warn(`The moderator has been inactive for ${MODERATOR_INACTIVITY_LIMIT_DAYS} days. We will move to invitation only.`); + + await this.changeToInvitationOnly(); + await this.notifyChangeToInvitationOnly(); + } else { + const remainingTime = evaluateResult.remainingTime; + if (remainingTime.asDays <= MODERATOR_INACTIVITY_WARNING_REMAINING_DAYS) { + const timeVariant = remainingTime.asDays === 0 ? `${remainingTime.asHours} hours` : `${remainingTime.asDays} days`; + this.logger.warn(`A moderator has been inactive for a period of time. If you are inactive for an additional ${timeVariant}, it will switch to invitation only.`); + + if (remainingTime.asHours % MODERATOR_INACTIVITY_WARNING_NOTIFY_INTERVAL_HOURS === 0) { + // ジョブの実行頻度と同等だと通知が多すぎるため期限から6時間ごとに通知する + // つまり、のこり2日を切ったら6時間ごとに通知が送られる + await this.notifyInactiveModeratorsWarning(remainingTime); + } + } + } + } + + /** + * モデレーターが不在であるかどうかを確認する。trueの場合はモデレーターが不在である。 + * isModerator, isAdministrator, isRootのいずれかがtrueのユーザを対象に、 + * {@link MiUser.lastActiveDate}の値が実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前よりも古いユーザがいるかどうかを確認する。 + * {@link MiUser.lastActiveDate}がnullの場合は、そのユーザは確認の対象外とする。 + * + * ----- + * + * ### サンプルパターン + * - 実行日時: 2022-01-30 12:00:00 + * - 判定基準: 2022-01-23 12:00:00(実行日時の{@link MODERATOR_INACTIVITY_LIMIT_DAYS}日前) + * + * #### パターン① + * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト + * - モデレータB: lastActiveDate = 2022-01-23 12:00:00 ※セーフ(判定基準と同値なのでギリギリ残り0日) + * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日) + * - モデレータD: lastActiveDate = null + * + * この場合、モデレータBのアクティビティのみ判定基準日よりも古くないため、モデレーターが在席と判断される。 + * + * #### パターン② + * - モデレータA: lastActiveDate = 2022-01-20 00:00:00 ※アウト + * - モデレータB: lastActiveDate = 2022-01-22 12:00:00 ※アウト(残り-1日) + * - モデレータC: lastActiveDate = 2022-01-23 11:59:59 ※アウト(残り-1日) + * - モデレータD: lastActiveDate = null + * + * この場合、モデレータA, B, Cのアクティビティは判定基準日よりも古いため、モデレーターが不在と判断される。 + */ + @bindThis + public async evaluateModeratorsInactiveDays(): Promise { + const today = new Date(); + const inactivePeriod = new Date(today); + inactivePeriod.setDate(today.getDate() - MODERATOR_INACTIVITY_LIMIT_DAYS); + + const moderators = await this.fetchModerators() + .then(it => it.filter(it => it.lastActiveDate != null)); + const inactiveModerators = moderators + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .filter(it => it.lastActiveDate!.getTime() < inactivePeriod.getTime()); + + // 残りの猶予を示したいので、最終アクティブ日時が一番若いモデレータの日数を基準に猶予を計算する + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const newestLastActiveDate = new Date(Math.max(...moderators.map(it => it.lastActiveDate!.getTime()))); + const remainingTime = newestLastActiveDate.getTime() - inactivePeriod.getTime(); + const remainingTimeAsDays = Math.floor(remainingTime / ONE_DAY_MILLI_SEC); + const remainingTimeAsHours = Math.floor((remainingTime / ONE_HOUR_MILLI_SEC)); + + return { + isModeratorsInactive: inactiveModerators.length === moderators.length, + inactiveModerators, + remainingTime: { + time: remainingTime, + asHours: remainingTimeAsHours, + asDays: remainingTimeAsDays, + }, + }; + } + + @bindThis + private async changeToInvitationOnly() { + await this.metaService.update({ disableRegistration: true }); + } + + @bindThis + public async notifyInactiveModeratorsWarning(remainingTime: ModeratorInactivityRemainingTime) { + // -- モデレータへのメール送信 + + const moderators = await this.fetchModerators(); + const moderatorProfiles = await this.userProfilesRepository + .findBy({ userId: In(moderators.map(it => it.id)) }) + .then(it => new Map(it.map(it => [it.userId, it]))); + + const mail = generateModeratorInactivityMail(remainingTime); + for (const moderator of moderators) { + const profile = moderatorProfiles.get(moderator.id); + if (profile && profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text); + } + } + + // -- SystemWebhook + + return this.systemWebhookService.enqueueSystemWebhook( + 'inactiveModeratorsWarning', + { remainingTime: remainingTime }, + ); + } + + @bindThis + public async notifyChangeToInvitationOnly() { + // -- モデレータへのメールとお知らせ(個人向け)送信 + + const moderators = await this.fetchModerators(); + const moderatorProfiles = await this.userProfilesRepository + .findBy({ userId: In(moderators.map(it => it.id)) }) + .then(it => new Map(it.map(it => [it.userId, it]))); + + const mail = generateInvitationOnlyChangedMail(); + for (const moderator of moderators) { + this.announcementService.create({ + title: mail.subject, + text: mail.text, + forExistingUsers: true, + needConfirmationToRead: true, + userId: moderator.id, + }); + + const profile = moderatorProfiles.get(moderator.id); + if (profile && profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, mail.subject, mail.html, mail.text); + } + } + + // -- SystemWebhook + + return this.systemWebhookService.enqueueSystemWebhook( + 'inactiveModeratorsInvitationOnlyChanged', + {}, + ); + } + + @bindThis + private async fetchModerators() { + // TODO: モデレーター以外にも特別な権限を持つユーザーがいる場合は考慮する + return this.roleService.getModerators({ + includeAdmins: true, + includeRoot: true, + excludeExpire: true, + }); + } +} diff --git a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts index 110468801c..8c5faa8d07 100644 --- a/packages/backend/src/queue/processors/CleanChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/CleanChartsProcessorService.ts @@ -48,20 +48,19 @@ export class CleanChartsProcessorService { public async process(): Promise { this.logger.info('Clean charts...'); - await Promise.all([ - this.federationChart.clean(), - this.notesChart.clean(), - this.usersChart.clean(), - this.activeUsersChart.clean(), - this.instanceChart.clean(), - this.perUserNotesChart.clean(), - this.perUserPvChart.clean(), - this.driveChart.clean(), - this.perUserReactionsChart.clean(), - this.perUserFollowingChart.clean(), - this.perUserDriveChart.clean(), - this.apRequestChart.clean(), - ]); + // DBへの同時接続を避けるためにPromise.allを使わずひとつずつ実行する + await this.federationChart.clean(); + await this.notesChart.clean(); + await this.usersChart.clean(); + await this.activeUsersChart.clean(); + await this.instanceChart.clean(); + await this.perUserNotesChart.clean(); + await this.perUserPvChart.clean(); + await this.driveChart.clean(); + await this.perUserReactionsChart.clean(); + await this.perUserFollowingChart.clean(); + await this.perUserDriveChart.clean(); + await this.apRequestChart.clean(); this.logger.succ('All charts successfully cleaned.'); } diff --git a/packages/backend/src/queue/processors/DeliverProcessorService.ts b/packages/backend/src/queue/processors/DeliverProcessorService.ts index 4076e9da90..5a16496011 100644 --- a/packages/backend/src/queue/processors/DeliverProcessorService.ts +++ b/packages/backend/src/queue/processors/DeliverProcessorService.ts @@ -7,9 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Bull from 'bullmq'; import { Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { InstancesRepository } from '@/models/_.js'; +import type { InstancesRepository, MiMeta } from '@/models/_.js'; import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; @@ -31,10 +30,12 @@ export class DeliverProcessorService { private latest: string | null; constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.instancesRepository) private instancesRepository: InstancesRepository, - private metaService: MetaService, private utilityService: UtilityService, private federatedInstanceService: FederatedInstanceService, private fetchInstanceMetadataService: FetchInstanceMetadataService, @@ -52,9 +53,7 @@ export class DeliverProcessorService { public async process(job: Bull.Job): Promise { const { host } = new URL(job.data.to); - // ブロックしてたら中断 - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, this.utilityService.toPuny(host))) { + if (!this.utilityService.isFederationAllowedUri(job.data.to)) { return 'skip (blocked)'; } @@ -75,8 +74,17 @@ export class DeliverProcessorService { try { await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest); - // Update stats - this.federatedInstanceService.fetch(host).then(i => { + this.apRequestChart.deliverSucc(); + this.federationChart.deliverd(host, true); + + // Update instance stats + process.nextTick(async () => { + const i = await (this.meta.enableStatsForFederatedInstances + ? this.federatedInstanceService.fetchOrRegister(host) + : this.federatedInstanceService.fetch(host)); + + if (i == null) return; + if (i.isNotResponding) { this.federatedInstanceService.update(i.id, { isNotResponding: false, @@ -84,19 +92,22 @@ export class DeliverProcessorService { }); } - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - this.apRequestChart.deliverSucc(); - this.federationChart.deliverd(i.host, true); + if (this.meta.enableStatsForFederatedInstances) { + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); + } - if (meta.enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestSent(i.host, true); } }); return 'Success'; } catch (res) { - // Update stats - this.federatedInstanceService.fetch(host).then(i => { + this.apRequestChart.deliverFail(); + this.federationChart.deliverd(host, false); + + // Update instance stats + this.federatedInstanceService.fetchOrRegister(host).then(i => { if (!i.isNotResponding) { this.federatedInstanceService.update(i.id, { isNotResponding: true, @@ -117,10 +128,7 @@ export class DeliverProcessorService { }); } - this.apRequestChart.deliverFail(); - this.federationChart.deliverd(i.host, false); - - if (meta.enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestSent(i.host, false); } }); @@ -130,7 +138,7 @@ export class DeliverProcessorService { if (!res.isRetryable) { // 相手が閉鎖していることを明示しているため、配送停止する if (job.data.isSharedInbox && res.statusCode === 410) { - this.federatedInstanceService.fetch(host).then(i => { + this.federatedInstanceService.fetchOrRegister(host).then(i => { this.federatedInstanceService.update(i.id, { suspensionState: 'goneSuspended', }); diff --git a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts index 88c4ea29c0..b3111865ad 100644 --- a/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportAntennasProcessorService.ts @@ -14,6 +14,7 @@ import { DriveService } from '@/core/DriveService.js'; import { bindThis } from '@/decorators.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type { DBExportAntennasData } from '../types.js'; import type * as Bull from 'bullmq'; @@ -35,6 +36,7 @@ export class ExportAntennasProcessorService { private driveService: DriveService, private utilityService: UtilityService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-antennas'); } @@ -95,6 +97,11 @@ export class ExportAntennasProcessorService { const fileName = 'antennas-' + DateFormat(new Date(), 'yyyy-MM-dd-HH-mm-ss') + '.json'; const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ('Exported to: ' + driveFile.id); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'antenna', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts index 6ec3c18786..ecc439db69 100644 --- a/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportBlockingProcessorService.ts @@ -13,6 +13,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -30,6 +31,7 @@ export class ExportBlockingProcessorService { private blockingsRepository: BlockingsRepository, private utilityService: UtilityService, + private notificationService: NotificationService, private driveService: DriveService, private queueLoggerService: QueueLoggerService, ) { @@ -109,6 +111,11 @@ export class ExportBlockingProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'blocking', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts index 01eab26e96..583ddbb745 100644 --- a/packages/backend/src/queue/processors/ExportClipsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportClipsProcessorService.ts @@ -19,6 +19,7 @@ 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 { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; import type { DbJobDataWithUser } from '../types.js'; @@ -43,6 +44,7 @@ export class ExportClipsProcessorService { private driveService: DriveService, private queueLoggerService: QueueLoggerService, private idService: IdService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-clips'); } @@ -79,6 +81,11 @@ export class ExportClipsProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'clip', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts index e4eb4791bd..e237cd4975 100644 --- a/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportCustomEmojisProcessorService.ts @@ -16,6 +16,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp, createTempDir } from '@/misc/create-temp.js'; import { DownloadService } from '@/core/DownloadService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -37,6 +38,7 @@ export class ExportCustomEmojisProcessorService { private driveService: DriveService, private downloadService: DownloadService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-custom-emojis'); } @@ -134,6 +136,12 @@ export class ExportCustomEmojisProcessorService { const driveFile = await this.driveService.addFile({ user, path: archivePath, name: fileName, force: true }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'customEmoji', + fileId: driveFile.id, + }); + cleanup(); archiveCleanup(); resolve(); diff --git a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts index 7bb626dd31..b81feece01 100644 --- a/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFavoritesProcessorService.ts @@ -16,6 +16,7 @@ import type { MiPoll } from '@/models/Poll.js'; 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 { 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 idService: IdService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-favorites'); } @@ -123,6 +125,11 @@ export class ExportFavoritesProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'favorite', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts index 1cc80e66d7..903f962515 100644 --- a/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportFollowingProcessorService.ts @@ -14,6 +14,7 @@ import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import type { MiFollowing } from '@/models/Following.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -36,6 +37,7 @@ export class ExportFollowingProcessorService { private utilityService: UtilityService, private driveService: DriveService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-following'); } @@ -113,6 +115,11 @@ export class ExportFollowingProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'following', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts index 243b74f2c2..f9867ade29 100644 --- a/packages/backend/src/queue/processors/ExportMutingProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportMutingProcessorService.ts @@ -13,6 +13,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -32,6 +33,7 @@ export class ExportMutingProcessorService { private utilityService: UtilityService, private driveService: DriveService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-muting'); } @@ -110,6 +112,11 @@ export class ExportMutingProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'muting', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts index c7611012d7..9e2b678219 100644 --- a/packages/backend/src/queue/processors/ExportNotesProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportNotesProcessorService.ts @@ -18,6 +18,7 @@ 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 { JsonArrayStream } from '@/misc/JsonArrayStream.js'; import { FileWriterStream } from '@/misc/FileWriterStream.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; @@ -112,6 +113,7 @@ export class ExportNotesProcessorService { private queueLoggerService: QueueLoggerService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-notes'); } @@ -150,6 +152,11 @@ export class ExportNotesProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'json' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'note', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts index ee87cff5d3..c483d79854 100644 --- a/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts +++ b/packages/backend/src/queue/processors/ExportUserListsProcessorService.ts @@ -13,6 +13,7 @@ import type Logger from '@/logger.js'; import { DriveService } from '@/core/DriveService.js'; import { createTemp } from '@/misc/create-temp.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -35,6 +36,7 @@ export class ExportUserListsProcessorService { private utilityService: UtilityService, private driveService: DriveService, private queueLoggerService: QueueLoggerService, + private notificationService: NotificationService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('export-user-lists'); } @@ -89,6 +91,11 @@ export class ExportUserListsProcessorService { const driveFile = await this.driveService.addFile({ user, path, name: fileName, force: true, ext: 'csv' }); this.logger.succ(`Exported to: ${driveFile.id}`); + + this.notificationService.createNotification(user.id, 'exportCompleted', { + exportedEntity: 'userList', + fileId: driveFile.id, + }); } finally { cleanup(); } diff --git a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts index 171809d25c..95fe0a2c6a 100644 --- a/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts +++ b/packages/backend/src/queue/processors/ImportCustomEmojisProcessorService.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs'; import { Inject, Injectable } from '@nestjs/common'; import { ZipReader } from 'slacc'; +import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { EmojisRepository, DriveFilesRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; @@ -86,24 +87,35 @@ export class ImportCustomEmojisProcessorService { const emojiPath = outputPath + '/' + record.fileName; await this.emojisRepository.delete({ name: emojiInfo.name, + host: IsNull(), }); - const driveFile = await this.driveService.addFile({ - user: null, - path: emojiPath, - name: record.fileName, - force: true, - }); - await this.customEmojiService.add({ - name: emojiInfo.name, - category: emojiInfo.category, - host: null, - aliases: emojiInfo.aliases, - driveFile, - license: emojiInfo.license, - isSensitive: emojiInfo.isSensitive, - localOnly: emojiInfo.localOnly, - roleIdsThatCanBeUsedThisEmojiAsReaction: [], - }); + + try { + const driveFile = await this.driveService.addFile({ + user: null, + path: emojiPath, + name: record.fileName, + force: true, + }); + await this.customEmojiService.add({ + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, + name: emojiInfo.name, + category: emojiInfo.category, + host: null, + aliases: emojiInfo.aliases, + license: emojiInfo.license, + isSensitive: emojiInfo.isSensitive, + localOnly: emojiInfo.localOnly, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + }); + } catch (e) { + if (e instanceof Error || typeof e === 'string') { + this.logger.error(`couldn't import ${emojiPath} for ${emojiInfo.name}: ${e}`); + } + continue; + } } cleanup(); diff --git a/packages/backend/src/queue/processors/InboxProcessorService.ts b/packages/backend/src/queue/processors/InboxProcessorService.ts index fa7009f8f5..079e014da8 100644 --- a/packages/backend/src/queue/processors/InboxProcessorService.ts +++ b/packages/backend/src/queue/processors/InboxProcessorService.ts @@ -4,11 +4,10 @@ */ import { URL } from 'node:url'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import httpSignature from '@peertube/http-signature'; import * as Bull from 'bullmq'; import type Logger from '@/logger.js'; -import { MetaService } from '@/core/MetaService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import InstanceChart from '@/core/chart/charts/instance.js'; @@ -26,16 +25,28 @@ import { JsonLdService } from '@/core/activitypub/JsonLdService.js'; import { ApInboxService } from '@/core/activitypub/ApInboxService.js'; import { bindThis } from '@/decorators.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { CollapsedQueue } from '@/misc/collapsed-queue.js'; +import { MiNote } from '@/models/Note.js'; +import { MiMeta } from '@/models/Meta.js'; +import { DI } from '@/di-symbols.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type { InboxJobData } from '../types.js'; +type UpdateInstanceJob = { + latestRequestReceivedAt: Date, + shouldUnsuspend: boolean, +}; + @Injectable() -export class InboxProcessorService { +export class InboxProcessorService implements OnApplicationShutdown { private logger: Logger; + private updateInstanceQueue: CollapsedQueue; constructor( + @Inject(DI.meta) + private meta: MiMeta, + private utilityService: UtilityService, - private metaService: MetaService, private apInboxService: ApInboxService, private federatedInstanceService: FederatedInstanceService, private fetchInstanceMetadataService: FetchInstanceMetadataService, @@ -48,6 +59,7 @@ export class InboxProcessorService { private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('inbox'); + this.updateInstanceQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseUpdateInstanceJobs, this.performUpdateInstance); } @bindThis @@ -63,9 +75,7 @@ export class InboxProcessorService { const host = this.utilityService.toPuny(new URL(signature.keyId).hostname); - // ブロックしてたら中断 - const meta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(meta.blockedHosts, host)) { + if (!this.utilityService.isFederationAllowedHost(host)) { return `Blocked request: ${host}`; } @@ -97,12 +107,12 @@ export class InboxProcessorService { // それでもわからなければ終了 if (authUser == null) { - throw new Bull.UnrecoverableError('skip: failed to resolve user'); + throw new Bull.UnrecoverableError(`skip: failed to resolve user ${getApId(activity.actor)}`); } // publicKey がなくても終了 if (authUser.key == null) { - throw new Bull.UnrecoverableError('skip: failed to resolve user publicKey'); + throw new Bull.UnrecoverableError(`skip: failed to resolve user publicKey ${getApId(activity.actor)}`); } // HTTP-Signatureの検証 @@ -164,9 +174,8 @@ export class InboxProcessorService { throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); } - // ブロックしてたら中断 const ldHost = this.utilityService.extractDbHost(authUser.user.uri); - if (this.utilityService.isBlockedHost(meta.blockedHosts, ldHost)) { + if (!this.utilityService.isFederationAllowedHost(ldHost)) { throw new Bull.UnrecoverableError(`Blocked request: ${ldHost}`); } } else { @@ -181,25 +190,31 @@ export class InboxProcessorService { if (signerHost !== activityIdHost) { throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`); } + } else { + throw new Bull.UnrecoverableError('skip: activity id is not a string'); } - // Update stats - this.federatedInstanceService.fetch(authUser.user.host).then(i => { - this.federatedInstanceService.update(i.id, { + this.apRequestChart.inbox(); + this.federationChart.inbox(authUser.user.host); + + // Update instance stats + process.nextTick(async () => { + const i = await (this.meta.enableStatsForFederatedInstances + ? this.federatedInstanceService.fetchOrRegister(authUser.user.host) + : this.federatedInstanceService.fetch(authUser.user.host)); + + if (i == null) return; + + this.updateInstanceQueue.enqueue(i.id, { latestRequestReceivedAt: new Date(), - isNotResponding: false, - // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる - suspensionState: i.suspensionState === 'autoSuspendedForNotResponding' ? 'none' : undefined, + shouldUnsuspend: i.suspensionState === 'autoSuspendedForNotResponding', }); - this.fetchInstanceMetadataService.fetchInstanceMetadata(i); - - this.apRequestChart.inbox(); - this.federationChart.inbox(i.host); - - if (meta.enableChartsForFederatedInstances) { + if (this.meta.enableChartsForFederatedInstances) { this.instanceChart.requestReceived(i.host); } + + this.fetchInstanceMetadataService.fetchInstanceMetadata(i); }); // アクティビティを処理 @@ -225,4 +240,36 @@ export class InboxProcessorService { } return 'ok'; } + + @bindThis + public collapseUpdateInstanceJobs(oldJob: UpdateInstanceJob, newJob: UpdateInstanceJob) { + const latestRequestReceivedAt = oldJob.latestRequestReceivedAt < newJob.latestRequestReceivedAt + ? newJob.latestRequestReceivedAt + : oldJob.latestRequestReceivedAt; + const shouldUnsuspend = oldJob.shouldUnsuspend || newJob.shouldUnsuspend; + return { + latestRequestReceivedAt, + shouldUnsuspend, + }; + } + + @bindThis + public async performUpdateInstance(id: string, job: UpdateInstanceJob) { + await this.federatedInstanceService.update(id, { + latestRequestReceivedAt: new Date(), + isNotResponding: false, + // もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる + suspensionState: job.shouldUnsuspend ? 'none' : undefined, + }); + } + + @bindThis + public async dispose(): Promise { + await this.updateInstanceQueue.performAllNow(); + } + + @bindThis + async onApplicationShutdown(signal?: string) { + await this.dispose(); + } } diff --git a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts index 570cdf9a75..0c47fdedb3 100644 --- a/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/ResyncChartsProcessorService.ts @@ -29,13 +29,12 @@ export class ResyncChartsProcessorService { public async process(): Promise { this.logger.info('Resync charts...'); + // DBへの同時接続を避けるためにPromise.allを使わずひとつずつ実行する // TODO: ユーザーごとのチャートも更新する // TODO: インスタンスごとのチャートも更新する - await Promise.all([ - this.driveChart.resync(), - this.notesChart.resync(), - this.usersChart.resync(), - ]); + await this.driveChart.resync(); + await this.notesChart.resync(); + await this.usersChart.resync(); this.logger.succ('All charts successfully resynced.'); } diff --git a/packages/backend/src/queue/processors/TickChartsProcessorService.ts b/packages/backend/src/queue/processors/TickChartsProcessorService.ts index 93ec34162d..fc8856a271 100644 --- a/packages/backend/src/queue/processors/TickChartsProcessorService.ts +++ b/packages/backend/src/queue/processors/TickChartsProcessorService.ts @@ -48,20 +48,19 @@ export class TickChartsProcessorService { public async process(): Promise { this.logger.info('Tick charts...'); - await Promise.all([ - this.federationChart.tick(false), - this.notesChart.tick(false), - this.usersChart.tick(false), - this.activeUsersChart.tick(false), - this.instanceChart.tick(false), - this.perUserNotesChart.tick(false), - this.perUserPvChart.tick(false), - this.driveChart.tick(false), - this.perUserReactionsChart.tick(false), - this.perUserFollowingChart.tick(false), - this.perUserDriveChart.tick(false), - this.apRequestChart.tick(false), - ]); + // DBへの同時接続を避けるためにPromise.allを使わずひとつずつ実行する + await this.federationChart.tick(false); + await this.notesChart.tick(false); + await this.usersChart.tick(false); + await this.activeUsersChart.tick(false); + await this.instanceChart.tick(false); + await this.perUserNotesChart.tick(false); + await this.perUserPvChart.tick(false); + await this.driveChart.tick(false); + await this.perUserReactionsChart.tick(false); + await this.perUserFollowingChart.tick(false); + await this.perUserDriveChart.tick(false); + await this.apRequestChart.tick(false); this.logger.succ('All charts successfully ticked.'); } diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index a4077a0547..757daea88b 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -6,9 +6,12 @@ import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; import type { MiNote } from '@/models/Note.js'; +import type { SystemWebhookEventType } from '@/models/SystemWebhook.js'; import type { MiUser } from '@/models/User.js'; -import type { MiWebhook } from '@/models/Webhook.js'; +import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; import type { IActivity } from '@/core/activitypub/type.js'; +import type { SystemWebhookPayload } from '@/core/SystemWebhookService.js'; +import type { UserWebhookPayload } from '@/core/UserWebhookService.js'; import type httpSignature from '@peertube/http-signature'; export type DeliverJobData = { @@ -35,7 +38,7 @@ export type RelationshipJobData = { silent?: boolean; requestId?: string; withReplies?: boolean; -} +}; export type DbJobData = DbJobMap[T]; @@ -58,11 +61,11 @@ export type DbJobMap = { importUserLists: DbUserImportJobData; importCustomEmojis: DbUserImportJobData; deleteAccount: DbUserDeleteJobData; -} +}; export type DbJobDataWithUser = { user: ThinUser; -} +}; export type DbExportFollowingData = { user: ThinUser; @@ -72,7 +75,7 @@ export type DbExportFollowingData = { export type DBExportAntennasData = { user: ThinUser -} +}; export type DbUserDeleteJobData = { user: ThinUser; @@ -88,7 +91,7 @@ export type DbUserImportJobData = { export type DBAntennaImportJobData = { user: ThinUser, antenna: Antenna -} +}; export type DbUserImportToDbJobData = { user: ThinUser; @@ -106,9 +109,9 @@ export type EndedPollNotificationJobData = { noteId: MiNote['id']; }; -export type SystemWebhookDeliverJobData = { - type: string; - content: unknown; +export type SystemWebhookDeliverJobData = { + type: T; + content: SystemWebhookPayload; webhookId: MiWebhook['id']; to: string; secret: string; @@ -116,9 +119,9 @@ export type SystemWebhookDeliverJobData = { eventId: string; }; -export type UserWebhookDeliverJobData = { - type: string; - content: unknown; +export type UserWebhookDeliverJobData = { + type: T; + content: UserWebhookPayload; webhookId: MiWebhook['id']; userId: MiUser['id']; to: string; diff --git a/packages/backend/src/server/ActivityPubServerService.ts b/packages/backend/src/server/ActivityPubServerService.ts index 3255d64621..48c80e5e61 100644 --- a/packages/backend/src/server/ActivityPubServerService.ts +++ b/packages/backend/src/server/ActivityPubServerService.ts @@ -13,7 +13,7 @@ import accepts from 'accepts'; import vary from 'vary'; import secureJson from 'secure-json-parse'; import { DI } from '@/di-symbols.js'; -import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js'; +import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js'; import * as url from '@/misc/prelude/url.js'; import type { Config } from '@/config.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; @@ -29,6 +29,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import { IActivity } from '@/core/activitypub/type.js'; import { isQuote, isRenote } from '@/misc/is-renote.js'; +import * as Acct from '@/misc/acct.js'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FindOptionsWhere } from 'typeorm'; @@ -41,6 +42,9 @@ export class ActivityPubServerService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -101,10 +105,15 @@ export class ActivityPubServerService { @bindThis private inbox(request: FastifyRequest, reply: FastifyReply) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + let signature; try { - signature = httpSignature.parseRequest(request.raw, { 'headers': [] }); + signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' }); } catch (e) { reply.code(401); return; @@ -172,6 +181,11 @@ export class ActivityPubServerService { request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, reply: FastifyReply, ) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const userId = request.params.user; const cursor = request.query.cursor; @@ -264,6 +278,11 @@ export class ActivityPubServerService { request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>, reply: FastifyReply, ) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const userId = request.params.user; const cursor = request.query.cursor; @@ -353,6 +372,11 @@ export class ActivityPubServerService { @bindThis private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ @@ -397,6 +421,11 @@ export class ActivityPubServerService { }>, reply: FastifyReply, ) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const userId = request.params.user; const sinceId = request.query.since_id; @@ -481,11 +510,26 @@ export class ActivityPubServerService { @bindThis private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + if (user == null) { reply.code(404); return; } + // リモートだったらリダイレクト + if (user.host != null) { + if (user.uri == null || this.utilityService.isSelfHost(user.host)) { + reply.code(500); + return; + } + reply.redirect(user.uri, 301); + return; + } + reply.header('Cache-Control', 'public, max-age=180'); this.setResponseType(request, reply); return (this.apRendererService.addContext(await this.apRendererService.renderPerson(user as MiLocalUser))); @@ -508,8 +552,8 @@ export class ActivityPubServerService { }, deriveConstraint(request: IncomingMessage) { const accepted = accepts(request).type(['html', ACTIVITY_JSON, LD_JSON]); - const isAp = typeof accepted === 'string' && !accepted.match(/html/); - return isAp ? 'ap' : 'html'; + if (accepted === false) return null; + return accepted !== 'html' ? 'ap' : 'html'; }, }); @@ -553,6 +597,11 @@ export class ActivityPubServerService { fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const note = await this.notesRepository.findOneBy({ id: request.params.note, visibility: In(['public', 'home']), @@ -583,6 +632,11 @@ export class ActivityPubServerService { fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => { vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const note = await this.notesRepository.findOneBy({ id: request.params.note, userHost: IsNull(), @@ -623,6 +677,11 @@ export class ActivityPubServerService { // publickey fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ @@ -650,23 +709,34 @@ export class ActivityPubServerService { fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const userId = request.params.user; const user = await this.usersRepository.findOneBy({ id: userId, - host: IsNull(), isSuspended: false, }); return await this.userInfo(request, reply, user); }); - fastify.get<{ Params: { user: string; } }>('/@:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { + fastify.get<{ Params: { acct: string; } }>('/@:acct', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => { vary(reply.raw, 'Accept'); + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + + const acct = Acct.parse(request.params.acct); + const user = await this.usersRepository.findOneBy({ - usernameLower: request.params.user.toLowerCase(), - host: IsNull(), + usernameLower: acct.username, + host: acct.host ?? IsNull(), isSuspended: false, }); @@ -676,6 +746,11 @@ export class ActivityPubServerService { // emoji fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const emoji = await this.emojisRepository.findOneBy({ host: IsNull(), name: request.params.emoji, @@ -693,6 +768,11 @@ export class ActivityPubServerService { // like fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like }); if (reaction == null) { @@ -714,6 +794,11 @@ export class ActivityPubServerService { // follow fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + // This may be used before the follow is completed, so we do not // check if the following exists. @@ -739,7 +824,12 @@ export class ActivityPubServerService { }); // follow - fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => { + fastify.get<{ Params: { followRequestId: string; } }>('/follows/:followRequestId', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + // This may be used before the follow is completed, so we do not // check if the following exists and only check if the follow request exists. diff --git a/packages/backend/src/server/FileServerService.ts b/packages/backend/src/server/FileServerService.ts index 77a637d895..772c37094c 100644 --- a/packages/backend/src/server/FileServerService.ts +++ b/packages/backend/src/server/FileServerService.ts @@ -82,7 +82,7 @@ export class FileServerService { .catch(err => this.errorHandler(request, reply, err)); }); fastify.get<{ Params: { key: string; } }>('/files/:key/*', async (request, reply) => { - return await reply.redirect(301, `${this.config.url}/files/${request.params.key}`); + return await reply.redirect(`${this.config.url}/files/${request.params.key}`, 301); }); done(); }); @@ -147,12 +147,12 @@ export class FileServerService { url.searchParams.set('static', '1'); file.cleanup(); - return await reply.redirect(301, url.toString()); + 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(301, externalThumbnail); + return await reply.redirect(externalThumbnail, 301); } image = await this.videoProcessingService.generateVideoThumbnail(file.path); @@ -167,7 +167,7 @@ export class FileServerService { url.searchParams.set('url', file.url); file.cleanup(); - return await reply.redirect(301, url.toString()); + return await reply.redirect(url.toString(), 301); } } @@ -314,11 +314,17 @@ export class FileServerService { } return await reply.redirect( - 301, 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') { @@ -491,7 +497,7 @@ export class FileServerService { @bindThis private async downloadAndDetectTypeFromUrl(url: string): Promise< - { state: 'remote' ; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } + { state: 'remote'; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; } > { const [path, cleanup] = await createTemp(); try { diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index 9a641007ee..239ef82dec 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -9,11 +9,11 @@ import type { Config } from '@/config.js'; import { MetaService } from '@/core/MetaService.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; import { MemorySingleCache } from '@/misc/cache.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import NotesChart from '@/core/chart/charts/notes.js'; import UsersChart from '@/core/chart/charts/users.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; const nodeinfo2_1path = '/nodeinfo/2.1'; @@ -26,7 +26,7 @@ export class NodeinfoServerService { @Inject(DI.config) private config: Config, - private userEntityService: UserEntityService, + private systemAccountService: SystemAccountService, private metaService: MetaService, private notesChart: NotesChart, private usersChart: UsersChart, @@ -70,7 +70,7 @@ export class NodeinfoServerService { const activeHalfyear = null; const activeMonth = null; - const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null; + const proxyAccount = await this.systemAccountService.fetch('proxy'); const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies }; @@ -123,7 +123,7 @@ export class NodeinfoServerService { maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, enableEmail: meta.enableEmail, enableServiceWorker: meta.enableServiceWorker, - proxyAccountName: proxyAccount ? proxyAccount.username : null, + proxyAccountName: proxyAccount.username, themeColor: meta.themeColor ?? '#86b300', }, }; diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 12d5061985..0223650329 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -44,8 +44,11 @@ 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 { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @Module({ imports: [ @@ -71,6 +74,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js AuthenticateService, RateLimiterService, SigninApiService, + SigninWithPasskeyApiService, SigninService, SignupApiService, StreamingApiServerService, @@ -82,6 +86,8 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js GlobalTimelineChannelService, HashtagChannelService, RoleTimelineChannelService, + ChatUserChannelService, + ChatRoomChannelService, ReversiChannelService, ReversiGameChannelService, HomeTimelineChannelService, diff --git a/packages/backend/src/server/ServerService.ts b/packages/backend/src/server/ServerService.ts index 9c849480f2..b899053287 100644 --- a/packages/backend/src/server/ServerService.ts +++ b/packages/backend/src/server/ServerService.ts @@ -13,7 +13,7 @@ import fastifyRawBody from 'fastify-raw-body'; import { IsNull } from 'typeorm'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import type { Config } from '@/config.js'; -import type { EmojisRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { EmojisRepository, MiMeta, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import type Logger from '@/logger.js'; import * as Acct from '@/misc/acct.js'; @@ -21,7 +21,6 @@ import { genIdenticon } from '@/misc/gen-identicon.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { MetaService } from '@/core/MetaService.js'; import { ActivityPubServerService } from './ActivityPubServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js'; import { ApiServerService } from './api/ApiServerService.js'; @@ -44,6 +43,9 @@ export class ServerService implements OnApplicationShutdown { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -53,7 +55,6 @@ export class ServerService implements OnApplicationShutdown { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, - private metaService: MetaService, private userEntityService: UserEntityService, private apiServerService: ApiServerService, private openApiServerService: OpenApiServerService, @@ -102,6 +103,43 @@ export class ServerService implements OnApplicationShutdown { serve: false, }); + // if the requester looks like to be performing an ActivityPub object lookup, reject all external redirects + // + // this will break lookup that involve copying a URL from a third-party server, like trying to lookup http://charlie.example.com/@alice@alice.com + // + // this is not required by standard but protect us from peers that did not validate final URL. + if (this.config.disallowExternalApRedirect) { + const maybeApLookupRegex = /application\/activity\+json|application\/ld\+json.+activitystreams/i; + fastify.addHook('onSend', (request, reply, _, done) => { + const location = reply.getHeader('location'); + if (reply.statusCode < 300 || reply.statusCode >= 400 || typeof location !== 'string') { + done(); + return; + } + + if (!maybeApLookupRegex.test(request.headers.accept ?? '')) { + done(); + return; + } + + const effectiveLocation = process.env.NODE_ENV === 'production' ? location : location.replace(/^http:\/\//, 'https://'); + if (effectiveLocation.startsWith(`https://${this.config.host}/`)) { + done(); + return; + } + + reply.status(406); + reply.removeHeader('location'); + reply.header('content-type', 'text/plain; charset=utf-8'); + reply.header('link', `<${encodeURI(location)}>; rel="canonical"`); + done(null, [ + "Refusing to relay remote ActivityPub object lookup.", + "", + `Please remove 'application/activity+json' and 'application/ld+json' from the Accept header or fetch using the authoritative URL at ${location}.`, + ].join('\n')); + }); + } + fastify.register(this.apiServerService.createServer, { prefix: '/api' }); fastify.register(this.openApiServerService.createServer); fastify.register(this.fileServerService.createServer); @@ -165,8 +203,8 @@ export class ServerService implements OnApplicationShutdown { } return await reply.redirect( - 301, url.toString(), + 301, ); }); @@ -193,7 +231,7 @@ export class ServerService implements OnApplicationShutdown { reply.header('Content-Type', 'image/png'); reply.header('Cache-Control', 'public, max-age=86400'); - if ((await this.metaService.fetch()).enableIdenticonGeneration) { + if (this.meta.enableIdenticonGeneration) { return await genIdenticon(request.params.x); } else { return reply.redirect('/static-assets/avatar.png'); diff --git a/packages/backend/src/server/WellKnownServerService.ts b/packages/backend/src/server/WellKnownServerService.ts index 8e326da89a..d106be5bc8 100644 --- a/packages/backend/src/server/WellKnownServerService.ts +++ b/packages/backend/src/server/WellKnownServerService.ts @@ -8,7 +8,7 @@ import { IsNull } from 'typeorm'; import vary from 'vary'; import fastifyAccepts from '@fastify/accepts'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsersRepository } from '@/models/_.js'; import type { Config } from '@/config.js'; import { escapeAttribute, escapeValue } from '@/misc/prelude/xml.js'; import type { MiUser } from '@/models/User.js'; @@ -26,6 +26,9 @@ export class WellKnownServerService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -66,6 +69,11 @@ export class WellKnownServerService { }); fastify.get('/.well-known/host-meta', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + reply.header('Content-Type', xrd); return XRD({ element: 'Link', attributes: { rel: 'lrdd', @@ -75,6 +83,11 @@ export class WellKnownServerService { }); fastify.get('/.well-known/host-meta.json', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + reply.header('Content-Type', 'application/json'); return { links: [{ @@ -86,6 +99,11 @@ export class WellKnownServerService { }); fastify.get('/.well-known/nodeinfo', async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + return { links: this.nodeinfoServerService.getLinks() }; }); @@ -99,6 +117,11 @@ fastify.get('/.well-known/change-password', async (request, reply) => { */ fastify.get<{ Querystring: { resource: string } }>(webFingerPath, async (request, reply) => { + if (this.meta.federation === 'none') { + reply.code(403); + return; + } + const fromId = (id: MiUser['id']): FindOptionsWhere => ({ id, host: IsNull(), diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index e8d56ee50a..a42fdaf730 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -13,8 +13,7 @@ import { getIpHash } from '@/misc/get-ip-hash.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; import type { MiAccessToken } from '@/models/AccessToken.js'; import type Logger from '@/logger.js'; -import type { UserIpsRepository } from '@/models/_.js'; -import { MetaService } from '@/core/MetaService.js'; +import type { MiMeta, UserIpsRepository } from '@/models/_.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; @@ -40,13 +39,15 @@ export class ApiCallService implements OnApplicationShutdown { private userIpHistoriesClearIntervalId: NodeJS.Timeout; constructor( + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.config) private config: Config, @Inject(DI.userIpsRepository) private userIpsRepository: UserIpsRepository, - private metaService: MetaService, private authenticateService: AuthenticateService, private rateLimiterService: RateLimiterService, private roleService: RoleService, @@ -265,9 +266,8 @@ export class ApiCallService implements OnApplicationShutdown { } @bindThis - private async logIp(request: FastifyRequest, user: MiLocalUser) { - const meta = await this.metaService.fetch(); - if (!meta.enableIpLogging) return; + private logIp(request: FastifyRequest, user: MiLocalUser) { + if (!this.meta.enableIpLogging) return; const ip = request.ip; const ips = this.userIpHistories.get(user.id); if (ips == null || !ips.has(ip)) { @@ -371,7 +371,7 @@ export class ApiCallService implements OnApplicationShutdown { } } - if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) { + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) { const myRoles = await this.roleService.getUserRoles(user!.id); if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { throw new ApiError({ @@ -391,10 +391,10 @@ export class ApiCallService implements OnApplicationShutdown { } } - if (ep.meta.requireRolePolicy != null && !user!.isRoot) { + if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user!.id)) { const myRoles = await this.roleService.getUserRoles(user!.id); const policies = await this.roleService.getUserPolicies(user!.id); - if (!policies[ep.meta.requireRolePolicy] && !myRoles.some(r => r.isAdministrator)) { + if (!policies[ep.meta.requiredRolePolicy] && !myRoles.some(r => r.isAdministrator)) { throw new ApiError({ message: 'You are not assigned to a required role.', code: 'ROLE_PERMISSION_DENIED', diff --git a/packages/backend/src/server/api/ApiServerService.ts b/packages/backend/src/server/api/ApiServerService.ts index 13cbdfc3be..32818003ad 100644 --- a/packages/backend/src/server/api/ApiServerService.ts +++ b/packages/backend/src/server/api/ApiServerService.ts @@ -6,8 +6,8 @@ import { Inject, Injectable } from '@nestjs/common'; import cors from '@fastify/cors'; import multipart from '@fastify/multipart'; -import fastifyCookie from '@fastify/cookie'; import { ModuleRef } from '@nestjs/core'; +import { AuthenticationResponseJSON } from '@simplewebauthn/types'; import type { Config } from '@/config.js'; import type { InstancesRepository, AccessTokensRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; @@ -17,6 +17,7 @@ import endpoints from './endpoints.js'; import { ApiCallService } from './ApiCallService.js'; import { SignupApiService } from './SignupApiService.js'; import { SigninApiService } from './SigninApiService.js'; +import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; @Injectable() @@ -37,6 +38,7 @@ export class ApiServerService { private apiCallService: ApiCallService, private signupApiService: SignupApiService, private signinApiService: SigninApiService, + private signinWithPasskeyApiService: SigninWithPasskeyApiService, ) { //this.createServer = this.createServer.bind(this); } @@ -54,8 +56,6 @@ export class ApiServerService { }, }); - fastify.register(fastifyCookie, {}); - // Prevent cache fastify.addHook('onRequest', (request, reply, done) => { reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); @@ -115,21 +115,31 @@ export class ApiServerService { 'hcaptcha-response'?: string; 'g-recaptcha-response'?: string; 'turnstile-response'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>('/signup', (request, reply) => this.signupApiService.signup(request, reply)); fastify.post<{ Body: { username: string; - password: string; + password?: string; token?: string; - signature?: string; - authenticatorData?: string; - clientDataJSON?: string; - credentialId?: string; - challengeId?: string; + credential?: AuthenticationResponseJSON; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; - }>('/signin', (request, reply) => this.signinApiService.signin(request, reply)); + }>('/signin-flow', (request, reply) => this.signinApiService.signin(request, reply)); + + fastify.post<{ + Body: { + credential?: AuthenticationResponseJSON; + context?: string; + }; + }>('/signin-with-passkey', (request, reply) => this.signinWithPasskeyApiService.signin(request, reply)); fastify.post<{ Body: { code: string; } }>('/signup-pending', (request, reply) => this.signupApiService.signupPending(request, reply)); diff --git a/packages/backend/src/server/api/AuthenticateService.ts b/packages/backend/src/server/api/AuthenticateService.ts index 690ff2e022..601618553e 100644 --- a/packages/backend/src/server/api/AuthenticateService.ts +++ b/packages/backend/src/server/api/AuthenticateService.ts @@ -11,7 +11,7 @@ import type { MiAccessToken } from '@/models/AccessToken.js'; import { MemoryKVCache } from '@/misc/cache.js'; import type { MiApp } from '@/models/App.js'; import { CacheService } from '@/core/CacheService.js'; -import isNativeToken from '@/misc/is-native-token.js'; +import { isNativeUserToken } from '@/misc/token.js'; import { bindThis } from '@/decorators.js'; export class AuthenticationError extends Error { @@ -46,7 +46,7 @@ export class AuthenticateService implements OnApplicationShutdown { return [null, null]; } - if (isNativeToken(token)) { + if (isNativeUserToken(token)) { const user = await this.cacheService.localUserByNativeTokenCache.fetch(token, () => this.usersRepository.findOneBy({ token }) as Promise); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 08a0468ab2..9cfb2f0ac0 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -6,772 +6,13 @@ import { Module } from '@nestjs/common'; import { CoreModule } from '@/core/CoreModule.js'; -import * as ep___admin_abuseReport_notificationRecipient_list from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; -import * as ep___admin_abuseReport_notificationRecipient_show from '@/server/api/endpoints/admin/abuse-report/notification-recipient/show.js'; -import * as ep___admin_abuseReport_notificationRecipient_create from '@/server/api/endpoints/admin/abuse-report/notification-recipient/create.js'; -import * as ep___admin_abuseReport_notificationRecipient_update from '@/server/api/endpoints/admin/abuse-report/notification-recipient/update.js'; -import * as ep___admin_abuseReport_notificationRecipient_delete from '@/server/api/endpoints/admin/abuse-report/notification-recipient/delete.js'; -import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; -import * as ep___admin_meta from './endpoints/admin/meta.js'; -import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; -import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; -import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; -import * as ep___admin_ad_create from './endpoints/admin/ad/create.js'; -import * as ep___admin_ad_delete from './endpoints/admin/ad/delete.js'; -import * as ep___admin_ad_list from './endpoints/admin/ad/list.js'; -import * as ep___admin_ad_update from './endpoints/admin/ad/update.js'; -import * as ep___admin_announcements_create from './endpoints/admin/announcements/create.js'; -import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; -import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; -import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; -import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; -import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; -import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; -import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; -import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; -import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; -import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; -import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; -import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; -import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; -import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; -import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; -import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; -import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; -import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; -import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; -import * as ep___admin_emoji_importZip from './endpoints/admin/emoji/import-zip.js'; -import * as ep___admin_emoji_listRemote from './endpoints/admin/emoji/list-remote.js'; -import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; -import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; -import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; -import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; -import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; -import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; -import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; -import * as ep___admin_federation_refreshRemoteInstanceMetadata from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; -import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; -import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; -import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; -import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; -import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; -import * as ep___admin_invite_create from './endpoints/admin/invite/create.js'; -import * as ep___admin_invite_list from './endpoints/admin/invite/list.js'; -import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; -import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; -import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; -import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; -import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; -import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; -import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; -import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; -import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; -import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; -import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; -import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; -import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; -import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; -import * as ep___admin_showUser from './endpoints/admin/show-user.js'; -import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; -import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; -import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; -import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; -import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; -import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; -import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; -import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; -import * as ep___admin_roles_list from './endpoints/admin/roles/list.js'; -import * as ep___admin_roles_show from './endpoints/admin/roles/show.js'; -import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; -import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; -import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; -import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; -import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; -import * as ep___admin_systemWebhook_create from './endpoints/admin/system-webhook/create.js'; -import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webhook/delete.js'; -import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; -import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; -import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; -import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; -import * as ep___announcements from './endpoints/announcements.js'; -import * as ep___announcements_show from './endpoints/announcements/show.js'; -import * as ep___antennas_create from './endpoints/antennas/create.js'; -import * as ep___antennas_delete from './endpoints/antennas/delete.js'; -import * as ep___antennas_list from './endpoints/antennas/list.js'; -import * as ep___antennas_notes from './endpoints/antennas/notes.js'; -import * as ep___antennas_show from './endpoints/antennas/show.js'; -import * as ep___antennas_update from './endpoints/antennas/update.js'; -import * as ep___ap_get from './endpoints/ap/get.js'; -import * as ep___ap_show from './endpoints/ap/show.js'; -import * as ep___app_create from './endpoints/app/create.js'; -import * as ep___app_show from './endpoints/app/show.js'; -import * as ep___auth_accept from './endpoints/auth/accept.js'; -import * as ep___auth_session_generate from './endpoints/auth/session/generate.js'; -import * as ep___auth_session_show from './endpoints/auth/session/show.js'; -import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js'; -import * as ep___blocking_create from './endpoints/blocking/create.js'; -import * as ep___blocking_delete from './endpoints/blocking/delete.js'; -import * as ep___blocking_list from './endpoints/blocking/list.js'; -import * as ep___channels_create from './endpoints/channels/create.js'; -import * as ep___channels_featured from './endpoints/channels/featured.js'; -import * as ep___channels_follow from './endpoints/channels/follow.js'; -import * as ep___channels_followed from './endpoints/channels/followed.js'; -import * as ep___channels_owned from './endpoints/channels/owned.js'; -import * as ep___channels_show from './endpoints/channels/show.js'; -import * as ep___channels_timeline from './endpoints/channels/timeline.js'; -import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; -import * as ep___channels_update from './endpoints/channels/update.js'; -import * as ep___channels_favorite from './endpoints/channels/favorite.js'; -import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; -import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; -import * as ep___channels_search from './endpoints/channels/search.js'; -import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; -import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; -import * as ep___charts_drive from './endpoints/charts/drive.js'; -import * as ep___charts_federation from './endpoints/charts/federation.js'; -import * as ep___charts_instance from './endpoints/charts/instance.js'; -import * as ep___charts_notes from './endpoints/charts/notes.js'; -import * as ep___charts_user_drive from './endpoints/charts/user/drive.js'; -import * as ep___charts_user_following from './endpoints/charts/user/following.js'; -import * as ep___charts_user_notes from './endpoints/charts/user/notes.js'; -import * as ep___charts_user_pv from './endpoints/charts/user/pv.js'; -import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js'; -import * as ep___charts_users from './endpoints/charts/users.js'; -import * as ep___clips_addNote from './endpoints/clips/add-note.js'; -import * as ep___clips_removeNote from './endpoints/clips/remove-note.js'; -import * as ep___clips_create from './endpoints/clips/create.js'; -import * as ep___clips_delete from './endpoints/clips/delete.js'; -import * as ep___clips_list from './endpoints/clips/list.js'; -import * as ep___clips_notes from './endpoints/clips/notes.js'; -import * as ep___clips_show from './endpoints/clips/show.js'; -import * as ep___clips_update from './endpoints/clips/update.js'; -import * as ep___clips_favorite from './endpoints/clips/favorite.js'; -import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; -import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; -import * as ep___drive from './endpoints/drive.js'; -import * as ep___drive_files from './endpoints/drive/files.js'; -import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; -import * as ep___drive_files_checkExistence from './endpoints/drive/files/check-existence.js'; -import * as ep___drive_files_create from './endpoints/drive/files/create.js'; -import * as ep___drive_files_delete from './endpoints/drive/files/delete.js'; -import * as ep___drive_files_findByHash from './endpoints/drive/files/find-by-hash.js'; -import * as ep___drive_files_find from './endpoints/drive/files/find.js'; -import * as ep___drive_files_show from './endpoints/drive/files/show.js'; -import * as ep___drive_files_update from './endpoints/drive/files/update.js'; -import * as ep___drive_files_uploadFromUrl from './endpoints/drive/files/upload-from-url.js'; -import * as ep___drive_folders from './endpoints/drive/folders.js'; -import * as ep___drive_folders_create from './endpoints/drive/folders/create.js'; -import * as ep___drive_folders_delete from './endpoints/drive/folders/delete.js'; -import * as ep___drive_folders_find from './endpoints/drive/folders/find.js'; -import * as ep___drive_folders_show from './endpoints/drive/folders/show.js'; -import * as ep___drive_folders_update from './endpoints/drive/folders/update.js'; -import * as ep___drive_stream from './endpoints/drive/stream.js'; -import * as ep___emailAddress_available from './endpoints/email-address/available.js'; -import * as ep___endpoint from './endpoints/endpoint.js'; -import * as ep___endpoints from './endpoints/endpoints.js'; -import * as ep___exportCustomEmojis from './endpoints/export-custom-emojis.js'; -import * as ep___federation_followers from './endpoints/federation/followers.js'; -import * as ep___federation_following from './endpoints/federation/following.js'; -import * as ep___federation_instances from './endpoints/federation/instances.js'; -import * as ep___federation_showInstance from './endpoints/federation/show-instance.js'; -import * as ep___federation_updateRemoteUser from './endpoints/federation/update-remote-user.js'; -import * as ep___federation_users from './endpoints/federation/users.js'; -import * as ep___federation_stats from './endpoints/federation/stats.js'; -import * as ep___following_create from './endpoints/following/create.js'; -import * as ep___following_delete from './endpoints/following/delete.js'; -import * as ep___following_update from './endpoints/following/update.js'; -import * as ep___following_update_all from './endpoints/following/update-all.js'; -import * as ep___following_invalidate from './endpoints/following/invalidate.js'; -import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; -import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; -import * as ep___following_requests_list from './endpoints/following/requests/list.js'; -import * as ep___following_requests_reject from './endpoints/following/requests/reject.js'; -import * as ep___gallery_featured from './endpoints/gallery/featured.js'; -import * as ep___gallery_popular from './endpoints/gallery/popular.js'; -import * as ep___gallery_posts from './endpoints/gallery/posts.js'; -import * as ep___gallery_posts_create from './endpoints/gallery/posts/create.js'; -import * as ep___gallery_posts_delete from './endpoints/gallery/posts/delete.js'; -import * as ep___gallery_posts_like from './endpoints/gallery/posts/like.js'; -import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; -import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; -import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; -import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; -import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; -import * as ep___hashtags_list from './endpoints/hashtags/list.js'; -import * as ep___hashtags_search from './endpoints/hashtags/search.js'; -import * as ep___hashtags_show from './endpoints/hashtags/show.js'; -import * as ep___hashtags_trend from './endpoints/hashtags/trend.js'; -import * as ep___hashtags_users from './endpoints/hashtags/users.js'; -import * as ep___i from './endpoints/i.js'; -import * as ep___i_2fa_done from './endpoints/i/2fa/done.js'; -import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; -import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; -import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; -import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; -import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; -import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; -import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; -import * as ep___i_apps from './endpoints/i/apps.js'; -import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; -import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; -import * as ep___i_changePassword from './endpoints/i/change-password.js'; -import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; -import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; -import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; -import * as ep___i_exportMute from './endpoints/i/export-mute.js'; -import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; -import * as ep___i_exportClips from './endpoints/i/export-clips.js'; -import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; -import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; -import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; -import * as ep___i_favorites from './endpoints/i/favorites.js'; -import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; -import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; -import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; -import * as ep___i_importFollowing from './endpoints/i/import-following.js'; -import * as ep___i_importMuting from './endpoints/i/import-muting.js'; -import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; -import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; -import * as ep___i_notifications from './endpoints/i/notifications.js'; -import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; -import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; -import * as ep___i_pages from './endpoints/i/pages.js'; -import * as ep___i_pin from './endpoints/i/pin.js'; -import * as ep___i_readAllUnreadNotes from './endpoints/i/read-all-unread-notes.js'; -import * as ep___i_readAnnouncement from './endpoints/i/read-announcement.js'; -import * as ep___i_regenerateToken from './endpoints/i/regenerate-token.js'; -import * as ep___i_registry_getAll from './endpoints/i/registry/get-all.js'; -import * as ep___i_registry_getDetail from './endpoints/i/registry/get-detail.js'; -import * as ep___i_registry_get from './endpoints/i/registry/get.js'; -import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; -import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; -import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; -import * as ep___i_registry_set from './endpoints/i/registry/set.js'; -import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; -import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; -import * as ep___i_unpin from './endpoints/i/unpin.js'; -import * as ep___i_updateEmail from './endpoints/i/update-email.js'; -import * as ep___i_update from './endpoints/i/update.js'; -import * as ep___i_move from './endpoints/i/move.js'; -import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; -import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; -import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; -import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; -import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; -import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js'; -import * as ep___invite_create from './endpoints/invite/create.js'; -import * as ep___invite_delete from './endpoints/invite/delete.js'; -import * as ep___invite_list from './endpoints/invite/list.js'; -import * as ep___invite_limit from './endpoints/invite/limit.js'; -import * as ep___meta from './endpoints/meta.js'; -import * as ep___emojis from './endpoints/emojis.js'; -import * as ep___emoji from './endpoints/emoji.js'; -import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; -import * as ep___mute_create from './endpoints/mute/create.js'; -import * as ep___mute_delete from './endpoints/mute/delete.js'; -import * as ep___mute_list from './endpoints/mute/list.js'; -import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; -import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; -import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; -import * as ep___my_apps from './endpoints/my/apps.js'; -import * as ep___notes from './endpoints/notes.js'; -import * as ep___notes_children from './endpoints/notes/children.js'; -import * as ep___notes_clips from './endpoints/notes/clips.js'; -import * as ep___notes_conversation from './endpoints/notes/conversation.js'; -import * as ep___notes_create from './endpoints/notes/create.js'; -import * as ep___notes_delete from './endpoints/notes/delete.js'; -import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; -import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; -import * as ep___notes_featured from './endpoints/notes/featured.js'; -import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; -import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; -import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; -import * as ep___notes_mentions from './endpoints/notes/mentions.js'; -import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; -import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; -import * as ep___notes_reactions from './endpoints/notes/reactions.js'; -import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js'; -import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; -import * as ep___notes_renotes from './endpoints/notes/renotes.js'; -import * as ep___notes_replies from './endpoints/notes/replies.js'; -import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; -import * as ep___notes_search from './endpoints/notes/search.js'; -import * as ep___notes_show from './endpoints/notes/show.js'; -import * as ep___notes_state from './endpoints/notes/state.js'; -import * as ep___notes_threadMuting_create from './endpoints/notes/thread-muting/create.js'; -import * as ep___notes_threadMuting_delete from './endpoints/notes/thread-muting/delete.js'; -import * as ep___notes_timeline from './endpoints/notes/timeline.js'; -import * as ep___notes_translate from './endpoints/notes/translate.js'; -import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; -import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; -import * as ep___notifications_create from './endpoints/notifications/create.js'; -import * as ep___notifications_flush from './endpoints/notifications/flush.js'; -import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; -import * as ep___pagePush from './endpoints/page-push.js'; -import * as ep___pages_create from './endpoints/pages/create.js'; -import * as ep___pages_delete from './endpoints/pages/delete.js'; -import * as ep___pages_featured from './endpoints/pages/featured.js'; -import * as ep___pages_like from './endpoints/pages/like.js'; -import * as ep___pages_show from './endpoints/pages/show.js'; -import * as ep___pages_unlike from './endpoints/pages/unlike.js'; -import * as ep___pages_update from './endpoints/pages/update.js'; -import * as ep___flash_create from './endpoints/flash/create.js'; -import * as ep___flash_delete from './endpoints/flash/delete.js'; -import * as ep___flash_featured from './endpoints/flash/featured.js'; -import * as ep___flash_like from './endpoints/flash/like.js'; -import * as ep___flash_show from './endpoints/flash/show.js'; -import * as ep___flash_unlike from './endpoints/flash/unlike.js'; -import * as ep___flash_update from './endpoints/flash/update.js'; -import * as ep___flash_my from './endpoints/flash/my.js'; -import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; -import * as ep___ping from './endpoints/ping.js'; -import * as ep___pinnedUsers from './endpoints/pinned-users.js'; -import * as ep___promo_read from './endpoints/promo/read.js'; -import * as ep___roles_list from './endpoints/roles/list.js'; -import * as ep___roles_show from './endpoints/roles/show.js'; -import * as ep___roles_users from './endpoints/roles/users.js'; -import * as ep___roles_notes from './endpoints/roles/notes.js'; -import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; -import * as ep___resetDb from './endpoints/reset-db.js'; -import * as ep___resetPassword from './endpoints/reset-password.js'; -import * as ep___serverInfo from './endpoints/server-info.js'; -import * as ep___stats from './endpoints/stats.js'; -import * as ep___sw_show_registration from './endpoints/sw/show-registration.js'; -import * as ep___sw_update_registration from './endpoints/sw/update-registration.js'; -import * as ep___sw_register from './endpoints/sw/register.js'; -import * as ep___sw_unregister from './endpoints/sw/unregister.js'; -import * as ep___test from './endpoints/test.js'; -import * as ep___username_available from './endpoints/username/available.js'; -import * as ep___users from './endpoints/users.js'; -import * as ep___users_clips from './endpoints/users/clips.js'; -import * as ep___users_followers from './endpoints/users/followers.js'; -import * as ep___users_following from './endpoints/users/following.js'; -import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; -import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; -import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; -import * as ep___users_lists_create from './endpoints/users/lists/create.js'; -import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; -import * as ep___users_lists_list from './endpoints/users/lists/list.js'; -import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; -import * as ep___users_lists_push from './endpoints/users/lists/push.js'; -import * as ep___users_lists_show from './endpoints/users/lists/show.js'; -import * as ep___users_lists_update from './endpoints/users/lists/update.js'; -import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; -import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; -import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; -import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; -import * as ep___users_notes from './endpoints/users/notes.js'; -import * as ep___users_pages from './endpoints/users/pages.js'; -import * as ep___users_flashs from './endpoints/users/flashs.js'; -import * as ep___users_reactions from './endpoints/users/reactions.js'; -import * as ep___users_recommendation from './endpoints/users/recommendation.js'; -import * as ep___users_relation from './endpoints/users/relation.js'; -import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; -import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; -import * as ep___users_search from './endpoints/users/search.js'; -import * as ep___users_show from './endpoints/users/show.js'; -import * as ep___users_achievements from './endpoints/users/achievements.js'; -import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; -import * as ep___fetchRss from './endpoints/fetch-rss.js'; -import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; -import * as ep___retention from './endpoints/retention.js'; -import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js'; -import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js'; -import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js'; -import * as ep___reversi_games from './endpoints/reversi/games.js'; -import * as ep___reversi_match from './endpoints/reversi/match.js'; -import * as ep___reversi_invitations from './endpoints/reversi/invitations.js'; -import * as ep___reversi_showGame from './endpoints/reversi/show-game.js'; -import * as ep___reversi_surrender from './endpoints/reversi/surrender.js'; -import * as ep___reversi_verify from './endpoints/reversi/verify.js'; +import * as endpointsObject from './endpoint-list.js'; import { GetterService } from './GetterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; import type { Provider } from '@nestjs/common'; -const $admin_meta: Provider = { provide: 'ep:admin/meta', useClass: ep___admin_meta.default }; -const $admin_abuseUserReports: Provider = { provide: 'ep:admin/abuse-user-reports', useClass: ep___admin_abuseUserReports.default }; -const $admin_abuseReport_notificationRecipient_list: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/list', useClass: ep___admin_abuseReport_notificationRecipient_list.default }; -const $admin_abuseReport_notificationRecipient_show: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/show', useClass: ep___admin_abuseReport_notificationRecipient_show.default }; -const $admin_abuseReport_notificationRecipient_create: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/create', useClass: ep___admin_abuseReport_notificationRecipient_create.default }; -const $admin_abuseReport_notificationRecipient_update: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/update', useClass: ep___admin_abuseReport_notificationRecipient_update.default }; -const $admin_abuseReport_notificationRecipient_delete: Provider = { provide: 'ep:admin/abuse-report/notification-recipient/delete', useClass: ep___admin_abuseReport_notificationRecipient_delete.default }; -const $admin_accounts_create: Provider = { provide: 'ep:admin/accounts/create', useClass: ep___admin_accounts_create.default }; -const $admin_accounts_delete: Provider = { provide: 'ep:admin/accounts/delete', useClass: ep___admin_accounts_delete.default }; -const $admin_accounts_findByEmail: Provider = { provide: 'ep:admin/accounts/find-by-email', useClass: ep___admin_accounts_findByEmail.default }; -const $admin_ad_create: Provider = { provide: 'ep:admin/ad/create', useClass: ep___admin_ad_create.default }; -const $admin_ad_delete: Provider = { provide: 'ep:admin/ad/delete', useClass: ep___admin_ad_delete.default }; -const $admin_ad_list: Provider = { provide: 'ep:admin/ad/list', useClass: ep___admin_ad_list.default }; -const $admin_ad_update: Provider = { provide: 'ep:admin/ad/update', useClass: ep___admin_ad_update.default }; -const $admin_announcements_create: Provider = { provide: 'ep:admin/announcements/create', useClass: ep___admin_announcements_create.default }; -const $admin_announcements_delete: Provider = { provide: 'ep:admin/announcements/delete', useClass: ep___admin_announcements_delete.default }; -const $admin_announcements_list: Provider = { provide: 'ep:admin/announcements/list', useClass: ep___admin_announcements_list.default }; -const $admin_announcements_update: Provider = { provide: 'ep:admin/announcements/update', useClass: ep___admin_announcements_update.default }; -const $admin_avatarDecorations_create: Provider = { provide: 'ep:admin/avatar-decorations/create', useClass: ep___admin_avatarDecorations_create.default }; -const $admin_avatarDecorations_delete: Provider = { provide: 'ep:admin/avatar-decorations/delete', useClass: ep___admin_avatarDecorations_delete.default }; -const $admin_avatarDecorations_list: Provider = { provide: 'ep:admin/avatar-decorations/list', useClass: ep___admin_avatarDecorations_list.default }; -const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-decorations/update', useClass: ep___admin_avatarDecorations_update.default }; -const $admin_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/delete-all-files-of-a-user', useClass: ep___admin_deleteAllFilesOfAUser.default }; -const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default }; -const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default }; -const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default }; -const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default }; -const $admin_drive_files: Provider = { provide: 'ep:admin/drive/files', useClass: ep___admin_drive_files.default }; -const $admin_drive_showFile: Provider = { provide: 'ep:admin/drive/show-file', useClass: ep___admin_drive_showFile.default }; -const $admin_emoji_addAliasesBulk: Provider = { provide: 'ep:admin/emoji/add-aliases-bulk', useClass: ep___admin_emoji_addAliasesBulk.default }; -const $admin_emoji_add: Provider = { provide: 'ep:admin/emoji/add', useClass: ep___admin_emoji_add.default }; -const $admin_emoji_copy: Provider = { provide: 'ep:admin/emoji/copy', useClass: ep___admin_emoji_copy.default }; -const $admin_emoji_deleteBulk: Provider = { provide: 'ep:admin/emoji/delete-bulk', useClass: ep___admin_emoji_deleteBulk.default }; -const $admin_emoji_delete: Provider = { provide: 'ep:admin/emoji/delete', useClass: ep___admin_emoji_delete.default }; -const $admin_emoji_importZip: Provider = { provide: 'ep:admin/emoji/import-zip', useClass: ep___admin_emoji_importZip.default }; -const $admin_emoji_listRemote: Provider = { provide: 'ep:admin/emoji/list-remote', useClass: ep___admin_emoji_listRemote.default }; -const $admin_emoji_list: Provider = { provide: 'ep:admin/emoji/list', useClass: ep___admin_emoji_list.default }; -const $admin_emoji_removeAliasesBulk: Provider = { provide: 'ep:admin/emoji/remove-aliases-bulk', useClass: ep___admin_emoji_removeAliasesBulk.default }; -const $admin_emoji_setAliasesBulk: Provider = { provide: 'ep:admin/emoji/set-aliases-bulk', useClass: ep___admin_emoji_setAliasesBulk.default }; -const $admin_emoji_setCategoryBulk: Provider = { provide: 'ep:admin/emoji/set-category-bulk', useClass: ep___admin_emoji_setCategoryBulk.default }; -const $admin_emoji_setLicenseBulk: Provider = { provide: 'ep:admin/emoji/set-license-bulk', useClass: ep___admin_emoji_setLicenseBulk.default }; -const $admin_emoji_update: Provider = { provide: 'ep:admin/emoji/update', useClass: ep___admin_emoji_update.default }; -const $admin_federation_deleteAllFiles: Provider = { provide: 'ep:admin/federation/delete-all-files', useClass: ep___admin_federation_deleteAllFiles.default }; -const $admin_federation_refreshRemoteInstanceMetadata: Provider = { provide: 'ep:admin/federation/refresh-remote-instance-metadata', useClass: ep___admin_federation_refreshRemoteInstanceMetadata.default }; -const $admin_federation_removeAllFollowing: Provider = { provide: 'ep:admin/federation/remove-all-following', useClass: ep___admin_federation_removeAllFollowing.default }; -const $admin_federation_updateInstance: Provider = { provide: 'ep:admin/federation/update-instance', useClass: ep___admin_federation_updateInstance.default }; -const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', useClass: ep___admin_getIndexStats.default }; -const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default }; -const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default }; -const $admin_invite_create: Provider = { provide: 'ep:admin/invite/create', useClass: ep___admin_invite_create.default }; -const $admin_invite_list: Provider = { provide: 'ep:admin/invite/list', useClass: ep___admin_invite_list.default }; -const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default }; -const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; -const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; -const $admin_queue_inboxDelayed: Provider = { provide: 'ep:admin/queue/inbox-delayed', useClass: ep___admin_queue_inboxDelayed.default }; -const $admin_queue_promote: Provider = { provide: 'ep:admin/queue/promote', useClass: ep___admin_queue_promote.default }; -const $admin_queue_stats: Provider = { provide: 'ep:admin/queue/stats', useClass: ep___admin_queue_stats.default }; -const $admin_relays_add: Provider = { provide: 'ep:admin/relays/add', useClass: ep___admin_relays_add.default }; -const $admin_relays_list: Provider = { provide: 'ep:admin/relays/list', useClass: ep___admin_relays_list.default }; -const $admin_relays_remove: Provider = { provide: 'ep:admin/relays/remove', useClass: ep___admin_relays_remove.default }; -const $admin_resetPassword: Provider = { provide: 'ep:admin/reset-password', useClass: ep___admin_resetPassword.default }; -const $admin_resolveAbuseUserReport: Provider = { provide: 'ep:admin/resolve-abuse-user-report', useClass: ep___admin_resolveAbuseUserReport.default }; -const $admin_sendEmail: Provider = { provide: 'ep:admin/send-email', useClass: ep___admin_sendEmail.default }; -const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: ep___admin_serverInfo.default }; -const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default }; -const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep___admin_showUser.default }; -const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default }; -const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default }; -const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default }; -const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default }; -const $admin_deleteAccount: Provider = { provide: 'ep:admin/delete-account', useClass: ep___admin_deleteAccount.default }; -const $admin_updateUserNote: Provider = { provide: 'ep:admin/update-user-note', useClass: ep___admin_updateUserNote.default }; -const $admin_roles_create: Provider = { provide: 'ep:admin/roles/create', useClass: ep___admin_roles_create.default }; -const $admin_roles_delete: Provider = { provide: 'ep:admin/roles/delete', useClass: ep___admin_roles_delete.default }; -const $admin_roles_list: Provider = { provide: 'ep:admin/roles/list', useClass: ep___admin_roles_list.default }; -const $admin_roles_show: Provider = { provide: 'ep:admin/roles/show', useClass: ep___admin_roles_show.default }; -const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useClass: ep___admin_roles_update.default }; -const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default }; -const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default }; -const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default }; -const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default }; -const $admin_systemWebhook_create: Provider = { provide: 'ep:admin/system-webhook/create', useClass: ep___admin_systemWebhook_create.default }; -const $admin_systemWebhook_delete: Provider = { provide: 'ep:admin/system-webhook/delete', useClass: ep___admin_systemWebhook_delete.default }; -const $admin_systemWebhook_list: Provider = { provide: 'ep:admin/system-webhook/list', useClass: ep___admin_systemWebhook_list.default }; -const $admin_systemWebhook_show: Provider = { provide: 'ep:admin/system-webhook/show', useClass: ep___admin_systemWebhook_show.default }; -const $admin_systemWebhook_update: Provider = { provide: 'ep:admin/system-webhook/update', useClass: ep___admin_systemWebhook_update.default }; -const $admin_systemWebhook_test: Provider = { provide: 'ep:admin/system-webhook/test', useClass: ep___admin_systemWebhook_test.default }; -const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; -const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default }; -const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; -const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default }; -const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default }; -const $antennas_notes: Provider = { provide: 'ep:antennas/notes', useClass: ep___antennas_notes.default }; -const $antennas_show: Provider = { provide: 'ep:antennas/show', useClass: ep___antennas_show.default }; -const $antennas_update: Provider = { provide: 'ep:antennas/update', useClass: ep___antennas_update.default }; -const $ap_get: Provider = { provide: 'ep:ap/get', useClass: ep___ap_get.default }; -const $ap_show: Provider = { provide: 'ep:ap/show', useClass: ep___ap_show.default }; -const $app_create: Provider = { provide: 'ep:app/create', useClass: ep___app_create.default }; -const $app_show: Provider = { provide: 'ep:app/show', useClass: ep___app_show.default }; -const $auth_accept: Provider = { provide: 'ep:auth/accept', useClass: ep___auth_accept.default }; -const $auth_session_generate: Provider = { provide: 'ep:auth/session/generate', useClass: ep___auth_session_generate.default }; -const $auth_session_show: Provider = { provide: 'ep:auth/session/show', useClass: ep___auth_session_show.default }; -const $auth_session_userkey: Provider = { provide: 'ep:auth/session/userkey', useClass: ep___auth_session_userkey.default }; -const $blocking_create: Provider = { provide: 'ep:blocking/create', useClass: ep___blocking_create.default }; -const $blocking_delete: Provider = { provide: 'ep:blocking/delete', useClass: ep___blocking_delete.default }; -const $blocking_list: Provider = { provide: 'ep:blocking/list', useClass: ep___blocking_list.default }; -const $channels_create: Provider = { provide: 'ep:channels/create', useClass: ep___channels_create.default }; -const $channels_featured: Provider = { provide: 'ep:channels/featured', useClass: ep___channels_featured.default }; -const $channels_follow: Provider = { provide: 'ep:channels/follow', useClass: ep___channels_follow.default }; -const $channels_followed: Provider = { provide: 'ep:channels/followed', useClass: ep___channels_followed.default }; -const $channels_owned: Provider = { provide: 'ep:channels/owned', useClass: ep___channels_owned.default }; -const $channels_show: Provider = { provide: 'ep:channels/show', useClass: ep___channels_show.default }; -const $channels_timeline: Provider = { provide: 'ep:channels/timeline', useClass: ep___channels_timeline.default }; -const $channels_unfollow: Provider = { provide: 'ep:channels/unfollow', useClass: ep___channels_unfollow.default }; -const $channels_update: Provider = { provide: 'ep:channels/update', useClass: ep___channels_update.default }; -const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass: ep___channels_favorite.default }; -const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; -const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; -const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default }; -const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; -const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; -const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; -const $charts_federation: Provider = { provide: 'ep:charts/federation', useClass: ep___charts_federation.default }; -const $charts_instance: Provider = { provide: 'ep:charts/instance', useClass: ep___charts_instance.default }; -const $charts_notes: Provider = { provide: 'ep:charts/notes', useClass: ep___charts_notes.default }; -const $charts_user_drive: Provider = { provide: 'ep:charts/user/drive', useClass: ep___charts_user_drive.default }; -const $charts_user_following: Provider = { provide: 'ep:charts/user/following', useClass: ep___charts_user_following.default }; -const $charts_user_notes: Provider = { provide: 'ep:charts/user/notes', useClass: ep___charts_user_notes.default }; -const $charts_user_pv: Provider = { provide: 'ep:charts/user/pv', useClass: ep___charts_user_pv.default }; -const $charts_user_reactions: Provider = { provide: 'ep:charts/user/reactions', useClass: ep___charts_user_reactions.default }; -const $charts_users: Provider = { provide: 'ep:charts/users', useClass: ep___charts_users.default }; -const $clips_addNote: Provider = { provide: 'ep:clips/add-note', useClass: ep___clips_addNote.default }; -const $clips_removeNote: Provider = { provide: 'ep:clips/remove-note', useClass: ep___clips_removeNote.default }; -const $clips_create: Provider = { provide: 'ep:clips/create', useClass: ep___clips_create.default }; -const $clips_delete: Provider = { provide: 'ep:clips/delete', useClass: ep___clips_delete.default }; -const $clips_list: Provider = { provide: 'ep:clips/list', useClass: ep___clips_list.default }; -const $clips_notes: Provider = { provide: 'ep:clips/notes', useClass: ep___clips_notes.default }; -const $clips_show: Provider = { provide: 'ep:clips/show', useClass: ep___clips_show.default }; -const $clips_update: Provider = { provide: 'ep:clips/update', useClass: ep___clips_update.default }; -const $clips_favorite: Provider = { provide: 'ep:clips/favorite', useClass: ep___clips_favorite.default }; -const $clips_unfavorite: Provider = { provide: 'ep:clips/unfavorite', useClass: ep___clips_unfavorite.default }; -const $clips_myFavorites: Provider = { provide: 'ep:clips/my-favorites', useClass: ep___clips_myFavorites.default }; -const $drive: Provider = { provide: 'ep:drive', useClass: ep___drive.default }; -const $drive_files: Provider = { provide: 'ep:drive/files', useClass: ep___drive_files.default }; -const $drive_files_attachedNotes: Provider = { provide: 'ep:drive/files/attached-notes', useClass: ep___drive_files_attachedNotes.default }; -const $drive_files_checkExistence: Provider = { provide: 'ep:drive/files/check-existence', useClass: ep___drive_files_checkExistence.default }; -const $drive_files_create: Provider = { provide: 'ep:drive/files/create', useClass: ep___drive_files_create.default }; -const $drive_files_delete: Provider = { provide: 'ep:drive/files/delete', useClass: ep___drive_files_delete.default }; -const $drive_files_findByHash: Provider = { provide: 'ep:drive/files/find-by-hash', useClass: ep___drive_files_findByHash.default }; -const $drive_files_find: Provider = { provide: 'ep:drive/files/find', useClass: ep___drive_files_find.default }; -const $drive_files_show: Provider = { provide: 'ep:drive/files/show', useClass: ep___drive_files_show.default }; -const $drive_files_update: Provider = { provide: 'ep:drive/files/update', useClass: ep___drive_files_update.default }; -const $drive_files_uploadFromUrl: Provider = { provide: 'ep:drive/files/upload-from-url', useClass: ep___drive_files_uploadFromUrl.default }; -const $drive_folders: Provider = { provide: 'ep:drive/folders', useClass: ep___drive_folders.default }; -const $drive_folders_create: Provider = { provide: 'ep:drive/folders/create', useClass: ep___drive_folders_create.default }; -const $drive_folders_delete: Provider = { provide: 'ep:drive/folders/delete', useClass: ep___drive_folders_delete.default }; -const $drive_folders_find: Provider = { provide: 'ep:drive/folders/find', useClass: ep___drive_folders_find.default }; -const $drive_folders_show: Provider = { provide: 'ep:drive/folders/show', useClass: ep___drive_folders_show.default }; -const $drive_folders_update: Provider = { provide: 'ep:drive/folders/update', useClass: ep___drive_folders_update.default }; -const $drive_stream: Provider = { provide: 'ep:drive/stream', useClass: ep___drive_stream.default }; -const $emailAddress_available: Provider = { provide: 'ep:email-address/available', useClass: ep___emailAddress_available.default }; -const $endpoint: Provider = { provide: 'ep:endpoint', useClass: ep___endpoint.default }; -const $endpoints: Provider = { provide: 'ep:endpoints', useClass: ep___endpoints.default }; -const $exportCustomEmojis: Provider = { provide: 'ep:export-custom-emojis', useClass: ep___exportCustomEmojis.default }; -const $federation_followers: Provider = { provide: 'ep:federation/followers', useClass: ep___federation_followers.default }; -const $federation_following: Provider = { provide: 'ep:federation/following', useClass: ep___federation_following.default }; -const $federation_instances: Provider = { provide: 'ep:federation/instances', useClass: ep___federation_instances.default }; -const $federation_showInstance: Provider = { provide: 'ep:federation/show-instance', useClass: ep___federation_showInstance.default }; -const $federation_updateRemoteUser: Provider = { provide: 'ep:federation/update-remote-user', useClass: ep___federation_updateRemoteUser.default }; -const $federation_users: Provider = { provide: 'ep:federation/users', useClass: ep___federation_users.default }; -const $federation_stats: Provider = { provide: 'ep:federation/stats', useClass: ep___federation_stats.default }; -const $following_create: Provider = { provide: 'ep:following/create', useClass: ep___following_create.default }; -const $following_delete: Provider = { provide: 'ep:following/delete', useClass: ep___following_delete.default }; -const $following_update: Provider = { provide: 'ep:following/update', useClass: ep___following_update.default }; -const $following_update_all: Provider = { provide: 'ep:following/update-all', useClass: ep___following_update_all.default }; -const $following_invalidate: Provider = { provide: 'ep:following/invalidate', useClass: ep___following_invalidate.default }; -const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default }; -const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default }; -const $following_requests_list: Provider = { provide: 'ep:following/requests/list', useClass: ep___following_requests_list.default }; -const $following_requests_reject: Provider = { provide: 'ep:following/requests/reject', useClass: ep___following_requests_reject.default }; -const $gallery_featured: Provider = { provide: 'ep:gallery/featured', useClass: ep___gallery_featured.default }; -const $gallery_popular: Provider = { provide: 'ep:gallery/popular', useClass: ep___gallery_popular.default }; -const $gallery_posts: Provider = { provide: 'ep:gallery/posts', useClass: ep___gallery_posts.default }; -const $gallery_posts_create: Provider = { provide: 'ep:gallery/posts/create', useClass: ep___gallery_posts_create.default }; -const $gallery_posts_delete: Provider = { provide: 'ep:gallery/posts/delete', useClass: ep___gallery_posts_delete.default }; -const $gallery_posts_like: Provider = { provide: 'ep:gallery/posts/like', useClass: ep___gallery_posts_like.default }; -const $gallery_posts_show: Provider = { provide: 'ep:gallery/posts/show', useClass: ep___gallery_posts_show.default }; -const $gallery_posts_unlike: Provider = { provide: 'ep:gallery/posts/unlike', useClass: ep___gallery_posts_unlike.default }; -const $gallery_posts_update: Provider = { provide: 'ep:gallery/posts/update', useClass: ep___gallery_posts_update.default }; -const $getOnlineUsersCount: Provider = { provide: 'ep:get-online-users-count', useClass: ep___getOnlineUsersCount.default }; -const $getAvatarDecorations: Provider = { provide: 'ep:get-avatar-decorations', useClass: ep___getAvatarDecorations.default }; -const $hashtags_list: Provider = { provide: 'ep:hashtags/list', useClass: ep___hashtags_list.default }; -const $hashtags_search: Provider = { provide: 'ep:hashtags/search', useClass: ep___hashtags_search.default }; -const $hashtags_show: Provider = { provide: 'ep:hashtags/show', useClass: ep___hashtags_show.default }; -const $hashtags_trend: Provider = { provide: 'ep:hashtags/trend', useClass: ep___hashtags_trend.default }; -const $hashtags_users: Provider = { provide: 'ep:hashtags/users', useClass: ep___hashtags_users.default }; -const $i: Provider = { provide: 'ep:i', useClass: ep___i.default }; -const $i_2fa_done: Provider = { provide: 'ep:i/2fa/done', useClass: ep___i_2fa_done.default }; -const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___i_2fa_keyDone.default }; -const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default }; -const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default }; -const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default }; -const $i_2fa_updateKey: Provider = { provide: 'ep:i/2fa/update-key', useClass: ep___i_2fa_updateKey.default }; -const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default }; -const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default }; -const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default }; -const $i_authorizedApps: Provider = { provide: 'ep:i/authorized-apps', useClass: ep___i_authorizedApps.default }; -const $i_claimAchievement: Provider = { provide: 'ep:i/claim-achievement', useClass: ep___i_claimAchievement.default }; -const $i_changePassword: Provider = { provide: 'ep:i/change-password', useClass: ep___i_changePassword.default }; -const $i_deleteAccount: Provider = { provide: 'ep:i/delete-account', useClass: ep___i_deleteAccount.default }; -const $i_exportBlocking: Provider = { provide: 'ep:i/export-blocking', useClass: ep___i_exportBlocking.default }; -const $i_exportFollowing: Provider = { provide: 'ep:i/export-following', useClass: ep___i_exportFollowing.default }; -const $i_exportMute: Provider = { provide: 'ep:i/export-mute', useClass: ep___i_exportMute.default }; -const $i_exportNotes: Provider = { provide: 'ep:i/export-notes', useClass: ep___i_exportNotes.default }; -const $i_exportClips: Provider = { provide: 'ep:i/export-clips', useClass: ep___i_exportClips.default }; -const $i_exportFavorites: Provider = { provide: 'ep:i/export-favorites', useClass: ep___i_exportFavorites.default }; -const $i_exportUserLists: Provider = { provide: 'ep:i/export-user-lists', useClass: ep___i_exportUserLists.default }; -const $i_exportAntennas: Provider = { provide: 'ep:i/export-antennas', useClass: ep___i_exportAntennas.default }; -const $i_favorites: Provider = { provide: 'ep:i/favorites', useClass: ep___i_favorites.default }; -const $i_gallery_likes: Provider = { provide: 'ep:i/gallery/likes', useClass: ep___i_gallery_likes.default }; -const $i_gallery_posts: Provider = { provide: 'ep:i/gallery/posts', useClass: ep___i_gallery_posts.default }; -const $i_importBlocking: Provider = { provide: 'ep:i/import-blocking', useClass: ep___i_importBlocking.default }; -const $i_importFollowing: Provider = { provide: 'ep:i/import-following', useClass: ep___i_importFollowing.default }; -const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep___i_importMuting.default }; -const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default }; -const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default }; -const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default }; -const $i_notificationsGrouped: Provider = { provide: 'ep:i/notifications-grouped', useClass: ep___i_notificationsGrouped.default }; -const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default }; -const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default }; -const $i_pin: Provider = { provide: 'ep:i/pin', useClass: ep___i_pin.default }; -const $i_readAllUnreadNotes: Provider = { provide: 'ep:i/read-all-unread-notes', useClass: ep___i_readAllUnreadNotes.default }; -const $i_readAnnouncement: Provider = { provide: 'ep:i/read-announcement', useClass: ep___i_readAnnouncement.default }; -const $i_regenerateToken: Provider = { provide: 'ep:i/regenerate-token', useClass: ep___i_regenerateToken.default }; -const $i_registry_getAll: Provider = { provide: 'ep:i/registry/get-all', useClass: ep___i_registry_getAll.default }; -const $i_registry_getDetail: Provider = { provide: 'ep:i/registry/get-detail', useClass: ep___i_registry_getDetail.default }; -const $i_registry_get: Provider = { provide: 'ep:i/registry/get', useClass: ep___i_registry_get.default }; -const $i_registry_keysWithType: Provider = { provide: 'ep:i/registry/keys-with-type', useClass: ep___i_registry_keysWithType.default }; -const $i_registry_keys: Provider = { provide: 'ep:i/registry/keys', useClass: ep___i_registry_keys.default }; -const $i_registry_remove: Provider = { provide: 'ep:i/registry/remove', useClass: ep___i_registry_remove.default }; -const $i_registry_scopesWithDomain: Provider = { provide: 'ep:i/registry/scopes-with-domain', useClass: ep___i_registry_scopesWithDomain.default }; -const $i_registry_set: Provider = { provide: 'ep:i/registry/set', useClass: ep___i_registry_set.default }; -const $i_revokeToken: Provider = { provide: 'ep:i/revoke-token', useClass: ep___i_revokeToken.default }; -const $i_signinHistory: Provider = { provide: 'ep:i/signin-history', useClass: ep___i_signinHistory.default }; -const $i_unpin: Provider = { provide: 'ep:i/unpin', useClass: ep___i_unpin.default }; -const $i_updateEmail: Provider = { provide: 'ep:i/update-email', useClass: ep___i_updateEmail.default }; -const $i_update: Provider = { provide: 'ep:i/update', useClass: ep___i_update.default }; -const $i_move: Provider = { provide: 'ep:i/move', useClass: ep___i_move.default }; -const $i_webhooks_create: Provider = { provide: 'ep:i/webhooks/create', useClass: ep___i_webhooks_create.default }; -const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep___i_webhooks_list.default }; -const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; -const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default }; -const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; -const $i_webhooks_test: Provider = { provide: 'ep:i/webhooks/test', useClass: ep___i_webhooks_test.default }; -const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default }; -const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default }; -const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default }; -const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default }; -const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default }; -const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default }; -const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default }; -const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default }; -const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; -const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default }; -const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default }; -const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default }; -const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default }; -const $renoteMute_list: Provider = { provide: 'ep:renote-mute/list', useClass: ep___renoteMute_list.default }; -const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default }; -const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default }; -const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default }; -const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes_clips.default }; -const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default }; -const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default }; -const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default }; -const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default }; -const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default }; -const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default }; -const $notes_globalTimeline: Provider = { provide: 'ep:notes/global-timeline', useClass: ep___notes_globalTimeline.default }; -const $notes_hybridTimeline: Provider = { provide: 'ep:notes/hybrid-timeline', useClass: ep___notes_hybridTimeline.default }; -const $notes_localTimeline: Provider = { provide: 'ep:notes/local-timeline', useClass: ep___notes_localTimeline.default }; -const $notes_mentions: Provider = { provide: 'ep:notes/mentions', useClass: ep___notes_mentions.default }; -const $notes_polls_recommendation: Provider = { provide: 'ep:notes/polls/recommendation', useClass: ep___notes_polls_recommendation.default }; -const $notes_polls_vote: Provider = { provide: 'ep:notes/polls/vote', useClass: ep___notes_polls_vote.default }; -const $notes_reactions: Provider = { provide: 'ep:notes/reactions', useClass: ep___notes_reactions.default }; -const $notes_reactions_create: Provider = { provide: 'ep:notes/reactions/create', useClass: ep___notes_reactions_create.default }; -const $notes_reactions_delete: Provider = { provide: 'ep:notes/reactions/delete', useClass: ep___notes_reactions_delete.default }; -const $notes_renotes: Provider = { provide: 'ep:notes/renotes', useClass: ep___notes_renotes.default }; -const $notes_replies: Provider = { provide: 'ep:notes/replies', useClass: ep___notes_replies.default }; -const $notes_searchByTag: Provider = { provide: 'ep:notes/search-by-tag', useClass: ep___notes_searchByTag.default }; -const $notes_search: Provider = { provide: 'ep:notes/search', useClass: ep___notes_search.default }; -const $notes_show: Provider = { provide: 'ep:notes/show', useClass: ep___notes_show.default }; -const $notes_state: Provider = { provide: 'ep:notes/state', useClass: ep___notes_state.default }; -const $notes_threadMuting_create: Provider = { provide: 'ep:notes/thread-muting/create', useClass: ep___notes_threadMuting_create.default }; -const $notes_threadMuting_delete: Provider = { provide: 'ep:notes/thread-muting/delete', useClass: ep___notes_threadMuting_delete.default }; -const $notes_timeline: Provider = { provide: 'ep:notes/timeline', useClass: ep___notes_timeline.default }; -const $notes_translate: Provider = { provide: 'ep:notes/translate', useClass: ep___notes_translate.default }; -const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep___notes_unrenote.default }; -const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; -const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; -const $notifications_flush: Provider = { provide: 'ep:notifications/flush', useClass: ep___notifications_flush.default }; -const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; -const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default }; -const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; -const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default }; -const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default }; -const $pages_featured: Provider = { provide: 'ep:pages/featured', useClass: ep___pages_featured.default }; -const $pages_like: Provider = { provide: 'ep:pages/like', useClass: ep___pages_like.default }; -const $pages_show: Provider = { provide: 'ep:pages/show', useClass: ep___pages_show.default }; -const $pages_unlike: Provider = { provide: 'ep:pages/unlike', useClass: ep___pages_unlike.default }; -const $pages_update: Provider = { provide: 'ep:pages/update', useClass: ep___pages_update.default }; -const $flash_create: Provider = { provide: 'ep:flash/create', useClass: ep___flash_create.default }; -const $flash_delete: Provider = { provide: 'ep:flash/delete', useClass: ep___flash_delete.default }; -const $flash_featured: Provider = { provide: 'ep:flash/featured', useClass: ep___flash_featured.default }; -const $flash_like: Provider = { provide: 'ep:flash/like', useClass: ep___flash_like.default }; -const $flash_show: Provider = { provide: 'ep:flash/show', useClass: ep___flash_show.default }; -const $flash_unlike: Provider = { provide: 'ep:flash/unlike', useClass: ep___flash_unlike.default }; -const $flash_update: Provider = { provide: 'ep:flash/update', useClass: ep___flash_update.default }; -const $flash_my: Provider = { provide: 'ep:flash/my', useClass: ep___flash_my.default }; -const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___flash_myLikes.default }; -const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default }; -const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default }; -const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default }; -const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default }; -const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default }; -const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default }; -const $roles_notes: Provider = { provide: 'ep:roles/notes', useClass: ep___roles_notes.default }; -const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default }; -const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default }; -const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default }; -const $serverInfo: Provider = { provide: 'ep:server-info', useClass: ep___serverInfo.default }; -const $stats: Provider = { provide: 'ep:stats', useClass: ep___stats.default }; -const $sw_show_registration: Provider = { provide: 'ep:sw/show-registration', useClass: ep___sw_show_registration.default }; -const $sw_update_registration: Provider = { provide: 'ep:sw/update-registration', useClass: ep___sw_update_registration.default }; -const $sw_register: Provider = { provide: 'ep:sw/register', useClass: ep___sw_register.default }; -const $sw_unregister: Provider = { provide: 'ep:sw/unregister', useClass: ep___sw_unregister.default }; -const $test: Provider = { provide: 'ep:test', useClass: ep___test.default }; -const $username_available: Provider = { provide: 'ep:username/available', useClass: ep___username_available.default }; -const $users: Provider = { provide: 'ep:users', useClass: ep___users.default }; -const $users_clips: Provider = { provide: 'ep:users/clips', useClass: ep___users_clips.default }; -const $users_followers: Provider = { provide: 'ep:users/followers', useClass: ep___users_followers.default }; -const $users_following: Provider = { provide: 'ep:users/following', useClass: ep___users_following.default }; -const $users_gallery_posts: Provider = { provide: 'ep:users/gallery/posts', useClass: ep___users_gallery_posts.default }; -const $users_getFrequentlyRepliedUsers: Provider = { provide: 'ep:users/get-frequently-replied-users', useClass: ep___users_getFrequentlyRepliedUsers.default }; -const $users_featuredNotes: Provider = { provide: 'ep:users/featured-notes', useClass: ep___users_featuredNotes.default }; -const $users_lists_create: Provider = { provide: 'ep:users/lists/create', useClass: ep___users_lists_create.default }; -const $users_lists_delete: Provider = { provide: 'ep:users/lists/delete', useClass: ep___users_lists_delete.default }; -const $users_lists_list: Provider = { provide: 'ep:users/lists/list', useClass: ep___users_lists_list.default }; -const $users_lists_pull: Provider = { provide: 'ep:users/lists/pull', useClass: ep___users_lists_pull.default }; -const $users_lists_push: Provider = { provide: 'ep:users/lists/push', useClass: ep___users_lists_push.default }; -const $users_lists_show: Provider = { provide: 'ep:users/lists/show', useClass: ep___users_lists_show.default }; -const $users_lists_update: Provider = { provide: 'ep:users/lists/update', useClass: ep___users_lists_update.default }; -const $users_lists_favorite: Provider = { provide: 'ep:users/lists/favorite', useClass: ep___users_lists_favorite.default }; -const $users_lists_unfavorite: Provider = { provide: 'ep:users/lists/unfavorite', useClass: ep___users_lists_unfavorite.default }; -const $users_lists_createFromPublic: Provider = { provide: 'ep:users/lists/create-from-public', useClass: ep___users_lists_createFromPublic.default }; -const $users_lists_updateMembership: Provider = { provide: 'ep:users/lists/update-membership', useClass: ep___users_lists_updateMembership.default }; -const $users_lists_getMemberships: Provider = { provide: 'ep:users/lists/get-memberships', useClass: ep___users_lists_getMemberships.default }; -const $users_notes: Provider = { provide: 'ep:users/notes', useClass: ep___users_notes.default }; -const $users_pages: Provider = { provide: 'ep:users/pages', useClass: ep___users_pages.default }; -const $users_flashs: Provider = { provide: 'ep:users/flashs', useClass: ep___users_flashs.default }; -const $users_reactions: Provider = { provide: 'ep:users/reactions', useClass: ep___users_reactions.default }; -const $users_recommendation: Provider = { provide: 'ep:users/recommendation', useClass: ep___users_recommendation.default }; -const $users_relation: Provider = { provide: 'ep:users/relation', useClass: ep___users_relation.default }; -const $users_reportAbuse: Provider = { provide: 'ep:users/report-abuse', useClass: ep___users_reportAbuse.default }; -const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by-username-and-host', useClass: ep___users_searchByUsernameAndHost.default }; -const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default }; -const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; -const $users_achievements: Provider = { provide: 'ep:users/achievements', useClass: ep___users_achievements.default }; -const $users_updateMemo: Provider = { provide: 'ep:users/update-memo', useClass: ep___users_updateMemo.default }; -const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; -const $fetchExternalResources: Provider = { provide: 'ep:fetch-external-resources', useClass: ep___fetchExternalResources.default }; -const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; -const $bubbleGame_register: Provider = { provide: 'ep:bubble-game/register', useClass: ep___bubbleGame_register.default }; -const $bubbleGame_ranking: Provider = { provide: 'ep:bubble-game/ranking', useClass: ep___bubbleGame_ranking.default }; -const $reversi_cancelMatch: Provider = { provide: 'ep:reversi/cancel-match', useClass: ep___reversi_cancelMatch.default }; -const $reversi_games: Provider = { provide: 'ep:reversi/games', useClass: ep___reversi_games.default }; -const $reversi_match: Provider = { provide: 'ep:reversi/match', useClass: ep___reversi_match.default }; -const $reversi_invitations: Provider = { provide: 'ep:reversi/invitations', useClass: ep___reversi_invitations.default }; -const $reversi_showGame: Provider = { provide: 'ep:reversi/show-game', useClass: ep___reversi_showGame.default }; -const $reversi_surrender: Provider = { provide: 'ep:reversi/surrender', useClass: ep___reversi_surrender.default }; -const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep___reversi_verify.default }; +const endpoints = Object.entries(endpointsObject); +const endpointProviders = endpoints.map(([path, endpoint]): Provider => ({ provide: `ep:${path}`, useClass: endpoint.default })); @Module({ imports: [ @@ -780,768 +21,10 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ providers: [ GetterService, ApiLoggerService, - $admin_meta, - $admin_abuseUserReports, - $admin_abuseReport_notificationRecipient_list, - $admin_abuseReport_notificationRecipient_show, - $admin_abuseReport_notificationRecipient_create, - $admin_abuseReport_notificationRecipient_update, - $admin_abuseReport_notificationRecipient_delete, - $admin_accounts_create, - $admin_accounts_delete, - $admin_accounts_findByEmail, - $admin_ad_create, - $admin_ad_delete, - $admin_ad_list, - $admin_ad_update, - $admin_announcements_create, - $admin_announcements_delete, - $admin_announcements_list, - $admin_announcements_update, - $admin_avatarDecorations_create, - $admin_avatarDecorations_delete, - $admin_avatarDecorations_list, - $admin_avatarDecorations_update, - $admin_deleteAllFilesOfAUser, - $admin_unsetUserAvatar, - $admin_unsetUserBanner, - $admin_drive_cleanRemoteFiles, - $admin_drive_cleanup, - $admin_drive_files, - $admin_drive_showFile, - $admin_emoji_addAliasesBulk, - $admin_emoji_add, - $admin_emoji_copy, - $admin_emoji_deleteBulk, - $admin_emoji_delete, - $admin_emoji_importZip, - $admin_emoji_listRemote, - $admin_emoji_list, - $admin_emoji_removeAliasesBulk, - $admin_emoji_setAliasesBulk, - $admin_emoji_setCategoryBulk, - $admin_emoji_setLicenseBulk, - $admin_emoji_update, - $admin_federation_deleteAllFiles, - $admin_federation_refreshRemoteInstanceMetadata, - $admin_federation_removeAllFollowing, - $admin_federation_updateInstance, - $admin_getIndexStats, - $admin_getTableStats, - $admin_getUserIps, - $admin_invite_create, - $admin_invite_list, - $admin_promo_create, - $admin_queue_clear, - $admin_queue_deliverDelayed, - $admin_queue_inboxDelayed, - $admin_queue_promote, - $admin_queue_stats, - $admin_relays_add, - $admin_relays_list, - $admin_relays_remove, - $admin_resetPassword, - $admin_resolveAbuseUserReport, - $admin_sendEmail, - $admin_serverInfo, - $admin_showModerationLogs, - $admin_showUser, - $admin_showUsers, - $admin_suspendUser, - $admin_unsuspendUser, - $admin_updateMeta, - $admin_deleteAccount, - $admin_updateUserNote, - $admin_roles_create, - $admin_roles_delete, - $admin_roles_list, - $admin_roles_show, - $admin_roles_update, - $admin_roles_assign, - $admin_roles_unassign, - $admin_roles_updateDefaultPolicies, - $admin_roles_users, - $admin_systemWebhook_create, - $admin_systemWebhook_delete, - $admin_systemWebhook_list, - $admin_systemWebhook_show, - $admin_systemWebhook_update, - $admin_systemWebhook_test, - $announcements, - $announcements_show, - $antennas_create, - $antennas_delete, - $antennas_list, - $antennas_notes, - $antennas_show, - $antennas_update, - $ap_get, - $ap_show, - $app_create, - $app_show, - $auth_accept, - $auth_session_generate, - $auth_session_show, - $auth_session_userkey, - $blocking_create, - $blocking_delete, - $blocking_list, - $channels_create, - $channels_featured, - $channels_follow, - $channels_followed, - $channels_owned, - $channels_show, - $channels_timeline, - $channels_unfollow, - $channels_update, - $channels_favorite, - $channels_unfavorite, - $channels_myFavorites, - $channels_search, - $charts_activeUsers, - $charts_apRequest, - $charts_drive, - $charts_federation, - $charts_instance, - $charts_notes, - $charts_user_drive, - $charts_user_following, - $charts_user_notes, - $charts_user_pv, - $charts_user_reactions, - $charts_users, - $clips_addNote, - $clips_removeNote, - $clips_create, - $clips_delete, - $clips_list, - $clips_notes, - $clips_show, - $clips_update, - $clips_favorite, - $clips_unfavorite, - $clips_myFavorites, - $drive, - $drive_files, - $drive_files_attachedNotes, - $drive_files_checkExistence, - $drive_files_create, - $drive_files_delete, - $drive_files_findByHash, - $drive_files_find, - $drive_files_show, - $drive_files_update, - $drive_files_uploadFromUrl, - $drive_folders, - $drive_folders_create, - $drive_folders_delete, - $drive_folders_find, - $drive_folders_show, - $drive_folders_update, - $drive_stream, - $emailAddress_available, - $endpoint, - $endpoints, - $exportCustomEmojis, - $federation_followers, - $federation_following, - $federation_instances, - $federation_showInstance, - $federation_updateRemoteUser, - $federation_users, - $federation_stats, - $following_create, - $following_delete, - $following_update, - $following_update_all, - $following_invalidate, - $following_requests_accept, - $following_requests_cancel, - $following_requests_list, - $following_requests_reject, - $gallery_featured, - $gallery_popular, - $gallery_posts, - $gallery_posts_create, - $gallery_posts_delete, - $gallery_posts_like, - $gallery_posts_show, - $gallery_posts_unlike, - $gallery_posts_update, - $getOnlineUsersCount, - $getAvatarDecorations, - $hashtags_list, - $hashtags_search, - $hashtags_show, - $hashtags_trend, - $hashtags_users, - $i, - $i_2fa_done, - $i_2fa_keyDone, - $i_2fa_passwordLess, - $i_2fa_registerKey, - $i_2fa_register, - $i_2fa_updateKey, - $i_2fa_removeKey, - $i_2fa_unregister, - $i_apps, - $i_authorizedApps, - $i_claimAchievement, - $i_changePassword, - $i_deleteAccount, - $i_exportBlocking, - $i_exportFollowing, - $i_exportMute, - $i_exportNotes, - $i_exportClips, - $i_exportFavorites, - $i_exportUserLists, - $i_exportAntennas, - $i_favorites, - $i_gallery_likes, - $i_gallery_posts, - $i_importBlocking, - $i_importFollowing, - $i_importMuting, - $i_importUserLists, - $i_importAntennas, - $i_notifications, - $i_notificationsGrouped, - $i_pageLikes, - $i_pages, - $i_pin, - $i_readAllUnreadNotes, - $i_readAnnouncement, - $i_regenerateToken, - $i_registry_getAll, - $i_registry_getDetail, - $i_registry_get, - $i_registry_keysWithType, - $i_registry_keys, - $i_registry_remove, - $i_registry_scopesWithDomain, - $i_registry_set, - $i_revokeToken, - $i_signinHistory, - $i_unpin, - $i_updateEmail, - $i_update, - $i_move, - $i_webhooks_create, - $i_webhooks_list, - $i_webhooks_show, - $i_webhooks_update, - $i_webhooks_delete, - $i_webhooks_test, - $invite_create, - $invite_delete, - $invite_list, - $invite_limit, - $meta, - $emojis, - $emoji, - $miauth_genToken, - $mute_create, - $mute_delete, - $mute_list, - $renoteMute_create, - $renoteMute_delete, - $renoteMute_list, - $my_apps, - $notes, - $notes_children, - $notes_clips, - $notes_conversation, - $notes_create, - $notes_delete, - $notes_favorites_create, - $notes_favorites_delete, - $notes_featured, - $notes_globalTimeline, - $notes_hybridTimeline, - $notes_localTimeline, - $notes_mentions, - $notes_polls_recommendation, - $notes_polls_vote, - $notes_reactions, - $notes_reactions_create, - $notes_reactions_delete, - $notes_renotes, - $notes_replies, - $notes_searchByTag, - $notes_search, - $notes_show, - $notes_state, - $notes_threadMuting_create, - $notes_threadMuting_delete, - $notes_timeline, - $notes_translate, - $notes_unrenote, - $notes_userListTimeline, - $notifications_create, - $notifications_flush, - $notifications_markAllAsRead, - $notifications_testNotification, - $pagePush, - $pages_create, - $pages_delete, - $pages_featured, - $pages_like, - $pages_show, - $pages_unlike, - $pages_update, - $flash_create, - $flash_delete, - $flash_featured, - $flash_like, - $flash_show, - $flash_unlike, - $flash_update, - $flash_my, - $flash_myLikes, - $ping, - $pinnedUsers, - $promo_read, - $roles_list, - $roles_show, - $roles_users, - $roles_notes, - $requestResetPassword, - $resetDb, - $resetPassword, - $serverInfo, - $stats, - $sw_show_registration, - $sw_update_registration, - $sw_register, - $sw_unregister, - $test, - $username_available, - $users, - $users_clips, - $users_followers, - $users_following, - $users_gallery_posts, - $users_getFrequentlyRepliedUsers, - $users_featuredNotes, - $users_lists_create, - $users_lists_delete, - $users_lists_list, - $users_lists_pull, - $users_lists_push, - $users_lists_show, - $users_lists_update, - $users_lists_favorite, - $users_lists_unfavorite, - $users_lists_createFromPublic, - $users_lists_updateMembership, - $users_lists_getMemberships, - $users_notes, - $users_pages, - $users_flashs, - $users_reactions, - $users_recommendation, - $users_relation, - $users_reportAbuse, - $users_searchByUsernameAndHost, - $users_search, - $users_show, - $users_achievements, - $users_updateMemo, - $fetchRss, - $fetchExternalResources, - $retention, - $bubbleGame_register, - $bubbleGame_ranking, - $reversi_cancelMatch, - $reversi_games, - $reversi_match, - $reversi_invitations, - $reversi_showGame, - $reversi_surrender, - $reversi_verify, + ...endpointProviders, ], exports: [ - $admin_meta, - $admin_abuseUserReports, - $admin_abuseReport_notificationRecipient_list, - $admin_abuseReport_notificationRecipient_show, - $admin_abuseReport_notificationRecipient_create, - $admin_abuseReport_notificationRecipient_update, - $admin_abuseReport_notificationRecipient_delete, - $admin_accounts_create, - $admin_accounts_delete, - $admin_accounts_findByEmail, - $admin_ad_create, - $admin_ad_delete, - $admin_ad_list, - $admin_ad_update, - $admin_announcements_create, - $admin_announcements_delete, - $admin_announcements_list, - $admin_announcements_update, - $admin_avatarDecorations_create, - $admin_avatarDecorations_delete, - $admin_avatarDecorations_list, - $admin_avatarDecorations_update, - $admin_deleteAllFilesOfAUser, - $admin_unsetUserAvatar, - $admin_unsetUserBanner, - $admin_drive_cleanRemoteFiles, - $admin_drive_cleanup, - $admin_drive_files, - $admin_drive_showFile, - $admin_emoji_addAliasesBulk, - $admin_emoji_add, - $admin_emoji_copy, - $admin_emoji_deleteBulk, - $admin_emoji_delete, - $admin_emoji_importZip, - $admin_emoji_listRemote, - $admin_emoji_list, - $admin_emoji_removeAliasesBulk, - $admin_emoji_setAliasesBulk, - $admin_emoji_setCategoryBulk, - $admin_emoji_setLicenseBulk, - $admin_emoji_update, - $admin_federation_deleteAllFiles, - $admin_federation_refreshRemoteInstanceMetadata, - $admin_federation_removeAllFollowing, - $admin_federation_updateInstance, - $admin_getIndexStats, - $admin_getTableStats, - $admin_getUserIps, - $admin_invite_create, - $admin_invite_list, - $admin_promo_create, - $admin_queue_clear, - $admin_queue_deliverDelayed, - $admin_queue_inboxDelayed, - $admin_queue_promote, - $admin_queue_stats, - $admin_relays_add, - $admin_relays_list, - $admin_relays_remove, - $admin_resetPassword, - $admin_resolveAbuseUserReport, - $admin_sendEmail, - $admin_serverInfo, - $admin_showModerationLogs, - $admin_showUser, - $admin_showUsers, - $admin_suspendUser, - $admin_unsuspendUser, - $admin_updateMeta, - $admin_deleteAccount, - $admin_updateUserNote, - $admin_roles_create, - $admin_roles_delete, - $admin_roles_list, - $admin_roles_show, - $admin_roles_update, - $admin_roles_assign, - $admin_roles_unassign, - $admin_roles_updateDefaultPolicies, - $admin_roles_users, - $admin_systemWebhook_create, - $admin_systemWebhook_delete, - $admin_systemWebhook_list, - $admin_systemWebhook_show, - $admin_systemWebhook_update, - $admin_systemWebhook_test, - $announcements, - $announcements_show, - $antennas_create, - $antennas_delete, - $antennas_list, - $antennas_notes, - $antennas_show, - $antennas_update, - $ap_get, - $ap_show, - $app_create, - $app_show, - $auth_accept, - $auth_session_generate, - $auth_session_show, - $auth_session_userkey, - $blocking_create, - $blocking_delete, - $blocking_list, - $channels_create, - $channels_featured, - $channels_follow, - $channels_followed, - $channels_owned, - $channels_show, - $channels_timeline, - $channels_unfollow, - $channels_update, - $channels_favorite, - $channels_unfavorite, - $channels_myFavorites, - $channels_search, - $charts_activeUsers, - $charts_apRequest, - $charts_drive, - $charts_federation, - $charts_instance, - $charts_notes, - $charts_user_drive, - $charts_user_following, - $charts_user_notes, - $charts_user_pv, - $charts_user_reactions, - $charts_users, - $clips_addNote, - $clips_removeNote, - $clips_create, - $clips_delete, - $clips_list, - $clips_notes, - $clips_show, - $clips_update, - $clips_favorite, - $clips_unfavorite, - $clips_myFavorites, - $drive, - $drive_files, - $drive_files_attachedNotes, - $drive_files_checkExistence, - $drive_files_create, - $drive_files_delete, - $drive_files_findByHash, - $drive_files_find, - $drive_files_show, - $drive_files_update, - $drive_files_uploadFromUrl, - $drive_folders, - $drive_folders_create, - $drive_folders_delete, - $drive_folders_find, - $drive_folders_show, - $drive_folders_update, - $drive_stream, - $emailAddress_available, - $endpoint, - $endpoints, - $exportCustomEmojis, - $federation_followers, - $federation_following, - $federation_instances, - $federation_showInstance, - $federation_updateRemoteUser, - $federation_users, - $federation_stats, - $following_create, - $following_delete, - $following_update, - $following_update_all, - $following_invalidate, - $following_requests_accept, - $following_requests_cancel, - $following_requests_list, - $following_requests_reject, - $gallery_featured, - $gallery_popular, - $gallery_posts, - $gallery_posts_create, - $gallery_posts_delete, - $gallery_posts_like, - $gallery_posts_show, - $gallery_posts_unlike, - $gallery_posts_update, - $getOnlineUsersCount, - $getAvatarDecorations, - $hashtags_list, - $hashtags_search, - $hashtags_show, - $hashtags_trend, - $hashtags_users, - $i, - $i_2fa_done, - $i_2fa_keyDone, - $i_2fa_passwordLess, - $i_2fa_registerKey, - $i_2fa_register, - $i_2fa_updateKey, - $i_2fa_removeKey, - $i_2fa_unregister, - $i_apps, - $i_authorizedApps, - $i_claimAchievement, - $i_changePassword, - $i_deleteAccount, - $i_exportBlocking, - $i_exportFollowing, - $i_exportMute, - $i_exportNotes, - $i_exportClips, - $i_exportFavorites, - $i_exportUserLists, - $i_exportAntennas, - $i_favorites, - $i_gallery_likes, - $i_gallery_posts, - $i_importBlocking, - $i_importFollowing, - $i_importMuting, - $i_importUserLists, - $i_importAntennas, - $i_notifications, - $i_notificationsGrouped, - $i_pageLikes, - $i_pages, - $i_pin, - $i_readAllUnreadNotes, - $i_readAnnouncement, - $i_regenerateToken, - $i_registry_getAll, - $i_registry_getDetail, - $i_registry_get, - $i_registry_keysWithType, - $i_registry_keys, - $i_registry_remove, - $i_registry_scopesWithDomain, - $i_registry_set, - $i_revokeToken, - $i_signinHistory, - $i_unpin, - $i_updateEmail, - $i_update, - $i_move, - $i_webhooks_create, - $i_webhooks_list, - $i_webhooks_show, - $i_webhooks_update, - $i_webhooks_delete, - $i_webhooks_test, - $invite_create, - $invite_delete, - $invite_list, - $invite_limit, - $meta, - $emojis, - $emoji, - $miauth_genToken, - $mute_create, - $mute_delete, - $mute_list, - $renoteMute_create, - $renoteMute_delete, - $renoteMute_list, - $my_apps, - $notes, - $notes_children, - $notes_clips, - $notes_conversation, - $notes_create, - $notes_delete, - $notes_favorites_create, - $notes_favorites_delete, - $notes_featured, - $notes_globalTimeline, - $notes_hybridTimeline, - $notes_localTimeline, - $notes_mentions, - $notes_polls_recommendation, - $notes_polls_vote, - $notes_reactions, - $notes_reactions_create, - $notes_reactions_delete, - $notes_renotes, - $notes_replies, - $notes_searchByTag, - $notes_search, - $notes_show, - $notes_state, - $notes_threadMuting_create, - $notes_threadMuting_delete, - $notes_timeline, - $notes_translate, - $notes_unrenote, - $notes_userListTimeline, - $notifications_create, - $notifications_flush, - $notifications_markAllAsRead, - $notifications_testNotification, - $pagePush, - $pages_create, - $pages_delete, - $pages_featured, - $pages_like, - $pages_show, - $pages_unlike, - $pages_update, - $flash_create, - $flash_delete, - $flash_featured, - $flash_like, - $flash_show, - $flash_unlike, - $flash_update, - $flash_my, - $flash_myLikes, - $ping, - $pinnedUsers, - $promo_read, - $roles_list, - $roles_show, - $roles_users, - $roles_notes, - $requestResetPassword, - $resetDb, - $resetPassword, - $serverInfo, - $stats, - $sw_register, - $sw_unregister, - $test, - $username_available, - $users, - $users_clips, - $users_followers, - $users_following, - $users_gallery_posts, - $users_getFrequentlyRepliedUsers, - $users_featuredNotes, - $users_lists_create, - $users_lists_delete, - $users_lists_list, - $users_lists_pull, - $users_lists_push, - $users_lists_show, - $users_lists_update, - $users_lists_favorite, - $users_lists_unfavorite, - $users_lists_createFromPublic, - $users_lists_updateMembership, - $users_lists_getMemberships, - $users_notes, - $users_pages, - $users_flashs, - $users_reactions, - $users_recommendation, - $users_relation, - $users_reportAbuse, - $users_searchByUsernameAndHost, - $users_search, - $users_show, - $users_achievements, - $users_updateMemo, - $fetchRss, - $fetchExternalResources, - $retention, - $bubbleGame_register, - $bubbleGame_ranking, - $reversi_cancelMatch, - $reversi_games, - $reversi_match, - $reversi_invitations, - $reversi_showGame, - $reversi_surrender, - $reversi_verify, + ...endpointProviders, ], }) export class EndpointsModule {} diff --git a/packages/backend/src/server/api/GetterService.ts b/packages/backend/src/server/api/GetterService.ts index bff3ab96f3..444e6db744 100644 --- a/packages/backend/src/server/api/GetterService.ts +++ b/packages/backend/src/server/api/GetterService.ts @@ -39,6 +39,17 @@ export class GetterService { return note; } + @bindThis + public async getNoteWithUser(noteId: MiNote['id']) { + const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user'] }); + + if (note == null) { + throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.'); + } + + return note; + } + /** * Get user for API processing */ diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts index edac9b3beb..1d983ca4bc 100644 --- a/packages/backend/src/server/api/SigninApiService.ts +++ b/packages/backend/src/server/api/SigninApiService.ts @@ -5,12 +5,14 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; -import * as OTPAuth from 'otpauth'; import { IsNull } from 'typeorm'; +import * as Misskey from 'misskey-js'; import { DI } from '@/di-symbols.js'; import type { + MiMeta, SigninsRepository, UserProfilesRepository, + UserSecurityKeysRepository, UsersRepository, } from '@/models/_.js'; import type { Config } from '@/config.js'; @@ -20,6 +22,8 @@ import { IdService } from '@/core/IdService.js'; import { bindThis } from '@/decorators.js'; import { WebAuthnService } from '@/core/WebAuthnService.js'; import { UserAuthService } from '@/core/UserAuthService.js'; +import { CaptchaService } from '@/core/CaptchaService.js'; +import { FastifyReplyError } from '@/misc/fastify-reply-error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { SigninService } from './SigninService.js'; import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; @@ -31,12 +35,18 @@ export class SigninApiService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, + @Inject(DI.userSecurityKeysRepository) + private userSecurityKeysRepository: UserSecurityKeysRepository, + @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, @@ -45,6 +55,7 @@ export class SigninApiService { private signinService: SigninService, private userAuthService: UserAuthService, private webAuthnService: WebAuthnService, + private captchaService: CaptchaService, ) { } @@ -53,9 +64,14 @@ export class SigninApiService { request: FastifyRequest<{ Body: { username: string; - password: string; + password?: string; token?: string; credential?: AuthenticationResponseJSON; + 'hcaptcha-response'?: string; + 'g-recaptcha-response'?: string; + 'turnstile-response'?: string; + 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; }; }>, reply: FastifyReply, @@ -92,11 +108,6 @@ export class SigninApiService { return; } - if (typeof password !== 'string') { - reply.code(400); - return; - } - if (token != null && typeof token !== 'string') { reply.code(400); return; @@ -121,11 +132,32 @@ export class SigninApiService { } const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + const securityKeysAvailable = await this.userSecurityKeysRepository.countBy({ userId: user.id }).then(result => result >= 1); + + if (password == null) { + reply.code(200); + if (profile.twoFactorEnabled) { + return { + finished: false, + next: 'password', + } satisfies Misskey.entities.SigninFlowResponse; + } else { + return { + finished: false, + next: 'captcha', + } satisfies Misskey.entities.SigninFlowResponse; + } + } + + if (typeof password !== 'string') { + reply.code(400); + return; + } // Compare password const same = await bcrypt.compare(password, profile.password!); - const fail = async (status?: number, failure?: { id: string }) => { + const fail = async (status?: number, failure?: { id: string; }) => { // Append signin history await this.signinsRepository.insert({ id: this.idService.gen(), @@ -139,6 +171,38 @@ export class SigninApiService { }; if (!profile.twoFactorEnabled) { + if (process.env.NODE_ENV !== 'test') { + if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { + await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { + await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { + await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { + await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + } + if (same) { return this.signinService.signin(request, reply, user); } else { @@ -180,7 +244,7 @@ export class SigninApiService { id: '93b86c4b-72f9-40eb-9815-798928603d1e', }); } - } else { + } else if (securityKeysAvailable) { if (!same && !profile.usePasswordLessLogin) { return await fail(403, { id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', @@ -190,7 +254,23 @@ export class SigninApiService { const authRequest = await this.webAuthnService.initiateAuthentication(user.id); reply.code(200); - return authRequest; + return { + finished: false, + next: 'passkey', + authRequest, + } satisfies Misskey.entities.SigninFlowResponse; + } else { + if (!same || !profile.twoFactorEnabled) { + return await fail(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + } else { + reply.code(200); + return { + finished: false, + next: 'totp', + } satisfies Misskey.entities.SigninFlowResponse; + } } // never get here } diff --git a/packages/backend/src/server/api/SigninService.ts b/packages/backend/src/server/api/SigninService.ts index 70306c3113..640356b50c 100644 --- a/packages/backend/src/server/api/SigninService.ts +++ b/packages/backend/src/server/api/SigninService.ts @@ -4,13 +4,16 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import * as Misskey from 'misskey-js'; import { DI } from '@/di-symbols.js'; -import type { SigninsRepository } from '@/models/_.js'; +import type { SigninsRepository, UserProfilesRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import type { MiLocalUser } from '@/models/User.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { SigninEntityService } from '@/core/entities/SigninEntityService.js'; import { bindThis } from '@/decorators.js'; +import { EmailService } from '@/core/EmailService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; @Injectable() @@ -19,7 +22,12 @@ export class SigninService { @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + private signinEntityService: SigninEntityService, + private emailService: EmailService, + private notificationService: NotificationService, private idService: IdService, private globalEventService: GlobalEventService, ) { @@ -28,7 +36,8 @@ export class SigninService { @bindThis public signin(request: FastifyRequest, reply: FastifyReply, user: MiLocalUser) { setImmediate(async () => { - // Append signin history + this.notificationService.createNotification(user.id, 'login', {}); + const record = await this.signinsRepository.insertOne({ id: this.idService.gen(), userId: user.id, @@ -37,15 +46,22 @@ export class SigninService { success: true, }); - // Publish signin event this.globalEventService.publishMainStream(user.id, 'signin', await this.signinEntityService.pack(record)); + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + if (profile.email && profile.emailVerified) { + this.emailService.sendEmail(profile.email, 'New login / ログインがありました', + 'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。', + 'There is a new login. If you do not recognize this login, update the security status of your account, including changing your password. / 新しいログインがありました。このログインに心当たりがない場合は、パスワードを変更するなど、アカウントのセキュリティ状態を更新してください。'); + } }); reply.code(200); return { + finished: true, id: user.id, - i: user.token, - }; + i: user.token!, + } satisfies Misskey.entities.SigninFlowResponse; } } diff --git a/packages/backend/src/server/api/SigninWithPasskeyApiService.ts b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts new file mode 100644 index 0000000000..9ba23c54e2 --- /dev/null +++ b/packages/backend/src/server/api/SigninWithPasskeyApiService.ts @@ -0,0 +1,173 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { randomUUID } from 'crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { IsNull } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { + SigninsRepository, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import type { Config } from '@/config.js'; +import { getIpHash } from '@/misc/get-ip-hash.js'; +import type { MiLocalUser, MiUser } from '@/models/User.js'; +import { IdService } from '@/core/IdService.js'; +import { bindThis } from '@/decorators.js'; +import { WebAuthnService } from '@/core/WebAuthnService.js'; +import Logger from '@/logger.js'; +import { LoggerService } from '@/core/LoggerService.js'; +import type { IdentifiableError } from '@/misc/identifiable-error.js'; +import { RateLimiterService } from './RateLimiterService.js'; +import { SigninService } from './SigninService.js'; +import type { AuthenticationResponseJSON } from '@simplewebauthn/types'; +import type { FastifyReply, FastifyRequest } from 'fastify'; + +@Injectable() +export class SigninWithPasskeyApiService { + private logger: Logger; + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userProfilesRepository) + private userProfilesRepository: UserProfilesRepository, + + @Inject(DI.signinsRepository) + private signinsRepository: SigninsRepository, + + private idService: IdService, + private rateLimiterService: RateLimiterService, + private signinService: SigninService, + private webAuthnService: WebAuthnService, + private loggerService: LoggerService, + ) { + this.logger = this.loggerService.getLogger('PasskeyAuth'); + } + + @bindThis + public async signin( + request: FastifyRequest<{ + Body: { + credential?: AuthenticationResponseJSON; + context?: string; + }; + }>, + reply: FastifyReply, + ) { + reply.header('Access-Control-Allow-Origin', this.config.url); + reply.header('Access-Control-Allow-Credentials', 'true'); + + const body = request.body; + const credential = body['credential']; + + function error(status: number, error: { id: string }) { + reply.code(status); + return { error }; + } + + const fail = async (userId: MiUser['id'], status?: number, failure?: { id: string }) => { + // Append signin history + await this.signinsRepository.insert({ + id: this.idService.gen(), + userId: userId, + ip: request.ip, + headers: request.headers as any, + success: false, + }); + return error(status ?? 500, failure ?? { id: '4e30e80c-e338-45a0-8c8f-44455efa3b76' }); + }; + + 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', + }, + }; + } + + // Initiate Passkey Auth challenge with context + if (!credential) { + const context = randomUUID(); + this.logger.info(`Initiate Passkey challenge: context: ${context}`); + const authChallengeOptions = { + option: await this.webAuthnService.initiateSignInWithPasskeyAuthentication(context), + context: context, + }; + reply.code(200); + return authChallengeOptions; + } + + const context = body.context; + if (!context || typeof context !== 'string') { + // If try Authentication without context + return error(400, { + id: '1658cc2e-4495-461f-aee4-d403cdf073c1', + }); + } + + this.logger.debug(`Try Sign-in with Passkey: context: ${context}`); + + let authorizedUserId: MiUser['id'] | null; + try { + authorizedUserId = await this.webAuthnService.verifySignInWithPasskeyAuthentication(context, credential); + } catch (err) { + this.logger.warn(`Passkey challenge Verify error! : ${err}`); + const errorId = (err as IdentifiableError).id; + return error(403, { + id: errorId, + }); + } + + if (!authorizedUserId) { + return error(403, { + id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c', + }); + } + + // Fetch user + const user = await this.usersRepository.findOneBy({ + id: authorizedUserId, + host: IsNull(), + }) as MiLocalUser | null; + + if (user == null) { + return error(403, { + id: '652f899f-66d4-490e-993e-6606c8ec04c3', + }); + } + + if (user.isSuspended) { + return error(403, { + id: 'e03a5f46-d309-4865-9b69-56282d94e1eb', + }); + } + + const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); + + // Authentication was successful, but passwordless login is not enabled + if (!profile.usePasswordLessLogin) { + return await fail(user.id, 403, { + id: '2d84773e-f7b7-4d0b-8f72-bb69b584c912', + }); + } + + const signinResponse = this.signinService.signin(request, reply, user); + return { + signinResponse: signinResponse, + }; + } +} diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts index 632b0c62bc..3ec5e5d3e6 100644 --- a/packages/backend/src/server/api/SignupApiService.ts +++ b/packages/backend/src/server/api/SignupApiService.ts @@ -7,9 +7,8 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { IsNull } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket } from '@/models/_.js'; +import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta } from '@/models/_.js'; import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; import { CaptchaService } from '@/core/CaptchaService.js'; import { IdService } from '@/core/IdService.js'; import { SignupService } from '@/core/SignupService.js'; @@ -28,6 +27,9 @@ export class SignupApiService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -45,7 +47,6 @@ export class SignupApiService { private userEntityService: UserEntityService, private idService: IdService, - private metaService: MetaService, private captchaService: CaptchaService, private signupService: SignupService, private signinService: SigninService, @@ -66,37 +67,42 @@ export class SignupApiService { 'g-recaptcha-response'?: string; 'turnstile-response'?: string; 'm-captcha-response'?: string; + 'testcaptcha-response'?: string; } }>, reply: FastifyReply, ) { const body = request.body; - const instance = await this.metaService.fetch(true); - // Verify *Captcha // ただしテスト時はこの機構は障害となるため無効にする if (process.env.NODE_ENV !== 'test') { - if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { - await this.captchaService.verifyHcaptcha(instance.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { + if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) { + await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => { throw new FastifyReplyError(400, err); }); } - if (instance.enableMcaptcha && instance.mcaptchaSecretKey && instance.mcaptchaSitekey && instance.mcaptchaInstanceUrl) { - await this.captchaService.verifyMcaptcha(instance.mcaptchaSecretKey, instance.mcaptchaSitekey, instance.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { + if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) { + await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => { throw new FastifyReplyError(400, err); }); } - if (instance.enableRecaptcha && instance.recaptchaSecretKey) { - await this.captchaService.verifyRecaptcha(instance.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { + if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) { + await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => { throw new FastifyReplyError(400, err); }); } - if (instance.enableTurnstile && instance.turnstileSecretKey) { - await this.captchaService.verifyTurnstile(instance.turnstileSecretKey, body['turnstile-response']).catch(err => { + if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) { + await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => { + throw new FastifyReplyError(400, err); + }); + } + + if (this.meta.enableTestcaptcha) { + await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => { throw new FastifyReplyError(400, err); }); } @@ -108,7 +114,7 @@ export class SignupApiService { const invitationCode = body['invitationCode']; const emailAddress = body['emailAddress']; - if (instance.emailRequiredForSignup) { + if (this.meta.emailRequiredForSignup) { if (emailAddress == null || typeof emailAddress !== 'string') { reply.code(400); return; @@ -123,7 +129,7 @@ export class SignupApiService { let ticket: MiRegistrationTicket | null = null; - if (instance.disableRegistration) { + if (this.meta.disableRegistration) { if (invitationCode == null || typeof invitationCode !== 'string') { reply.code(400); return; @@ -144,7 +150,7 @@ export class SignupApiService { } // メアド認証が有効の場合 - if (instance.emailRequiredForSignup) { + if (this.meta.emailRequiredForSignup) { // メアド認証済みならエラー if (ticket.usedBy) { reply.code(400); @@ -162,7 +168,7 @@ export class SignupApiService { } } - if (instance.emailRequiredForSignup) { + if (this.meta.emailRequiredForSignup) { if (await this.usersRepository.exists({ where: { usernameLower: username.toLowerCase(), host: IsNull() } })) { throw new FastifyReplyError(400, 'DUPLICATED_USERNAME'); } @@ -172,7 +178,7 @@ export class SignupApiService { throw new FastifyReplyError(400, 'USED_USERNAME'); } - const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); + const isPreserved = this.meta.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); if (isPreserved) { throw new FastifyReplyError(400, 'DENIED_USERNAME'); } diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index b8f448477b..2a4e1fc574 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -9,7 +9,6 @@ import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, MiAccessToken } from '@/models/_.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; @@ -35,7 +34,6 @@ export class StreamingApiServerService { private usersRepository: UsersRepository, private cacheService: CacheService, - private noteReadService: NoteReadService, private authenticateService: AuthenticateService, private channelsService: ChannelsService, private notificationService: NotificationService, @@ -96,7 +94,6 @@ export class StreamingApiServerService { const stream = new MainStreamConnection( this.channelsService, - this.noteReadService, this.notificationService, this.cacheService, this.channelFollowingService, diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts new file mode 100644 index 0000000000..34aaef3cc7 --- /dev/null +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -0,0 +1,423 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* + * This file contains list of all endpoints exported as pathname of API endpoint + * + * When you add new endpoint, you should add it to this file. + * This file is used to generate API documentation and EndpointsModule. + */ + +export * as 'admin/abuse-report/notification-recipient/create' from './endpoints/admin/abuse-report/notification-recipient/create.js'; +export * as 'admin/abuse-report/notification-recipient/delete' from './endpoints/admin/abuse-report/notification-recipient/delete.js'; +export * as 'admin/abuse-report/notification-recipient/list' from './endpoints/admin/abuse-report/notification-recipient/list.js'; +export * as 'admin/abuse-report/notification-recipient/show' from './endpoints/admin/abuse-report/notification-recipient/show.js'; +export * as 'admin/abuse-report/notification-recipient/update' from './endpoints/admin/abuse-report/notification-recipient/update.js'; +export * as 'admin/abuse-user-reports' from './endpoints/admin/abuse-user-reports.js'; +export * as 'admin/accounts/create' from './endpoints/admin/accounts/create.js'; +export * as 'admin/accounts/delete' from './endpoints/admin/accounts/delete.js'; +export * as 'admin/accounts/find-by-email' from './endpoints/admin/accounts/find-by-email.js'; +export * as 'admin/ad/create' from './endpoints/admin/ad/create.js'; +export * as 'admin/ad/delete' from './endpoints/admin/ad/delete.js'; +export * as 'admin/ad/list' from './endpoints/admin/ad/list.js'; +export * as 'admin/ad/update' from './endpoints/admin/ad/update.js'; +export * as 'admin/announcements/create' from './endpoints/admin/announcements/create.js'; +export * as 'admin/announcements/delete' from './endpoints/admin/announcements/delete.js'; +export * as 'admin/announcements/list' from './endpoints/admin/announcements/list.js'; +export * as 'admin/announcements/update' from './endpoints/admin/announcements/update.js'; +export * as 'admin/avatar-decorations/create' from './endpoints/admin/avatar-decorations/create.js'; +export * as 'admin/avatar-decorations/delete' from './endpoints/admin/avatar-decorations/delete.js'; +export * as 'admin/avatar-decorations/list' from './endpoints/admin/avatar-decorations/list.js'; +export * as 'admin/avatar-decorations/update' from './endpoints/admin/avatar-decorations/update.js'; +export * as 'admin/captcha/current' from './endpoints/admin/captcha/current.js'; +export * as 'admin/captcha/save' from './endpoints/admin/captcha/save.js'; +export * as 'admin/delete-account' from './endpoints/admin/delete-account.js'; +export * as 'admin/delete-all-files-of-a-user' from './endpoints/admin/delete-all-files-of-a-user.js'; +export * as 'admin/drive/clean-remote-files' from './endpoints/admin/drive/clean-remote-files.js'; +export * as 'admin/drive/cleanup' from './endpoints/admin/drive/cleanup.js'; +export * as 'admin/drive/files' from './endpoints/admin/drive/files.js'; +export * as 'admin/drive/show-file' from './endpoints/admin/drive/show-file.js'; +export * as 'admin/emoji/add' from './endpoints/admin/emoji/add.js'; +export * as 'admin/emoji/add-aliases-bulk' from './endpoints/admin/emoji/add-aliases-bulk.js'; +export * as 'admin/emoji/copy' from './endpoints/admin/emoji/copy.js'; +export * as 'admin/emoji/delete' from './endpoints/admin/emoji/delete.js'; +export * as 'admin/emoji/delete-bulk' from './endpoints/admin/emoji/delete-bulk.js'; +export * as 'admin/emoji/import-zip' from './endpoints/admin/emoji/import-zip.js'; +export * as 'admin/emoji/list' from './endpoints/admin/emoji/list.js'; +export * as 'admin/emoji/list-remote' from './endpoints/admin/emoji/list-remote.js'; +export * as 'admin/emoji/remove-aliases-bulk' from './endpoints/admin/emoji/remove-aliases-bulk.js'; +export * as 'admin/emoji/set-aliases-bulk' from './endpoints/admin/emoji/set-aliases-bulk.js'; +export * as 'admin/emoji/set-category-bulk' from './endpoints/admin/emoji/set-category-bulk.js'; +export * as 'admin/emoji/set-license-bulk' from './endpoints/admin/emoji/set-license-bulk.js'; +export * as 'admin/emoji/update' from './endpoints/admin/emoji/update.js'; +export * as 'admin/federation/delete-all-files' from './endpoints/admin/federation/delete-all-files.js'; +export * as 'admin/federation/refresh-remote-instance-metadata' from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; +export * as 'admin/federation/remove-all-following' from './endpoints/admin/federation/remove-all-following.js'; +export * as 'admin/federation/update-instance' from './endpoints/admin/federation/update-instance.js'; +export * as 'admin/forward-abuse-user-report' from './endpoints/admin/forward-abuse-user-report.js'; +export * as 'admin/get-index-stats' from './endpoints/admin/get-index-stats.js'; +export * as 'admin/get-table-stats' from './endpoints/admin/get-table-stats.js'; +export * as 'admin/get-user-ips' from './endpoints/admin/get-user-ips.js'; +export * as 'admin/invite/create' from './endpoints/admin/invite/create.js'; +export * as 'admin/invite/list' from './endpoints/admin/invite/list.js'; +export * as 'admin/meta' from './endpoints/admin/meta.js'; +export * as 'admin/promo/create' from './endpoints/admin/promo/create.js'; +export * as 'admin/queue/clear' from './endpoints/admin/queue/clear.js'; +export * as 'admin/queue/deliver-delayed' from './endpoints/admin/queue/deliver-delayed.js'; +export * as 'admin/queue/inbox-delayed' from './endpoints/admin/queue/inbox-delayed.js'; +export * as 'admin/queue/promote' from './endpoints/admin/queue/promote.js'; +export * as 'admin/queue/stats' from './endpoints/admin/queue/stats.js'; +export * as 'admin/relays/add' from './endpoints/admin/relays/add.js'; +export * as 'admin/relays/list' from './endpoints/admin/relays/list.js'; +export * as 'admin/relays/remove' from './endpoints/admin/relays/remove.js'; +export * as 'admin/reset-password' from './endpoints/admin/reset-password.js'; +export * as 'admin/resolve-abuse-user-report' from './endpoints/admin/resolve-abuse-user-report.js'; +export * as 'admin/roles/assign' from './endpoints/admin/roles/assign.js'; +export * as 'admin/roles/create' from './endpoints/admin/roles/create.js'; +export * as 'admin/roles/delete' from './endpoints/admin/roles/delete.js'; +export * as 'admin/roles/list' from './endpoints/admin/roles/list.js'; +export * as 'admin/roles/show' from './endpoints/admin/roles/show.js'; +export * as 'admin/roles/unassign' from './endpoints/admin/roles/unassign.js'; +export * as 'admin/roles/update' from './endpoints/admin/roles/update.js'; +export * as 'admin/roles/update-default-policies' from './endpoints/admin/roles/update-default-policies.js'; +export * as 'admin/roles/users' from './endpoints/admin/roles/users.js'; +export * as 'admin/send-email' from './endpoints/admin/send-email.js'; +export * as 'admin/server-info' from './endpoints/admin/server-info.js'; +export * as 'admin/show-moderation-logs' from './endpoints/admin/show-moderation-logs.js'; +export * as 'admin/show-user' from './endpoints/admin/show-user.js'; +export * as 'admin/show-users' from './endpoints/admin/show-users.js'; +export * as 'admin/suspend-user' from './endpoints/admin/suspend-user.js'; +export * as 'admin/system-webhook/create' from './endpoints/admin/system-webhook/create.js'; +export * as 'admin/system-webhook/delete' from './endpoints/admin/system-webhook/delete.js'; +export * as 'admin/system-webhook/list' from './endpoints/admin/system-webhook/list.js'; +export * as 'admin/system-webhook/show' from './endpoints/admin/system-webhook/show.js'; +export * as 'admin/system-webhook/test' from './endpoints/admin/system-webhook/test.js'; +export * as 'admin/system-webhook/update' from './endpoints/admin/system-webhook/update.js'; +export * as 'admin/unset-user-avatar' from './endpoints/admin/unset-user-avatar.js'; +export * as 'admin/unset-user-banner' from './endpoints/admin/unset-user-banner.js'; +export * as 'admin/unsuspend-user' from './endpoints/admin/unsuspend-user.js'; +export * as 'admin/update-abuse-user-report' from './endpoints/admin/update-abuse-user-report.js'; +export * as 'admin/update-meta' from './endpoints/admin/update-meta.js'; +export * as 'admin/update-proxy-account' from './endpoints/admin/update-proxy-account.js'; +export * as 'admin/update-user-note' from './endpoints/admin/update-user-note.js'; +export * as 'announcements' from './endpoints/announcements.js'; +export * as 'announcements/show' from './endpoints/announcements/show.js'; +export * as 'antennas/create' from './endpoints/antennas/create.js'; +export * as 'antennas/delete' from './endpoints/antennas/delete.js'; +export * as 'antennas/list' from './endpoints/antennas/list.js'; +export * as 'antennas/notes' from './endpoints/antennas/notes.js'; +export * as 'antennas/show' from './endpoints/antennas/show.js'; +export * as 'antennas/update' from './endpoints/antennas/update.js'; +export * as 'ap/get' from './endpoints/ap/get.js'; +export * as 'ap/show' from './endpoints/ap/show.js'; +export * as 'app/create' from './endpoints/app/create.js'; +export * as 'app/show' from './endpoints/app/show.js'; +export * as 'auth/accept' from './endpoints/auth/accept.js'; +export * as 'auth/session/generate' from './endpoints/auth/session/generate.js'; +export * as 'auth/session/show' from './endpoints/auth/session/show.js'; +export * as 'auth/session/userkey' from './endpoints/auth/session/userkey.js'; +export * as 'blocking/create' from './endpoints/blocking/create.js'; +export * as 'blocking/delete' from './endpoints/blocking/delete.js'; +export * as 'blocking/list' from './endpoints/blocking/list.js'; +export * as 'bubble-game/ranking' from './endpoints/bubble-game/ranking.js'; +export * as 'bubble-game/register' from './endpoints/bubble-game/register.js'; +export * as 'channels/create' from './endpoints/channels/create.js'; +export * as 'channels/favorite' from './endpoints/channels/favorite.js'; +export * as 'channels/featured' from './endpoints/channels/featured.js'; +export * as 'channels/follow' from './endpoints/channels/follow.js'; +export * as 'channels/followed' from './endpoints/channels/followed.js'; +export * as 'channels/my-favorites' from './endpoints/channels/my-favorites.js'; +export * as 'channels/owned' from './endpoints/channels/owned.js'; +export * as 'channels/search' from './endpoints/channels/search.js'; +export * as 'channels/show' from './endpoints/channels/show.js'; +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 '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'; +export * as 'charts/federation' from './endpoints/charts/federation.js'; +export * as 'charts/instance' from './endpoints/charts/instance.js'; +export * as 'charts/notes' from './endpoints/charts/notes.js'; +export * as 'charts/user/drive' from './endpoints/charts/user/drive.js'; +export * as 'charts/user/following' from './endpoints/charts/user/following.js'; +export * as 'charts/user/notes' from './endpoints/charts/user/notes.js'; +export * as 'charts/user/pv' from './endpoints/charts/user/pv.js'; +export * as 'charts/user/reactions' from './endpoints/charts/user/reactions.js'; +export * as 'charts/users' from './endpoints/charts/users.js'; +export * as 'clips/add-note' from './endpoints/clips/add-note.js'; +export * as 'clips/create' from './endpoints/clips/create.js'; +export * as 'clips/delete' from './endpoints/clips/delete.js'; +export * as 'clips/favorite' from './endpoints/clips/favorite.js'; +export * as 'clips/list' from './endpoints/clips/list.js'; +export * as 'clips/my-favorites' from './endpoints/clips/my-favorites.js'; +export * as 'clips/notes' from './endpoints/clips/notes.js'; +export * as 'clips/remove-note' from './endpoints/clips/remove-note.js'; +export * as 'clips/show' from './endpoints/clips/show.js'; +export * as 'clips/unfavorite' from './endpoints/clips/unfavorite.js'; +export * as 'clips/update' from './endpoints/clips/update.js'; +export * as 'drive' from './endpoints/drive.js'; +export * as 'drive/files' from './endpoints/drive/files.js'; +export * as 'drive/files/attached-notes' from './endpoints/drive/files/attached-notes.js'; +export * as 'drive/files/check-existence' from './endpoints/drive/files/check-existence.js'; +export * as 'drive/files/create' from './endpoints/drive/files/create.js'; +export * as 'drive/files/delete' from './endpoints/drive/files/delete.js'; +export * as 'drive/files/find' from './endpoints/drive/files/find.js'; +export * as 'drive/files/find-by-hash' from './endpoints/drive/files/find-by-hash.js'; +export * as 'drive/files/show' from './endpoints/drive/files/show.js'; +export * as 'drive/files/update' from './endpoints/drive/files/update.js'; +export * as 'drive/files/upload-from-url' from './endpoints/drive/files/upload-from-url.js'; +export * as 'drive/folders' from './endpoints/drive/folders.js'; +export * as 'drive/folders/create' from './endpoints/drive/folders/create.js'; +export * as 'drive/folders/delete' from './endpoints/drive/folders/delete.js'; +export * as 'drive/folders/find' from './endpoints/drive/folders/find.js'; +export * as 'drive/folders/show' from './endpoints/drive/folders/show.js'; +export * as 'drive/folders/update' from './endpoints/drive/folders/update.js'; +export * as 'drive/stream' from './endpoints/drive/stream.js'; +export * as 'email-address/available' from './endpoints/email-address/available.js'; +export * as 'emoji' from './endpoints/emoji.js'; +export * as 'emojis' from './endpoints/emojis.js'; +export * as 'endpoint' from './endpoints/endpoint.js'; +export * as 'endpoints' from './endpoints/endpoints.js'; +export * as 'export-custom-emojis' from './endpoints/export-custom-emojis.js'; +export * as 'federation/followers' from './endpoints/federation/followers.js'; +export * as 'federation/following' from './endpoints/federation/following.js'; +export * as 'federation/instances' from './endpoints/federation/instances.js'; +export * as 'federation/show-instance' from './endpoints/federation/show-instance.js'; +export * as 'federation/stats' from './endpoints/federation/stats.js'; +export * as 'federation/update-remote-user' from './endpoints/federation/update-remote-user.js'; +export * as 'federation/users' from './endpoints/federation/users.js'; +export * as 'fetch-external-resources' from './endpoints/fetch-external-resources.js'; +export * as 'fetch-rss' from './endpoints/fetch-rss.js'; +export * as 'flash/create' from './endpoints/flash/create.js'; +export * as 'flash/delete' from './endpoints/flash/delete.js'; +export * as 'flash/featured' from './endpoints/flash/featured.js'; +export * as 'flash/like' from './endpoints/flash/like.js'; +export * as 'flash/my' from './endpoints/flash/my.js'; +export * as 'flash/my-likes' from './endpoints/flash/my-likes.js'; +export * as 'flash/show' from './endpoints/flash/show.js'; +export * as 'flash/unlike' from './endpoints/flash/unlike.js'; +export * as 'flash/update' from './endpoints/flash/update.js'; +export * as 'following/create' from './endpoints/following/create.js'; +export * as 'following/delete' from './endpoints/following/delete.js'; +export * as 'following/invalidate' from './endpoints/following/invalidate.js'; +export * as 'following/requests/accept' from './endpoints/following/requests/accept.js'; +export * as 'following/requests/cancel' from './endpoints/following/requests/cancel.js'; +export * as 'following/requests/list' from './endpoints/following/requests/list.js'; +export * as 'following/requests/reject' from './endpoints/following/requests/reject.js'; +export * as 'following/requests/sent' from './endpoints/following/requests/sent.js'; +export * as 'following/update' from './endpoints/following/update.js'; +export * as 'following/update-all' from './endpoints/following/update-all.js'; +export * as 'gallery/featured' from './endpoints/gallery/featured.js'; +export * as 'gallery/popular' from './endpoints/gallery/popular.js'; +export * as 'gallery/posts' from './endpoints/gallery/posts.js'; +export * as 'gallery/posts/create' from './endpoints/gallery/posts/create.js'; +export * as 'gallery/posts/delete' from './endpoints/gallery/posts/delete.js'; +export * as 'gallery/posts/like' from './endpoints/gallery/posts/like.js'; +export * as 'gallery/posts/show' from './endpoints/gallery/posts/show.js'; +export * as 'gallery/posts/unlike' from './endpoints/gallery/posts/unlike.js'; +export * as 'gallery/posts/update' from './endpoints/gallery/posts/update.js'; +export * as 'get-avatar-decorations' from './endpoints/get-avatar-decorations.js'; +export * as 'get-online-users-count' from './endpoints/get-online-users-count.js'; +export * as 'hashtags/list' from './endpoints/hashtags/list.js'; +export * as 'hashtags/search' from './endpoints/hashtags/search.js'; +export * as 'hashtags/show' from './endpoints/hashtags/show.js'; +export * as 'hashtags/trend' from './endpoints/hashtags/trend.js'; +export * as 'hashtags/users' from './endpoints/hashtags/users.js'; +export * as 'i' from './endpoints/i.js'; +export * as 'i/2fa/done' from './endpoints/i/2fa/done.js'; +export * as 'i/2fa/key-done' from './endpoints/i/2fa/key-done.js'; +export * as 'i/2fa/password-less' from './endpoints/i/2fa/password-less.js'; +export * as 'i/2fa/register' from './endpoints/i/2fa/register.js'; +export * as 'i/2fa/register-key' from './endpoints/i/2fa/register-key.js'; +export * as 'i/2fa/remove-key' from './endpoints/i/2fa/remove-key.js'; +export * as 'i/2fa/unregister' from './endpoints/i/2fa/unregister.js'; +export * as 'i/2fa/update-key' from './endpoints/i/2fa/update-key.js'; +export * as 'i/apps' from './endpoints/i/apps.js'; +export * as 'i/authorized-apps' from './endpoints/i/authorized-apps.js'; +export * as 'i/change-password' from './endpoints/i/change-password.js'; +export * as 'i/claim-achievement' from './endpoints/i/claim-achievement.js'; +export * as 'i/delete-account' from './endpoints/i/delete-account.js'; +export * as 'i/export-antennas' from './endpoints/i/export-antennas.js'; +export * as 'i/export-blocking' from './endpoints/i/export-blocking.js'; +export * as 'i/export-clips' from './endpoints/i/export-clips.js'; +export * as 'i/export-favorites' from './endpoints/i/export-favorites.js'; +export * as 'i/export-following' from './endpoints/i/export-following.js'; +export * as 'i/export-mute' from './endpoints/i/export-mute.js'; +export * as 'i/export-notes' from './endpoints/i/export-notes.js'; +export * as 'i/export-user-lists' from './endpoints/i/export-user-lists.js'; +export * as 'i/favorites' from './endpoints/i/favorites.js'; +export * as 'i/gallery/likes' from './endpoints/i/gallery/likes.js'; +export * as 'i/gallery/posts' from './endpoints/i/gallery/posts.js'; +export * as 'i/import-antennas' from './endpoints/i/import-antennas.js'; +export * as 'i/import-blocking' from './endpoints/i/import-blocking.js'; +export * as 'i/import-following' from './endpoints/i/import-following.js'; +export * as 'i/import-muting' from './endpoints/i/import-muting.js'; +export * as 'i/import-user-lists' from './endpoints/i/import-user-lists.js'; +export * as 'i/move' from './endpoints/i/move.js'; +export * as 'i/notifications' from './endpoints/i/notifications.js'; +export * as 'i/notifications-grouped' from './endpoints/i/notifications-grouped.js'; +export * as 'i/page-likes' from './endpoints/i/page-likes.js'; +export * as 'i/pages' from './endpoints/i/pages.js'; +export * as 'i/pin' from './endpoints/i/pin.js'; +export * as 'i/read-announcement' from './endpoints/i/read-announcement.js'; +export * as 'i/regenerate-token' from './endpoints/i/regenerate-token.js'; +export * as 'i/registry/get' from './endpoints/i/registry/get.js'; +export * as 'i/registry/get-all' from './endpoints/i/registry/get-all.js'; +export * as 'i/registry/get-detail' from './endpoints/i/registry/get-detail.js'; +export * as 'i/registry/keys' from './endpoints/i/registry/keys.js'; +export * as 'i/registry/keys-with-type' from './endpoints/i/registry/keys-with-type.js'; +export * as 'i/registry/remove' from './endpoints/i/registry/remove.js'; +export * as 'i/registry/scopes-with-domain' from './endpoints/i/registry/scopes-with-domain.js'; +export * as 'i/registry/set' from './endpoints/i/registry/set.js'; +export * as 'i/revoke-token' from './endpoints/i/revoke-token.js'; +export * as 'i/signin-history' from './endpoints/i/signin-history.js'; +export * as 'i/unpin' from './endpoints/i/unpin.js'; +export * as 'i/update' from './endpoints/i/update.js'; +export * as 'i/update-email' from './endpoints/i/update-email.js'; +export * as 'i/webhooks/create' from './endpoints/i/webhooks/create.js'; +export * as 'i/webhooks/delete' from './endpoints/i/webhooks/delete.js'; +export * as 'i/webhooks/list' from './endpoints/i/webhooks/list.js'; +export * as 'i/webhooks/show' from './endpoints/i/webhooks/show.js'; +export * as 'i/webhooks/test' from './endpoints/i/webhooks/test.js'; +export * as 'i/webhooks/update' from './endpoints/i/webhooks/update.js'; +export * as 'invite/create' from './endpoints/invite/create.js'; +export * as 'invite/delete' from './endpoints/invite/delete.js'; +export * as 'invite/limit' from './endpoints/invite/limit.js'; +export * as 'invite/list' from './endpoints/invite/list.js'; +export * as 'meta' from './endpoints/meta.js'; +export * as 'miauth/gen-token' from './endpoints/miauth/gen-token.js'; +export * as 'mute/create' from './endpoints/mute/create.js'; +export * as 'mute/delete' from './endpoints/mute/delete.js'; +export * as 'mute/list' from './endpoints/mute/list.js'; +export * as 'my/apps' from './endpoints/my/apps.js'; +export * as 'notes' from './endpoints/notes.js'; +export * as 'notes/children' from './endpoints/notes/children.js'; +export * as 'notes/clips' from './endpoints/notes/clips.js'; +export * as 'notes/conversation' from './endpoints/notes/conversation.js'; +export * as 'notes/create' from './endpoints/notes/create.js'; +export * as 'notes/delete' from './endpoints/notes/delete.js'; +export * as 'notes/favorites/create' from './endpoints/notes/favorites/create.js'; +export * as 'notes/favorites/delete' from './endpoints/notes/favorites/delete.js'; +export * as 'notes/featured' from './endpoints/notes/featured.js'; +export * as 'notes/global-timeline' from './endpoints/notes/global-timeline.js'; +export * as 'notes/hybrid-timeline' from './endpoints/notes/hybrid-timeline.js'; +export * as 'notes/local-timeline' from './endpoints/notes/local-timeline.js'; +export * as 'notes/mentions' from './endpoints/notes/mentions.js'; +export * as 'notes/polls/recommendation' from './endpoints/notes/polls/recommendation.js'; +export * as 'notes/polls/vote' from './endpoints/notes/polls/vote.js'; +export * as 'notes/reactions' from './endpoints/notes/reactions.js'; +export * as 'notes/reactions/create' from './endpoints/notes/reactions/create.js'; +export * as 'notes/reactions/delete' from './endpoints/notes/reactions/delete.js'; +export * as 'notes/renotes' from './endpoints/notes/renotes.js'; +export * as 'notes/replies' from './endpoints/notes/replies.js'; +export * as 'notes/search' from './endpoints/notes/search.js'; +export * as 'notes/search-by-tag' from './endpoints/notes/search-by-tag.js'; +export * as 'notes/show' from './endpoints/notes/show.js'; +export * as 'notes/state' from './endpoints/notes/state.js'; +export * as 'notes/thread-muting/create' from './endpoints/notes/thread-muting/create.js'; +export * as 'notes/thread-muting/delete' from './endpoints/notes/thread-muting/delete.js'; +export * as 'notes/timeline' from './endpoints/notes/timeline.js'; +export * as 'notes/translate' from './endpoints/notes/translate.js'; +export * as 'notes/unrenote' from './endpoints/notes/unrenote.js'; +export * as 'notes/user-list-timeline' from './endpoints/notes/user-list-timeline.js'; +export * as 'notifications/create' from './endpoints/notifications/create.js'; +export * as 'notifications/flush' from './endpoints/notifications/flush.js'; +export * as 'notifications/mark-all-as-read' from './endpoints/notifications/mark-all-as-read.js'; +export * as 'notifications/test-notification' from './endpoints/notifications/test-notification.js'; +export * as 'page-push' from './endpoints/page-push.js'; +export * as 'pages/create' from './endpoints/pages/create.js'; +export * as 'pages/delete' from './endpoints/pages/delete.js'; +export * as 'pages/featured' from './endpoints/pages/featured.js'; +export * as 'pages/like' from './endpoints/pages/like.js'; +export * as 'pages/show' from './endpoints/pages/show.js'; +export * as 'pages/unlike' from './endpoints/pages/unlike.js'; +export * as 'pages/update' from './endpoints/pages/update.js'; +export * as 'ping' from './endpoints/ping.js'; +export * as 'pinned-users' from './endpoints/pinned-users.js'; +export * as 'promo/read' from './endpoints/promo/read.js'; +export * as 'renote-mute/create' from './endpoints/renote-mute/create.js'; +export * as 'renote-mute/delete' from './endpoints/renote-mute/delete.js'; +export * as 'renote-mute/list' from './endpoints/renote-mute/list.js'; +export * as 'request-reset-password' from './endpoints/request-reset-password.js'; +export * as 'reset-db' from './endpoints/reset-db.js'; +export * as 'reset-password' from './endpoints/reset-password.js'; +export * as 'retention' from './endpoints/retention.js'; +export * as 'reversi/cancel-match' from './endpoints/reversi/cancel-match.js'; +export * as 'reversi/games' from './endpoints/reversi/games.js'; +export * as 'reversi/invitations' from './endpoints/reversi/invitations.js'; +export * as 'reversi/match' from './endpoints/reversi/match.js'; +export * as 'reversi/show-game' from './endpoints/reversi/show-game.js'; +export * as 'reversi/surrender' from './endpoints/reversi/surrender.js'; +export * as 'reversi/verify' from './endpoints/reversi/verify.js'; +export * as 'roles/list' from './endpoints/roles/list.js'; +export * as 'roles/notes' from './endpoints/roles/notes.js'; +export * as 'roles/show' from './endpoints/roles/show.js'; +export * as 'roles/users' from './endpoints/roles/users.js'; +export * as 'server-info' from './endpoints/server-info.js'; +export * as 'stats' from './endpoints/stats.js'; +export * as 'sw/register' from './endpoints/sw/register.js'; +export * as 'sw/show-registration' from './endpoints/sw/show-registration.js'; +export * as 'sw/unregister' from './endpoints/sw/unregister.js'; +export * as 'sw/update-registration' from './endpoints/sw/update-registration.js'; +export * as 'test' from './endpoints/test.js'; +export * as 'username/available' from './endpoints/username/available.js'; +export * as 'users' from './endpoints/users.js'; +export * as 'users/achievements' from './endpoints/users/achievements.js'; +export * as 'users/clips' from './endpoints/users/clips.js'; +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/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'; +export * as 'users/lists/create-from-public' from './endpoints/users/lists/create-from-public.js'; +export * as 'users/lists/delete' from './endpoints/users/lists/delete.js'; +export * as 'users/lists/favorite' from './endpoints/users/lists/favorite.js'; +export * as 'users/lists/get-memberships' from './endpoints/users/lists/get-memberships.js'; +export * as 'users/lists/list' from './endpoints/users/lists/list.js'; +export * as 'users/lists/pull' from './endpoints/users/lists/pull.js'; +export * as 'users/lists/push' from './endpoints/users/lists/push.js'; +export * as 'users/lists/show' from './endpoints/users/lists/show.js'; +export * as 'users/lists/unfavorite' from './endpoints/users/lists/unfavorite.js'; +export * as 'users/lists/update' from './endpoints/users/lists/update.js'; +export * as 'users/lists/update-membership' from './endpoints/users/lists/update-membership.js'; +export * as 'users/notes' from './endpoints/users/notes.js'; +export * as 'users/pages' from './endpoints/users/pages.js'; +export * as 'users/reactions' from './endpoints/users/reactions.js'; +export * as 'users/recommendation' from './endpoints/users/recommendation.js'; +export * as 'users/relation' from './endpoints/users/relation.js'; +export * as 'users/report-abuse' from './endpoints/users/report-abuse.js'; +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 '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'; +export * as 'chat/messages/show' from './endpoints/chat/messages/show.js'; +export * as 'chat/messages/react' from './endpoints/chat/messages/react.js'; +export * as 'chat/messages/unreact' from './endpoints/chat/messages/unreact.js'; +export * as 'chat/messages/user-timeline' from './endpoints/chat/messages/user-timeline.js'; +export * as 'chat/messages/room-timeline' from './endpoints/chat/messages/room-timeline.js'; +export * as 'chat/messages/search' from './endpoints/chat/messages/search.js'; +export * as 'chat/rooms/create' from './endpoints/chat/rooms/create.js'; +export * as 'chat/rooms/delete' from './endpoints/chat/rooms/delete.js'; +export * as 'chat/rooms/join' from './endpoints/chat/rooms/join.js'; +export * as 'chat/rooms/leave' from './endpoints/chat/rooms/leave.js'; +export * as 'chat/rooms/mute' from './endpoints/chat/rooms/mute.js'; +export * as 'chat/rooms/show' from './endpoints/chat/rooms/show.js'; +export * as 'chat/rooms/owned' from './endpoints/chat/rooms/owned.js'; +export * as 'chat/rooms/joining' from './endpoints/chat/rooms/joining.js'; +export * as 'chat/rooms/update' from './endpoints/chat/rooms/update.js'; +export * as 'chat/rooms/members' from './endpoints/chat/rooms/members.js'; +export * as 'chat/rooms/invitations/create' from './endpoints/chat/rooms/invitations/create.js'; +export * as 'chat/rooms/invitations/ignore' from './endpoints/chat/rooms/invitations/ignore.js'; +export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitations/inbox.js'; +export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.js'; +export * as 'chat/history' from './endpoints/chat/history.js'; +export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js'; diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 2462781f7b..03c729ed18 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -6,777 +6,7 @@ import { permissions } from 'misskey-js'; import type { KeyOf, Schema } from '@/misc/json-schema.js'; -import * as ep___admin_abuseReport_notificationRecipient_list - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/list.js'; -import * as ep___admin_abuseReport_notificationRecipient_show - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/show.js'; -import * as ep___admin_abuseReport_notificationRecipient_create - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/create.js'; -import * as ep___admin_abuseReport_notificationRecipient_update - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/update.js'; -import * as ep___admin_abuseReport_notificationRecipient_delete - from '@/server/api/endpoints/admin/abuse-report/notification-recipient/delete.js'; -import * as ep___admin_abuseUserReports from './endpoints/admin/abuse-user-reports.js'; -import * as ep___admin_meta from './endpoints/admin/meta.js'; -import * as ep___admin_accounts_create from './endpoints/admin/accounts/create.js'; -import * as ep___admin_accounts_delete from './endpoints/admin/accounts/delete.js'; -import * as ep___admin_accounts_findByEmail from './endpoints/admin/accounts/find-by-email.js'; -import * as ep___admin_ad_create from './endpoints/admin/ad/create.js'; -import * as ep___admin_ad_delete from './endpoints/admin/ad/delete.js'; -import * as ep___admin_ad_list from './endpoints/admin/ad/list.js'; -import * as ep___admin_ad_update from './endpoints/admin/ad/update.js'; -import * as ep___admin_announcements_create from './endpoints/admin/announcements/create.js'; -import * as ep___admin_announcements_delete from './endpoints/admin/announcements/delete.js'; -import * as ep___admin_announcements_list from './endpoints/admin/announcements/list.js'; -import * as ep___admin_announcements_update from './endpoints/admin/announcements/update.js'; -import * as ep___admin_avatarDecorations_create from './endpoints/admin/avatar-decorations/create.js'; -import * as ep___admin_avatarDecorations_delete from './endpoints/admin/avatar-decorations/delete.js'; -import * as ep___admin_avatarDecorations_list from './endpoints/admin/avatar-decorations/list.js'; -import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-decorations/update.js'; -import * as ep___admin_deleteAllFilesOfAUser from './endpoints/admin/delete-all-files-of-a-user.js'; -import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js'; -import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js'; -import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js'; -import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js'; -import * as ep___admin_drive_files from './endpoints/admin/drive/files.js'; -import * as ep___admin_drive_showFile from './endpoints/admin/drive/show-file.js'; -import * as ep___admin_emoji_addAliasesBulk from './endpoints/admin/emoji/add-aliases-bulk.js'; -import * as ep___admin_emoji_add from './endpoints/admin/emoji/add.js'; -import * as ep___admin_emoji_copy from './endpoints/admin/emoji/copy.js'; -import * as ep___admin_emoji_deleteBulk from './endpoints/admin/emoji/delete-bulk.js'; -import * as ep___admin_emoji_delete from './endpoints/admin/emoji/delete.js'; -import * as ep___admin_emoji_importZip from './endpoints/admin/emoji/import-zip.js'; -import * as ep___admin_emoji_listRemote from './endpoints/admin/emoji/list-remote.js'; -import * as ep___admin_emoji_list from './endpoints/admin/emoji/list.js'; -import * as ep___admin_emoji_removeAliasesBulk from './endpoints/admin/emoji/remove-aliases-bulk.js'; -import * as ep___admin_emoji_setAliasesBulk from './endpoints/admin/emoji/set-aliases-bulk.js'; -import * as ep___admin_emoji_setCategoryBulk from './endpoints/admin/emoji/set-category-bulk.js'; -import * as ep___admin_emoji_setLicenseBulk from './endpoints/admin/emoji/set-license-bulk.js'; -import * as ep___admin_emoji_update from './endpoints/admin/emoji/update.js'; -import * as ep___admin_federation_deleteAllFiles from './endpoints/admin/federation/delete-all-files.js'; -import * as ep___admin_federation_refreshRemoteInstanceMetadata - from './endpoints/admin/federation/refresh-remote-instance-metadata.js'; -import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/federation/remove-all-following.js'; -import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; -import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; -import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; -import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; -import * as ep___admin_invite_create from './endpoints/admin/invite/create.js'; -import * as ep___admin_invite_list from './endpoints/admin/invite/list.js'; -import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; -import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; -import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; -import * as ep___admin_queue_inboxDelayed from './endpoints/admin/queue/inbox-delayed.js'; -import * as ep___admin_queue_promote from './endpoints/admin/queue/promote.js'; -import * as ep___admin_queue_stats from './endpoints/admin/queue/stats.js'; -import * as ep___admin_relays_add from './endpoints/admin/relays/add.js'; -import * as ep___admin_relays_list from './endpoints/admin/relays/list.js'; -import * as ep___admin_relays_remove from './endpoints/admin/relays/remove.js'; -import * as ep___admin_resetPassword from './endpoints/admin/reset-password.js'; -import * as ep___admin_resolveAbuseUserReport from './endpoints/admin/resolve-abuse-user-report.js'; -import * as ep___admin_sendEmail from './endpoints/admin/send-email.js'; -import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; -import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; -import * as ep___admin_showUser from './endpoints/admin/show-user.js'; -import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; -import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; -import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; -import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; -import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; -import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; -import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; -import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; -import * as ep___admin_roles_list from './endpoints/admin/roles/list.js'; -import * as ep___admin_roles_show from './endpoints/admin/roles/show.js'; -import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; -import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; -import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; -import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js'; -import * as ep___admin_roles_users from './endpoints/admin/roles/users.js'; -import * as ep___admin_systemWebhook_create from './endpoints/admin/system-webhook/create.js'; -import * as ep___admin_systemWebhook_delete from './endpoints/admin/system-webhook/delete.js'; -import * as ep___admin_systemWebhook_list from './endpoints/admin/system-webhook/list.js'; -import * as ep___admin_systemWebhook_show from './endpoints/admin/system-webhook/show.js'; -import * as ep___admin_systemWebhook_update from './endpoints/admin/system-webhook/update.js'; -import * as ep___admin_systemWebhook_test from './endpoints/admin/system-webhook/test.js'; -import * as ep___announcements from './endpoints/announcements.js'; -import * as ep___announcements_show from './endpoints/announcements/show.js'; -import * as ep___antennas_create from './endpoints/antennas/create.js'; -import * as ep___antennas_delete from './endpoints/antennas/delete.js'; -import * as ep___antennas_list from './endpoints/antennas/list.js'; -import * as ep___antennas_notes from './endpoints/antennas/notes.js'; -import * as ep___antennas_show from './endpoints/antennas/show.js'; -import * as ep___antennas_update from './endpoints/antennas/update.js'; -import * as ep___ap_get from './endpoints/ap/get.js'; -import * as ep___ap_show from './endpoints/ap/show.js'; -import * as ep___app_create from './endpoints/app/create.js'; -import * as ep___app_show from './endpoints/app/show.js'; -import * as ep___auth_accept from './endpoints/auth/accept.js'; -import * as ep___auth_session_generate from './endpoints/auth/session/generate.js'; -import * as ep___auth_session_show from './endpoints/auth/session/show.js'; -import * as ep___auth_session_userkey from './endpoints/auth/session/userkey.js'; -import * as ep___blocking_create from './endpoints/blocking/create.js'; -import * as ep___blocking_delete from './endpoints/blocking/delete.js'; -import * as ep___blocking_list from './endpoints/blocking/list.js'; -import * as ep___channels_create from './endpoints/channels/create.js'; -import * as ep___channels_featured from './endpoints/channels/featured.js'; -import * as ep___channels_follow from './endpoints/channels/follow.js'; -import * as ep___channels_followed from './endpoints/channels/followed.js'; -import * as ep___channels_owned from './endpoints/channels/owned.js'; -import * as ep___channels_show from './endpoints/channels/show.js'; -import * as ep___channels_timeline from './endpoints/channels/timeline.js'; -import * as ep___channels_unfollow from './endpoints/channels/unfollow.js'; -import * as ep___channels_update from './endpoints/channels/update.js'; -import * as ep___channels_favorite from './endpoints/channels/favorite.js'; -import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; -import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; -import * as ep___channels_search from './endpoints/channels/search.js'; -import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; -import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; -import * as ep___charts_drive from './endpoints/charts/drive.js'; -import * as ep___charts_federation from './endpoints/charts/federation.js'; -import * as ep___charts_instance from './endpoints/charts/instance.js'; -import * as ep___charts_notes from './endpoints/charts/notes.js'; -import * as ep___charts_user_drive from './endpoints/charts/user/drive.js'; -import * as ep___charts_user_following from './endpoints/charts/user/following.js'; -import * as ep___charts_user_notes from './endpoints/charts/user/notes.js'; -import * as ep___charts_user_pv from './endpoints/charts/user/pv.js'; -import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js'; -import * as ep___charts_users from './endpoints/charts/users.js'; -import * as ep___clips_addNote from './endpoints/clips/add-note.js'; -import * as ep___clips_removeNote from './endpoints/clips/remove-note.js'; -import * as ep___clips_create from './endpoints/clips/create.js'; -import * as ep___clips_delete from './endpoints/clips/delete.js'; -import * as ep___clips_list from './endpoints/clips/list.js'; -import * as ep___clips_notes from './endpoints/clips/notes.js'; -import * as ep___clips_show from './endpoints/clips/show.js'; -import * as ep___clips_update from './endpoints/clips/update.js'; -import * as ep___clips_favorite from './endpoints/clips/favorite.js'; -import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js'; -import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js'; -import * as ep___drive from './endpoints/drive.js'; -import * as ep___drive_files from './endpoints/drive/files.js'; -import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js'; -import * as ep___drive_files_checkExistence from './endpoints/drive/files/check-existence.js'; -import * as ep___drive_files_create from './endpoints/drive/files/create.js'; -import * as ep___drive_files_delete from './endpoints/drive/files/delete.js'; -import * as ep___drive_files_findByHash from './endpoints/drive/files/find-by-hash.js'; -import * as ep___drive_files_find from './endpoints/drive/files/find.js'; -import * as ep___drive_files_show from './endpoints/drive/files/show.js'; -import * as ep___drive_files_update from './endpoints/drive/files/update.js'; -import * as ep___drive_files_uploadFromUrl from './endpoints/drive/files/upload-from-url.js'; -import * as ep___drive_folders from './endpoints/drive/folders.js'; -import * as ep___drive_folders_create from './endpoints/drive/folders/create.js'; -import * as ep___drive_folders_delete from './endpoints/drive/folders/delete.js'; -import * as ep___drive_folders_find from './endpoints/drive/folders/find.js'; -import * as ep___drive_folders_show from './endpoints/drive/folders/show.js'; -import * as ep___drive_folders_update from './endpoints/drive/folders/update.js'; -import * as ep___drive_stream from './endpoints/drive/stream.js'; -import * as ep___emailAddress_available from './endpoints/email-address/available.js'; -import * as ep___endpoint from './endpoints/endpoint.js'; -import * as ep___endpoints from './endpoints/endpoints.js'; -import * as ep___exportCustomEmojis from './endpoints/export-custom-emojis.js'; -import * as ep___federation_followers from './endpoints/federation/followers.js'; -import * as ep___federation_following from './endpoints/federation/following.js'; -import * as ep___federation_instances from './endpoints/federation/instances.js'; -import * as ep___federation_showInstance from './endpoints/federation/show-instance.js'; -import * as ep___federation_updateRemoteUser from './endpoints/federation/update-remote-user.js'; -import * as ep___federation_users from './endpoints/federation/users.js'; -import * as ep___federation_stats from './endpoints/federation/stats.js'; -import * as ep___following_create from './endpoints/following/create.js'; -import * as ep___following_delete from './endpoints/following/delete.js'; -import * as ep___following_update from './endpoints/following/update.js'; -import * as ep___following_update_all from './endpoints/following/update-all.js'; -import * as ep___following_invalidate from './endpoints/following/invalidate.js'; -import * as ep___following_requests_accept from './endpoints/following/requests/accept.js'; -import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js'; -import * as ep___following_requests_list from './endpoints/following/requests/list.js'; -import * as ep___following_requests_reject from './endpoints/following/requests/reject.js'; -import * as ep___gallery_featured from './endpoints/gallery/featured.js'; -import * as ep___gallery_popular from './endpoints/gallery/popular.js'; -import * as ep___gallery_posts from './endpoints/gallery/posts.js'; -import * as ep___gallery_posts_create from './endpoints/gallery/posts/create.js'; -import * as ep___gallery_posts_delete from './endpoints/gallery/posts/delete.js'; -import * as ep___gallery_posts_like from './endpoints/gallery/posts/like.js'; -import * as ep___gallery_posts_show from './endpoints/gallery/posts/show.js'; -import * as ep___gallery_posts_unlike from './endpoints/gallery/posts/unlike.js'; -import * as ep___gallery_posts_update from './endpoints/gallery/posts/update.js'; -import * as ep___getOnlineUsersCount from './endpoints/get-online-users-count.js'; -import * as ep___getAvatarDecorations from './endpoints/get-avatar-decorations.js'; -import * as ep___hashtags_list from './endpoints/hashtags/list.js'; -import * as ep___hashtags_search from './endpoints/hashtags/search.js'; -import * as ep___hashtags_show from './endpoints/hashtags/show.js'; -import * as ep___hashtags_trend from './endpoints/hashtags/trend.js'; -import * as ep___hashtags_users from './endpoints/hashtags/users.js'; -import * as ep___i from './endpoints/i.js'; -import * as ep___i_2fa_done from './endpoints/i/2fa/done.js'; -import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js'; -import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js'; -import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js'; -import * as ep___i_2fa_register from './endpoints/i/2fa/register.js'; -import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js'; -import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js'; -import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js'; -import * as ep___i_apps from './endpoints/i/apps.js'; -import * as ep___i_authorizedApps from './endpoints/i/authorized-apps.js'; -import * as ep___i_claimAchievement from './endpoints/i/claim-achievement.js'; -import * as ep___i_changePassword from './endpoints/i/change-password.js'; -import * as ep___i_deleteAccount from './endpoints/i/delete-account.js'; -import * as ep___i_exportBlocking from './endpoints/i/export-blocking.js'; -import * as ep___i_exportFollowing from './endpoints/i/export-following.js'; -import * as ep___i_exportMute from './endpoints/i/export-mute.js'; -import * as ep___i_exportNotes from './endpoints/i/export-notes.js'; -import * as ep___i_exportClips from './endpoints/i/export-clips.js'; -import * as ep___i_exportFavorites from './endpoints/i/export-favorites.js'; -import * as ep___i_exportUserLists from './endpoints/i/export-user-lists.js'; -import * as ep___i_exportAntennas from './endpoints/i/export-antennas.js'; -import * as ep___i_favorites from './endpoints/i/favorites.js'; -import * as ep___i_gallery_likes from './endpoints/i/gallery/likes.js'; -import * as ep___i_gallery_posts from './endpoints/i/gallery/posts.js'; -import * as ep___i_importBlocking from './endpoints/i/import-blocking.js'; -import * as ep___i_importFollowing from './endpoints/i/import-following.js'; -import * as ep___i_importMuting from './endpoints/i/import-muting.js'; -import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; -import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; -import * as ep___i_notifications from './endpoints/i/notifications.js'; -import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; -import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; -import * as ep___i_pages from './endpoints/i/pages.js'; -import * as ep___i_pin from './endpoints/i/pin.js'; -import * as ep___i_readAllUnreadNotes from './endpoints/i/read-all-unread-notes.js'; -import * as ep___i_readAnnouncement from './endpoints/i/read-announcement.js'; -import * as ep___i_regenerateToken from './endpoints/i/regenerate-token.js'; -import * as ep___i_registry_getAll from './endpoints/i/registry/get-all.js'; -import * as ep___i_registry_getDetail from './endpoints/i/registry/get-detail.js'; -import * as ep___i_registry_get from './endpoints/i/registry/get.js'; -import * as ep___i_registry_keysWithType from './endpoints/i/registry/keys-with-type.js'; -import * as ep___i_registry_keys from './endpoints/i/registry/keys.js'; -import * as ep___i_registry_remove from './endpoints/i/registry/remove.js'; -import * as ep___i_registry_scopesWithDomain from './endpoints/i/registry/scopes-with-domain.js'; -import * as ep___i_registry_set from './endpoints/i/registry/set.js'; -import * as ep___i_revokeToken from './endpoints/i/revoke-token.js'; -import * as ep___i_signinHistory from './endpoints/i/signin-history.js'; -import * as ep___i_unpin from './endpoints/i/unpin.js'; -import * as ep___i_updateEmail from './endpoints/i/update-email.js'; -import * as ep___i_update from './endpoints/i/update.js'; -import * as ep___i_move from './endpoints/i/move.js'; -import * as ep___i_webhooks_create from './endpoints/i/webhooks/create.js'; -import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; -import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; -import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; -import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; -import * as ep___i_webhooks_test from './endpoints/i/webhooks/test.js'; -import * as ep___invite_create from './endpoints/invite/create.js'; -import * as ep___invite_delete from './endpoints/invite/delete.js'; -import * as ep___invite_list from './endpoints/invite/list.js'; -import * as ep___invite_limit from './endpoints/invite/limit.js'; -import * as ep___meta from './endpoints/meta.js'; -import * as ep___emojis from './endpoints/emojis.js'; -import * as ep___emoji from './endpoints/emoji.js'; -import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; -import * as ep___mute_create from './endpoints/mute/create.js'; -import * as ep___mute_delete from './endpoints/mute/delete.js'; -import * as ep___mute_list from './endpoints/mute/list.js'; -import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; -import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; -import * as ep___renoteMute_list from './endpoints/renote-mute/list.js'; -import * as ep___my_apps from './endpoints/my/apps.js'; -import * as ep___notes from './endpoints/notes.js'; -import * as ep___notes_children from './endpoints/notes/children.js'; -import * as ep___notes_clips from './endpoints/notes/clips.js'; -import * as ep___notes_conversation from './endpoints/notes/conversation.js'; -import * as ep___notes_create from './endpoints/notes/create.js'; -import * as ep___notes_delete from './endpoints/notes/delete.js'; -import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js'; -import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js'; -import * as ep___notes_featured from './endpoints/notes/featured.js'; -import * as ep___notes_globalTimeline from './endpoints/notes/global-timeline.js'; -import * as ep___notes_hybridTimeline from './endpoints/notes/hybrid-timeline.js'; -import * as ep___notes_localTimeline from './endpoints/notes/local-timeline.js'; -import * as ep___notes_mentions from './endpoints/notes/mentions.js'; -import * as ep___notes_polls_recommendation from './endpoints/notes/polls/recommendation.js'; -import * as ep___notes_polls_vote from './endpoints/notes/polls/vote.js'; -import * as ep___notes_reactions from './endpoints/notes/reactions.js'; -import * as ep___notes_reactions_create from './endpoints/notes/reactions/create.js'; -import * as ep___notes_reactions_delete from './endpoints/notes/reactions/delete.js'; -import * as ep___notes_renotes from './endpoints/notes/renotes.js'; -import * as ep___notes_replies from './endpoints/notes/replies.js'; -import * as ep___notes_searchByTag from './endpoints/notes/search-by-tag.js'; -import * as ep___notes_search from './endpoints/notes/search.js'; -import * as ep___notes_show from './endpoints/notes/show.js'; -import * as ep___notes_state from './endpoints/notes/state.js'; -import * as ep___notes_threadMuting_create from './endpoints/notes/thread-muting/create.js'; -import * as ep___notes_threadMuting_delete from './endpoints/notes/thread-muting/delete.js'; -import * as ep___notes_timeline from './endpoints/notes/timeline.js'; -import * as ep___notes_translate from './endpoints/notes/translate.js'; -import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; -import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; -import * as ep___notifications_create from './endpoints/notifications/create.js'; -import * as ep___notifications_flush from './endpoints/notifications/flush.js'; -import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; -import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; -import * as ep___pagePush from './endpoints/page-push.js'; -import * as ep___pages_create from './endpoints/pages/create.js'; -import * as ep___pages_delete from './endpoints/pages/delete.js'; -import * as ep___pages_featured from './endpoints/pages/featured.js'; -import * as ep___pages_like from './endpoints/pages/like.js'; -import * as ep___pages_show from './endpoints/pages/show.js'; -import * as ep___pages_unlike from './endpoints/pages/unlike.js'; -import * as ep___pages_update from './endpoints/pages/update.js'; -import * as ep___flash_create from './endpoints/flash/create.js'; -import * as ep___flash_delete from './endpoints/flash/delete.js'; -import * as ep___flash_featured from './endpoints/flash/featured.js'; -import * as ep___flash_like from './endpoints/flash/like.js'; -import * as ep___flash_show from './endpoints/flash/show.js'; -import * as ep___flash_unlike from './endpoints/flash/unlike.js'; -import * as ep___flash_update from './endpoints/flash/update.js'; -import * as ep___flash_my from './endpoints/flash/my.js'; -import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; -import * as ep___ping from './endpoints/ping.js'; -import * as ep___pinnedUsers from './endpoints/pinned-users.js'; -import * as ep___promo_read from './endpoints/promo/read.js'; -import * as ep___roles_list from './endpoints/roles/list.js'; -import * as ep___roles_show from './endpoints/roles/show.js'; -import * as ep___roles_users from './endpoints/roles/users.js'; -import * as ep___roles_notes from './endpoints/roles/notes.js'; -import * as ep___requestResetPassword from './endpoints/request-reset-password.js'; -import * as ep___resetDb from './endpoints/reset-db.js'; -import * as ep___resetPassword from './endpoints/reset-password.js'; -import * as ep___serverInfo from './endpoints/server-info.js'; -import * as ep___stats from './endpoints/stats.js'; -import * as ep___sw_show_registration from './endpoints/sw/show-registration.js'; -import * as ep___sw_update_registration from './endpoints/sw/update-registration.js'; -import * as ep___sw_register from './endpoints/sw/register.js'; -import * as ep___sw_unregister from './endpoints/sw/unregister.js'; -import * as ep___test from './endpoints/test.js'; -import * as ep___username_available from './endpoints/username/available.js'; -import * as ep___users from './endpoints/users.js'; -import * as ep___users_clips from './endpoints/users/clips.js'; -import * as ep___users_followers from './endpoints/users/followers.js'; -import * as ep___users_following from './endpoints/users/following.js'; -import * as ep___users_gallery_posts from './endpoints/users/gallery/posts.js'; -import * as ep___users_getFrequentlyRepliedUsers from './endpoints/users/get-frequently-replied-users.js'; -import * as ep___users_featuredNotes from './endpoints/users/featured-notes.js'; -import * as ep___users_lists_create from './endpoints/users/lists/create.js'; -import * as ep___users_lists_delete from './endpoints/users/lists/delete.js'; -import * as ep___users_lists_list from './endpoints/users/lists/list.js'; -import * as ep___users_lists_pull from './endpoints/users/lists/pull.js'; -import * as ep___users_lists_push from './endpoints/users/lists/push.js'; -import * as ep___users_lists_show from './endpoints/users/lists/show.js'; -import * as ep___users_lists_favorite from './endpoints/users/lists/favorite.js'; -import * as ep___users_lists_unfavorite from './endpoints/users/lists/unfavorite.js'; -import * as ep___users_lists_createFromPublic from './endpoints/users/lists/create-from-public.js'; -import * as ep___users_lists_update from './endpoints/users/lists/update.js'; -import * as ep___users_lists_updateMembership from './endpoints/users/lists/update-membership.js'; -import * as ep___users_lists_getMemberships from './endpoints/users/lists/get-memberships.js'; -import * as ep___users_notes from './endpoints/users/notes.js'; -import * as ep___users_pages from './endpoints/users/pages.js'; -import * as ep___users_flashs from './endpoints/users/flashs.js'; -import * as ep___users_reactions from './endpoints/users/reactions.js'; -import * as ep___users_recommendation from './endpoints/users/recommendation.js'; -import * as ep___users_relation from './endpoints/users/relation.js'; -import * as ep___users_reportAbuse from './endpoints/users/report-abuse.js'; -import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by-username-and-host.js'; -import * as ep___users_search from './endpoints/users/search.js'; -import * as ep___users_show from './endpoints/users/show.js'; -import * as ep___users_achievements from './endpoints/users/achievements.js'; -import * as ep___users_updateMemo from './endpoints/users/update-memo.js'; -import * as ep___fetchRss from './endpoints/fetch-rss.js'; -import * as ep___fetchExternalResources from './endpoints/fetch-external-resources.js'; -import * as ep___retention from './endpoints/retention.js'; -import * as ep___bubbleGame_register from './endpoints/bubble-game/register.js'; -import * as ep___bubbleGame_ranking from './endpoints/bubble-game/ranking.js'; -import * as ep___reversi_cancelMatch from './endpoints/reversi/cancel-match.js'; -import * as ep___reversi_games from './endpoints/reversi/games.js'; -import * as ep___reversi_match from './endpoints/reversi/match.js'; -import * as ep___reversi_invitations from './endpoints/reversi/invitations.js'; -import * as ep___reversi_showGame from './endpoints/reversi/show-game.js'; -import * as ep___reversi_surrender from './endpoints/reversi/surrender.js'; -import * as ep___reversi_verify from './endpoints/reversi/verify.js'; - -const eps = [ - ['admin/meta', ep___admin_meta], - ['admin/abuse-user-reports', ep___admin_abuseUserReports], - ['admin/abuse-report/notification-recipient/list', ep___admin_abuseReport_notificationRecipient_list], - ['admin/abuse-report/notification-recipient/show', ep___admin_abuseReport_notificationRecipient_show], - ['admin/abuse-report/notification-recipient/create', ep___admin_abuseReport_notificationRecipient_create], - ['admin/abuse-report/notification-recipient/update', ep___admin_abuseReport_notificationRecipient_update], - ['admin/abuse-report/notification-recipient/delete', ep___admin_abuseReport_notificationRecipient_delete], - ['admin/accounts/create', ep___admin_accounts_create], - ['admin/accounts/delete', ep___admin_accounts_delete], - ['admin/accounts/find-by-email', ep___admin_accounts_findByEmail], - ['admin/ad/create', ep___admin_ad_create], - ['admin/ad/delete', ep___admin_ad_delete], - ['admin/ad/list', ep___admin_ad_list], - ['admin/ad/update', ep___admin_ad_update], - ['admin/announcements/create', ep___admin_announcements_create], - ['admin/announcements/delete', ep___admin_announcements_delete], - ['admin/announcements/list', ep___admin_announcements_list], - ['admin/announcements/update', ep___admin_announcements_update], - ['admin/avatar-decorations/create', ep___admin_avatarDecorations_create], - ['admin/avatar-decorations/delete', ep___admin_avatarDecorations_delete], - ['admin/avatar-decorations/list', ep___admin_avatarDecorations_list], - ['admin/avatar-decorations/update', ep___admin_avatarDecorations_update], - ['admin/delete-all-files-of-a-user', ep___admin_deleteAllFilesOfAUser], - ['admin/unset-user-avatar', ep___admin_unsetUserAvatar], - ['admin/unset-user-banner', ep___admin_unsetUserBanner], - ['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles], - ['admin/drive/cleanup', ep___admin_drive_cleanup], - ['admin/drive/files', ep___admin_drive_files], - ['admin/drive/show-file', ep___admin_drive_showFile], - ['admin/emoji/add-aliases-bulk', ep___admin_emoji_addAliasesBulk], - ['admin/emoji/add', ep___admin_emoji_add], - ['admin/emoji/copy', ep___admin_emoji_copy], - ['admin/emoji/delete-bulk', ep___admin_emoji_deleteBulk], - ['admin/emoji/delete', ep___admin_emoji_delete], - ['admin/emoji/import-zip', ep___admin_emoji_importZip], - ['admin/emoji/list-remote', ep___admin_emoji_listRemote], - ['admin/emoji/list', ep___admin_emoji_list], - ['admin/emoji/remove-aliases-bulk', ep___admin_emoji_removeAliasesBulk], - ['admin/emoji/set-aliases-bulk', ep___admin_emoji_setAliasesBulk], - ['admin/emoji/set-category-bulk', ep___admin_emoji_setCategoryBulk], - ['admin/emoji/set-license-bulk', ep___admin_emoji_setLicenseBulk], - ['admin/emoji/update', ep___admin_emoji_update], - ['admin/federation/delete-all-files', ep___admin_federation_deleteAllFiles], - ['admin/federation/refresh-remote-instance-metadata', ep___admin_federation_refreshRemoteInstanceMetadata], - ['admin/federation/remove-all-following', ep___admin_federation_removeAllFollowing], - ['admin/federation/update-instance', ep___admin_federation_updateInstance], - ['admin/get-index-stats', ep___admin_getIndexStats], - ['admin/get-table-stats', ep___admin_getTableStats], - ['admin/get-user-ips', ep___admin_getUserIps], - ['admin/invite/create', ep___admin_invite_create], - ['admin/invite/list', ep___admin_invite_list], - ['admin/promo/create', ep___admin_promo_create], - ['admin/queue/clear', ep___admin_queue_clear], - ['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed], - ['admin/queue/inbox-delayed', ep___admin_queue_inboxDelayed], - ['admin/queue/promote', ep___admin_queue_promote], - ['admin/queue/stats', ep___admin_queue_stats], - ['admin/relays/add', ep___admin_relays_add], - ['admin/relays/list', ep___admin_relays_list], - ['admin/relays/remove', ep___admin_relays_remove], - ['admin/reset-password', ep___admin_resetPassword], - ['admin/resolve-abuse-user-report', ep___admin_resolveAbuseUserReport], - ['admin/send-email', ep___admin_sendEmail], - ['admin/server-info', ep___admin_serverInfo], - ['admin/show-moderation-logs', ep___admin_showModerationLogs], - ['admin/show-user', ep___admin_showUser], - ['admin/show-users', ep___admin_showUsers], - ['admin/suspend-user', ep___admin_suspendUser], - ['admin/unsuspend-user', ep___admin_unsuspendUser], - ['admin/update-meta', ep___admin_updateMeta], - ['admin/delete-account', ep___admin_deleteAccount], - ['admin/update-user-note', ep___admin_updateUserNote], - ['admin/roles/create', ep___admin_roles_create], - ['admin/roles/delete', ep___admin_roles_delete], - ['admin/roles/list', ep___admin_roles_list], - ['admin/roles/show', ep___admin_roles_show], - ['admin/roles/update', ep___admin_roles_update], - ['admin/roles/assign', ep___admin_roles_assign], - ['admin/roles/unassign', ep___admin_roles_unassign], - ['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies], - ['admin/roles/users', ep___admin_roles_users], - ['admin/system-webhook/create', ep___admin_systemWebhook_create], - ['admin/system-webhook/delete', ep___admin_systemWebhook_delete], - ['admin/system-webhook/list', ep___admin_systemWebhook_list], - ['admin/system-webhook/show', ep___admin_systemWebhook_show], - ['admin/system-webhook/update', ep___admin_systemWebhook_update], - ['admin/system-webhook/test', ep___admin_systemWebhook_test], - ['announcements', ep___announcements], - ['announcements/show', ep___announcements_show], - ['antennas/create', ep___antennas_create], - ['antennas/delete', ep___antennas_delete], - ['antennas/list', ep___antennas_list], - ['antennas/notes', ep___antennas_notes], - ['antennas/show', ep___antennas_show], - ['antennas/update', ep___antennas_update], - ['ap/get', ep___ap_get], - ['ap/show', ep___ap_show], - ['app/create', ep___app_create], - ['app/show', ep___app_show], - ['auth/accept', ep___auth_accept], - ['auth/session/generate', ep___auth_session_generate], - ['auth/session/show', ep___auth_session_show], - ['auth/session/userkey', ep___auth_session_userkey], - ['blocking/create', ep___blocking_create], - ['blocking/delete', ep___blocking_delete], - ['blocking/list', ep___blocking_list], - ['channels/create', ep___channels_create], - ['channels/featured', ep___channels_featured], - ['channels/follow', ep___channels_follow], - ['channels/followed', ep___channels_followed], - ['channels/owned', ep___channels_owned], - ['channels/show', ep___channels_show], - ['channels/timeline', ep___channels_timeline], - ['channels/unfollow', ep___channels_unfollow], - ['channels/update', ep___channels_update], - ['channels/favorite', ep___channels_favorite], - ['channels/unfavorite', ep___channels_unfavorite], - ['channels/my-favorites', ep___channels_myFavorites], - ['channels/search', ep___channels_search], - ['charts/active-users', ep___charts_activeUsers], - ['charts/ap-request', ep___charts_apRequest], - ['charts/drive', ep___charts_drive], - ['charts/federation', ep___charts_federation], - ['charts/instance', ep___charts_instance], - ['charts/notes', ep___charts_notes], - ['charts/user/drive', ep___charts_user_drive], - ['charts/user/following', ep___charts_user_following], - ['charts/user/notes', ep___charts_user_notes], - ['charts/user/pv', ep___charts_user_pv], - ['charts/user/reactions', ep___charts_user_reactions], - ['charts/users', ep___charts_users], - ['clips/add-note', ep___clips_addNote], - ['clips/remove-note', ep___clips_removeNote], - ['clips/create', ep___clips_create], - ['clips/delete', ep___clips_delete], - ['clips/list', ep___clips_list], - ['clips/notes', ep___clips_notes], - ['clips/show', ep___clips_show], - ['clips/update', ep___clips_update], - ['clips/favorite', ep___clips_favorite], - ['clips/unfavorite', ep___clips_unfavorite], - ['clips/my-favorites', ep___clips_myFavorites], - ['drive', ep___drive], - ['drive/files', ep___drive_files], - ['drive/files/attached-notes', ep___drive_files_attachedNotes], - ['drive/files/check-existence', ep___drive_files_checkExistence], - ['drive/files/create', ep___drive_files_create], - ['drive/files/delete', ep___drive_files_delete], - ['drive/files/find-by-hash', ep___drive_files_findByHash], - ['drive/files/find', ep___drive_files_find], - ['drive/files/show', ep___drive_files_show], - ['drive/files/update', ep___drive_files_update], - ['drive/files/upload-from-url', ep___drive_files_uploadFromUrl], - ['drive/folders', ep___drive_folders], - ['drive/folders/create', ep___drive_folders_create], - ['drive/folders/delete', ep___drive_folders_delete], - ['drive/folders/find', ep___drive_folders_find], - ['drive/folders/show', ep___drive_folders_show], - ['drive/folders/update', ep___drive_folders_update], - ['drive/stream', ep___drive_stream], - ['email-address/available', ep___emailAddress_available], - ['endpoint', ep___endpoint], - ['endpoints', ep___endpoints], - ['export-custom-emojis', ep___exportCustomEmojis], - ['federation/followers', ep___federation_followers], - ['federation/following', ep___federation_following], - ['federation/instances', ep___federation_instances], - ['federation/show-instance', ep___federation_showInstance], - ['federation/update-remote-user', ep___federation_updateRemoteUser], - ['federation/users', ep___federation_users], - ['federation/stats', ep___federation_stats], - ['following/create', ep___following_create], - ['following/delete', ep___following_delete], - ['following/update', ep___following_update], - ['following/update-all', ep___following_update_all], - ['following/invalidate', ep___following_invalidate], - ['following/requests/accept', ep___following_requests_accept], - ['following/requests/cancel', ep___following_requests_cancel], - ['following/requests/list', ep___following_requests_list], - ['following/requests/reject', ep___following_requests_reject], - ['gallery/featured', ep___gallery_featured], - ['gallery/popular', ep___gallery_popular], - ['gallery/posts', ep___gallery_posts], - ['gallery/posts/create', ep___gallery_posts_create], - ['gallery/posts/delete', ep___gallery_posts_delete], - ['gallery/posts/like', ep___gallery_posts_like], - ['gallery/posts/show', ep___gallery_posts_show], - ['gallery/posts/unlike', ep___gallery_posts_unlike], - ['gallery/posts/update', ep___gallery_posts_update], - ['get-online-users-count', ep___getOnlineUsersCount], - ['get-avatar-decorations', ep___getAvatarDecorations], - ['hashtags/list', ep___hashtags_list], - ['hashtags/search', ep___hashtags_search], - ['hashtags/show', ep___hashtags_show], - ['hashtags/trend', ep___hashtags_trend], - ['hashtags/users', ep___hashtags_users], - ['i', ep___i], - ['i/2fa/done', ep___i_2fa_done], - ['i/2fa/key-done', ep___i_2fa_keyDone], - ['i/2fa/password-less', ep___i_2fa_passwordLess], - ['i/2fa/register-key', ep___i_2fa_registerKey], - ['i/2fa/register', ep___i_2fa_register], - ['i/2fa/update-key', ep___i_2fa_updateKey], - ['i/2fa/remove-key', ep___i_2fa_removeKey], - ['i/2fa/unregister', ep___i_2fa_unregister], - ['i/apps', ep___i_apps], - ['i/authorized-apps', ep___i_authorizedApps], - ['i/claim-achievement', ep___i_claimAchievement], - ['i/change-password', ep___i_changePassword], - ['i/delete-account', ep___i_deleteAccount], - ['i/export-blocking', ep___i_exportBlocking], - ['i/export-following', ep___i_exportFollowing], - ['i/export-mute', ep___i_exportMute], - ['i/export-notes', ep___i_exportNotes], - ['i/export-clips', ep___i_exportClips], - ['i/export-favorites', ep___i_exportFavorites], - ['i/export-user-lists', ep___i_exportUserLists], - ['i/export-antennas', ep___i_exportAntennas], - ['i/favorites', ep___i_favorites], - ['i/gallery/likes', ep___i_gallery_likes], - ['i/gallery/posts', ep___i_gallery_posts], - ['i/import-blocking', ep___i_importBlocking], - ['i/import-following', ep___i_importFollowing], - ['i/import-muting', ep___i_importMuting], - ['i/import-user-lists', ep___i_importUserLists], - ['i/import-antennas', ep___i_importAntennas], - ['i/notifications', ep___i_notifications], - ['i/notifications-grouped', ep___i_notificationsGrouped], - ['i/page-likes', ep___i_pageLikes], - ['i/pages', ep___i_pages], - ['i/pin', ep___i_pin], - ['i/read-all-unread-notes', ep___i_readAllUnreadNotes], - ['i/read-announcement', ep___i_readAnnouncement], - ['i/regenerate-token', ep___i_regenerateToken], - ['i/registry/get-all', ep___i_registry_getAll], - ['i/registry/get-detail', ep___i_registry_getDetail], - ['i/registry/get', ep___i_registry_get], - ['i/registry/keys-with-type', ep___i_registry_keysWithType], - ['i/registry/keys', ep___i_registry_keys], - ['i/registry/remove', ep___i_registry_remove], - ['i/registry/scopes-with-domain', ep___i_registry_scopesWithDomain], - ['i/registry/set', ep___i_registry_set], - ['i/revoke-token', ep___i_revokeToken], - ['i/signin-history', ep___i_signinHistory], - ['i/unpin', ep___i_unpin], - ['i/update-email', ep___i_updateEmail], - ['i/update', ep___i_update], - ['i/move', ep___i_move], - ['i/webhooks/create', ep___i_webhooks_create], - ['i/webhooks/list', ep___i_webhooks_list], - ['i/webhooks/show', ep___i_webhooks_show], - ['i/webhooks/update', ep___i_webhooks_update], - ['i/webhooks/delete', ep___i_webhooks_delete], - ['i/webhooks/test', ep___i_webhooks_test], - ['invite/create', ep___invite_create], - ['invite/delete', ep___invite_delete], - ['invite/list', ep___invite_list], - ['invite/limit', ep___invite_limit], - ['meta', ep___meta], - ['emojis', ep___emojis], - ['emoji', ep___emoji], - ['miauth/gen-token', ep___miauth_genToken], - ['mute/create', ep___mute_create], - ['mute/delete', ep___mute_delete], - ['mute/list', ep___mute_list], - ['renote-mute/create', ep___renoteMute_create], - ['renote-mute/delete', ep___renoteMute_delete], - ['renote-mute/list', ep___renoteMute_list], - ['my/apps', ep___my_apps], - ['notes', ep___notes], - ['notes/children', ep___notes_children], - ['notes/clips', ep___notes_clips], - ['notes/conversation', ep___notes_conversation], - ['notes/create', ep___notes_create], - ['notes/delete', ep___notes_delete], - ['notes/favorites/create', ep___notes_favorites_create], - ['notes/favorites/delete', ep___notes_favorites_delete], - ['notes/featured', ep___notes_featured], - ['notes/global-timeline', ep___notes_globalTimeline], - ['notes/hybrid-timeline', ep___notes_hybridTimeline], - ['notes/local-timeline', ep___notes_localTimeline], - ['notes/mentions', ep___notes_mentions], - ['notes/polls/recommendation', ep___notes_polls_recommendation], - ['notes/polls/vote', ep___notes_polls_vote], - ['notes/reactions', ep___notes_reactions], - ['notes/reactions/create', ep___notes_reactions_create], - ['notes/reactions/delete', ep___notes_reactions_delete], - ['notes/renotes', ep___notes_renotes], - ['notes/replies', ep___notes_replies], - ['notes/search-by-tag', ep___notes_searchByTag], - ['notes/search', ep___notes_search], - ['notes/show', ep___notes_show], - ['notes/state', ep___notes_state], - ['notes/thread-muting/create', ep___notes_threadMuting_create], - ['notes/thread-muting/delete', ep___notes_threadMuting_delete], - ['notes/timeline', ep___notes_timeline], - ['notes/translate', ep___notes_translate], - ['notes/unrenote', ep___notes_unrenote], - ['notes/user-list-timeline', ep___notes_userListTimeline], - ['notifications/create', ep___notifications_create], - ['notifications/flush', ep___notifications_flush], - ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], - ['notifications/test-notification', ep___notifications_testNotification], - ['page-push', ep___pagePush], - ['pages/create', ep___pages_create], - ['pages/delete', ep___pages_delete], - ['pages/featured', ep___pages_featured], - ['pages/like', ep___pages_like], - ['pages/show', ep___pages_show], - ['pages/unlike', ep___pages_unlike], - ['pages/update', ep___pages_update], - ['flash/create', ep___flash_create], - ['flash/delete', ep___flash_delete], - ['flash/featured', ep___flash_featured], - ['flash/like', ep___flash_like], - ['flash/show', ep___flash_show], - ['flash/unlike', ep___flash_unlike], - ['flash/update', ep___flash_update], - ['flash/my', ep___flash_my], - ['flash/my-likes', ep___flash_myLikes], - ['ping', ep___ping], - ['pinned-users', ep___pinnedUsers], - ['promo/read', ep___promo_read], - ['roles/list', ep___roles_list], - ['roles/show', ep___roles_show], - ['roles/users', ep___roles_users], - ['roles/notes', ep___roles_notes], - ['request-reset-password', ep___requestResetPassword], - ['reset-db', ep___resetDb], - ['reset-password', ep___resetPassword], - ['server-info', ep___serverInfo], - ['stats', ep___stats], - ['sw/show-registration', ep___sw_show_registration], - ['sw/update-registration', ep___sw_update_registration], - ['sw/register', ep___sw_register], - ['sw/unregister', ep___sw_unregister], - ['test', ep___test], - ['username/available', ep___username_available], - ['users', ep___users], - ['users/clips', ep___users_clips], - ['users/followers', ep___users_followers], - ['users/following', ep___users_following], - ['users/gallery/posts', ep___users_gallery_posts], - ['users/get-frequently-replied-users', ep___users_getFrequentlyRepliedUsers], - ['users/featured-notes', ep___users_featuredNotes], - ['users/lists/create', ep___users_lists_create], - ['users/lists/delete', ep___users_lists_delete], - ['users/lists/list', ep___users_lists_list], - ['users/lists/pull', ep___users_lists_pull], - ['users/lists/push', ep___users_lists_push], - ['users/lists/show', ep___users_lists_show], - ['users/lists/favorite', ep___users_lists_favorite], - ['users/lists/unfavorite', ep___users_lists_unfavorite], - ['users/lists/update', ep___users_lists_update], - ['users/lists/create-from-public', ep___users_lists_createFromPublic], - ['users/lists/update-membership', ep___users_lists_updateMembership], - ['users/lists/get-memberships', ep___users_lists_getMemberships], - ['users/notes', ep___users_notes], - ['users/pages', ep___users_pages], - ['users/flashs', ep___users_flashs], - ['users/reactions', ep___users_reactions], - ['users/recommendation', ep___users_recommendation], - ['users/relation', ep___users_relation], - ['users/report-abuse', ep___users_reportAbuse], - ['users/search-by-username-and-host', ep___users_searchByUsernameAndHost], - ['users/search', ep___users_search], - ['users/show', ep___users_show], - ['users/achievements', ep___users_achievements], - ['users/update-memo', ep___users_updateMemo], - ['fetch-rss', ep___fetchRss], - ['fetch-external-resources', ep___fetchExternalResources], - ['retention', ep___retention], - ['bubble-game/register', ep___bubbleGame_register], - ['bubble-game/ranking', ep___bubbleGame_ranking], - ['reversi/cancel-match', ep___reversi_cancelMatch], - ['reversi/games', ep___reversi_games], - ['reversi/match', ep___reversi_match], - ['reversi/invitations', ep___reversi_invitations], - ['reversi/show-game', ep___reversi_showGame], - ['reversi/surrender', ep___reversi_surrender], - ['reversi/verify', ep___reversi_verify], -]; +import * as endpointsObject from './endpoint-list.js'; interface IEndpointMetaBase { readonly stability?: 'deprecated' | 'experimental' | 'stable'; @@ -809,7 +39,7 @@ interface IEndpointMetaBase { */ readonly requireAdmin?: boolean; - readonly requireRolePolicy?: KeyOf<'RolePolicies'>; + readonly requiredRolePolicy?: KeyOf<'RolePolicies'>; /** * 引っ越し済みのユーザーによるリクエストを禁止するか @@ -892,7 +122,7 @@ export type IEndpointMeta = (Omit & { requireAdmin: true, kind: (typeof permissions)[number], -}) +}); export interface IEndpoint { name: string; @@ -900,7 +130,7 @@ export interface IEndpoint { params: Schema; } -const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { +const endpoints: IEndpoint[] = Object.entries(endpointsObject).map(([name, ep]) => { return { name: name, get meta() { diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts index cf3f257ca6..0dbfaae054 100644 --- a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -71,9 +71,22 @@ export const meta = { }, assignee: { type: 'object', - nullable: true, optional: true, + nullable: true, optional: false, ref: 'UserDetailedNotMe', }, + forwarded: { + type: 'boolean', + nullable: false, optional: false, + }, + resolvedAs: { + type: 'string', + nullable: true, optional: false, + enum: ['accept', 'reject', null], + }, + moderationNote: { + type: 'string', + nullable: false, optional: false, + }, }, }, }, @@ -88,7 +101,6 @@ export const paramDef = { state: { type: 'string', nullable: true, default: null }, reporterOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, targetUserOrigin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, - forwarded: { type: 'boolean', default: false }, }, required: [], } as const; 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 a7e8a3b018..06047b58a6 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -4,19 +4,33 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { IsNull } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsersRepository } from '@/models/_.js'; import { SignupService } from '@/core/SignupService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { InstanceActorService } from '@/core/InstanceActorService.js'; import { localUsernameSchema, passwordSchema } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import { ApiError } from '@/server/api/error.js'; import { Packed } from '@/misc/json-schema.js'; export const meta = { tags: ['admin'], + errors: { + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '1fb7cb09-d46a-4fff-b8df-057708cce513', + }, + + wrongInitialPassword: { + message: 'Initial password is incorrect.', + code: 'INCORRECT_INITIAL_PASSWORD', + id: '97147c55-1ae1-4f6f-91d6-e1c3e0e76d62', + }, + }, + res: { type: 'object', optional: false, nullable: false, @@ -35,6 +49,7 @@ export const paramDef = { properties: { username: localUsernameSchema, password: passwordSchema, + setupPassword: { type: 'string', nullable: true }, }, required: ['username', 'password'], } as const; @@ -42,17 +57,37 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, private userEntityService: UserEntityService, private signupService: SignupService, - private instanceActorService: InstanceActorService, ) { super(meta, paramDef, async (ps, _me, token) => { const me = _me ? await this.usersRepository.findOneByOrFail({ id: _me.id }) : null; - const realUsers = await this.instanceActorService.realLocalUsersPresent(); - if ((realUsers && !me?.isRoot) || token !== null) throw new Error('access denied'); + + if (this.serverSettings.rootUserId == null && me == null && token == null) { + // 初回セットアップの場合 + if (this.config.setupPassword != null) { + // 初期パスワードが設定されている場合 + if (ps.setupPassword !== this.config.setupPassword) { + // 初期パスワードが違う場合 + throw new ApiError(meta.errors.wrongInitialPassword); + } + } else if (ps.setupPassword != null && ps.setupPassword.trim() !== '') { + // 初期パスワードが設定されていないのに初期パスワードが入力された場合 + throw new ApiError(meta.errors.wrongInitialPassword); + } + } else if ((this.serverSettings.rootUserId != null && (this.serverSettings.rootUserId !== me?.id)) || token !== null) { + // 初回セットアップではなく、管理者でない場合 or 外部トークンを使用している場合 + throw new ApiError(meta.errors.accessDenied); + } const { account, secret } = await this.signupService.signup({ username: ps.username, diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index 01dea703a3..d04f52dd64 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -42,11 +42,7 @@ export default class extends Endpoint { // eslint- throw new Error('user not found'); } - if (user.isRoot) { - throw new Error('cannot delete a root account'); - } - - await this.deleteAccoountService.deleteAccount(user); + await this.deleteAccoountService.deleteAccount(user, me); }); } } 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 2dae1df87d..b8bfda73a4 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -55,7 +55,7 @@ export const paramDef = { properties: { title: { type: 'string', minLength: 1 }, text: { type: 'string', minLength: 1 }, - imageUrl: { type: 'string', nullable: true, minLength: 1 }, + imageUrl: { type: 'string', nullable: true, minLength: 0 }, icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' }, display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, forExistingUsers: { type: 'boolean', default: false }, @@ -76,7 +76,8 @@ export default class extends Endpoint { // eslint- updatedAt: null, title: ps.title, text: ps.text, - imageUrl: ps.imageUrl, + /* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */ + imageUrl: ps.imageUrl || null, icon: ps.icon, display: ps.display, forExistingUsers: ps.forExistingUsers, diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts index fd21309818..0121c302ac 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/create.ts @@ -6,13 +6,57 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; +import { IdService } from '@/core/IdService.js'; export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', + + res: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + updatedAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, + name: { + type: 'string', + optional: false, nullable: false, + }, + description: { + type: 'string', + optional: false, nullable: false, + }, + url: { + type: 'string', + optional: false, nullable: false, + }, + roleIdsThatCanBeUsedThisDecoration: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, + }, } as const; export const paramDef = { @@ -32,14 +76,25 @@ export const paramDef = { export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( private avatarDecorationService: AvatarDecorationService, + private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { - await this.avatarDecorationService.create({ + const created = await this.avatarDecorationService.create({ name: ps.name, description: ps.description, url: ps.url, roleIdsThatCanBeUsedThisDecoration: ps.roleIdsThatCanBeUsedThisDecoration, }, me); + + return { + id: created.id, + createdAt: this.idService.parse(created.id).date.toISOString(), + updatedAt: null, + name: created.name, + description: created.description, + url: created.url, + roleIdsThatCanBeUsedThisDecoration: created.roleIdsThatCanBeUsedThisDecoration, + }; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts index 3a5673d99d..13660d0b8c 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/delete.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', errors: { }, diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts index aee90023e1..d4d9a7235b 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/list.ts @@ -4,10 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js'; -import type { MiAnnouncement } from '@/models/Announcement.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { QueryService } from '@/core/QueryService.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; @@ -16,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'read:admin:avatar-decorations', res: { diff --git a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts index 34b3b5a11f..22476a6888 100644 --- a/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/avatar-decorations/update.ts @@ -13,7 +13,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageAvatarDecorations', + requiredRolePolicy: 'canManageAvatarDecorations', kind: 'write:admin:avatar-decorations', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/current.ts b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts new file mode 100644 index 0000000000..63ec740348 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/captcha/current.ts @@ -0,0 +1,70 @@ +/* + * 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 { CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js'; + +export const meta = { + tags: ['admin', 'captcha'], + + requireCredential: true, + requireAdmin: true, + + // 実態はmetaの取得であるため + kind: 'read:admin:meta', + + res: { + type: 'object', + properties: { + provider: { + type: 'string', + enum: supportedCaptchaProviders, + }, + hcaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + mcaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + instanceUrl: { type: 'string', nullable: true }, + }, + }, + recaptcha: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + turnstile: { + type: 'object', + properties: { + siteKey: { type: 'string', nullable: true }, + secretKey: { type: 'string', nullable: true }, + }, + }, + }, + }, +} as const; + +export const paramDef = {} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private captchaService: CaptchaService, + ) { + super(meta, paramDef, async () => { + return this.captchaService.get(); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/captcha/save.ts b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts new file mode 100644 index 0000000000..98ec278ebe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/captcha/save.ts @@ -0,0 +1,129 @@ +/* + * 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 { captchaErrorCodes, CaptchaService, supportedCaptchaProviders } from '@/core/CaptchaService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'captcha'], + + requireCredential: true, + requireAdmin: true, + + // 実態はmetaの更新であるため + kind: 'write:admin:meta', + + errors: { + invalidProvider: { + message: 'Invalid provider.', + code: 'INVALID_PROVIDER', + id: '14bf7ae1-80cc-4363-acb2-4fd61d086af0', + httpStatusCode: 400, + }, + invalidParameters: { + message: 'Invalid parameters.', + code: 'INVALID_PARAMETERS', + id: '26654194-410e-44e2-b42e-460ff6f92476', + httpStatusCode: 400, + }, + noResponseProvided: { + message: 'No response provided.', + code: 'NO_RESPONSE_PROVIDED', + id: '40acbba8-0937-41fb-bb3f-474514d40afe', + httpStatusCode: 400, + }, + requestFailed: { + message: 'Request failed.', + code: 'REQUEST_FAILED', + id: '0f4fe2f1-2c15-4d6e-b714-efbfcde231cd', + httpStatusCode: 500, + }, + verificationFailed: { + message: 'Verification failed.', + code: 'VERIFICATION_FAILED', + id: 'c41c067f-24f3-4150-84b2-b5a3ae8c2214', + httpStatusCode: 400, + }, + unknown: { + message: 'unknown', + code: 'UNKNOWN', + id: 'f868d509-e257-42a9-99c1-42614b031a97', + httpStatusCode: 500, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + provider: { + type: 'string', + enum: supportedCaptchaProviders, + }, + captchaResult: { + type: 'string', nullable: true, + }, + sitekey: { + type: 'string', nullable: true, + }, + secret: { + type: 'string', nullable: true, + }, + instanceUrl: { + type: 'string', nullable: true, + }, + }, + required: ['provider'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private captchaService: CaptchaService, + ) { + super(meta, paramDef, async (ps) => { + const result = await this.captchaService.save(ps.provider, { + sitekey: ps.sitekey, + secret: ps.secret, + instanceUrl: ps.instanceUrl, + captchaResult: ps.captchaResult, + }); + + if (!result.success) { + switch (result.error.code) { + case captchaErrorCodes.invalidProvider: + throw new ApiError({ + ...meta.errors.invalidProvider, + message: result.error.message, + }); + case captchaErrorCodes.invalidParameters: + throw new ApiError({ + ...meta.errors.invalidParameters, + message: result.error.message, + }); + case captchaErrorCodes.noResponseProvided: + throw new ApiError({ + ...meta.errors.noResponseProvided, + message: result.error.message, + }); + case captchaErrorCodes.requestFailed: + throw new ApiError({ + ...meta.errors.requestFailed, + message: result.error.message, + }); + case captchaErrorCodes.verificationFailed: + throw new ApiError({ + ...meta.errors.verificationFailed, + message: result.error.message, + }); + default: + throw new ApiError(meta.errors.unknown); + } + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/delete-account.ts b/packages/backend/src/server/api/endpoints/admin/delete-account.ts index b6f0f22d60..9065a71f6a 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-account.ts @@ -33,13 +33,13 @@ export default class extends Endpoint { // eslint- private deleteAccountService: DeleteAccountService, ) { - super(meta, paramDef, async (ps) => { + super(meta, paramDef, async (ps, me) => { const user = await this.usersRepository.findOneByOrFail({ id: ps.userId }); if (user.isDeleted) { return; } - await this.deleteAccountService.deleteAccount(user); + await this.deleteAccountService.deleteAccount(user, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts index a30a080e59..1459351d37 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts index 796f273330..3852146177 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -9,13 +9,14 @@ import type { DriveFilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { FILE_TYPE_IMAGE } from '@/const.js'; import { ApiError } from '../../../error.js'; export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { @@ -24,6 +25,11 @@ export const meta = { code: 'NO_SUCH_FILE', id: 'fc46b5a4-6b92-4c33-ac66-b806659bb5cf', }, + unsupportedFileType: { + message: 'Unsupported file type.', + code: 'UNSUPPORTED_FILE_TYPE', + id: 'f7599d96-8750-af68-1633-9575d625c1a7', + }, duplicateName: { message: 'Duplicate name.', code: 'DUPLICATE_NAME', @@ -47,15 +53,21 @@ export const paramDef = { nullable: true, description: 'Use `null` to reset the category.', }, - aliases: { type: 'array', items: { - type: 'string', - } }, + aliases: { + type: 'array', + items: { + type: 'string', + }, + }, license: { type: 'string', nullable: true }, isSensitive: { type: 'boolean' }, localOnly: { type: 'boolean' }, - roleIdsThatCanBeUsedThisEmojiAsReaction: { type: 'array', items: { - type: 'string', - } }, + roleIdsThatCanBeUsedThisEmojiAsReaction: { + type: 'array', + items: { + type: 'string', + }, + }, }, required: ['name', 'fileId'], } as const; @@ -67,9 +79,7 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private customEmojiService: CustomEmojiService, - private emojiEntityService: EmojiEntityService, ) { super(meta, paramDef, async (ps, me) => { @@ -77,9 +87,12 @@ export default class extends Endpoint { // eslint- if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); if (isDuplicate) throw new ApiError(meta.errors.duplicateName); + if (!FILE_TYPE_IMAGE.includes(driveFile.type)) throw new ApiError(meta.errors.unsupportedFileType); const emoji = await this.customEmojiService.add({ - driveFile, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: ps.name, category: ps.category ?? null, aliases: ps.aliases ?? [], diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts index 975f892df9..cf03859ce5 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -17,7 +17,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { @@ -86,7 +86,9 @@ export default class extends Endpoint { // eslint- if (isDuplicate) throw new ApiError(meta.errors.duplicateName); const addedEmoji = await this.customEmojiService.add({ - driveFile, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + fileType: driveFile.webpublicType ?? driveFile.type, name: emoji.name, category: emoji.category, aliases: emoji.aliases, diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts index cec9f700c3..7993edcc07 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts index 50c45b6ac5..87ed3f5f18 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts index 8e5f69c894..7ca931eb21 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts @@ -10,7 +10,7 @@ import { QueueService } from '@/core/QueueService.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', } as const; export const paramDef = { 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 0889ceb76f..b44007962d 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 @@ -16,7 +16,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'read:admin:emoji', res: { 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 ffb5dbf4b5..4342e178cc 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -16,7 +16,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'read:admin:emoji', res: { diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts index 0fa119eabe..161c3b9f37 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts index d9ee18699c..2e700809d8 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts index dc25df2767..ee87858b0e 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts index 4ba99faab7..7ab5916951 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', } 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 22609a16a3..6834a6d213 100644 --- a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; -import type { DriveFilesRepository } from '@/models/_.js'; +import type { DriveFilesRepository, MiEmoji } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -14,7 +14,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireRolePolicy: 'canManageCustomEmojis', + requiredRolePolicy: 'canManageCustomEmojis', kind: 'write:admin:emoji', errors: { @@ -78,25 +78,16 @@ export default class extends Endpoint { // eslint- if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); } - let emojiId; - if (ps.id) { - emojiId = ps.id; - const emoji = await this.customEmojiService.getEmojiById(ps.id); - if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); - if (ps.name && (ps.name !== emoji.name)) { - const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); - if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); - } - } else { - if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.'); - const emoji = await this.customEmojiService.getEmojiByName(ps.name); - if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); - emojiId = emoji.id; - } + // JSON schemeのanyOfの型変換がうまくいっていないらしい + const required = { id: ps.id, name: ps.name } as + | { id: MiEmoji['id']; name?: string } + | { id?: MiEmoji['id']; name: string }; - await this.customEmojiService.update(emojiId, { - driveFile, - name: ps.name, + const error = await this.customEmojiService.update({ + ...required, + originalUrl: driveFile != null ? driveFile.url : undefined, + publicUrl: driveFile != null ? (driveFile.webpublicUrl ?? driveFile.url) : undefined, + fileType: driveFile != null ? (driveFile.webpublicType ?? driveFile.type) : undefined, category: ps.category, aliases: ps.aliases, license: ps.license, @@ -104,6 +95,14 @@ export default class extends Endpoint { // eslint- localOnly: ps.localOnly, roleIdsThatCanBeUsedThisEmojiAsReaction: ps.roleIdsThatCanBeUsedThisEmojiAsReaction, }, me); + + switch (error) { + case null: return; + case 'NO_SUCH_EMOJI': throw new ApiError(meta.errors.noSuchEmoji); + case 'SAME_NAME_EMOJI_EXISTS': throw new ApiError(meta.errors.sameNameEmojiExists); + } + // 網羅性チェック + const mustBeNever: never = error; }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts new file mode 100644 index 0000000000..3e42c91fed --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/forward-abuse-user-report.ts @@ -0,0 +1,55 @@ +/* + * 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 { AbuseUserReportsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:resolve-abuse-user-report', + + errors: { + noSuchAbuseReport: { + message: 'No such abuse report.', + code: 'NO_SUCH_ABUSE_REPORT', + id: '8763e21b-d9bc-40be-acf6-54c1a6986493', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + reportId: { type: 'string', format: 'misskey:id' }, + }, + required: ['reportId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + private abuseReportService: AbuseReportService, + ) { + super(meta, paramDef, async (ps, me) => { + const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); + if (!report) { + throw new ApiError(meta.errors.noSuchAbuseReport); + } + + await this.abuseReportService.forward(report.id, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 29e8bfaf14..53e2b2b237 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -9,6 +9,7 @@ import { MetaService } from '@/core/MetaService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; export const meta = { tags: ['meta'], @@ -69,6 +70,14 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + enableTestcaptcha: { + type: 'boolean', + optional: false, nullable: false, + }, + googleAnalyticsMeasurementId: { + type: 'string', + optional: false, nullable: true, + }, swPublickey: { type: 'string', optional: false, nullable: true, @@ -173,6 +182,13 @@ export const meta = { type: 'string', }, }, + prohibitedWordsForNameOfUser: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + }, + }, bannedEmailDomains: { type: 'array', optional: true, nullable: false, @@ -222,7 +238,7 @@ export const meta = { }, proxyAccountId: { type: 'string', - optional: false, nullable: true, + optional: false, nullable: false, format: 'id', }, email: { @@ -337,6 +353,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + enableStatsForFederatedInstances: { + type: 'boolean', + optional: false, nullable: false, + }, enableServerMachineStats: { type: 'boolean', optional: false, nullable: false, @@ -495,6 +515,19 @@ export const meta = { type: 'string', optional: false, nullable: true, }, + federation: { + type: 'string', + enum: ['all', 'specified', 'none'], + optional: false, nullable: false, + }, + federationHosts: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + }, + }, }, }, } as const; @@ -513,10 +546,13 @@ export default class extends Endpoint { // eslint- private config: Config, private metaService: MetaService, + private systemAccountService: SystemAccountService, ) { super(meta, paramDef, async () => { const instance = await this.metaService.fetch(true); + const proxy = await this.systemAccountService.fetch('proxy'); + return { maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, @@ -543,6 +579,8 @@ export default class extends Endpoint { // eslint- recaptchaSiteKey: instance.recaptchaSiteKey, enableTurnstile: instance.enableTurnstile, turnstileSiteKey: instance.turnstileSiteKey, + enableTestcaptcha: instance.enableTestcaptcha, + googleAnalyticsMeasurementId: instance.googleAnalyticsMeasurementId, swPublickey: instance.swPublicKey, themeColor: instance.themeColor, mascotImageUrl: instance.mascotImageUrl, @@ -569,6 +607,7 @@ export default class extends Endpoint { // eslint- mediaSilencedHosts: instance.mediaSilencedHosts, sensitiveWords: instance.sensitiveWords, prohibitedWords: instance.prohibitedWords, + prohibitedWordsForNameOfUser: instance.prohibitedWordsForNameOfUser, preservedUsernames: instance.preservedUsernames, hcaptchaSecretKey: instance.hcaptchaSecretKey, mcaptchaSecretKey: instance.mcaptchaSecretKey, @@ -578,7 +617,7 @@ export default class extends Endpoint { // eslint- sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity, setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically, enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos, - proxyAccountId: instance.proxyAccountId, + proxyAccountId: proxy.id, email: instance.email, smtpSecure: instance.smtpSecure, smtpHost: instance.smtpHost, @@ -610,6 +649,7 @@ export default class extends Endpoint { // eslint- truemailAuthKey: instance.truemailAuthKey, enableChartsForRemoteUser: instance.enableChartsForRemoteUser, enableChartsForFederatedInstances: instance.enableChartsForFederatedInstances, + enableStatsForFederatedInstances: instance.enableStatsForFederatedInstances, enableServerMachineStats: instance.enableServerMachineStats, enableIdenticonGeneration: instance.enableIdenticonGeneration, bannedEmailDomains: instance.bannedEmailDomains, @@ -630,6 +670,8 @@ export default class extends Endpoint { // eslint- urlPreviewRequireContentLength: instance.urlPreviewRequireContentLength, urlPreviewUserAgent: instance.urlPreviewUserAgent, urlPreviewSummaryProxyUrl: instance.urlPreviewSummaryProxyUrl, + federation: instance.federation, + federationHosts: instance.federationHosts, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index 53db096c1d..fc246631c2 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import bcrypt from 'bcryptjs'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; +import type { UsersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; @@ -43,6 +43,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -58,7 +61,7 @@ export default class extends Endpoint { // eslint- throw new Error('user not found'); } - if (user.isRoot) { + if (this.serverSettings.rootUserId === user.id) { throw new Error('cannot reset password of root'); } diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts index 9b79100fcf..554d324ff2 100644 --- a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -32,7 +32,7 @@ export const paramDef = { type: 'object', properties: { reportId: { type: 'string', format: 'misskey:id' }, - forward: { type: 'boolean', default: false }, + resolvedAs: { type: 'string', enum: ['accept', 'reject', null], nullable: true }, }, required: ['reportId'], } as const; @@ -50,7 +50,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchAbuseReport); } - await this.abuseReportService.resolve([{ reportId: report.id, forward: ps.forward }], me); + await this.abuseReportService.resolve([{ reportId: report.id, resolvedAs: ps.resolvedAs ?? null }], me); }); } } 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 5a1c05f41a..1ba6853dbe 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -31,6 +31,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + followedMessage: { + type: 'string', + optional: false, nullable: true, + }, autoAcceptFollowed: { type: 'boolean', optional: false, nullable: false, @@ -102,6 +106,7 @@ export const meta = { receiveFollowRequest: { optional: true, ...notificationRecieveConfig }, followRequestAccepted: { optional: true, ...notificationRecieveConfig }, roleAssigned: { optional: true, ...notificationRecieveConfig }, + chatRoomInvitationReceived: { optional: true, ...notificationRecieveConfig }, achievementEarned: { optional: true, ...notificationRecieveConfig }, app: { optional: true, ...notificationRecieveConfig }, test: { optional: true, ...notificationRecieveConfig }, @@ -226,6 +231,7 @@ export default class extends Endpoint { // eslint- return { email: profile.email, emailVerified: profile.emailVerified, + followedMessage: profile.followedMessage, autoAcceptFollowed: profile.autoAcceptFollowed, noCrawle: profile.noCrawle, preventAiLearning: profile.preventAiLearning, diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 2fef9abbf9..2b2c8c60ab 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -71,13 +71,13 @@ export default class extends Endpoint { // eslint- break; } case 'moderator': { - const moderatorIds = await this.roleService.getModeratorIds(false); + const moderatorIds = await this.roleService.getModeratorIds({ includeAdmins: false }); if (moderatorIds.length === 0) return []; query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds }); break; } case 'adminOrModerator': { - const adminOrModeratorIds = await this.roleService.getModeratorIds(); + const adminOrModeratorIds = await this.roleService.getModeratorIds({ includeAdmins: true }); if (adminOrModeratorIds.length === 0) return []; query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds }); break; diff --git a/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts new file mode 100644 index 0000000000..73d4b843f0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-abuse-user-report.ts @@ -0,0 +1,58 @@ +/* + * 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 { AbuseUserReportsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { AbuseReportService } from '@/core/AbuseReportService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:resolve-abuse-user-report', + + errors: { + noSuchAbuseReport: { + message: 'No such abuse report.', + code: 'NO_SUCH_ABUSE_REPORT', + id: '15f51cf5-46d1-4b1d-a618-b35bcbed0662', + kind: 'server', + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + reportId: { type: 'string', format: 'misskey:id' }, + moderationNote: { type: 'string' }, + }, + required: ['reportId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.abuseUserReportsRepository) + private abuseUserReportsRepository: AbuseUserReportsRepository, + private abuseReportService: AbuseReportService, + ) { + super(meta, paramDef, async (ps, me) => { + const report = await this.abuseUserReportsRepository.findOneBy({ id: ps.reportId }); + if (!report) { + throw new ApiError(meta.errors.noSuchAbuseReport); + } + + await this.abuseReportService.update(report.id, { + moderationNote: ps.moderationNote, + }, me); + }); + } +} 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 865e73f274..bc05587668 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -46,6 +46,11 @@ export const paramDef = { type: 'string', }, }, + prohibitedWordsForNameOfUser: { + type: 'array', nullable: true, items: { + type: 'string', + }, + }, themeColor: { type: 'string', nullable: true, pattern: '^#[0-9a-fA-F]{6}$' }, mascotImageUrl: { type: 'string', nullable: true }, bannerUrl: { type: 'string', nullable: true }, @@ -78,11 +83,12 @@ export const paramDef = { enableTurnstile: { type: 'boolean' }, turnstileSiteKey: { type: 'string', nullable: true }, turnstileSecretKey: { type: 'string', nullable: true }, + enableTestcaptcha: { type: 'boolean' }, + googleAnalyticsMeasurementId: { type: 'string', nullable: true }, sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] }, sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] }, setSensitiveFlagAutomatically: { type: 'boolean' }, enableSensitiveMediaDetectionForVideos: { type: 'boolean' }, - proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true }, maintainerName: { type: 'string', nullable: true }, maintainerEmail: { type: 'string', nullable: true }, langs: { @@ -111,7 +117,7 @@ export const paramDef = { useObjectStorage: { type: 'boolean' }, objectStorageBaseUrl: { type: 'string', nullable: true }, objectStorageBucket: { type: 'string', nullable: true }, - objectStoragePrefix: { type: 'string', nullable: true }, + objectStoragePrefix: { type: 'string', pattern: /^[a-zA-Z0-9-._]*$/.source, nullable: true }, objectStorageEndpoint: { type: 'string', nullable: true }, objectStorageRegion: { type: 'string', nullable: true }, objectStoragePort: { type: 'integer', nullable: true }, @@ -130,6 +136,7 @@ export const paramDef = { truemailAuthKey: { type: 'string', nullable: true }, enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' }, + enableStatsForFederatedInstances: { type: 'boolean' }, enableServerMachineStats: { type: 'boolean' }, enableIdenticonGeneration: { type: 'boolean' }, serverRules: { type: 'array', items: { type: 'string' } }, @@ -168,6 +175,16 @@ export const paramDef = { urlPreviewRequireContentLength: { type: 'boolean' }, urlPreviewUserAgent: { type: 'string', nullable: true }, urlPreviewSummaryProxyUrl: { type: 'string', nullable: true }, + federation: { + type: 'string', + enum: ['all', 'none', 'specified'], + }, + federationHosts: { + type: 'array', + items: { + type: 'string', + }, + }, }, required: [], } as const; @@ -203,6 +220,9 @@ export default class extends Endpoint { // eslint- if (Array.isArray(ps.prohibitedWords)) { set.prohibitedWords = ps.prohibitedWords.filter(Boolean); } + if (Array.isArray(ps.prohibitedWordsForNameOfUser)) { + set.prohibitedWordsForNameOfUser = ps.prohibitedWordsForNameOfUser.filter(Boolean); + } if (Array.isArray(ps.silencedHosts)) { let lastValue = ''; set.silencedHosts = ps.silencedHosts.sort().filter((h) => { @@ -347,6 +367,16 @@ export default class extends Endpoint { // eslint- set.turnstileSecretKey = ps.turnstileSecretKey; } + if (ps.enableTestcaptcha !== undefined) { + set.enableTestcaptcha = ps.enableTestcaptcha; + } + + if (ps.googleAnalyticsMeasurementId !== undefined) { + // 空文字列をnullにしたいので??は使わない + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + set.googleAnalyticsMeasurementId = ps.googleAnalyticsMeasurementId || null; + } + if (ps.sensitiveMediaDetection !== undefined) { set.sensitiveMediaDetection = ps.sensitiveMediaDetection; } @@ -363,10 +393,6 @@ export default class extends Endpoint { // eslint- set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos; } - if (ps.proxyAccountId !== undefined) { - set.proxyAccountId = ps.proxyAccountId; - } - if (ps.maintainerName !== undefined) { set.maintainerName = ps.maintainerName; } @@ -555,6 +581,10 @@ export default class extends Endpoint { // eslint- set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; } + if (ps.enableStatsForFederatedInstances !== undefined) { + set.enableStatsForFederatedInstances = ps.enableStatsForFederatedInstances; + } + if (ps.enableServerMachineStats !== undefined) { set.enableServerMachineStats = ps.enableServerMachineStats; } @@ -637,6 +667,14 @@ export default class extends Endpoint { // eslint- set.urlPreviewSummaryProxyUrl = value === '' ? null : value; } + if (ps.federation !== undefined) { + set.federation = ps.federation; + } + + if (Array.isArray(ps.federationHosts)) { + set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase()); + } + const before = await this.metaService.fetch(true); await this.metaService.update(set); diff --git a/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts new file mode 100644 index 0000000000..6c9612c71a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-proxy-account.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { + descriptionSchema, +} from '@/models/User.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + kind: 'write:admin:account', + + res: { + type: 'object', + nullable: false, optional: false, + ref: 'UserDetailed', + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + description: { ...descriptionSchema, nullable: true }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private userEntityService: UserEntityService, + private moderationLogService: ModerationLogService, + private systemAccountService: SystemAccountService, + ) { + super(meta, paramDef, async (ps, me) => { + const proxy = await this.systemAccountService.updateCorrespondingUserProfile('proxy', { + description: ps.description, + }); + + const updated = await this.userEntityService.pack(proxy.id, proxy, { + schema: 'MeDetailed', + }); + + if (ps.description !== undefined) { + this.moderationLogService.log(me, 'updateProxyAccountDescription', { + before: null, //TODO + after: ps.description, + }); + } + + return updated; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index f4dfe1ecc4..4b8543c2d1 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -8,7 +8,6 @@ import * as Redis from 'ioredis'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, AntennasRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; @@ -59,9 +58,6 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.redisForTimelines) - private redisForTimelines: Redis.Redis, - @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -71,7 +67,6 @@ export default class extends Endpoint { // eslint- private idService: IdService, private noteEntityService: NoteEntityService, private queryService: QueryService, - private noteReadService: NoteReadService, private fanoutTimelineService: FanoutTimelineService, private globalEventService: GlobalEventService, ) { @@ -114,8 +109,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); const notes = await query.getMany(); if (sinceId != null && untilId == null) { @@ -124,8 +119,6 @@ export default class extends Endpoint { // eslint- notes.sort((a, b) => a.id > b.id ? -1 : 1); } - this.noteReadService.read(me.id, notes); - return await this.noteEntityService.packMany(notes, me); }); } diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts index d8c55de7ec..14286bc23e 100644 --- a/packages/backend/src/server/api/endpoints/ap/get.ts +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -11,6 +11,7 @@ import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; export const meta = { tags: ['federation'], + requireAdmin: true, requireCredential: true, kind: 'read:federation', diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index d3c40dba59..4afed7dc5c 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { MiNote } from '@/models/Note.js'; @@ -12,7 +12,6 @@ import { isActor, isPost, getApId } from '@/core/activitypub/type.js'; import type { SchemaType } from '@/misc/json-schema.js'; import { ApResolverService } from '@/core/activitypub/ApResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; -import { MetaService } from '@/core/MetaService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; @@ -20,6 +19,8 @@ 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'; export const meta = { tags: ['federation'], @@ -33,6 +34,26 @@ export const meta = { }, errors: { + federationNotAllowed: { + message: 'Federation for this host is not allowed.', + code: 'FEDERATION_NOT_ALLOWED', + id: '974b799e-1a29-4889-b706-18d4dd93e266', + }, + uriInvalid: { + message: 'URI is invalid.', + code: 'URI_INVALID', + id: '1a5eab56-e47b-48c2-8d5e-217b897d70db', + }, + requestFailed: { + message: 'Request failed.', + code: 'REQUEST_FAILED', + id: '81b539cf-4f57-4b29-bc98-032c33c0792e', + }, + responseInvalid: { + message: 'Response from remote server is invalid.', + code: 'RESPONSE_INVALID', + id: '70193c39-54f3-4813-82f0-70a680f7495b', + }, noSuchObject: { message: 'No such object.', code: 'NO_SUCH_OBJECT', @@ -91,7 +112,6 @@ export default class extends Endpoint { // eslint- private utilityService: UtilityService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, - private metaService: MetaService, private apResolverService: ApResolverService, private apDbResolverService: ApDbResolverService, private apPersonService: ApPersonService, @@ -112,9 +132,9 @@ export default class extends Endpoint { // eslint- */ @bindThis private async fetchAny(uri: string, me: MiLocalUser | null | undefined): Promise | null> { - // ブロックしてたら中断 - const fetchedMeta = await this.metaService.fetch(); - if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, this.utilityService.extractDbHost(uri))) return null; + if (!this.utilityService.isFederationAllowedUri(uri)) { + throw new ApiError(meta.errors.federationNotAllowed); + } let local = await this.mergePack(me, ...await Promise.all([ this.apDbResolverService.getUserFromApId(uri), @@ -122,9 +142,45 @@ export default class extends Endpoint { // eslint- ])); if (local != null) return local; + const host = this.utilityService.extractDbHost(uri); + + // local object, not found in db? fail + if (this.utilityService.isSelfHost(host)) return null; + // リモートから一旦オブジェクトフェッチ const resolver = this.apResolverService.createResolver(); - const object = await resolver.resolve(uri) as any; + // 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) { + switch (err.id) { + // resolve + case 'b94fd5b1-0e3b-4678-9df2-dad4cd515ab2': + throw new ApiError(meta.errors.uriInvalid); + case '0dc86cf6-7cd6-4e56-b1e6-5903d62d7ea5': + case 'd592da9f-822f-4d91-83d7-4ceefabcf3d2': + throw new ApiError(meta.errors.requestFailed); + case '09d79f9e-64f1-4316-9cfa-e75c4d091574': + throw new ApiError(meta.errors.federationNotAllowed); + case '72180409-793c-4973-868e-5a118eb5519b': + throw new ApiError(meta.errors.responseInvalid); + + // resolveLocal + case '02b40cd0-fa92-4b0c-acc9-fb2ada952ab8': + throw new ApiError(meta.errors.uriInvalid); + case 'a9d946e5-d276-47f8-95fb-f04230289bb0': + case '06ae3170-1796-4d93-a697-2611ea6d83b6': + throw new ApiError(meta.errors.noSuchObject); + case '7a5d2fc0-94bc-4db6-b8b8-1bf24a2e23d0': + throw new ApiError(meta.errors.responseInvalid); + } + } + + throw new ApiError(meta.errors.requestFailed); + }); + + if (object.id == null) { + throw new ApiError(meta.errors.responseInvalid); + } // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する // これはDBに存在する可能性があるため再度DB検索 @@ -136,10 +192,11 @@ export default class extends Endpoint { // eslint- if (local != null) return local; } + // 同一ユーザーの情報を再度処理するので、使用済みのresolverを再利用してはいけない return await this.mergePack( me, isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null, - isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null, + isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null, ); } diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 8c55673590..cec5f8fd9c 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -5,14 +5,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { ChannelsRepository, NotesRepository } from '@/models/_.js'; +import type { ChannelsRepository, MiMeta, NotesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; -import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { MiLocalUser } from '@/models/User.js'; import { ApiError } from '../../error.js'; @@ -58,6 +56,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -68,16 +69,12 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - private cacheService: CacheService, private activeUsersChart: ActiveUsersChart, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const serverSettings = await this.metaService.fetch(); - const channel = await this.channelsRepository.findOneBy({ id: ps.channelId, }); @@ -88,7 +85,7 @@ export default class extends Endpoint { // eslint- if (me) this.activeUsersChart.read(me); - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me); } @@ -125,8 +122,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.channel', 'channel'); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } //#endregion diff --git a/packages/backend/src/server/api/endpoints/chat/history.ts b/packages/backend/src/server/api/endpoints/chat/history.ts new file mode 100644 index 0000000000..7553a751e0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/history.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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + room: { type: 'boolean', default: false }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit); + + const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me); + + if (ps.room) { + const roomIds = history.map(m => m.toRoomId!); + const readStateMap = await this.chatService.getRoomReadStateMap(me.id, roomIds); + + for (const message of packedMessages) { + message.isRead = readStateMap[message.toRoomId!] ?? false; + } + } else { + const otherIds = history.map(m => m.fromUserId === me.id ? m.toUserId! : m.fromUserId!); + const readStateMap = await this.chatService.getUserReadStateMap(me.id, otherIds); + + for (const message of packedMessages) { + const otherId = message.fromUserId === me.id ? message.toUserId! : message.fromUserId!; + message.isRead = readStateMap[otherId] ?? false; + } + } + + return packedMessages; + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts new file mode 100644 index 0000000000..1f334d5750 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-room.ts @@ -0,0 +1,105 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import type { DriveFilesRepository, MiUser } from '@/models/_.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + requiredRolePolicy: 'canChat', + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1hour'), + max: 500, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLite', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '8098520d-2da5-4e8f-8ee1-df78b55a4ec6', + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: 'b6accbd3-1d7b-4d9f-bdb7-eb185bac06db', + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '340517b7-6d04-42c0-bac1-37ee804e3594', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + text: { type: 'string', nullable: true, maxLength: 2000 }, + fileId: { type: 'string', format: 'misskey:id' }, + toRoomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['toRoomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.findRoomById(ps.toRoomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + let file = null; + if (ps.fileId != null) { + file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + return await this.chatService.createMessageToRoom(me, room, { + text: ps.text, + file: file, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts new file mode 100644 index 0000000000..6b77a026fb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/create-to-user.ts @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import type { DriveFilesRepository, MiUser } from '@/models/_.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + requiredRolePolicy: 'canChat', + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1hour'), + max: 500, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLite', + }, + + errors: { + recipientIsYourself: { + message: 'You can not send a message to yourself.', + code: 'RECIPIENT_IS_YOURSELF', + id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d', + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '4372b8e2-185d-4146-8749-2f68864a3e5f', + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '25587321-b0e6-449c-9239-f8925092942c', + }, + + youHaveBeenBlocked: { + message: 'You cannot send a message because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'c15a5199-7422-4968-941a-2a462c478f7d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + text: { type: 'string', nullable: true, maxLength: 2000 }, + fileId: { type: 'string', format: 'misskey:id' }, + toUserId: { type: 'string', format: 'misskey:id' }, + }, + required: ['toUserId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + let file = null; + if (ps.fileId != null) { + file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + // Myself + if (ps.toUserId === me.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + const toUser = await this.getterService.getUser(ps.toUserId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + return await this.chatService.createMessageToUser(me, toUser, { + text: ps.text, + file: file, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts new file mode 100644 index 0000000000..959599ddcf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts @@ -0,0 +1,52 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '36b67f0e-66a6-414b-83df-992a55294f17', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + const message = await this.chatService.findMyMessageById(me.id, ps.messageId); + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + await this.chatService.deleteMessage(message); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/react.ts b/packages/backend/src/server/api/endpoints/chat/messages/react.ts new file mode 100644 index 0000000000..561e36ed19 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/react.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 { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '9b5839b9-0ba0-4351-8c35-37082093d200', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + reaction: { type: 'string' }, + }, + required: ['messageId', 'reaction'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.react(ps.messageId, me.id, ps.reaction); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.ts new file mode 100644 index 0000000000..7aef35db04 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/room-timeline.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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLite', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!await this.chatService.hasPermissionToViewRoomTimeline(me.id, room)) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const messages = await this.chatService.roomTimeline(room.id, ps.limit, ps.sinceId, ps.untilId); + + this.chatService.readRoomChatMessage(me.id, room.id); + + return await this.chatEntityService.packMessagesLiteForRoom(messages); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/search.ts b/packages/backend/src/server/api/endpoints/chat/messages/search.ts new file mode 100644 index 0000000000..4c989e5ca9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/search.ts @@ -0,0 +1,76 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '460b3669-81b0-4dc9-a997-44442141bf83', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { type: 'string', minLength: 1, maxLength: 256 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, + roomId: { type: 'string', format: 'misskey:id', nullable: true }, + }, + required: ['query'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + if (ps.roomId != null) { + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!(await this.chatService.isRoomMember(room, me.id))) { + throw new ApiError(meta.errors.noSuchRoom); + } + } + + const messages = await this.chatService.searchMessages(me.id, ps.query, ps.limit, { + userId: ps.userId, + roomId: ps.roomId, + }); + + return await this.chatEntityService.packMessagesDetailed(messages, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/show.ts b/packages/backend/src/server/api/endpoints/chat/messages/show.ts new file mode 100644 index 0000000000..371f7a7071 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/show.ts @@ -0,0 +1,63 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; +import { RoleService } from '@/core/RoleService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessage', + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '3710865b-1848-4da9-8d61-cfed15510b93', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private roleService: RoleService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const message = await this.chatService.findMessageById(ps.messageId); + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + if (message.fromUserId !== me.id && message.toUserId !== me.id && !(await this.roleService.isModerator(me))) { + throw new ApiError(meta.errors.noSuchMessage); + } + return this.chatEntityService.packMessageDetailed(message, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts b/packages/backend/src/server/api/endpoints/chat/messages/unreact.ts new file mode 100644 index 0000000000..4eb25259fb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/unreact.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 { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: 'c39ea42f-e3ca-428a-ad57-390e0a711595', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + reaction: { type: 'string' }, + }, + required: ['messageId', 'reaction'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.unreact(ps.messageId, me.id, ps.reaction); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts new file mode 100644 index 0000000000..9d308d79b0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/messages/user-timeline.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatMessageLite', + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const other = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + const messages = await this.chatService.userTimeline(me.id, other.id, ps.limit, ps.sinceId, ps.untilId); + + this.chatService.readUserChatMessage(me.id, other.id); + + return await this.chatEntityService.packMessagesLiteFor1on1(messages); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts new file mode 100644 index 0000000000..fa4cc8ceb4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts @@ -0,0 +1,62 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + requiredRolePolicy: 'canChat', + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1day'), + max: 10, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', maxLength: 256 }, + description: { type: 'string', maxLength: 1024 }, + }, + required: ['name'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.createRoom(me, { + name: ps.name, + description: ps.description ?? '', + }); + return await this.chatEntityService.packRoom(room); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts new file mode 100644 index 0000000000..1d77a06dd8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts @@ -0,0 +1,57 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4b', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!await this.chatService.hasPermissionToDeleteRoom(me.id, room)) { + throw new ApiError(meta.errors.noSuchRoom); + } + + await this.chatService.deleteRoom(room, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts new file mode 100644 index 0000000000..5da4a1a772 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + requiredRolePolicy: 'canChat', + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1day'), + max: 50, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomInvitation', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '916f9507-49ba-4e90-b57f-1fd4deaa47a5', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId', 'userId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + const invitation = await this.chatService.createRoomInvitation(me.id, room.id, ps.userId); + return await this.chatEntityService.packRoomInvitation(invitation, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts new file mode 100644 index 0000000000..8c017f7d01 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/ignore.ts @@ -0,0 +1,48 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '5130557e-5a11-4cfb-9cc5-fe60cda5de0d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.ignoreRoomInvitation(me.id, ps.roomId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts new file mode 100644 index 0000000000..07337480fc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts @@ -0,0 +1,54 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomInvitation', + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + const invitations = await this.chatService.getReceivedRoomInvitationsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); + return this.chatEntityService.packRoomInvitations(invitations, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts new file mode 100644 index 0000000000..12d496e94b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/outbox.ts @@ -0,0 +1,67 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomInvitation', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'a3c6b309-9717-4316-ae94-a69b53437237', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const invitations = await this.chatService.getSentRoomInvitationsWithPagination(ps.roomId, ps.limit, ps.sinceId, ps.untilId); + return this.chatEntityService.packRoomInvitations(invitations, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/join.ts b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts new file mode 100644 index 0000000000..dbd4d1ea5a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/join.ts @@ -0,0 +1,48 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '84416476-5ce8-4a2c-b568-9569f1b10733', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.joinToRoom(me.id, ps.roomId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts new file mode 100644 index 0000000000..c4c6253236 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/joining.ts @@ -0,0 +1,58 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomMembership', + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const memberships = await this.chatService.getMyMemberships(me.id, ps.limit, ps.sinceId, ps.untilId); + + return this.chatEntityService.packRoomMemberships(memberships, me, { + populateUser: false, + populateRoom: true, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts new file mode 100644 index 0000000000..724ad61f7e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/leave.ts @@ -0,0 +1,48 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'cb7f3179-50e8-4389-8c30-dbe2650a67c9', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.leaveRoom(me.id, ps.roomId); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/members.ts b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts new file mode 100644 index 0000000000..407bfe74f1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/members.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoomMembership', + }, + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '7b9fe84c-eafc-4d21-bf89-485458ed2c18', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + if (!(await this.chatService.isRoomMember(room, me.id))) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const memberships = await this.chatService.getRoomMembershipsWithPagination(room.id, ps.limit, ps.sinceId, ps.untilId); + + return this.chatEntityService.packRoomMemberships(memberships, me, { + populateUser: true, + populateRoom: false, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts b/packages/backend/src/server/api/endpoints/chat/rooms/mute.ts new file mode 100644 index 0000000000..5208b8a253 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/mute.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 { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'c2cde4eb-8d0f-42f1-8f2f-c4d6bfc8e5df', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + mute: { type: 'boolean' }, + }, + required: ['roomId', 'mute'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + await this.chatService.muteRoom(me.id, ps.roomId, ps.mute); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts new file mode 100644 index 0000000000..6516120bca --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts @@ -0,0 +1,54 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatEntityService: ChatEntityService, + private chatService: ChatService, + ) { + super(meta, paramDef, async (ps, me) => { + const rooms = await this.chatService.getOwnedRoomsWithPagination(me.id, ps.limit, ps.sinceId, ps.untilId); + return this.chatEntityService.packRooms(rooms, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/show.ts b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts new file mode 100644 index 0000000000..547618ee7d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/show.ts @@ -0,0 +1,58 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'read:chat', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: '857ae02f-8759-4d20-9adb-6e95fffe4fd7', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.findRoomById(ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + return this.chatEntityService.packRoom(room, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/update.ts b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts new file mode 100644 index 0000000000..6f2a9c10b5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/update.ts @@ -0,0 +1,65 @@ +/* + * 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 { DI } from '@/di-symbols.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + + kind: 'write:chat', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + noSuchRoom: { + message: 'No such room.', + code: 'NO_SUCH_ROOM', + id: 'fcdb0f92-bda6-47f9-bd05-343e0e020932', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roomId: { type: 'string', format: 'misskey:id' }, + name: { type: 'string', maxLength: 256 }, + description: { type: 'string', maxLength: 1024 }, + }, + required: ['roomId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.findMyRoomById(me.id, ps.roomId); + if (room == null) { + throw new ApiError(meta.errors.noSuchRoom); + } + + const updated = await this.chatService.updateRoom(room, { + name: ps.name, + description: ps.description, + }); + + return this.chatEntityService.packRoom(updated, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts index ceebc8ba5e..b40706297d 100644 --- a/packages/backend/src/server/api/endpoints/clips/create.ts +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -39,7 +39,7 @@ export const paramDef = { properties: { name: { type: 'string', minLength: 1, maxLength: 100 }, isPublic: { type: 'boolean', default: false }, - description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, + description: { type: 'string', nullable: true, maxLength: 2048 }, }, required: ['name'], } as const; @@ -53,7 +53,9 @@ export default class extends Endpoint { // eslint- super(meta, paramDef, async (ps, me) => { let clip: MiClip; try { - clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description ?? null); + // 空文字列をnullにしたいので??は使わない + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description || null); } catch (e) { if (e instanceof ClipService.TooManyClipsError) { throw new ApiError(meta.errors.tooManyClips); diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 943c31c894..7638aae442 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -87,8 +87,8 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } const notes = await query diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts index 603a3ccf3d..6ff3f9aada 100644 --- a/packages/backend/src/server/api/endpoints/clips/update.ts +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -39,7 +39,7 @@ export const paramDef = { clipId: { type: 'string', format: 'misskey:id' }, name: { type: 'string', minLength: 1, maxLength: 100 }, isPublic: { type: 'boolean' }, - description: { type: 'string', nullable: true, minLength: 1, maxLength: 2048 }, + description: { type: 'string', nullable: true, maxLength: 2048 }, }, required: ['clipId'], } as const; @@ -53,7 +53,9 @@ export default class extends Endpoint { // eslint- ) { super(meta, paramDef, async (ps, me) => { try { - await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description); + // 空文字列をnullにしたいので??は使わない + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description || null); } catch (e) { if (e instanceof ClipService.NoSuchClipError) { throw new ApiError(meta.errors.noSuchClip); diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts index 7e9b0fa0e1..eb45e29f9e 100644 --- a/packages/backend/src/server/api/endpoints/drive.ts +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -5,7 +5,6 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { RoleService } from '@/core/RoleService.js'; @@ -41,14 +40,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - private metaService: MetaService, private driveFileEntityService: DriveFileEntityService, private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const instance = await this.metaService.fetch(true); - - // Calculate drive usage const usage = await this.driveFileEntityService.calcDriveUsageOf(me.id); const policies = await this.roleService.getUserPolicies(me.id); diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts index 4670392025..b86059b5e7 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -10,6 +10,7 @@ import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['drive', 'notes'], @@ -61,12 +62,13 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { // Fetch file const file = await this.driveFilesRepository.findOneBy({ id: ps.fileId, - userId: me.id, + userId: await this.roleService.isModerator(me) ? undefined : me.id, }); if (file == null) { diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 9c17f93ab2..74eb4dded7 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -4,14 +4,15 @@ */ import ms from 'ms'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/const.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; import { DriveService } from '@/core/DriveService.js'; import { ApiError } from '../../../error.js'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['drive'], @@ -73,8 +74,10 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private driveFileEntityService: DriveFileEntityService, - private metaService: MetaService, private driveService: DriveService, ) { super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => { @@ -91,8 +94,6 @@ export default class extends Endpoint { // eslint- } } - const instance = await this.metaService.fetch(); - try { // Create file const driveFile = await this.driveService.addFile({ @@ -103,8 +104,8 @@ export default class extends Endpoint { // eslint- folderId: ps.folderId, force: ps.force, sensitive: ps.isSensitive, - requestIp: instance.enableIpLogging ? ip : null, - requestHeaders: instance.enableIpLogging ? headers : null, + requestIp: this.serverSettings.enableIpLogging ? ip : null, + requestHeaders: this.serverSettings.enableIpLogging ? headers : null, }); return await this.driveFileEntityService.pack(driveFile, { self: true }); } catch (err) { diff --git a/packages/backend/src/server/api/endpoints/flash/featured.ts b/packages/backend/src/server/api/endpoints/flash/featured.ts index c2d6ab5085..9a0cb461f2 100644 --- a/packages/backend/src/server/api/endpoints/flash/featured.ts +++ b/packages/backend/src/server/api/endpoints/flash/featured.ts @@ -8,6 +8,7 @@ import type { FlashsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; import { DI } from '@/di-symbols.js'; +import { FlashService } from '@/core/FlashService.js'; export const meta = { tags: ['flash'], @@ -27,26 +28,25 @@ export const meta = { export const paramDef = { type: 'object', - properties: {}, + properties: { + offset: { type: 'integer', minimum: 0, default: 0 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, required: [], } as const; @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - @Inject(DI.flashsRepository) - private flashsRepository: FlashsRepository, - + private flashService: FlashService, private flashEntityService: FlashEntityService, ) { super(meta, paramDef, async (ps, me) => { - const query = this.flashsRepository.createQueryBuilder('flash') - .andWhere('flash.likedCount > 0') - .orderBy('flash.likedCount', 'DESC'); - - const flashs = await query.limit(10).getMany(); - - return await this.flashEntityService.packMany(flashs, me); + const result = await this.flashService.featured({ + offset: ps.offset, + limit: ps.limit, + }); + return await this.flashEntityService.packMany(result, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts index 8935c2c2da..b45d21410b 100644 --- a/packages/backend/src/server/api/endpoints/following/invalidate.ts +++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts @@ -96,7 +96,7 @@ export default class extends Endpoint { // eslint- await this.userFollowingService.unfollow(follower, followee); - return await this.userEntityService.pack(followee.id, me); + return await this.userEntityService.pack(follower.id, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/following/requests/sent.ts b/packages/backend/src/server/api/endpoints/following/requests/sent.ts new file mode 100644 index 0000000000..6325f01bb8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/sent.ts @@ -0,0 +1,77 @@ +/* + * 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 { QueryService } from '@/core/QueryService.js'; +import type { FollowRequestsRepository } from '@/models/_.js'; +import { FollowRequestEntityService } from '@/core/entities/FollowRequestEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['following', 'account'], + + requireCredential: true, + + kind: 'read:following', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + follower: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + followee: { + type: 'object', + optional: false, nullable: false, + ref: 'UserLite', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.followRequestsRepository) + private followRequestsRepository: FollowRequestsRepository, + + private followRequestEntityService: FollowRequestEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.followRequestsRepository.createQueryBuilder('request'), ps.sinceId, ps.untilId) + .andWhere('request.followerId = :meId', { meId: me.id }); + + const requests = await query + .limit(ps.limit) + .getMany(); + + return await this.followRequestEntityService.packMany(requests, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts index 91c8597b1b..055b5cc061 100644 --- a/packages/backend/src/server/api/endpoints/i/apps.ts +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -87,7 +87,7 @@ export default class extends Endpoint { // eslint- name: token.name ?? token.app?.name, createdAt: this.idService.parse(token.id).date.toISOString(), lastUsedAt: token.lastUsedAt?.toISOString(), - permission: token.permission, + permission: token.app ? token.app.permission : token.permission, }))); }); } diff --git a/packages/backend/src/server/api/endpoints/i/import-antennas.ts b/packages/backend/src/server/api/endpoints/i/import-antennas.ts index bdf6c065e8..ccec96ffbb 100644 --- a/packages/backend/src/server/api/endpoints/i/import-antennas.ts +++ b/packages/backend/src/server/api/endpoints/i/import-antennas.ts @@ -16,7 +16,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportAntennas', + requiredRolePolicy: 'canImportAntennas', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts index d7bb6bcd22..2fa450558b 100644 --- a/packages/backend/src/server/api/endpoints/i/import-blocking.ts +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportBlocking', + requiredRolePolicy: 'canImportBlocking', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts index e03192d8c6..9186fca162 100644 --- a/packages/backend/src/server/api/endpoints/i/import-following.ts +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportFollowing', + requiredRolePolicy: 'canImportFollowing', prohibitMoved: true, limit: { duration: ms('1hour'), diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts index 76b285bb7e..b6dbacd371 100644 --- a/packages/backend/src/server/api/endpoints/i/import-muting.ts +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportMuting', + requiredRolePolicy: 'canImportMuting', prohibitMoved: true, limit: { diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts index 76ecfd082c..5de0a70bbb 100644 --- a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -15,7 +15,7 @@ import { ApiError } from '../../error.js'; export const meta = { secure: true, requireCredential: true, - requireRolePolicy: 'canImportUserLists', + requiredRolePolicy: 'canImportUserLists', prohibitMoved: true, limit: { duration: ms('1hour'), diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts index 1bd641232c..7852b5a2e1 100644 --- a/packages/backend/src/server/api/endpoints/i/move.ts +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -19,6 +19,8 @@ import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import * as Acct from '@/misc/acct.js'; +import { DI } from '@/di-symbols.js'; +import { MiMeta } from '@/models/_.js'; export const meta = { tags: ['users'], @@ -81,6 +83,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 remoteUserResolveService: RemoteUserResolveService, private apiLoggerService: ApiLoggerService, private accountMoveService: AccountMoveService, @@ -92,7 +97,7 @@ export default class extends Endpoint { // eslint- // check parameter if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser); // abort if user is the root - if (me.isRoot) throw new ApiError(meta.errors.rootForbidden); + if (this.serverSettings.rootUserId === me.id) throw new ApiError(meta.errors.rootForbidden); // abort if user has already moved if (me.movedToUri) throw new ApiError(meta.errors.alreadyMoved); 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 dc6ffd3e02..88d7f51c26 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -9,7 +9,6 @@ import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; import { obsoleteNotificationTypes, groupedNotificationTypes, FilterUnionByProperty } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; @@ -63,13 +62,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.redis) private redisClient: Redis.Redis, - @Inject(DI.notesRepository) - private notesRepository: NotesRepository, - private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, - private noteReadService: NoteReadService, ) { super(meta, paramDef, async (ps, me) => { const EXTRA_LIMIT = 100; @@ -162,14 +157,6 @@ export default class extends Endpoint { // eslint- } groupedNotifications = groupedNotifications.slice(0, ps.limit); - const noteIds = groupedNotifications - .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) - .map(notification => notification.noteId!); - - if (noteIds.length > 0) { - const notes = await this.notesRepository.findBy({ id: In(noteIds) }); - this.noteReadService.read(me.id, notes); - } return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id); }); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 2f619380e9..be8d0cfb34 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -9,7 +9,6 @@ import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; import { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { DI } from '@/di-symbols.js'; @@ -69,7 +68,6 @@ export default class extends Endpoint { // eslint- private idService: IdService, private notificationEntityService: NotificationEntityService, private notificationService: NotificationService, - private noteReadService: NoteReadService, ) { super(meta, paramDef, async (ps, me) => { // includeTypes が空の場合はクエリしない @@ -136,15 +134,6 @@ export default class extends Endpoint { // eslint- this.notificationService.readAllNotification(me.id); } - const noteIds = notifications - .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) - .map(notification => notification.noteId); - - if (noteIds.length > 0) { - const notes = await this.notesRepository.findBy({ id: In(noteIds) }); - this.noteReadService.read(me.id, notes); - } - return await this.notificationEntityService.packMany(notifications, me.id); }); } diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts deleted file mode 100644 index d1a8eccb1d..0000000000 --- a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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 { NoteUnreadsRepository } from '@/models/_.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['account'], - - requireCredential: true, - - kind: 'write:account', -} 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( - @Inject(DI.noteUnreadsRepository) - private noteUnreadsRepository: NoteUnreadsRepository, - - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - // Remove documents - await this.noteUnreadsRepository.delete({ - userId: me.id, - }); - - // 全て既読になったイベントを発行 - this.globalEventService.publishMainStream(me.id, 'readAllUnreadMentions'); - this.globalEventService.publishMainStream(me.id, 'readAllUnreadSpecifiedNotes'); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts index 78f3cce9ad..d25d5d5e0e 100644 --- a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -7,7 +7,7 @@ import bcrypt from 'bcryptjs'; import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; -import generateUserToken from '@/misc/generate-native-user-token.js'; +import { generateNativeUserToken } from '@/misc/token.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; @@ -49,7 +49,7 @@ export default class extends Endpoint { // eslint- throw new Error('incorrect password'); } - const newToken = generateUserToken(); + const newToken = generateNativeUserToken(); await this.usersRepository.update(me.id, { token: newToken, 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 eea657ebbd..da1faee30d 100644 --- a/packages/backend/src/server/api/endpoints/i/update-email.ts +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import ms from 'ms'; import bcrypt from 'bcryptjs'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UserProfilesRepository } from '@/models/_.js'; +import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { EmailService } from '@/core/EmailService.js'; import type { Config } from '@/config.js'; @@ -15,7 +15,6 @@ import { DI } from '@/di-symbols.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; import { UserAuthService } from '@/core/UserAuthService.js'; -import { MetaService } from '@/core/MetaService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -70,10 +69,12 @@ export default class extends Endpoint { // eslint- @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.userProfilesRepository) private userProfilesRepository: UserProfilesRepository, - private metaService: MetaService, private userEntityService: UserEntityService, private emailService: EmailService, private userAuthService: UserAuthService, @@ -105,7 +106,7 @@ export default class extends Endpoint { // eslint- if (!res.available) { throw new ApiError(meta.errors.unavailable); } - } else if ((await this.metaService.fetch()).emailRequiredForSignup) { + } else if (this.serverSettings.emailRequiredForSignup) { throw new ApiError(meta.errors.emailRequired); } diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index a1e2fa5e4c..082d97f5d4 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -11,11 +11,10 @@ import { JSDOM } from 'jsdom'; import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js'; import { extractHashtags } from '@/misc/extract-hashtags.js'; import * as Acct from '@/misc/acct.js'; -import type { UsersRepository, DriveFilesRepository, UserProfilesRepository, PagesRepository } from '@/models/_.js'; +import type { UsersRepository, DriveFilesRepository, MiMeta, UserProfilesRepository, PagesRepository } from '@/models/_.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; -import { birthdaySchema, descriptionSchema, locationSchema, nameSchema } from '@/models/User.js'; +import { birthdaySchema, descriptionSchema, followedMessageSchema, locationSchema, nameSchema } from '@/models/User.js'; import type { MiUserProfile } from '@/models/UserProfile.js'; -import { notificationTypes } from '@/types.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; import { langmap } from '@/misc/langmap.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -23,6 +22,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { AccountUpdateService } from '@/core/AccountUpdateService.js'; +import { UtilityService } from '@/core/UtilityService.js'; import { HashtagService } from '@/core/HashtagService.js'; import { DI } from '@/di-symbols.js'; import { RolePolicies, RoleService } from '@/core/RoleService.js'; @@ -115,6 +115,13 @@ export const meta = { code: 'RESTRICTED_BY_ROLE', id: '8feff0ba-5ab5-585b-31f4-4df816663fad', }, + + nameContainsProhibitedWords: { + message: 'Your new name contains prohibited words.', + code: 'YOUR_NAME_CONTAINS_PROHIBITED_WORDS', + id: '0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191', + httpStatusCode: 422, + }, }, res: { @@ -134,6 +141,7 @@ export const paramDef = { properties: { name: { ...nameSchema, nullable: true }, description: { ...descriptionSchema, nullable: true }, + followedMessage: { ...followedMessageSchema, nullable: true }, location: { ...locationSchema, nullable: true }, birthday: { ...birthdaySchema, nullable: true }, lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true }, @@ -171,6 +179,9 @@ export const paramDef = { autoAcceptFollowed: { type: 'boolean' }, noCrawle: { type: 'boolean' }, preventAiLearning: { type: 'boolean' }, + requireSigninToViewContents: { type: 'boolean' }, + makeNotesFollowersOnlyBefore: { type: 'integer', nullable: true }, + makeNotesHiddenBefore: { type: 'integer', nullable: true }, isBot: { type: 'boolean' }, isCat: { type: 'boolean' }, injectFeaturedNote: { type: 'boolean' }, @@ -179,6 +190,7 @@ export const paramDef = { autoSensitive: { type: 'boolean' }, followingVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, followersVisibility: { type: 'string', enum: ['public', 'followers', 'private'] }, + chatScope: { type: 'string', enum: ['everyone', 'followers', 'following', 'mutual', 'none'] }, pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true }, mutedWords: muteWords, hardMutedWords: muteWords, @@ -200,6 +212,7 @@ export const paramDef = { receiveFollowRequest: notificationRecieveConfig, followRequestAccepted: notificationRecieveConfig, roleAssigned: notificationRecieveConfig, + chatRoomInvitationReceived: notificationRecieveConfig, achievementEarned: notificationRecieveConfig, app: notificationRecieveConfig, test: notificationRecieveConfig, @@ -223,6 +236,9 @@ export default class extends Endpoint { // eslint- @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private instanceMeta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -247,6 +263,7 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private httpRequestService: HttpRequestService, private avatarDecorationService: AvatarDecorationService, + private utilityService: UtilityService, ) { super(meta, paramDef, async (ps, _user, token) => { const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser; @@ -267,11 +284,13 @@ export default class extends Endpoint { // eslint- } } if (ps.description !== undefined) profileUpdates.description = ps.description; + if (ps.followedMessage !== undefined) profileUpdates.followedMessage = ps.followedMessage; if (ps.lang !== undefined) profileUpdates.lang = ps.lang; if (ps.location !== undefined) profileUpdates.location = ps.location; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.followingVisibility !== undefined) profileUpdates.followingVisibility = ps.followingVisibility; if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility; + if (ps.chatScope !== undefined) updates.chatScope = ps.chatScope; function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) { // TODO: ちゃんと数える @@ -321,6 +340,9 @@ export default class extends Endpoint { // eslint- if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; if (typeof ps.preventAiLearning === 'boolean') profileUpdates.preventAiLearning = ps.preventAiLearning; + if (typeof ps.requireSigninToViewContents === 'boolean') updates.requireSigninToViewContents = ps.requireSigninToViewContents; + if ((typeof ps.makeNotesFollowersOnlyBefore === 'number') || (ps.makeNotesFollowersOnlyBefore === null)) updates.makeNotesFollowersOnlyBefore = ps.makeNotesFollowersOnlyBefore; + if ((typeof ps.makeNotesHiddenBefore === 'number') || (ps.makeNotesHiddenBefore === null)) updates.makeNotesHiddenBefore = ps.makeNotesHiddenBefore; if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; @@ -446,8 +468,17 @@ export default class extends Endpoint { // eslint- const newName = updates.name === undefined ? user.name : updates.name; const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description; const newFields = profileUpdates.fields === undefined ? profile.fields : profileUpdates.fields; + const newFollowedMessage = profileUpdates.followedMessage === undefined ? profile.followedMessage : profileUpdates.followedMessage; if (newName != null) { + let hasProhibitedWords = false; + if (!await this.roleService.isModerator(user)) { + hasProhibitedWords = this.utilityService.isKeyWordIncluded(newName, this.instanceMeta.prohibitedWordsForNameOfUser); + } + if (hasProhibitedWords) { + throw new ApiError(meta.errors.nameContainsProhibitedWords); + } + const tokens = mfm.parseSimple(newName); emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); } @@ -467,6 +498,11 @@ export default class extends Endpoint { // eslint- ]); } + if (newFollowedMessage != null) { + const tokens = mfm.parse(newFollowedMessage); + emojis = emojis.concat(extractCustomEmojisFromMfm(tokens)); + } + updates.emojis = emojis; updates.tags = tags; @@ -520,7 +556,7 @@ export default class extends Endpoint { // eslint- const html = await this.httpRequestService.getHtml(url); const { window } = new JSDOM(html); - const doc = window.document; + const doc: Document = window.document; const myLink = `${this.config.url}/@${user.username}`; diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts index a70b587da7..f2e683ddf2 100644 --- a/packages/backend/src/server/api/endpoints/invite/create.ts +++ b/packages/backend/src/server/api/endpoints/invite/create.ts @@ -18,7 +18,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'write:invite-codes', errors: { diff --git a/packages/backend/src/server/api/endpoints/invite/delete.ts b/packages/backend/src/server/api/endpoints/invite/delete.ts index e960ff9f4e..06f47e90bc 100644 --- a/packages/backend/src/server/api/endpoints/invite/delete.ts +++ b/packages/backend/src/server/api/endpoints/invite/delete.ts @@ -14,7 +14,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'write:invite-codes', errors: { diff --git a/packages/backend/src/server/api/endpoints/invite/limit.ts b/packages/backend/src/server/api/endpoints/invite/limit.ts index 2786bd98d5..0067dce231 100644 --- a/packages/backend/src/server/api/endpoints/invite/limit.ts +++ b/packages/backend/src/server/api/endpoints/invite/limit.ts @@ -15,7 +15,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'read:invite-codes', res: { @@ -49,7 +49,7 @@ export default class extends Endpoint { // eslint- const policies = await this.roleService.getUserPolicies(me.id); const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({ - id: MoreThan(this.idService.gen(Date.now() - (policies.inviteExpirationTime * 60 * 1000))), + id: MoreThan(this.idService.gen(Date.now() - (policies.inviteLimitCycle * 60 * 1000))), createdById: me.id, }) : null; diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts index 23aefe83a2..a99974a91e 100644 --- a/packages/backend/src/server/api/endpoints/invite/list.ts +++ b/packages/backend/src/server/api/endpoints/invite/list.ts @@ -14,7 +14,7 @@ export const meta = { tags: ['meta'], requireCredential: true, - requireRolePolicy: 'canInvite', + requiredRolePolicy: 'canInvite', kind: 'read:invite-codes', res: { diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts index fc9a8f3ebe..f1e3726641 100644 --- a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { AccessTokensRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; +import { NotificationService } from '@/core/NotificationService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DI } from '@/di-symbols.js'; @@ -50,6 +51,7 @@ export default class extends Endpoint { // eslint- private accessTokensRepository: AccessTokensRepository, private idService: IdService, + private notificationService: NotificationService, ) { super(meta, paramDef, async (ps, me) => { // Generate access token @@ -71,6 +73,9 @@ export default class extends Endpoint { // eslint- permission: ps.permission, }); + // アクセストークンが生成されたことを通知 + this.notificationService.createNotification(me.id, 'createToken', {}); + return { token: accessToken, }; diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index 0c6533d336..e73c98282c 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -71,8 +71,8 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); } const notes = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index beb77ca7ab..253a360815 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -17,8 +17,6 @@ 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 { MetaService } from '@/core/MetaService.js'; -import { UtilityService } from '@/core/UtilityService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { ApiError } from '../../error.js'; 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 258a0bfb8f..8d38bb1c65 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -79,8 +79,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (me) { - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 2a2c659942..99d1c9f19c 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; +import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -16,7 +16,6 @@ import { CacheService } from '@/core/CacheService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { QueryService } from '@/core/QueryService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; @@ -74,6 +73,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -87,7 +89,6 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private queryService: QueryService, private userFollowingService: UserFollowingService, - private metaService: MetaService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, ) { super(meta, paramDef, async (ps, me) => { @@ -101,9 +102,7 @@ export default class extends Endpoint { // eslint- if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb({ untilId, sinceId, @@ -156,7 +155,7 @@ export default class extends Endpoint { // eslint- allowPartial: ps.allowPartial, me, redisTimelines: timelineConfig, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, noteFilter: note => { @@ -244,8 +243,8 @@ export default class extends Endpoint { // eslint- } this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { 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 be82b5a8a7..97acf2ad39 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -5,16 +5,14 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import type { MiMeta, NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; -import { CacheService } from '@/core/CacheService.js'; import { QueryService } from '@/core/QueryService.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; @@ -66,6 +64,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -73,10 +74,8 @@ export default class extends Endpoint { // eslint- private roleService: RoleService, private activeUsersChart: ActiveUsersChart, private idService: IdService, - private cacheService: CacheService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -89,9 +88,7 @@ export default class extends Endpoint { // eslint- if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb({ untilId, sinceId, @@ -115,7 +112,7 @@ export default class extends Endpoint { // eslint- limit: ps.limit, allowPartial: ps.allowPartial, me, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, redisTimelines: ps.withFiles ? ['localTimelineWithFiles'] : ps.withReplies ? ['localTimeline', 'localTimelineWithReplies'] @@ -159,8 +156,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserQueryForNotes(query, me); + if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index 5558dd3a8b..bbb63646e9 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -9,7 +9,6 @@ import type { NotesRepository, FollowingsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; export const meta = { @@ -52,7 +51,6 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, - private noteReadService: NoteReadService, ) { super(meta, paramDef, async (ps, me) => { const followingQuery = this.followingsRepository.createQueryBuilder('following') @@ -74,9 +72,9 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedNoteThreadQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); if (ps.visibility) { query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); @@ -89,8 +87,6 @@ export default class extends Endpoint { // eslint- const mentions = await query.limit(ps.limit).getMany(); - this.noteReadService.read(me.id, mentions); - return await this.noteEntityService.packMany(mentions, me); }); } diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index ffe1ee6eb8..b34d9261a1 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -72,8 +72,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserQueryForNotes(query, me); + if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); const renotes = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index 5f32332a6a..f36af1a328 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -56,8 +56,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserQueryForNotes(query, me); + if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); const timeline = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index 626ff080c7..c45851548a 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -81,8 +81,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserQueryForNotes(query, me); + if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); try { if (ps.tag) { diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts index adcda30a7d..11839bce36 100644 --- a/packages/backend/src/server/api/endpoints/notes/show.ts +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -26,6 +26,12 @@ export const meta = { code: 'NO_SUCH_NOTE', id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', }, + + signinRequired: { + message: 'Signin required.', + code: 'SIGNIN_REQUIRED', + id: '8e75455b-738c-471d-9f80-62693f33372e', + }, }, } as const; @@ -44,11 +50,15 @@ export default class extends Endpoint { // eslint- private getterService: GetterService, ) { super(meta, paramDef, async (ps, me) => { - const note = await this.getterService.getNote(ps.noteId).catch(err => { + const note = await this.getterService.getNoteWithUser(ps.noteId).catch(err => { if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); throw err; }); + if (note.user!.requireSigninToViewContents && me == null) { + throw new ApiError(meta.errors.signinRequired); + } + return await this.noteEntityService.pack(note, me, { detail: true, }); 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 732d644a29..29c6aa7434 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 @@ -9,7 +9,6 @@ import type { NotesRepository, NoteThreadMutingsRepository } from '@/models/_.js import { IdService } from '@/core/IdService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { GetterService } from '@/server/api/GetterService.js'; -import { NoteReadService } from '@/core/NoteReadService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -52,7 +51,6 @@ export default class extends Endpoint { // eslint- private noteThreadMutingsRepository: NoteThreadMutingsRepository, private getterService: GetterService, - private noteReadService: NoteReadService, private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { @@ -69,8 +67,6 @@ export default class extends Endpoint { // eslint- }], }); - await this.noteReadService.read(me.id, mutedNotes); - await this.noteThreadMutingsRepository.insert({ id: this.idService.gen(), threadId: 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 c9b43b5359..a88b28892e 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 } from '@/models/_.js'; +import type { NotesRepository, ChannelFollowingsRepository, 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'; @@ -15,7 +15,6 @@ import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MiLocalUser } from '@/models/User.js'; -import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; export const meta = { @@ -56,6 +55,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -69,15 +71,12 @@ export default class extends Endpoint { // eslint- private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private userFollowingService: UserFollowingService, private queryService: QueryService, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb({ untilId, sinceId, @@ -108,7 +107,7 @@ export default class extends Endpoint { // eslint- limit: ps.limit, allowPartial: ps.allowPartial, me, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, @@ -200,8 +199,8 @@ export default class extends Endpoint { // eslint- })); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts index 38a9660aa2..e9a6a36b02 100644 --- a/packages/backend/src/server/api/endpoints/notes/translate.ts +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -4,14 +4,15 @@ */ import { URLSearchParams } from 'node:url'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; -import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import { GetterService } from '@/server/api/GetterService.js'; import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; export const meta = { tags: ['notes'], @@ -59,9 +60,11 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + private noteEntityService: NoteEntityService, private getterService: GetterService, - private metaService: MetaService, private httpRequestService: HttpRequestService, private roleService: RoleService, ) { @@ -84,9 +87,7 @@ export default class extends Endpoint { // eslint- return; } - const instance = await this.metaService.fetch(); - - if (instance.deeplAuthKey == null) { + if (this.serverSettings.deeplAuthKey == null) { throw new ApiError(meta.errors.unavailable); } @@ -94,11 +95,11 @@ export default class extends Endpoint { // eslint- if (targetLang.includes('-')) targetLang = targetLang.split('-')[0]; const params = new URLSearchParams(); - params.append('auth_key', instance.deeplAuthKey); + params.append('auth_key', this.serverSettings.deeplAuthKey); params.append('text', note.text); params.append('target_lang', targetLang); - const endpoint = instance.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; + const endpoint = this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate'; const res = await this.httpRequestService.send(endpoint, { method: 'POST', 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 43877e61ef..80f1c69b25 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 @@ -5,16 +5,14 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; -import type { MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; +import type { MiMeta, MiUserList, NotesRepository, UserListMembershipsRepository, UserListsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; -import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; -import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; @@ -69,6 +67,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -80,11 +81,9 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private activeUsersChart: ActiveUsersChart, - private cacheService: CacheService, private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -99,9 +98,7 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchList); } - const serverSettings = await this.metaService.fetch(); - - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb(list, { untilId, sinceId, @@ -115,7 +112,7 @@ export default class extends Endpoint { // eslint- this.activeUsersChart.read(me); - await this.noteEntityService.packMany(timeline, me); + return await this.noteEntityService.packMany(timeline, me); } const timeline = await this.fanoutTimelineEndpointService.timeline({ @@ -124,7 +121,7 @@ export default class extends Endpoint { // eslint- limit: ps.limit, allowPartial: ps.allowPartial, me, - useDbFallback: serverSettings.enableFanoutTimelineDbFallback, + useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, @@ -187,8 +184,8 @@ export default class extends Endpoint { // eslint- })); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts index fa03b0b457..6de5fe3d44 100644 --- a/packages/backend/src/server/api/endpoints/pages/create.ts +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -7,7 +7,7 @@ import ms from 'ms'; import { Inject, Injectable } from '@nestjs/common'; import type { DriveFilesRepository, PagesRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; -import { MiPage } from '@/models/Page.js'; +import { MiPage, pageNameSchema } from '@/models/Page.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -51,7 +51,7 @@ export const paramDef = { type: 'object', properties: { title: { type: 'string' }, - name: { type: 'string', minLength: 1 }, + name: { ...pageNameSchema, minLength: 1 }, summary: { type: 'string', nullable: true }, content: { type: 'array', items: { type: 'object', additionalProperties: true, diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts index f11bbbcb1a..a6aeb6002e 100644 --- a/packages/backend/src/server/api/endpoints/pages/update.ts +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -10,6 +10,7 @@ import type { PagesRepository, DriveFilesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../error.js'; +import { pageNameSchema } from '@/models/Page.js'; export const meta = { tags: ['pages'], @@ -31,13 +32,11 @@ export const meta = { code: 'NO_SUCH_PAGE', id: '21149b9e-3616-4778-9592-c4ce89f5a864', }, - accessDenied: { message: 'Access denied.', code: 'ACCESS_DENIED', id: '3c15cd52-3b4b-4274-967d-6456fc4f792b', }, - noSuchFile: { message: 'No such file.', code: 'NO_SUCH_FILE', @@ -56,7 +55,7 @@ export const paramDef = { properties: { pageId: { type: 'string', format: 'misskey:id' }, title: { type: 'string' }, - name: { type: 'string', minLength: 1 }, + name: { ...pageNameSchema, minLength: 1 }, summary: { type: 'string', nullable: true }, content: { type: 'array', items: { type: 'object', additionalProperties: true, @@ -102,15 +101,17 @@ export default class extends Endpoint { // eslint- } } - await this.pagesRepository.findBy({ - id: Not(ps.pageId), - userId: me.id, - name: ps.name, - }).then(result => { - if (result.length > 0) { - throw new ApiError(meta.errors.nameAlreadyExists); - } - }); + if (ps.name != null) { + await this.pagesRepository.findBy({ + id: Not(ps.pageId), + userId: me.id, + name: ps.name, + }).then(result => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + } await this.pagesRepository.update(page.id, { updatedAt: new Date(), diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts index 15832ef7f8..5b0b656c63 100644 --- a/packages/backend/src/server/api/endpoints/pinned-users.ts +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -5,11 +5,10 @@ import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsersRepository } from '@/models/_.js'; import * as Acct from '@/misc/acct.js'; import type { MiUser } from '@/models/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; @@ -38,16 +37,16 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, - private metaService: MetaService, private userEntityService: UserEntityService, ) { super(meta, paramDef, async (ps, me) => { - const meta = await this.metaService.fetch(); - - const users = await Promise.all(meta.pinnedUsers.map(acct => Acct.parse(acct)).map(acct => this.usersRepository.findOneBy({ + const users = await Promise.all(this.serverSettings.pinnedUsers.map(acct => Acct.parse(acct)).map(acct => this.usersRepository.findOneBy({ usernameLower: acct.username.toLowerCase(), host: acct.host ?? IsNull(), }))); diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts index 67d5fabd86..552362b64a 100644 --- a/packages/backend/src/server/api/endpoints/reset-db.ts +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -6,9 +6,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; import * as Redis from 'ioredis'; +import { LoggerService } from '@/core/LoggerService.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { resetDb } from '@/misc/reset-db.js'; +import { MetaService } from '@/core/MetaService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; export const meta = { tags: ['non-productive'], @@ -36,13 +39,27 @@ export default class extends Endpoint { // eslint- @Inject(DI.redis) private redisClient: Redis.Redis, + + private loggerService: LoggerService, + private metaService: MetaService, + private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { if (process.env.NODE_ENV !== 'test') throw new Error('NODE_ENV is not a test'); - await redisClient.flushdb(); + const logger = this.loggerService.getLogger('reset-db'); + logger.info('---- Resetting database...'); + + await this.redisClient.flushdb(); await resetDb(this.db); + // DIコンテナで管理しているmetaのインスタンスには上記のリセット処理が届かないため、 + // 初期値を流して明示的にリフレッシュする + const meta = await this.metaService.fetch(true); + this.globalEventService.publishInternalEvent('metaUpdated', { after: meta }); + + logger.info('---- Database reset complete.'); + await new Promise(resolve => setTimeout(resolve, 1000)); }); } diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 71f2782a5d..6cd9f80929 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -102,8 +102,8 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - this.queryService.generateMutedUserQuery(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); const notes = await query.getMany(); notes.sort((a, b) => a.id > b.id ? -1 : 1); diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts index c13802eb06..8301c85f2e 100644 --- a/packages/backend/src/server/api/endpoints/server-info.ts +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -5,9 +5,10 @@ import * as os from 'node:os'; import si from 'systeminformation'; -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; +import { MiMeta } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: false, @@ -73,10 +74,11 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - private metaService: MetaService, + @Inject(DI.meta) + private serverSettings: MiMeta, ) { super(meta, paramDef, async () => { - if (!(await this.metaService.fetch()).enableServerMachineStats) return { + if (!this.serverSettings.enableServerMachineStats) return { machine: '?', cpu: { model: '?', diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts index a9a33149f9..fd76df2d3c 100644 --- a/packages/backend/src/server/api/endpoints/sw/register.ts +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -5,9 +5,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { IdService } from '@/core/IdService.js'; -import type { SwSubscriptionsRepository } from '@/models/_.js'; +import type { MiMeta, SwSubscriptionsRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; -import { MetaService } from '@/core/MetaService.js'; import { DI } from '@/di-symbols.js'; import { PushNotificationService } from '@/core/PushNotificationService.js'; @@ -62,11 +61,13 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.swSubscriptionsRepository) private swSubscriptionsRepository: SwSubscriptionsRepository, private idService: IdService, - private metaService: MetaService, private pushNotificationService: PushNotificationService, ) { super(meta, paramDef, async (ps, me) => { @@ -78,12 +79,10 @@ export default class extends Endpoint { // eslint- publickey: ps.publickey, }); - const instance = await this.metaService.fetch(true); - if (exist != null) { return { state: 'already-subscribed' as const, - key: instance.swPublicKey, + key: this.serverSettings.swPublicKey, userId: me.id, endpoint: exist.endpoint, sendReadMessage: exist.sendReadMessage, @@ -103,7 +102,7 @@ export default class extends Endpoint { // eslint- return { state: 'subscribed' as const, - key: instance.swPublicKey, + key: this.serverSettings.swPublicKey, userId: me.id, endpoint: ps.endpoint, sendReadMessage: ps.sendReadMessage, diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts index affb0996f1..4944be9b05 100644 --- a/packages/backend/src/server/api/endpoints/username/available.ts +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -5,11 +5,10 @@ import { IsNull } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; +import type { MiMeta, UsedUsernamesRepository, UsersRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { localUsernameSchema } from '@/models/User.js'; import { DI } from '@/di-symbols.js'; -import { MetaService } from '@/core/MetaService.js'; export const meta = { tags: ['users'], @@ -39,13 +38,14 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @Inject(DI.usedUsernamesRepository) private usedUsernamesRepository: UsedUsernamesRepository, - - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const exist = await this.usersRepository.countBy({ @@ -55,8 +55,7 @@ export default class extends Endpoint { // eslint- const exist2 = await this.usedUsernamesRepository.countBy({ username: ps.username.toLowerCase() }); - const meta = await this.metaService.fetch(); - const isPreserved = meta.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase()); + const isPreserved = this.serverSettings.preservedUsernames.map(x => x.toLowerCase()).includes(ps.username.toLowerCase()); return { available: exist === 0 && exist2 === 0 && !isPreserved, diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index cc76c12f1d..f5b7a07b01 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -5,14 +5,13 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository } from '@/models/_.js'; +import type { MiMeta, NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; -import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; @@ -43,6 +42,12 @@ export const meta = { code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', id: '91c8cb9f-36ed-46e7-9ca2-7df96ed6e222', }, + + signinRequired: { + message: 'Signin required.', + code: 'SIGNIN_REQUIRED', + id: 'd1588a9e-4b4d-4c07-807f-16f1486577a2', + }, }, } as const; @@ -67,6 +72,9 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( + @Inject(DI.meta) + private serverSettings: MiMeta, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -75,15 +83,12 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, - private metaService: MetaService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null); const isSelf = me && (me.id === ps.userId); - const serverSettings = await this.metaService.fetch(); - if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles); // early return if me is blocked by requesting user @@ -94,7 +99,7 @@ export default class extends Endpoint { // eslint- } } - if (!serverSettings.enableFanoutTimeline) { + if (!this.serverSettings.enableFanoutTimeline) { const timeline = await this.getFromDb({ untilId, sinceId, @@ -180,8 +185,8 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); if (me) { - this.queryService.generateMutedUserQuery(query, me, { id: ps.userId }); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId }); + this.queryService.generateBlockedUserQueryForNotes(query, me); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts index 5b3b4527f7..5b1c6b514b 100644 --- a/packages/backend/src/server/api/endpoints/users/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -63,7 +63,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQueryForUsers(query, me); this.queryService.generateBlockQueryForUsers(query, me); - this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); const followingQuery = this.followingsRepository.createQueryBuilder('following') .select('following.followeeId') diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts index 8ff952dcb5..134f1a8e87 100644 --- a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -46,7 +46,7 @@ export default class extends Endpoint { // eslint- private userSearchService: UserSearchService, ) { super(meta, paramDef, (ps, me) => { - return this.userSearchService.search({ + return this.userSearchService.searchByUsernameAndHost({ username: ps.username, host: ps.host, }, { diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts index 0b0136066d..5d36847e03 100644 --- a/packages/backend/src/server/api/endpoints/users/search.ts +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -3,14 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { UsersRepository, UserProfilesRepository } from '@/models/_.js'; -import type { MiUser } from '@/models/User.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { DI } from '@/di-symbols.js'; -import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { UserSearchService } from '@/core/UserSearchService.js'; export const meta = { tags: ['users'], @@ -45,79 +42,15 @@ 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.userProfilesRepository) - private userProfilesRepository: UserProfilesRepository, - private userEntityService: UserEntityService, + private userSearchService: UserSearchService, ) { super(meta, paramDef, async (ps, me) => { - const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日 - - ps.query = ps.query.trim(); - const isUsername = ps.query.startsWith('@') && !ps.query.includes(' ') && ps.query.indexOf('@', 1) === -1; - - let users: MiUser[] = []; - - const nameQuery = this.usersRepository.createQueryBuilder('user') - .where(new Brackets(qb => { - qb.where('user.name ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - - if (isUsername) { - qb.orWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.query.replace('@', '').toLowerCase()) + '%' }); - } else if (this.userEntityService.validateLocalUsername(ps.query)) { // Also search username if it qualifies as username - qb.orWhere('user.usernameLower LIKE :username', { username: '%' + sqlLikeEscape(ps.query.toLowerCase()) + '%' }); - } - })) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE'); - - if (ps.origin === 'local') { - nameQuery.andWhere('user.host IS NULL'); - } else if (ps.origin === 'remote') { - nameQuery.andWhere('user.host IS NOT NULL'); - } - - users = await nameQuery - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(); - - if (users.length < ps.limit) { - const profQuery = this.userProfilesRepository.createQueryBuilder('prof') - .select('prof.userId') - .where('prof.description ILIKE :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }); - - if (ps.origin === 'local') { - profQuery.andWhere('prof.userHost IS NULL'); - } else if (ps.origin === 'remote') { - profQuery.andWhere('prof.userHost IS NOT NULL'); - } - - const query = this.usersRepository.createQueryBuilder('user') - .where(`user.id IN (${ profQuery.getQuery() })`) - .andWhere(new Brackets(qb => { - qb - .where('user.updatedAt IS NULL') - .orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold }); - })) - .andWhere('user.isSuspended = FALSE') - .setParameters(profQuery.getParameters()); - - users = users.concat(await query - .orderBy('user.updatedAt', 'DESC', 'NULLS LAST') - .limit(ps.limit) - .offset(ps.offset) - .getMany(), - ); - } + const users = await this.userSearchService.search(ps.query.trim(), me?.id ?? null, { + offset: ps.offset, + limit: ps.limit, + origin: ps.origin, + }); return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }); }); diff --git a/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts new file mode 100644 index 0000000000..7139715293 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/v2/admin/emoji/list.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { CustomEmojiService, fetchEmojisHostTypes, fetchEmojisSortKeys } from '@/core/CustomEmojiService.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requiredRolePolicy: 'canManageCustomEmojis', + kind: 'read:admin:emoji', + + res: { + type: 'object', + properties: { + emojis: { + type: 'array', + items: { + type: 'object', + ref: 'EmojiDetailedAdmin', + }, + }, + count: { type: 'integer' }, + allCount: { type: 'integer' }, + allPages: { type: 'integer' }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + query: { + type: 'object', + nullable: true, + properties: { + updatedAtFrom: { type: 'string' }, + updatedAtTo: { type: 'string' }, + name: { type: 'string' }, + host: { type: 'string' }, + uri: { type: 'string' }, + publicUrl: { type: 'string' }, + originalUrl: { type: 'string' }, + type: { type: 'string' }, + aliases: { type: 'string' }, + category: { type: 'string' }, + license: { type: 'string' }, + isSensitive: { type: 'boolean' }, + localOnly: { type: 'boolean' }, + hostType: { + type: 'string', + enum: fetchEmojisHostTypes, + default: 'all', + }, + roleIds: { + type: 'array', + items: { type: 'string', format: 'misskey:id' }, + }, + }, + }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + page: { type: 'integer' }, + sortKeys: { + type: 'array', + default: ['-id'], + items: { + type: 'string', + enum: fetchEmojisSortKeys, + }, + }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private customEmojiService: CustomEmojiService, + private emojiEntityService: EmojiEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const q = ps.query; + const result = await this.customEmojiService.fetchEmojis( + { + query: { + updatedAtFrom: q?.updatedAtFrom, + updatedAtTo: q?.updatedAtTo, + name: q?.name, + host: q?.host, + uri: q?.uri, + publicUrl: q?.publicUrl, + type: q?.type, + aliases: q?.aliases, + category: q?.category, + license: q?.license, + isSensitive: q?.isSensitive, + localOnly: q?.localOnly, + hostType: q?.hostType, + roleIds: q?.roleIds, + }, + sinceId: ps.sinceId, + untilId: ps.untilId, + }, + { + limit: ps.limit, + page: ps.page, + sortKeys: ps.sortKeys, + }, + ); + + return { + emojis: await this.emojiEntityService.packDetailedAdminMany(result.emojis), + count: result.count, + allCount: result.allCount, + allPages: result.allPages, + }; + }); + } +} diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts index efa47a6986..ea64e32ee6 100644 --- a/packages/backend/src/server/api/openapi/gen-spec.ts +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -183,7 +183,7 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { }, ...(endpoint.meta.limit ? { '429': { - description: 'To many requests', + description: 'Too many requests', content: { 'application/json': { schema: { @@ -210,9 +210,15 @@ export function genOpenapiSpec(config: Config, includeSelfRef = false) { spec.paths['/' + endpoint.name] = { ...(endpoint.meta.allowGet ? { - get: info, + get: { + ...info, + operationId: 'get___' + info.operationId, + }, } : {}), - post: info, + post: { + ...info, + operationId: 'post___' + info.operationId, + }, }; } diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts index eb854a7141..c80dda8d96 100644 --- a/packages/backend/src/server/api/openapi/schemas.ts +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -3,13 +3,15 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { deepClone } from '@/misc/clone.js'; import type { Schema } from '@/misc/json-schema.js'; 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 { optional, nullable, ref, selfRef, ..._res }: any = schema; + const res = deepClone(_res); 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 253409259f..c0ef589dea 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -19,6 +19,8 @@ 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'; @@ -40,6 +42,8 @@ export class ChannelsService { private serverStatsChannelService: ServerStatsChannelService, private queueStatsChannelService: QueueStatsChannelService, private adminChannelService: AdminChannelService, + private chatUserChannelService: ChatUserChannelService, + private chatRoomChannelService: ChatRoomChannelService, private reversiChannelService: ReversiChannelService, private reversiGameChannelService: ReversiGameChannelService, ) { @@ -62,6 +66,8 @@ export class ChannelsService { 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; diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 0fb5238c78..c9801d8314 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -7,7 +7,6 @@ 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 { NoteReadService } from '@/core/NoteReadService.js'; import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; @@ -45,7 +44,6 @@ export default class Connection { constructor( private channelsService: ChannelsService, - private noteReadService: NoteReadService, private notificationService: NotificationService, private cacheService: CacheService, private channelFollowingService: ChannelFollowingService, @@ -119,7 +117,7 @@ export default class Connection { case 'readNotification': this.onReadNotification(body); break; case 'subNote': this.onSubscribeNote(body); break; case 's': this.onSubscribeNote(body); break; // alias - case 'sr': this.onSubscribeNote(body); this.readNote(body); break; + case 'sr': this.onSubscribeNote(body); break; case 'unsubNote': this.onUnsubscribeNote(body); break; case 'un': this.onUnsubscribeNote(body); break; // alias case 'connect': this.onChannelConnectRequested(body); break; @@ -154,19 +152,6 @@ export default class Connection { if (note.renote) add(note.renote); } - @bindThis - private readNote(body: JsonValue | undefined) { - if (!isJsonObject(body)) return; - const id = body.id; - - const note = this.cachedNotes.find(n => n.id === id); - if (note == null) return; - - if (this.user && (note.userId !== this.user.id)) { - this.noteReadService.read(this.user.id, [note]); - } - } - @bindThis private onReadNotification(payload: JsonValue | undefined) { this.notificationService.readAllNotification(this.user!.id); diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 84cb552369..686aea423c 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -82,8 +82,8 @@ export default abstract class Channel { this.connection = connection; } - public send(payload: { type: string, body: JsonValue }): void - public send(type: string, payload: JsonValue): void + public send(payload: { type: string, body: JsonValue }): void; + public send(type: string, payload: JsonValue): void; @bindThis public send(typeOrPayload: { type: string, body: JsonValue } | string, payload?: JsonValue) { const type = payload === undefined ? (typeOrPayload as { type: string, body: JsonValue }).type : (typeOrPayload as string); @@ -108,4 +108,4 @@ export type MiChannelService = { 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/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts new file mode 100644 index 0000000000..eda333dd30 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/chat-room.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } 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'; + +class ChatRoomChannel extends Channel { + public readonly chName = 'chatRoom'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:chat'; + private roomId: string; + + constructor( + private chatService: ChatService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + + @bindThis + public async init(params: JsonObject) { + if (typeof params.roomId !== 'string') return; + this.roomId = params.roomId; + + this.subscriber.on(`chatRoomStream:${this.roomId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: GlobalEvents['chatRoom']['payload']) { + this.send(data.type, data.body); + } + + @bindThis + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.roomId) { + this.chatService.readRoomChatMessage(this.user!.id, this.roomId); + } + break; + } + } + + @bindThis + public dispose() { + 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 new file mode 100644 index 0000000000..5323484ed7 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/chat-user.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } 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'; + +class ChatUserChannel extends Channel { + public readonly chName = 'chatUser'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:chat'; + private otherId: string; + + constructor( + private chatService: ChatService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + + @bindThis + public async init(params: JsonObject) { + if (typeof params.otherId !== 'string') return; + this.otherId = params.otherId; + + this.subscriber.on(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: GlobalEvents['chatUser']['payload']) { + this.send(data.type, data.body); + } + + @bindThis + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.otherId) { + this.chatService.readUserChatMessage(this.user!.id, this.otherId); + } + break; + } + } + + @bindThis + public dispose() { + 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/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 75bd13221f..5681311493 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -95,7 +95,6 @@ class HybridTimelineChannel extends Channel { if (this.user && note.renoteId && !note.text) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { - console.log(note.renote.reactionAndUserPairCache); const myRenoteReaction = await this.noteEntityService.populateMyReaction(note.renote, this.user.id); note.renote.myReaction = myRenoteReaction; } diff --git a/packages/backend/src/server/oauth/OAuth2ProviderService.ts b/packages/backend/src/server/oauth/OAuth2ProviderService.ts index e065c451f1..cdd7102666 100644 --- a/packages/backend/src/server/oauth/OAuth2ProviderService.ts +++ b/packages/backend/src/server/oauth/OAuth2ProviderService.ts @@ -95,6 +95,7 @@ interface ClientInformation { id: string; redirectUris: string[]; name: string; + logo: string | null; } // https://indieauth.spec.indieweb.org/#client-information-discovery @@ -124,11 +125,19 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt redirectUris.push(...[...fragment.querySelectorAll('link[rel=redirect_uri][href]')].map(el => el.href)); let name = id; + let logo: string | null = null; if (text) { const microformats = mf2(text, { baseUrl: res.url }); - const nameProperty = microformats.items.find(item => item.type?.includes('h-app') && item.properties.url.includes(id))?.properties.name[0]; - if (typeof nameProperty === 'string') { - name = nameProperty; + 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; + } + const logoProperty = correspondingProperties.properties.logo?.[0]; + if (typeof logoProperty === 'string') { + logo = logoProperty; + } } } @@ -136,6 +145,7 @@ async function discoverClientInformation(logger: Logger, httpRequestService: Htt id, redirectUris: redirectUris.map(uri => new URL(uri, res.url).toString()), name: typeof name === 'string' ? name : id, + logo, }; } catch (err) { console.error(err); @@ -379,6 +389,7 @@ export class OAuth2ProviderService { return await reply.view('oauth', { transactionId: oauth2.transactionID, clientName: oauth2.client.name, + clientLogo: oauth2.client.logo, scope: oauth2.req.scope.join(' '), }); }); diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 5e0ec390f2..927970e2e2 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -7,16 +7,12 @@ import { randomUUID } from 'node:crypto'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Inject, Injectable } from '@nestjs/common'; -import { createBullBoard } from '@bull-board/api'; -import { BullMQAdapter } from '@bull-board/api/bullMQAdapter.js'; -import { FastifyAdapter } from '@bull-board/fastify'; 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 fastifyCookie from '@fastify/cookie'; import fastifyProxy from '@fastify/http-proxy'; import vary from 'vary'; import htmlSafeJsonStringify from 'htmlescape'; @@ -24,13 +20,13 @@ 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 { MetaService } from '@/core/MetaService.js'; import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, + RelationshipQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue, @@ -42,13 +38,26 @@ 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'; -import type { ChannelsRepository, ClipsRepository, FlashsRepository, GalleryPostsRepository, MiMeta, NotesRepository, PagesRepository, ReversiGamesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import type { + AnnouncementsRepository, + ChannelsRepository, + ClipsRepository, + FlashsRepository, + GalleryPostsRepository, + MiMeta, + NotesRepository, + PagesRepository, + ReversiGamesRepository, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; import type Logger from '@/logger.js'; import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.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'; @@ -73,6 +82,9 @@ export class ClientServerService { @Inject(DI.config) private config: Config, + @Inject(DI.meta) + private meta: MiMeta, + @Inject(DI.usersRepository) private usersRepository: UsersRepository, @@ -100,6 +112,9 @@ export class ClientServerService { @Inject(DI.reversiGamesRepository) private reversiGamesRepository: ReversiGamesRepository, + @Inject(DI.announcementsRepository) + private announcementsRepository: AnnouncementsRepository, + private flashEntityService: FlashEntityService, private userEntityService: UserEntityService, private noteEntityService: NoteEntityService, @@ -109,7 +124,7 @@ export class ClientServerService { private clipEntityService: ClipEntityService, private channelEntityService: ChannelEntityService, private reversiGameEntityService: ReversiGameEntityService, - private metaService: MetaService, + private announcementEntityService: AnnouncementEntityService, private urlPreviewService: UrlPreviewService, private feedService: FeedService, private roleService: RoleService, @@ -120,6 +135,7 @@ export class ClientServerService { @Inject('queue:deliver') public deliverQueue: DeliverQueue, @Inject('queue:inbox') public inboxQueue: InboxQueue, @Inject('queue:db') public dbQueue: DbQueue, + @Inject('queue:relationship') public relationshipQueue: RelationshipQueue, @Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue, @Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue, @Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue, @@ -129,32 +145,30 @@ export class ClientServerService { @bindThis private async manifestHandler(reply: FastifyReply) { - const instance = await this.metaService.fetch(true); - let manifest = { // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'short_name': instance.shortName || instance.name || this.config.host, + 'short_name': this.meta.shortName || this.meta.name || this.config.host, // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'name': instance.name || this.config.host, + 'name': this.meta.name || this.config.host, 'start_url': '/', 'display': 'standalone', 'background_color': '#313a42', // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'theme_color': instance.themeColor || '#86b300', + 'theme_color': this.meta.themeColor || '#86b300', 'icons': [{ // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'src': instance.app192IconUrl || '/static-assets/icons/192.png', + 'src': this.meta.app192IconUrl || '/static-assets/icons/192.png', 'sizes': '192x192', 'type': 'image/png', 'purpose': 'maskable', }, { // 空文字列の場合右辺を使いたいため // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - 'src': instance.app512IconUrl || '/static-assets/icons/512.png', + 'src': this.meta.app512IconUrl || '/static-assets/icons/512.png', 'sizes': '512x512', 'type': 'image/png', 'purpose': 'maskable', @@ -178,7 +192,7 @@ export class ClientServerService { manifest = { ...manifest, - ...JSON.parse(instance.manifestJsonOverride === '' ? '{}' : instance.manifestJsonOverride), + ...JSON.parse(this.meta.manifestJsonOverride === '' ? '{}' : this.meta.manifestJsonOverride), }; reply.header('Cache-Control', 'max-age=300'); @@ -203,63 +217,6 @@ export class ClientServerService { @bindThis public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) { - fastify.register(fastifyCookie, {}); - - //#region Bull Dashboard - const bullBoardPath = '/queue'; - - // Authenticate - fastify.addHook('onRequest', async (request, reply) => { - if (request.routeOptions.url == null) { - reply.code(404).send('Not found'); - return; - } - - // %71ueueとかでリクエストされたら困るため - const url = decodeURI(request.routeOptions.url); - if (url === bullBoardPath || url.startsWith(bullBoardPath + '/')) { - if (!url.startsWith(bullBoardPath + '/static/')) { - reply.header('Cache-Control', 'private, max-age=0, must-revalidate'); - } - - const token = request.cookies.token; - if (token == null) { - reply.code(401).send('Login required'); - return; - } - const user = await this.usersRepository.findOneBy({ token }); - if (user == null) { - reply.code(403).send('No such user'); - return; - } - const isAdministrator = await this.roleService.isAdministrator(user); - if (!isAdministrator) { - reply.code(403).send('Access denied'); - return; - } - } - }); - - const serverAdapter = new FastifyAdapter(); - - createBullBoard({ - queues: [ - this.systemQueue, - this.endedPollNotificationQueue, - this.deliverQueue, - this.inboxQueue, - this.dbQueue, - this.objectStorageQueue, - this.userWebhookDeliverQueue, - this.systemWebhookDeliverQueue, - ].map(q => new BullMQAdapter(q)), - serverAdapter, - }); - - serverAdapter.setBasePath(bullBoardPath); - (fastify.register as any)(serverAdapter.registerPlugin(), { prefix: bullBoardPath }); - //#endregion - fastify.register(fastifyView, { root: _dirname + '/views', engine: { @@ -298,16 +255,19 @@ export class ClientServerService { done(); }); } else { + const configUrl = new URL(this.config.url); + const urlOriginWithoutPort = configUrl.origin.replace(/:\d+$/, ''); + const port = (process.env.VITE_PORT ?? '5173'); fastify.register(fastifyProxy, { - upstream: 'http://localhost:' + port, + upstream: urlOriginWithoutPort + ':' + port, prefix: '/vite', rewritePrefix: '/vite', }); const embedPort = (process.env.EMBED_VITE_PORT ?? '5174'); fastify.register(fastifyProxy, { - upstream: 'http://localhost:' + embedPort, + upstream: urlOriginWithoutPort + ':' + embedPort, prefix: '/embed_vite', rewritePrefix: '/embed_vite', }); @@ -453,9 +413,7 @@ export class ClientServerService { // OpenSearch XML fastify.get('/opensearch.xml', async (request, reply) => { - const meta = await this.metaService.fetch(); - - const name = meta.name ?? 'Misskey'; + const name = this.meta.name ?? 'Misskey'; let content = ''; content += ''; content += `${name}`; @@ -472,14 +430,13 @@ export class ClientServerService { //#endregion const renderBase = async (reply: FastifyReply, data: { [key: string]: any } = {}) => { - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=30'); return await reply.view('base', { - img: meta.bannerUrl, + img: this.meta.bannerUrl, url: this.config.url, - title: meta.name ?? 'Misskey', - desc: meta.description, - ...await this.generateCommonPugData(meta), + title: this.meta.name ?? 'Misskey', + desc: this.meta.description, + ...await this.generateCommonPugData(this.meta), ...data, }); }; @@ -493,6 +450,7 @@ export class ClientServerService { usernameLower: username.toLowerCase(), host: host ?? IsNull(), isSuspended: false, + requireSigninToViewContents: false, }); return user && await this.feedService.packFeed(user); @@ -543,7 +501,7 @@ export class ClientServerService { } }); - //#region SSR (for crawlers) + //#region SSR // User fastify.get<{ Params: { user: string; sub?: string; } }>('/@:user/:sub?', async (request, reply) => { const { username, host } = Acct.parse(request.params.user); @@ -557,7 +515,6 @@ export class ClientServerService { if (user != null) { const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); - const meta = await this.metaService.fetch(); const me = profile.fields ? profile.fields .filter(filed => filed.value != null && filed.value.match(/^https?:/)) @@ -569,11 +526,20 @@ export class ClientServerService { reply.header('X-Robots-Tag', 'noimageai'); reply.header('X-Robots-Tag', 'noai'); } + + const _user = await this.userEntityService.pack(user, null, { + schema: 'UserDetailed', + userProfile: profile, + }); + return await reply.view('user', { user, profile, me, avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), sub: request.params.sub, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), + clientCtx: htmlSafeJsonStringify({ + user: _user, + }), }); } else { // リモートユーザーなので @@ -603,15 +569,17 @@ export class ClientServerService { fastify.get<{ Params: { note: string; } }>('/notes/:note', async (request, reply) => { vary(reply.raw, 'Accept'); - const note = await this.notesRepository.findOneBy({ - id: request.params.note, - visibility: In(['public', 'home']), + const note = await this.notesRepository.findOne({ + where: { + id: request.params.note, + visibility: In(['public', 'home']), + }, + relations: ['user'], }); - if (note) { + if (note && !note.user!.requireSigninToViewContents) { const _note = await this.noteEntityService.pack(note); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: note.userId }); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { reply.header('X-Robots-Tag', 'noimageai'); @@ -623,7 +591,10 @@ export class ClientServerService { avatarUrl: _note.user.avatarUrl, // TODO: Let locale changeable by instance setting summary: getNoteSummary(_note), - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), + clientCtx: htmlSafeJsonStringify({ + note: _note, + }), }); } else { return await renderBase(reply); @@ -648,7 +619,6 @@ export class ClientServerService { if (page) { const _page = await this.pageEntityService.pack(page); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: page.userId }); - const meta = await this.metaService.fetch(); if (['public'].includes(page.visibility)) { reply.header('Cache-Control', 'public, max-age=15'); } else { @@ -662,7 +632,7 @@ export class ClientServerService { page: _page, profile, avatarUrl: _page.user.avatarUrl, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -678,7 +648,6 @@ export class ClientServerService { if (flash) { const _flash = await this.flashEntityService.pack(flash); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: flash.userId }); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { reply.header('X-Robots-Tag', 'noimageai'); @@ -688,7 +657,7 @@ export class ClientServerService { flash: _flash, profile, avatarUrl: _flash.user.avatarUrl, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -704,7 +673,6 @@ export class ClientServerService { if (clip && clip.isPublic) { const _clip = await this.clipEntityService.pack(clip); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: clip.userId }); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { reply.header('X-Robots-Tag', 'noimageai'); @@ -714,7 +682,10 @@ export class ClientServerService { clip: _clip, profile, avatarUrl: _clip.user.avatarUrl, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), + clientCtx: htmlSafeJsonStringify({ + clip: _clip, + }), }); } else { return await renderBase(reply); @@ -728,7 +699,6 @@ export class ClientServerService { if (post) { const _post = await this.galleryPostEntityService.pack(post); const profile = await this.userProfilesRepository.findOneByOrFail({ userId: post.userId }); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); if (profile.preventAiLearning) { reply.header('X-Robots-Tag', 'noimageai'); @@ -738,7 +708,7 @@ export class ClientServerService { post: _post, profile, avatarUrl: _post.user.avatarUrl, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -753,11 +723,10 @@ export class ClientServerService { if (channel) { const _channel = await this.channelEntityService.pack(channel); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=15'); return await reply.view('channel', { channel: _channel, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -772,11 +741,29 @@ export class ClientServerService { if (game) { const _game = await this.reversiGameEntityService.packDetail(game); - const meta = await this.metaService.fetch(); reply.header('Cache-Control', 'public, max-age=3600'); return await reply.view('reversi-game', { game: _game, - ...await this.generateCommonPugData(meta), + ...await this.generateCommonPugData(this.meta), + }); + } else { + return await renderBase(reply); + } + }); + + // 個別お知らせページ + fastify.get<{ Params: { announcementId: string; } }>('/announcements/:announcementId', async (request, reply) => { + const announcement = await this.announcementsRepository.findOneBy({ + id: request.params.announcementId, + userId: IsNull(), + }); + + if (announcement) { + const _announcement = await this.announcementEntityService.pack(announcement); + reply.header('Cache-Control', 'public, max-age=3600'); + return await reply.view('announcement', { + announcement: _announcement, + ...await this.generateCommonPugData(this.meta), }); } else { return await renderBase(reply); @@ -797,27 +784,89 @@ export class ClientServerService { //#endregion //#region embed pages - fastify.get('/embed/*', async (request, reply) => { - const meta = await this.metaService.fetch(); + fastify.get<{ Params: { user: string; } }>('/embed/user-timeline/:user', async (request, reply) => { + reply.removeHeader('X-Frame-Options'); + const user = await this.usersRepository.findOneBy({ + id: request.params.user, + }); + + if (user == null) return; + if (user.host != null) return; + + const _user = await this.userEntityService.pack(user); + + reply.header('Cache-Control', 'public, max-age=3600'); + return await reply.view('base-embed', { + title: this.meta.name ?? 'Misskey', + ...await this.generateCommonPugData(this.meta), + embedCtx: htmlSafeJsonStringify({ + user: _user, + }), + }); + }); + + fastify.get<{ Params: { note: string; } }>('/embed/notes/:note', async (request, reply) => { + reply.removeHeader('X-Frame-Options'); + + const note = await this.notesRepository.findOneBy({ + id: request.params.note, + }); + + if (note == null) return; + if (['specified', 'followers'].includes(note.visibility)) return; + if (note.userHost != null) return; + + const _note = await this.noteEntityService.pack(note, null, { detail: true }); + + reply.header('Cache-Control', 'public, max-age=3600'); + return await reply.view('base-embed', { + title: this.meta.name ?? 'Misskey', + ...await this.generateCommonPugData(this.meta), + embedCtx: htmlSafeJsonStringify({ + note: _note, + }), + }); + }); + + fastify.get<{ Params: { clip: string; } }>('/embed/clips/:clip', async (request, reply) => { + reply.removeHeader('X-Frame-Options'); + + const clip = await this.clipsRepository.findOneBy({ + id: request.params.clip, + }); + + if (clip == null) return; + + const _clip = await this.clipEntityService.pack(clip); + + reply.header('Cache-Control', 'public, max-age=3600'); + return await reply.view('base-embed', { + title: this.meta.name ?? 'Misskey', + ...await this.generateCommonPugData(this.meta), + embedCtx: 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', { - title: meta.name ?? 'Misskey', - ...await this.generateCommonPugData(meta), + title: this.meta.name ?? 'Misskey', + ...await this.generateCommonPugData(this.meta), }); }); fastify.get('/_info_card_', async (request, reply) => { - const meta = await this.metaService.fetch(true); - reply.removeHeader('X-Frame-Options'); return await reply.view('info-card', { version: this.config.version, host: this.config.host, - meta: meta, + meta: this.meta, originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }), originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), }); diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 8f8f08a305..9b5f0acd2c 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -8,7 +8,6 @@ import { summaly } from '@misskey-dev/summaly'; import { SummalyResult } from '@misskey-dev/summaly/built/summary.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; -import { MetaService } from '@/core/MetaService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js'; import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; @@ -26,7 +25,9 @@ export class UrlPreviewService { @Inject(DI.config) private config: Config, - private metaService: MetaService, + @Inject(DI.meta) + private meta: MiMeta, + private httpRequestService: HttpRequestService, private loggerService: LoggerService, ) { @@ -62,9 +63,7 @@ export class UrlPreviewService { return; } - const meta = await this.metaService.fetch(); - - if (!meta.urlPreviewEnabled) { + if (!this.meta.urlPreviewEnabled) { reply.code(403); return { error: new ApiError({ @@ -75,14 +74,14 @@ export class UrlPreviewService { }; } - this.logger.info(meta.urlPreviewSummaryProxyUrl + this.logger.info(this.meta.urlPreviewSummaryProxyUrl ? `(Proxy) Getting preview of ${url}@${lang} ...` : `Getting preview of ${url}@${lang} ...`); try { - const summary = meta.urlPreviewSummaryProxyUrl - ? await this.fetchSummaryFromProxy(url, meta, lang) - : await this.fetchSummary(url, meta, lang); + const summary = this.meta.urlPreviewSummaryProxyUrl + ? await this.fetchSummaryFromProxy(url, this.meta, lang) + : await this.fetchSummary(url, this.meta, lang); this.logger.succ(`Got preview of ${url}: ${summary.title}`); @@ -146,6 +145,6 @@ export class UrlPreviewService { contentLengthRequired: meta.urlPreviewRequireContentLength, }); - return this.httpRequestService.getJson(`${proxy}?${queryStr}`); + return this.httpRequestService.getJson(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true); } } diff --git a/packages/backend/src/server/web/boot.embed.js b/packages/backend/src/server/web/boot.embed.js index 48d1cd262b..9de1275380 100644 --- a/packages/backend/src/server/web/boot.embed.js +++ b/packages/backend/src/server/web/boot.embed.js @@ -114,13 +114,17 @@ if (document.readyState === 'loading') { await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } + + const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + + const title = locale?._bootErrors?.title || 'Failed to initialize Misskey'; + const reload = locale?.reload || 'Reload'; + document.body.innerHTML = ` -

読み込みに失敗しました
-
Failed to initialize Misskey
+
${title}
Error Code: ${code}
`; addStyle(` #misskey_app, diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 7c6a533429..b55d327f86 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -98,7 +98,7 @@ const theme = localStorage.getItem('theme'); if (theme) { for (const [k, v] of Object.entries(JSON.parse(theme))) { - document.documentElement.style.setProperty(`--${k}`, v.toString()); + document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); // HTMLの theme-color 適用 if (k === 'htmlThemeColor') { @@ -151,6 +151,22 @@ await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve)); } + const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + + const messages = Object.assign({ + title: 'Failed to initialize Misskey', + solution: 'The following actions may solve the problem.', + solution1: 'Update your os and browser', + solution2: 'Disable an adblocker', + solution3: 'Clear the browser cache', + solution4: '(Tor Browser) Set dom.webaudio.enabled to true', + otherOption: 'Other options', + otherOption1: 'Clear preferences and cache', + otherOption2: 'Start the simple client', + otherOption3: 'Start the repair tool', + }, locale?._bootErrors || {}); + const reload = locale?.reload || 'Reload'; + let errorsElement = document.getElementById('errors'); if (!errorsElement) { @@ -160,32 +176,32 @@ -

Failed to load
読み込みに失敗しました

+

${messages.title}

-

The following actions may solve the problem. / 以下を行うと解決する可能性があります。

-

Update your os and browser / ブラウザおよびOSを最新バージョンに更新する

-

Disable an adblocker / アドブロッカーを無効にする

-

Clear the browser cache / ブラウザのキャッシュをクリアする

-

(Tor Browser) Set dom.webaudio.enabled to true / dom.webaudio.enabledをtrueに設定する

+

${messages.solution}

+

${messages.solution1}

+

${messages.solution2}

+

${messages.solution3}

+

${messages.solution4}

- Other options / その他のオプション + ${messages.otherOption}

diff --git a/packages/backend/src/server/web/error.css b/packages/backend/src/server/web/error.css index f2b63296eb..803bd1b4b5 100644 --- a/packages/backend/src/server/web/error.css +++ b/packages/backend/src/server/web/error.css @@ -5,112 +5,107 @@ */ * { - font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; + font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; } #misskey_app, #splash { - display: none !important; + display: none !important; } body, html { - background-color: #222; - color: #dfddcc; - justify-content: center; - margin: auto; - padding: 10px; - text-align: center; + background-color: #222; + color: #dfddcc; + justify-content: center; + margin: auto; + padding: 10px; + text-align: center; } button { - border-radius: 999px; - padding: 0px 12px 0px 12px; - border: none; - cursor: pointer; - margin-bottom: 12px; + border-radius: 999px; + padding: 0px 12px 0px 12px; + border: none; + cursor: pointer; + margin-bottom: 12px; } .button-big { - background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); - line-height: 50px; + background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0)); + line-height: 50px; } .button-big:hover { - background: rgb(153, 204, 0); + background: rgb(153, 204, 0); } .button-small { - background: #444; - line-height: 40px; + background: #444; + line-height: 40px; } .button-small:hover { - background: #555; + background: #555; } .button-label-big { - color: #222; - font-weight: bold; - font-size: 20px; - padding: 12px; + color: #222; + font-weight: bold; + font-size: 1.2em; + padding: 12px; } .button-label-small { - color: rgb(153, 204, 0); - font-size: 16px; - padding: 12px; + color: rgb(153, 204, 0); + font-size: 16px; + padding: 12px; } a { - color: rgb(134, 179, 0); - text-decoration: none; + color: rgb(134, 179, 0); + text-decoration: none; } p, li { - font-size: 16px; -} - -.dont-worry, -#msg { - font-size: 18px; + font-size: 16px; } .icon-warning { - color: #dec340; - height: 4rem; - padding-top: 2rem; + color: #dec340; + height: 4rem; + padding-top: 2rem; } h1 { - font-size: 32px; + font-size: 1.5em; + margin: 1em; } code { - display: block; - font-family: Fira, FiraCode, monospace; - background: #333; - padding: 0.5rem 1rem; - max-width: 40rem; - border-radius: 10px; - justify-content: center; - margin: auto; - white-space: pre-wrap; - word-break: break-word; + display: block; + font-family: Fira, FiraCode, monospace; + background: #333; + padding: 0.5rem 1rem; + max-width: 40rem; + border-radius: 10px; + justify-content: center; + margin: auto; + white-space: pre-wrap; + word-break: break-word; } -summary { - cursor: pointer; +#errorInfo summary { + cursor: pointer; } -summary > * { - display: inline; - white-space: pre-wrap; +#errorInfo summary>* { + display: inline; } @media screen and (max-width: 500px) { - details { - width: 50%; - } + #errorInfo { + width: 50%; + } } diff --git a/packages/backend/src/server/web/error.js b/packages/backend/src/server/web/error.js new file mode 100644 index 0000000000..4838dd6ef3 --- /dev/null +++ b/packages/backend/src/server/web/error.js @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +'use strict'; + +(() => { + document.addEventListener('DOMContentLoaded', () => { + const locale = JSON.parse(localStorage.getItem('locale') || '{}'); + + const messages = Object.assign({ + title: 'Failed to initialize Misskey', + serverError: 'If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID.', + solution: 'The following actions may solve the problem.', + solution1: 'Update your os and browser', + solution2: 'Disable an adblocker', + solution3: 'Clear the browser cache', + solution4: '(Tor Browser) Set dom.webaudio.enabled to true', + otherOption: 'Other options', + otherOption1: 'Clear preferences and cache', + otherOption2: 'Start the simple client', + otherOption3: 'Start the repair tool', + }, locale?._bootErrors || {}); + const reload = locale?.reload || 'Reload'; + + const reloadEls = document.querySelectorAll('[data-i18n-reload]'); + for (const el of reloadEls) { + el.textContent = reload; + } + + const i18nEls = document.querySelectorAll('[data-i18n]'); + for (const el of i18nEls) { + const key = el.dataset.i18n; + if (key && messages[key]) { + el.textContent = messages[key]; + } + } + }); +})(); diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css index dbcc8f537c..5d81f2bed0 100644 --- a/packages/backend/src/server/web/style.css +++ b/packages/backend/src/server/web/style.css @@ -5,8 +5,8 @@ */ html { - background-color: var(--bg); - color: var(--fg); + background-color: var(--MI_THEME-bg); + color: var(--MI_THEME-fg); } #splash { @@ -17,7 +17,7 @@ html { width: 100vw; height: 100vh; cursor: wait; - background-color: var(--bg); + background-color: var(--MI_THEME-bg); opacity: 1; transition: opacity 0.5s ease; } @@ -45,7 +45,7 @@ html { width: 28px; height: 28px; transform: translateY(70px); - color: var(--accent); + color: var(--MI_THEME-accent); } #splashSpinner > .spinner { diff --git a/packages/backend/src/server/web/style.embed.css b/packages/backend/src/server/web/style.embed.css index a7b110d80a..5e8786cc4e 100644 --- a/packages/backend/src/server/web/style.embed.css +++ b/packages/backend/src/server/web/style.embed.css @@ -5,8 +5,8 @@ */ html { - background-color: var(--bg); - color: var(--fg); + background-color: var(--MI_THEME-bg); + color: var(--MI_THEME-fg); } html.embed { @@ -24,7 +24,7 @@ html.embed { width: 100vw; height: 100vh; cursor: wait; - background-color: var(--bg); + background-color: var(--MI_THEME-bg); opacity: 1; transition: opacity 0.5s ease; } @@ -33,7 +33,7 @@ html.embed #splash { box-sizing: border-box; min-height: 300px; border-radius: var(--radius, 12px); - border: 1px solid var(--divider, #e8e8e8); + border: 1px solid var(--MI_THEME-divider, #e8e8e8); } html.embed.norounded #splash { @@ -67,7 +67,7 @@ html.embed.noborder #splash { width: 28px; height: 28px; transform: translateY(70px); - color: var(--accent); + color: var(--MI_THEME-accent); } #splashSpinner > .spinner { diff --git a/packages/backend/src/server/web/views/announcement.pug b/packages/backend/src/server/web/views/announcement.pug new file mode 100644 index 0000000000..7a4052e8a4 --- /dev/null +++ b/packages/backend/src/server/web/views/announcement.pug @@ -0,0 +1,21 @@ +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/base-embed.pug b/packages/backend/src/server/web/views/base-embed.pug index d773f2676a..baa0909676 100644 --- a/packages/backend/src/server/web/views/base-embed.pug +++ b/packages/backend/src/server/web/views/base-embed.pug @@ -13,6 +13,8 @@ html(class='embed') 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') @@ -43,6 +45,9 @@ html(class='embed') 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 diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 88714b2556..3883b5e5ab 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -2,6 +2,7 @@ block vars block loadClientEntry - const entry = config.frontendEntry; + - const baseUrl = config.url; doctype html @@ -32,7 +33,7 @@ html 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=`${url}/opensearch.xml`) + 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) @@ -73,6 +74,9 @@ html 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 diff --git a/packages/backend/src/server/web/views/error.pug b/packages/backend/src/server/web/views/error.pug index 44ebf53cf7..6a78d1878c 100644 --- a/packages/backend/src/server/web/views/error.pug +++ b/packages/backend/src/server/web/views/error.pug @@ -2,15 +2,15 @@ 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 @@ -27,39 +27,45 @@ html 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 An error has occurred! + h1(data-i18n="title") Failed to initialize Misskey button.button-big(onclick="location.reload();") - span.button-label-big Refresh + span.button-label-big(data-i18n-reload) Reload - p.dont-worry Don't worry, it's (probably) not your fault. - - p If reloading after a period of time does not resolve the problem, contact the server administrator with the following ERROR ID. + 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 You may also try the following options: + p + b(data-i18n="solution") The following actions may solve the problem. - p Update your os and browser. - p Disable an adblocker. + 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 - a(href="/flush") - button.button-small - span.button-label-small Clear preferences and cache - br - a(href="/cli") - button.button-small - span.button-label-small Start the simple client - br - a(href="/bios") - button.button-small - span.button-label-small Start the repair tool + 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/oauth.pug b/packages/backend/src/server/web/views/oauth.pug index 1470dbfbdf..4195ccc3a3 100644 --- a/packages/backend/src/server/web/views/oauth.pug +++ b/packages/backend/src/server/web/views/oauth.pug @@ -6,4 +6,6 @@ block meta //- 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/types.ts b/packages/backend/src/types.ts index e852cf5ae2..5d5f1e3b71 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -15,7 +15,11 @@ * receiveFollowRequest - フォローリクエストされた * followRequestAccepted - 自分の送ったフォローリクエストが承認された * roleAssigned - ロールが付与された + * chatRoomInvitationReceived - チャットルームに招待された * achievementEarned - 実績を獲得 + * exportCompleted - エクスポートが完了 + * login - ログイン + * createToken - トークン作成 * app - アプリ通知 * test - テスト通知(サーバー側) */ @@ -31,7 +35,11 @@ export const notificationTypes = [ 'receiveFollowRequest', 'followRequestAccepted', 'roleAssigned', + 'chatRoomInvitationReceived', 'achievementEarned', + 'exportCompleted', + 'login', + 'createToken', 'app', 'test', ] as const; @@ -51,6 +59,20 @@ export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; export const followingVisibilities = ['public', 'followers', 'private'] as const; export const followersVisibilities = ['public', 'followers', 'private'] as const; +/** + * ユーザーがエクスポートできるものの種類 + * + * (主にエクスポート完了通知で使用するものであり、既存のDBの名称等と必ずしも一致しない) + */ +export const userExportableEntities = ['antenna', 'blocking', 'clip', 'customEmoji', 'favorite', 'following', 'muting', 'note', 'userList'] as const; + +/** + * ユーザーがインポートできるものの種類 + * + * (主にインポート完了通知で使用するものであり、既存のDBの名称等と必ずしも一致しない) + */ +export const userImportableEntities = ['antenna', 'blocking', 'customEmoji', 'following', 'muting', 'userList'] as const; + export const moderationLogTypes = [ 'updateServerSettings', 'suspend', @@ -81,6 +103,8 @@ export const moderationLogTypes = [ 'markSensitiveDriveFile', 'unmarkSensitiveDriveFile', 'resolveAbuseReport', + 'forwardAbuseReport', + 'updateAbuseReportNote', 'createInvitation', 'createAd', 'updateAd', @@ -100,6 +124,8 @@ export const moderationLogTypes = [ 'deletePage', 'deleteFlash', 'deleteGalleryPost', + 'deleteChatRoom', + 'updateProxyAccountDescription', ] as const; export type ModerationLogPayloads = { @@ -249,7 +275,18 @@ export type ModerationLogPayloads = { resolveAbuseReport: { reportId: string; report: any; - forwarded: boolean; + forwarded?: boolean; + resolvedAs?: string | null; + }; + forwardAbuseReport: { + reportId: string; + report: any; + }; + updateAbuseReportNote: { + reportId: string; + report: any; + before: string; + after: string; }; createInvitation: { invitations: any[]; @@ -341,25 +378,33 @@ export type ModerationLogPayloads = { postUserUsername: string; post: any; }; + deleteChatRoom: { + roomId: string; + room: any; + }; + updateProxyAccountDescription: { + before: string | null; + after: string | null; + }; }; export type Serialized = { [K in keyof T]: - T[K] extends Date - ? string - : T[K] extends (Date | null) - ? (string | null) - : T[K] extends Record - ? Serialized - : T[K] extends (Record | null) + T[K] extends Date + ? string + : T[K] extends (Date | null) + ? (string | null) + : T[K] extends Record + ? Serialized + : T[K] extends (Record | null) ? (Serialized | null) - : T[K] extends (Record | undefined) + : T[K] extends (Record | undefined) ? (Serialized | undefined) - : T[K]; + : T[K]; }; export type FilterUnionByProperty< - Union, - Property extends string | number | symbol, - Condition + Union, + Property extends string | number | symbol, + Condition, > = Union extends Record ? Union : never; diff --git a/packages/backend/test-federation/.config/example.conf b/packages/backend/test-federation/.config/example.conf new file mode 100644 index 0000000000..83d04eb39d --- /dev/null +++ b/packages/backend/test-federation/.config/example.conf @@ -0,0 +1,70 @@ +# based on https://github.com/misskey-dev/misskey-hub/blob/7071f63a1c80ee35c71f0fd8a6d8722c118c7574/src/docs/admin/nginx.md + +# For WebSocket +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off; + +server { + listen 80; + listen [::]:80; + server_name ${HOST}; + + # For SSL domain validation + root /var/www/html; + location /.well-known/acme-challenge/ { allow all; } + location /.well-known/pki-validation/ { allow all; } + location / { return 301 https://$server_name$request_uri; } +} + +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name ${HOST}; + + ssl_session_timeout 1d; + ssl_session_cache shared:ssl_session_cache:10m; + ssl_session_tickets off; + + ssl_trusted_certificate /etc/nginx/certificates/rootCA.crt; + ssl_certificate /etc/nginx/certificates/$server_name.crt; + ssl_certificate_key /etc/nginx/certificates/$server_name.key; + + # SSL protocol settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_stapling on; + ssl_stapling_verify on; + + # Change to your upload limit + client_max_body_size 80m; + + # Proxy to Node + location / { + proxy_pass http://misskey.${HOST}:3000; + proxy_set_header Host $host; + proxy_http_version 1.1; + proxy_redirect off; + + # If it's behind another reverse proxy or CDN, remove the following. + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # For WebSocket + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Cache settings + proxy_cache cache1; + proxy_cache_lock on; + proxy_cache_use_stale updating; + proxy_force_ranges on; + add_header X-Cache $upstream_cache_status; + } +} diff --git a/packages/backend/test-federation/.config/example.default.yml b/packages/backend/test-federation/.config/example.default.yml new file mode 100644 index 0000000000..28d51ac86e --- /dev/null +++ b/packages/backend/test-federation/.config/example.default.yml @@ -0,0 +1,24 @@ +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 +proxyRemoteFiles: true +signToActivityPubGet: true +allowedPrivateNetworks: + - 127.0.0.1/32 + - 172.20.0.0/16 diff --git a/packages/backend/test-federation/.config/example.docker.env b/packages/backend/test-federation/.config/example.docker.env new file mode 100644 index 0000000000..a8af7cce49 --- /dev/null +++ b/packages/backend/test-federation/.config/example.docker.env @@ -0,0 +1,5 @@ +NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt +POSTGRES_DB=misskey +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +MK_VERBOSE=true diff --git a/packages/backend/test-federation/.gitignore b/packages/backend/test-federation/.gitignore new file mode 100644 index 0000000000..e00f952cb5 --- /dev/null +++ b/packages/backend/test-federation/.gitignore @@ -0,0 +1,6 @@ +certificates +volumes +.env +docker.env +*.test.conf +*.test.default.yml diff --git a/packages/backend/test-federation/README.md b/packages/backend/test-federation/README.md new file mode 100644 index 0000000000..967d51f085 --- /dev/null +++ b/packages/backend/test-federation/README.md @@ -0,0 +1,24 @@ +## test-federation +Test federation between two Misskey servers: `a.test` and `b.test`. + +Before testing, you need to build the entire project, and change working directory to here: +```sh +pnpm build +cd packages/backend/test-federation +``` + +First, you need to start servers by executing following commands: +```sh +bash ./setup.sh +docker compose up --scale tester=0 +``` + +Then you can run all tests by a following command: +```sh +docker compose run --no-deps --rm tester +``` + +For testing a specific file, run a following command: +```sh +docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts +``` diff --git a/packages/backend/test-federation/compose.a.yml b/packages/backend/test-federation/compose.a.yml new file mode 100644 index 0000000000..6a305b404c --- /dev/null +++ b/packages/backend/test-federation/compose.a.yml @@ -0,0 +1,64 @@ +services: + a.test: + extends: + file: ./compose.tpl.yml + service: nginx + depends_on: + misskey.a.test: + condition: service_healthy + networks: + - internal_network_a + volumes: + - type: bind + source: ./.config/a.test.conf + target: /etc/nginx/conf.d/a.test.conf + read_only: true + - type: bind + source: ./certificates/a.test.crt + target: /etc/nginx/certificates/a.test.crt + read_only: true + - type: bind + source: ./certificates/a.test.key + target: /etc/nginx/certificates/a.test.key + read_only: true + + misskey.a.test: + extends: + file: ./compose.tpl.yml + service: misskey + depends_on: + db.a.test: + condition: service_healthy + redis.test: + condition: service_healthy + setup: + condition: service_completed_successfully + networks: + - internal_network_a + volumes: + - type: bind + source: ./.config/a.test.default.yml + target: /misskey/.config/default.yml + read_only: true + + db.a.test: + extends: + file: ./compose.tpl.yml + service: db + networks: + - internal_network_a + volumes: + - type: bind + source: ./volumes/db.a + target: /var/lib/postgresql/data + bind: + create_host_path: true + +networks: + internal_network_a: + internal: true + driver: bridge + ipam: + config: + - subnet: 172.21.0.0/16 + ip_range: 172.21.0.0/24 diff --git a/packages/backend/test-federation/compose.b.yml b/packages/backend/test-federation/compose.b.yml new file mode 100644 index 0000000000..1158b53bae --- /dev/null +++ b/packages/backend/test-federation/compose.b.yml @@ -0,0 +1,64 @@ +services: + b.test: + extends: + file: ./compose.tpl.yml + service: nginx + depends_on: + misskey.b.test: + condition: service_healthy + networks: + - internal_network_b + volumes: + - type: bind + source: ./.config/b.test.conf + target: /etc/nginx/conf.d/b.test.conf + read_only: true + - type: bind + source: ./certificates/b.test.crt + target: /etc/nginx/certificates/b.test.crt + read_only: true + - type: bind + source: ./certificates/b.test.key + target: /etc/nginx/certificates/b.test.key + read_only: true + + misskey.b.test: + extends: + file: ./compose.tpl.yml + service: misskey + depends_on: + db.b.test: + condition: service_healthy + redis.test: + condition: service_healthy + setup: + condition: service_completed_successfully + networks: + - internal_network_b + volumes: + - type: bind + source: ./.config/b.test.default.yml + target: /misskey/.config/default.yml + read_only: true + + db.b.test: + extends: + file: ./compose.tpl.yml + service: db + networks: + - internal_network_b + volumes: + - type: bind + source: ./volumes/db.b + target: /var/lib/postgresql/data + bind: + create_host_path: true + +networks: + internal_network_b: + internal: true + driver: bridge + ipam: + config: + - subnet: 172.22.0.0/16 + ip_range: 172.22.0.0/24 diff --git a/packages/backend/test-federation/compose.override.yaml b/packages/backend/test-federation/compose.override.yaml new file mode 100644 index 0000000000..60a7631ab5 --- /dev/null +++ b/packages/backend/test-federation/compose.override.yaml @@ -0,0 +1,117 @@ +services: + setup: + volumes: + - type: volume + source: node_modules + target: /misskey/node_modules + - type: volume + source: node_modules_backend + target: /misskey/packages/backend/node_modules + - type: volume + source: node_modules_misskey-js + target: /misskey/packages/misskey-js/node_modules + - type: volume + source: node_modules_misskey-reversi + target: /misskey/packages/misskey-reversi/node_modules + + tester: + networks: + external_network: + internal_network: + ipv4_address: 172.20.1.1 + volumes: + - type: volume + source: node_modules_dev + target: /misskey/node_modules + - type: volume + source: node_modules_backend_dev + target: /misskey/packages/backend/node_modules + - type: volume + source: node_modules_misskey-js_dev + target: /misskey/packages/misskey-js/node_modules + + daemon: + networks: + - external_network + - internal_network_a + - internal_network_b + volumes: + - type: volume + source: node_modules_dev + target: /misskey/node_modules + - type: volume + source: node_modules_backend_dev + target: /misskey/packages/backend/node_modules + + redis.test: + networks: + - internal_network_a + - internal_network_b + + a.test: + networks: + - internal_network + + misskey.a.test: + networks: + - external_network + - internal_network + volumes: + - type: volume + source: node_modules + target: /misskey/node_modules + - type: volume + source: node_modules_backend + target: /misskey/packages/backend/node_modules + - type: volume + source: node_modules_misskey-js + target: /misskey/packages/misskey-js/node_modules + - type: volume + source: node_modules_misskey-reversi + target: /misskey/packages/misskey-reversi/node_modules + + b.test: + networks: + - internal_network + + misskey.b.test: + networks: + - external_network + - internal_network + volumes: + - type: volume + source: node_modules + target: /misskey/node_modules + - type: volume + source: node_modules_backend + target: /misskey/packages/backend/node_modules + - type: volume + source: node_modules_misskey-js + target: /misskey/packages/misskey-js/node_modules + - type: volume + source: node_modules_misskey-reversi + target: /misskey/packages/misskey-reversi/node_modules + +networks: + external_network: + driver: bridge + ipam: + config: + - subnet: 172.23.0.0/16 + ip_range: 172.23.0.0/24 + internal_network: + internal: true + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + ip_range: 172.20.0.0/24 + +volumes: + node_modules: + node_modules_dev: + node_modules_backend: + node_modules_backend_dev: + node_modules_misskey-js: + node_modules_misskey-js_dev: + node_modules_misskey-reversi: diff --git a/packages/backend/test-federation/compose.tpl.yml b/packages/backend/test-federation/compose.tpl.yml new file mode 100644 index 0000000000..25770063d3 --- /dev/null +++ b/packages/backend/test-federation/compose.tpl.yml @@ -0,0 +1,105 @@ +services: + nginx: + image: nginx:1.27 + volumes: + - type: bind + source: ./certificates/rootCA.crt + target: /etc/nginx/certificates/rootCA.crt + read_only: true + healthcheck: + test: service nginx status + interval: 5s + retries: 20 + + misskey: + image: node:20 + env_file: + - ./.config/docker.env + environment: + - NODE_ENV=production + volumes: + - type: bind + source: ../../../built + target: /misskey/built + read_only: true + - type: bind + source: ../assets + target: /misskey/packages/backend/assets + read_only: true + - type: bind + source: ../built + target: /misskey/packages/backend/built + read_only: true + - type: bind + source: ../migration + target: /misskey/packages/backend/migration + read_only: true + - type: bind + source: ../ormconfig.js + target: /misskey/packages/backend/ormconfig.js + read_only: true + - type: bind + source: ../package.json + target: /misskey/packages/backend/package.json + read_only: true + - type: bind + source: ../../misskey-js/built + target: /misskey/packages/misskey-js/built + read_only: true + - type: bind + source: ../../misskey-js/package.json + target: /misskey/packages/misskey-js/package.json + read_only: true + - type: bind + source: ../../misskey-reversi/built + target: /misskey/packages/misskey-reversi/built + read_only: true + - type: bind + source: ../../misskey-reversi/package.json + target: /misskey/packages/misskey-reversi/package.json + read_only: true + - type: bind + source: ../../../healthcheck.sh + target: /misskey/healthcheck.sh + read_only: true + - type: bind + source: ../../../package.json + target: /misskey/package.json + read_only: true + - type: bind + source: ../../../pnpm-lock.yaml + target: /misskey/pnpm-lock.yaml + read_only: true + - type: bind + source: ../../../pnpm-workspace.yaml + target: /misskey/pnpm-workspace.yaml + read_only: true + - type: bind + source: ../../../scripts/dependency-patches + target: /misskey/scripts/dependency-patches + read_only: true + - type: bind + source: ./certificates/rootCA.crt + target: /usr/local/share/ca-certificates/rootCA.crt + read_only: true + working_dir: /misskey + command: > + bash -c " + npm install -g pnpm + pnpm -F backend migrate + pnpm -F backend start + " + healthcheck: + test: bash /misskey/healthcheck.sh + interval: 5s + retries: 20 + + db: + image: postgres:15-alpine + env_file: + - ./.config/docker.env + volumes: + healthcheck: + test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB + interval: 5s + retries: 20 diff --git a/packages/backend/test-federation/compose.yml b/packages/backend/test-federation/compose.yml new file mode 100644 index 0000000000..dfa51b940a --- /dev/null +++ b/packages/backend/test-federation/compose.yml @@ -0,0 +1,145 @@ +include: + - ./compose.a.yml + - ./compose.b.yml + +services: + setup: + extends: + file: ./compose.tpl.yml + service: misskey + command: > + bash -c " + npm install -g pnpm + pnpm -F backend i + pnpm -F misskey-js i + pnpm -F misskey-reversi i + " + + tester: + image: node:20 + depends_on: + a.test: + condition: service_healthy + misskey.a.test: + condition: service_healthy + b.test: + condition: service_healthy + misskey.b.test: + condition: service_healthy + environment: + - NODE_ENV=development + - NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/rootCA.crt + volumes: + - type: bind + source: ../package.json + target: /misskey/packages/backend/package.json + read_only: true + - type: bind + source: ../test/resources + target: /misskey/packages/backend/test/resources + read_only: true + - type: bind + source: ./test + target: /misskey/packages/backend/test-federation/test + read_only: true + - type: bind + source: ../jest.config.cjs + target: /misskey/packages/backend/jest.config.cjs + read_only: true + - type: bind + source: ../jest.config.fed.cjs + target: /misskey/packages/backend/jest.config.fed.cjs + read_only: true + - type: bind + source: ../../misskey-js/built + target: /misskey/packages/misskey-js/built + read_only: true + - type: bind + source: ../../misskey-js/package.json + target: /misskey/packages/misskey-js/package.json + read_only: true + - type: bind + source: ../../../package.json + target: /misskey/package.json + read_only: true + - type: bind + source: ../../../pnpm-lock.yaml + target: /misskey/pnpm-lock.yaml + read_only: true + - type: bind + source: ../../../pnpm-workspace.yaml + target: /misskey/pnpm-workspace.yaml + read_only: true + - type: bind + source: ../../../scripts/dependency-patches + target: /misskey/scripts/dependency-patches + read_only: true + - type: bind + source: ./certificates/rootCA.crt + target: /usr/local/share/ca-certificates/rootCA.crt + read_only: true + working_dir: /misskey + entrypoint: > + bash -c ' + npm install -g pnpm + pnpm -F misskey-js i --frozen-lockfile + pnpm -F backend i --frozen-lockfile + exec "$0" "$@" + ' + command: pnpm -F backend test:fed + + daemon: + image: node:20 + depends_on: + redis.test: + condition: service_healthy + volumes: + - type: bind + source: ../package.json + target: /misskey/packages/backend/package.json + read_only: true + - type: bind + source: ./daemon.ts + target: /misskey/packages/backend/test-federation/daemon.ts + read_only: true + - type: bind + source: ./tsconfig.json + target: /misskey/packages/backend/test-federation/tsconfig.json + read_only: true + - type: bind + source: ../../../package.json + target: /misskey/package.json + read_only: true + - type: bind + source: ../../../pnpm-lock.yaml + target: /misskey/pnpm-lock.yaml + read_only: true + - type: bind + source: ../../../pnpm-workspace.yaml + target: /misskey/pnpm-workspace.yaml + read_only: true + - type: bind + source: ../../../scripts/dependency-patches + target: /misskey/scripts/dependency-patches + read_only: true + working_dir: /misskey + command: > + bash -c " + npm install -g pnpm + pnpm -F backend i --frozen-lockfile + pnpm exec tsc -p ./packages/backend/test-federation + node ./packages/backend/test-federation/built/daemon.js + " + + redis.test: + image: redis:7-alpine + volumes: + - type: bind + source: ./volumes/redis + target: /data + bind: + create_host_path: true + healthcheck: + test: redis-cli ping + interval: 5s + retries: 20 diff --git a/packages/backend/test-federation/daemon.ts b/packages/backend/test-federation/daemon.ts new file mode 100644 index 0000000000..46b6963c79 --- /dev/null +++ b/packages/backend/test-federation/daemon.ts @@ -0,0 +1,38 @@ +import IPCIDR from 'ip-cidr'; +import { Redis } from 'ioredis'; + +const TESTER_IP_ADDRESS = '172.20.1.1'; + +/** + * This should be same as {@link file://./../src/misc/get-ip-hash.ts}. + */ +function getIpHash(ip: string) { + const prefix = IPCIDR.createAddress(ip).mask(64); + return `ip-${BigInt('0b' + prefix).toString(36)}`; +} + +/** + * This prevents hitting rate limit when login. + */ +export async function purgeLimit(host: string, client: Redis) { + const ipHash = getIpHash(TESTER_IP_ADDRESS); + const key = `${host}:limit:${ipHash}:signin`; + const res = await client.zrange(key, 0, -1); + if (res.length !== 0) { + console.log(`${key} - ${JSON.stringify(res)}`); + await client.del(key); + } +} + +console.log('Daemon started running'); + +{ + const redisClient = new Redis({ + host: 'redis.test', + }); + + setInterval(() => { + purgeLimit('a.test', redisClient); + purgeLimit('b.test', redisClient); + }, 200); +} diff --git a/packages/backend/test-federation/eslint.config.js b/packages/backend/test-federation/eslint.config.js new file mode 100644 index 0000000000..e3bcf4c0fe --- /dev/null +++ b/packages/backend/test-federation/eslint.config.js @@ -0,0 +1,21 @@ +import globals from 'globals'; +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../../shared/eslint.config.js'; + +export default [ + ...sharedConfig, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + globals: { + ...globals.node, + }, + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/backend/test-federation/setup.sh b/packages/backend/test-federation/setup.sh new file mode 100644 index 0000000000..1bc3a2a87c --- /dev/null +++ b/packages/backend/test-federation/setup.sh @@ -0,0 +1,35 @@ +#!/bin/bash +mkdir certificates + +# rootCA +openssl genrsa -des3 \ + -passout pass:rootCA \ + -out certificates/rootCA.key 4096 +openssl req -x509 -new -nodes -batch \ + -key certificates/rootCA.key \ + -sha256 \ + -days 1024 \ + -passin pass:rootCA \ + -out certificates/rootCA.crt + +# domain +function generate { + openssl req -new -newkey rsa:2048 -sha256 -nodes \ + -keyout certificates/$1.key \ + -subj "/CN=$1/emailAddress=admin@$1/C=JP/ST=/L=/O=Misskey Tester/OU=Some Unit" \ + -out certificates/$1.csr + openssl x509 -req -sha256 \ + -in certificates/$1.csr \ + -CA certificates/rootCA.crt \ + -CAkey certificates/rootCA.key \ + -CAcreateserial \ + -passin pass:rootCA \ + -out certificates/$1.crt \ + -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 +} + +generate a.test +generate b.test diff --git a/packages/backend/test-federation/test/abuse-report.test.ts b/packages/backend/test-federation/test/abuse-report.test.ts new file mode 100644 index 0000000000..ddc8e4f9d0 --- /dev/null +++ b/packages/backend/test-federation/test/abuse-report.test.ts @@ -0,0 +1,52 @@ +import { rejects, strictEqual } from 'node:assert'; +import * as Misskey from 'misskey-js'; +import { createAccount, createModerator, resolveRemoteUser, sleep, type LoginUser } from './utils.js'; + +describe('Abuse report', () => { + describe('Forwarding report', () => { + let alice: LoginUser, bob: LoginUser, aModerator: LoginUser, bModerator: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [aModerator, bModerator] = await Promise.all([ + createModerator('a.test'), + createModerator('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Alice reports Bob, moderator in A forwards it, and B moderator receives it', async () => { + const comment = crypto.randomUUID(); + await alice.client.request('users/report-abuse', { userId: bobInA.id, comment }); + const reports = await aModerator.client.request('admin/abuse-user-reports', {}); + const report = reports.filter(report => report.comment === comment)[0]; + await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }); + await sleep(); + + const reportsInB = await bModerator.client.request('admin/abuse-user-reports', {}); + const reportInB = reportsInB.filter(report => report.comment.includes(comment))[0]; + // NOTE: reporter is not Alice, and is not moderator in A + strictEqual(reportInB.reporter.url, 'https://a.test/@system.actor'); + strictEqual(reportInB.targetUserId, bob.id); + + // NOTE: cannot forward multiple times + await rejects( + async () => await aModerator.client.request('admin/forward-abuse-user-report', { reportId: report.id }), + (err: any) => { + strictEqual(err.code, 'INTERNAL_ERROR'); + strictEqual(err.info.e.message, 'The report has already been forwarded.'); + return true; + }, + ); + }); + }); +}); diff --git a/packages/backend/test-federation/test/block.test.ts b/packages/backend/test-federation/test/block.test.ts new file mode 100644 index 0000000000..ef910eeaea --- /dev/null +++ b/packages/backend/test-federation/test/block.test.ts @@ -0,0 +1,224 @@ +import { deepStrictEqual, rejects, strictEqual } from 'node:assert'; +import * as Misskey from 'misskey-js'; +import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; + +describe('Block', () => { + describe('Check follow', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Cannot follow if blocked', async () => { + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'BLOCKED'); + return true; + }, + ); + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 0); + }); + + // FIXME: this is invalid case + test('Cannot follow even if unblocked', async () => { + // unblock here + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + // TODO: why still being blocked? + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'BLOCKED'); + return true; + }, + ); + }); + + test.skip('Can follow if unblocked', async () => { + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 1); + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); + }); + + test.skip('Remove follower when block them', async () => { + test('before block', async () => { + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 1); + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); + }); + + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + + test('after block', async () => { + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 0); + }); + }); + }); + + describe('Check reply', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Cannot reply if blocked', async () => { + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + await rejects( + async () => await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id }), + (err: any) => { + strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED'); + return true; + }, + ); + }); + + test('Can reply if unblocked', async () => { + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + const reply = (await bob.client.request('notes/create', { text: 'b', replyId: resolvedNote.id })).createdNote; + + await resolveRemoteNote('b.test', reply.id, alice); + }); + }); + + describe('Check reaction', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Cannot reaction if blocked', async () => { + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + await rejects( + async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }), + (err: any) => { + strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED'); + return true; + }, + ); + }); + + // FIXME: this is invalid case + test('Cannot reaction even if unblocked', async () => { + // unblock here + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + + // TODO: why still being blocked? + await rejects( + async () => await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }), + (err: any) => { + strictEqual(err.code, 'YOU_HAVE_BEEN_BLOCKED'); + return true; + }, + ); + }); + + test.skip('Can reaction if unblocked', async () => { + await alice.client.request('blocking/delete', { userId: bobInA.id }); + await sleep(); + + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: '😅' }); + + const _note = await alice.client.request('notes/show', { noteId: note.id }); + deepStrictEqual(_note.reactions, { '😅': 1 }); + }); + }); + + describe('Check mention', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + /** NOTE: You should mute the target to stop receiving notifications */ + test('Can mention and notified even if blocked', async () => { + await alice.client.request('blocking/create', { userId: bobInA.id }); + await sleep(); + + const text = `@${alice.username}@a.test plz unblock me!`; + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { text }), + notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text, + true, + ); + }); + }); +}); diff --git a/packages/backend/test-federation/test/drive.test.ts b/packages/backend/test-federation/test/drive.test.ts new file mode 100644 index 0000000000..f755183b4d --- /dev/null +++ b/packages/backend/test-federation/test/drive.test.ts @@ -0,0 +1,175 @@ +import assert, { strictEqual } from 'node:assert'; +import * as Misskey from 'misskey-js'; +import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js'; + +const bAdmin = await fetchAdmin('b.test'); + +describe('Drive', () => { + describe('Upload image in a.test and resolve from b.test', () => { + let uploader: LoginUser; + + beforeAll(async () => { + uploader = await createAccount('a.test'); + }); + + let image: Misskey.entities.DriveFile, imageInB: Misskey.entities.DriveFile; + + describe('Upload', () => { + beforeAll(async () => { + image = await uploadFile('a.test', uploader); + const noteWithImage = (await uploader.client.request('notes/create', { fileIds: [image.id] })).createdNote; + const noteInB = await resolveRemoteNote('a.test', noteWithImage.id, bAdmin); + assert(noteInB.files != null); + strictEqual(noteInB.files.length, 1); + imageInB = noteInB.files[0]; + }); + + test('Check consistency of DriveFile', () => { + // console.log(`a.test: ${JSON.stringify(image, null, '\t')}`); + // console.log(`b.test: ${JSON.stringify(imageInB, null, '\t')}`); + + deepStrictEqualWithExcludedFields(image, imageInB, [ + 'id', + 'createdAt', + 'size', + 'url', + 'thumbnailUrl', + 'userId', + ]); + }); + }); + + let updatedImage: Misskey.entities.DriveFile, updatedImageInB: Misskey.entities.DriveFile; + + describe('Update', () => { + beforeAll(async () => { + updatedImage = await uploader.client.request('drive/files/update', { + fileId: image.id, + name: 'updated_192.jpg', + isSensitive: true, + }); + + updatedImageInB = await bAdmin.client.request('drive/files/show', { + fileId: imageInB.id, + }); + }); + + test('Check consistency', () => { + // console.log(`a.test: ${JSON.stringify(updatedImage, null, '\t')}`); + // console.log(`b.test: ${JSON.stringify(updatedImageInB, null, '\t')}`); + + // FIXME: not updated with `drive/files/update` + strictEqual(updatedImage.isSensitive, true); + strictEqual(updatedImage.name, 'updated_192.jpg'); + strictEqual(updatedImageInB.isSensitive, false); + strictEqual(updatedImageInB.name, '192.jpg'); + }); + }); + + let reupdatedImageInB: Misskey.entities.DriveFile; + + describe('Re-update with attaching to Note', () => { + beforeAll(async () => { + const noteWithUpdatedImage = (await uploader.client.request('notes/create', { fileIds: [updatedImage.id] })).createdNote; + const noteWithUpdatedImageInB = await resolveRemoteNote('a.test', noteWithUpdatedImage.id, bAdmin); + assert(noteWithUpdatedImageInB.files != null); + strictEqual(noteWithUpdatedImageInB.files.length, 1); + reupdatedImageInB = noteWithUpdatedImageInB.files[0]; + }); + + test('Check consistency', () => { + // console.log(`b.test: ${JSON.stringify(reupdatedImageInB, null, '\t')}`); + + // `isSensitive` is updated + strictEqual(reupdatedImageInB.isSensitive, true); + // FIXME: but `name` is not updated + strictEqual(reupdatedImageInB.name, '192.jpg'); + }); + }); + }); + + describe('Sensitive flag', () => { + describe('isSensitive is federated in delivering to followers', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + }); + + test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => { + const file = await uploadFile('a.test', alice); + await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true }); + await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] }); + await sleep(); + + const notes = await bob.client.request('notes/timeline', {}); + strictEqual(notes.length, 1); + const noteInB = notes[0]; + assert(noteInB.files != null); + strictEqual(noteInB.files.length, 1); + strictEqual(noteInB.files[0].isSensitive, true); + }); + }); + + describe('isSensitive is federated in resolving', () => { + let alice: LoginUser, bob: LoginUser; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + }); + + test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => { + const file = await uploadFile('a.test', alice); + await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true }); + const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id] })).createdNote; + + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + assert(noteInB.files != null); + strictEqual(noteInB.files.length, 1); + strictEqual(noteInB.files[0].isSensitive, true); + }); + }); + + /** @see https://github.com/misskey-dev/misskey/issues/12208 */ + describe('isSensitive is federated in replying', () => { + let alice: LoginUser, bob: LoginUser; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + }); + + test('Alice uploads sensitive image and it is shown as sensitive from Bob', async () => { + const bobNote = (await bob.client.request('notes/create', { text: 'I\'m Bob' })).createdNote; + + const file = await uploadFile('a.test', alice); + await alice.client.request('drive/files/update', { fileId: file.id, isSensitive: true }); + const bobNoteInA = await resolveRemoteNote('b.test', bobNote.id, alice); + const note = (await alice.client.request('notes/create', { text: 'sensitive', fileIds: [file.id], replyId: bobNoteInA.id })).createdNote; + await sleep(); + + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + assert(noteInB.files != null); + strictEqual(noteInB.files.length, 1); + strictEqual(noteInB.files[0].isSensitive, true); + }); + }); + }); +}); diff --git a/packages/backend/test-federation/test/emoji.test.ts b/packages/backend/test-federation/test/emoji.test.ts new file mode 100644 index 0000000000..3119ca6e4d --- /dev/null +++ b/packages/backend/test-federation/test/emoji.test.ts @@ -0,0 +1,97 @@ +import assert, { deepStrictEqual, strictEqual } from 'assert'; +import * as Misskey from 'misskey-js'; +import { addCustomEmoji, createAccount, type LoginUser, resolveRemoteUser, sleep } from './utils.js'; + +describe('Emoji', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + }); + + test('Custom emoji are delivered with Note delivery', async () => { + const emoji = await addCustomEmoji('a.test'); + await alice.client.request('notes/create', { text: `I love :${emoji.name}:` }); + await sleep(); + + const notes = await bob.client.request('notes/timeline', {}); + const noteInB = notes[0]; + + strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`); + assert(noteInB.emojis != null); + assert(emoji.name in noteInB.emojis); + strictEqual(noteInB.emojis[emoji.name], emoji.url); + }); + + test('Custom emoji are delivered with Reaction delivery', async () => { + const emoji = await addCustomEmoji('a.test'); + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + await sleep(); + + await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const noteInB = (await bob.client.request('notes/timeline', {}))[0]; + deepStrictEqual(noteInB.reactions[`:${emoji.name}@a.test:`], 1); + deepStrictEqual(noteInB.reactionEmojis[`${emoji.name}@a.test`], emoji.url); + }); + + test('Custom emoji are delivered with Profile delivery', async () => { + const emoji = await addCustomEmoji('a.test'); + const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` }); + await sleep(); + + const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(renewedaliceInB.name, renewedAlice.name); + assert(emoji.name in renewedaliceInB.emojis); + strictEqual(renewedaliceInB.emojis[emoji.name], emoji.url); + }); + + test('Local-only custom emoji aren\'t delivered with Note delivery', async () => { + const emoji = await addCustomEmoji('a.test', { localOnly: true }); + await alice.client.request('notes/create', { text: `I love :${emoji.name}:` }); + await sleep(); + + const notes = await bob.client.request('notes/timeline', {}); + const noteInB = notes[0]; + + strictEqual(noteInB.text, `I love \u200b:${emoji.name}:\u200b`); + // deepStrictEqual(noteInB.emojis, {}); // TODO: this fails (why?) + deepStrictEqual({ ...noteInB.emojis }, {}); + }); + + test('Local-only custom emoji aren\'t delivered with Reaction delivery', async () => { + const emoji = await addCustomEmoji('a.test', { localOnly: true }); + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + await sleep(); + + await alice.client.request('notes/reactions/create', { noteId: note.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const noteInB = (await bob.client.request('notes/timeline', {}))[0]; + deepStrictEqual({ ...noteInB.reactions }, { '❤': 1 }); + deepStrictEqual({ ...noteInB.reactionEmojis }, {}); + }); + + test('Local-only custom emoji aren\'t delivered with Profile delivery', async () => { + const emoji = await addCustomEmoji('a.test', { localOnly: true }); + const renewedAlice = await alice.client.request('i/update', { name: `:${emoji.name}:` }); + await sleep(); + + const renewedaliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(renewedaliceInB.name, renewedAlice.name); + deepStrictEqual({ ...renewedaliceInB.emojis }, {}); + }); +}); diff --git a/packages/backend/test-federation/test/move.test.ts b/packages/backend/test-federation/test/move.test.ts new file mode 100644 index 0000000000..56a57de8a4 --- /dev/null +++ b/packages/backend/test-federation/test/move.test.ts @@ -0,0 +1,52 @@ +import assert, { strictEqual } from 'node:assert'; +import { createAccount, type LoginUser, sleep } from './utils.js'; + +describe('Move', () => { + test('Minimum move', async () => { + const [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] }); + await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` }); + }); + + /** @see https://github.com/misskey-dev/misskey/issues/11320 */ + describe('Following relation is transferred after move', () => { + let alice: LoginUser, bob: LoginUser, carol: LoginUser; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + carol = await createAccount('a.test'); + + // Follow @carol@a.test ==> @alice@a.test + await carol.client.request('following/create', { userId: alice.id }); + + // Move @alice@a.test ==> @bob@b.test + await bob.client.request('i/update', { alsoKnownAs: [`@${alice.username}@a.test`] }); + await alice.client.request('i/move', { moveToAccount: `@${bob.username}@b.test` }); + await sleep(); + }); + + test('Check from follower', async () => { + const following = await carol.client.request('users/following', { userId: carol.id }); + strictEqual(following.length, 2); + const followees = following.map(({ followee }) => followee); + assert(followees.every(followee => followee != null)); + assert(followees.some(({ id, url }) => id === alice.id && url === null)); + assert(followees.some(({ url }) => url === `https://b.test/@${bob.username}`)); + }); + + test('Check from followee', async () => { + const followers = await bob.client.request('users/followers', { userId: bob.id }); + strictEqual(followers.length, 1); + const follower = followers[0].follower; + assert(follower != null); + strictEqual(follower.url, `https://a.test/@${carol.username}`); + }); + }); +}); diff --git a/packages/backend/test-federation/test/note.test.ts b/packages/backend/test-federation/test/note.test.ts new file mode 100644 index 0000000000..1584f9587e --- /dev/null +++ b/packages/backend/test-federation/test/note.test.ts @@ -0,0 +1,383 @@ +import assert, { rejects, strictEqual } from 'node:assert'; +import * as Misskey from 'misskey-js'; +import { addCustomEmoji, createAccount, createModerator, deepStrictEqualWithExcludedFields, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep, uploadFile } from './utils.js'; + +describe('Note', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + describe('Note content', () => { + test('Consistency of Public Note', async () => { + const image = await uploadFile('a.test', alice); + const note = (await alice.client.request('notes/create', { + text: 'I am Alice!', + fileIds: [image.id], + poll: { + choices: ['neko', 'inu'], + multiple: false, + expiredAfter: 60 * 60 * 1000, + }, + })).createdNote; + + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + deepStrictEqualWithExcludedFields(note, resolvedNote, [ + 'id', + 'emojis', + /** Consistency of files is checked at {@link file://./drive.test.ts}, so let's skip. */ + 'fileIds', + 'files', + /** @see https://github.com/misskey-dev/misskey/issues/12409 */ + 'reactionAcceptance', + 'userId', + 'user', + 'uri', + ]); + strictEqual(aliceInB.id, resolvedNote.userId); + }); + + test('Consistency of reply', async () => { + const _replyedNote = (await alice.client.request('notes/create', { + text: 'a', + })).createdNote; + const note = (await alice.client.request('notes/create', { + text: 'b', + replyId: _replyedNote.id, + })).createdNote; + // NOTE: the repliedCount is incremented, so fetch again + const replyedNote = await alice.client.request('notes/show', { noteId: _replyedNote.id }); + strictEqual(replyedNote.repliesCount, 1); + + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + deepStrictEqualWithExcludedFields(note, resolvedNote, [ + 'id', + 'emojis', + 'reactionAcceptance', + 'replyId', + 'reply', + 'userId', + 'user', + 'uri', + ]); + assert(resolvedNote.replyId != null); + assert(resolvedNote.reply != null); + deepStrictEqualWithExcludedFields(replyedNote, resolvedNote.reply, [ + 'id', + // TODO: why clippedCount loses consistency? + 'clippedCount', + 'emojis', + 'userId', + 'user', + 'uri', + // flaky because this is parallelly incremented, so let's check it below + 'repliesCount', + ]); + strictEqual(aliceInB.id, resolvedNote.userId); + + await sleep(); + + const resolvedReplyedNote = await bob.client.request('notes/show', { noteId: resolvedNote.replyId }); + strictEqual(resolvedReplyedNote.repliesCount, 1); + }); + + test('Consistency of Renote', async () => { + // NOTE: the renoteCount is not incremented, so no need to fetch again + const renotedNote = (await alice.client.request('notes/create', { + text: 'a', + })).createdNote; + const note = (await alice.client.request('notes/create', { + text: 'b', + renoteId: renotedNote.id, + })).createdNote; + + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + deepStrictEqualWithExcludedFields(note, resolvedNote, [ + 'id', + 'emojis', + 'reactionAcceptance', + 'renoteId', + 'renote', + 'userId', + 'user', + 'uri', + ]); + assert(resolvedNote.renoteId != null); + assert(resolvedNote.renote != null); + deepStrictEqualWithExcludedFields(renotedNote, resolvedNote.renote, [ + 'id', + 'emojis', + 'userId', + 'user', + 'uri', + ]); + strictEqual(aliceInB.id, resolvedNote.userId); + }); + }); + + describe('Other props', () => { + test('localOnly', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote; + rejects( + async () => await bob.client.request('ap/show', { uri: `https://a.test/notes/${note.id}` }), + (err: any) => { + strictEqual(err.code, 'REQUEST_FAILED'); + return true; + }, + ); + }); + }); + + describe('Deletion', () => { + describe('Check Delete is delivered', () => { + describe('To followers', () => { + let carol: LoginUser; + + beforeAll(async () => { + carol = await createAccount('a.test'); + + await carol.client.request('following/create', { userId: bobInA.id }); + await sleep(); + }); + + test('Check', async () => { + const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, carol); + await bob.client.request('notes/delete', { noteId: note.id }); + await sleep(); + + await rejects( + async () => await carol.client.request('notes/show', { noteId: noteInA.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + + afterAll(async () => { + await carol.client.request('following/delete', { userId: bobInA.id }); + await sleep(); + }); + }); + + describe('To renoted and not followed user', () => { + test('Check', async () => { + const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, alice); + await alice.client.request('notes/create', { renoteId: noteInA.id }); + await sleep(); + + await bob.client.request('notes/delete', { noteId: note.id }); + await sleep(); + + await rejects( + async () => await alice.client.request('notes/show', { noteId: noteInA.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + }); + + describe('To replied and not followed user', () => { + test('Check', async () => { + const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, alice); + await alice.client.request('notes/create', { text: 'Hello Bob!', replyId: noteInA.id }); + await sleep(); + + await bob.client.request('notes/delete', { noteId: note.id }); + await sleep(); + + await rejects( + async () => await alice.client.request('notes/show', { noteId: noteInA.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + }); + + /** + * FIXME: not delivered + * @see https://github.com/misskey-dev/misskey/issues/15548 + */ + describe('To only resolved and not followed user', () => { + test.failing('Check', async () => { + const note = (await bob.client.request('notes/create', { text: 'I\'m Bob.' })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, alice); + await sleep(); + + await bob.client.request('notes/delete', { noteId: note.id }); + await sleep(); + + await rejects( + async () => await alice.client.request('notes/show', { noteId: noteInA.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + }); + }); + + describe('Deletion of remote user\'s note for moderation', () => { + let note: Misskey.entities.Note; + + test('Alice post is deleted in B', async () => { + note = (await alice.client.request('notes/create', { text: 'Hello' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const bMod = await createModerator('b.test'); + await bMod.client.request('notes/delete', { noteId: noteInB.id }); + await rejects( + async () => await bob.client.request('notes/show', { noteId: noteInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + + /** + * FIXME: implement soft deletion as well as user? + * @see https://github.com/misskey-dev/misskey/issues/11437 + */ + test.failing('Not found even if resolve again', async () => { + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + await rejects( + async () => await bob.client.request('notes/show', { noteId: noteInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_NOTE'); + return true; + }, + ); + }); + }); + }); + + describe('Reaction', () => { + describe('Consistency', () => { + test('Unicode reaction', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + const reaction = '😅'; + await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction }); + await sleep(); + + const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); + strictEqual(reactions.length, 1); + strictEqual(reactions[0].type, reaction); + strictEqual(reactions[0].user.id, bobInA.id); + }); + + test('Custom emoji reaction', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const resolvedNote = await resolveRemoteNote('a.test', note.id, bob); + const emoji = await addCustomEmoji('b.test'); + await bob.client.request('notes/reactions/create', { noteId: resolvedNote.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); + strictEqual(reactions.length, 1); + strictEqual(reactions[0].type, `:${emoji.name}@b.test:`); + strictEqual(reactions[0].user.id, bobInA.id); + }); + }); + + describe('Acceptance', () => { + test('Even if likeOnly, remote users can react with custom emoji, but it is converted to like', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'likeOnly' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const emoji = await addCustomEmoji('b.test'); + await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); + strictEqual(reactions.length, 1); + strictEqual(reactions[0].type, '❤'); + }); + + /** + * TODO: this may be unexpected behavior? + * @see https://github.com/misskey-dev/misskey/issues/12409 + */ + test('Even if nonSensitiveOnly, remote users can react with sensitive emoji, and it is not converted', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', reactionAcceptance: 'nonSensitiveOnly' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const emoji = await addCustomEmoji('b.test', { isSensitive: true }); + await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction: `:${emoji.name}:` }); + await sleep(); + + const reactions = await alice.client.request('notes/reactions', { noteId: note.id }); + strictEqual(reactions.length, 1); + strictEqual(reactions[0].type, `:${emoji.name}@b.test:`); + }); + }); + }); + + describe('Poll', () => { + describe('Any remote user\'s vote is delivered to the author', () => { + let carol: LoginUser; + + beforeAll(async () => { + carol = await createAccount('a.test'); + }); + + test('Bob creates poll and receives a vote from Carol', async () => { + const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote; + const noteInA = await resolveRemoteNote('b.test', note.id, carol); + await carol.client.request('notes/polls/vote', { noteId: noteInA.id, choice: 0 }); + await sleep(); + + const noteAfterVote = await bob.client.request('notes/show', { noteId: note.id }); + assert(noteAfterVote.poll != null); + strictEqual(noteAfterVote.poll.choices[0].votes, 1); + strictEqual(noteAfterVote.poll.choices[1].votes, 0); + }); + }); + + describe('Local user\'s vote is delivered to the author\'s remote followers', () => { + let bobRemoteFollower: LoginUser, localVoter: LoginUser; + + beforeAll(async () => { + [ + bobRemoteFollower, + localVoter, + ] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + await bobRemoteFollower.client.request('following/create', { userId: bobInA.id }); + await sleep(); + }); + + test('A vote in Bob\'s server is delivered to Bob\'s remote followers', async () => { + const note = (await bob.client.request('notes/create', { poll: { choices: ['inu', 'neko'] } })).createdNote; + // NOTE: resolve before voting + const noteInA = await resolveRemoteNote('b.test', note.id, bobRemoteFollower); + await localVoter.client.request('notes/polls/vote', { noteId: note.id, choice: 0 }); + await sleep(); + + const noteAfterVote = await bobRemoteFollower.client.request('notes/show', { noteId: noteInA.id }); + assert(noteAfterVote.poll != null); + strictEqual(noteAfterVote.poll.choices[0].votes, 1); + strictEqual(noteAfterVote.poll.choices[1].votes, 0); + }); + }); + }); +}); diff --git a/packages/backend/test-federation/test/notification.test.ts b/packages/backend/test-federation/test/notification.test.ts new file mode 100644 index 0000000000..6d55353653 --- /dev/null +++ b/packages/backend/test-federation/test/notification.test.ts @@ -0,0 +1,107 @@ +import * as Misskey from 'misskey-js'; +import { assertNotificationReceived, createAccount, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; + +describe('Notification', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + describe('Follow', () => { + test('Get notification when follow', async () => { + await assertNotificationReceived( + 'b.test', bob, + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + notification => notification.type === 'followRequestAccepted' && notification.userId === aliceInB.id, + true, + ); + + await bob.client.request('following/delete', { userId: aliceInB.id }); + await sleep(); + }); + + test('Get notification when get followed', async () => { + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + notification => notification.type === 'follow' && notification.userId === bobInA.id, + true, + ); + }); + + afterAll(async () => await bob.client.request('following/delete', { userId: aliceInB.id })); + }); + + describe('Note', () => { + test('Get notification when get a reaction', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const reaction = '😅'; + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/reactions/create', { noteId: noteInB.id, reaction }), + notification => + notification.type === 'reaction' && notification.note.id === note.id && notification.userId === bobInA.id && notification.reaction === reaction, + true, + ); + }); + + test('Get notification when replied', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const text = crypto.randomUUID(); + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { text, replyId: noteInB.id }), + notification => + notification.type === 'reply' && notification.note.reply!.id === note.id && notification.userId === bobInA.id && notification.note.text === text, + true, + ); + }); + + test('Get notification when renoted', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { renoteId: noteInB.id }), + notification => + notification.type === 'renote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id, + true, + ); + }); + + test('Get notification when quoted', async () => { + const note = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + const noteInB = await resolveRemoteNote('a.test', note.id, bob); + const text = crypto.randomUUID(); + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { text, renoteId: noteInB.id }), + notification => + notification.type === 'quote' && notification.note.renote!.id === note.id && notification.userId === bobInA.id && notification.note.text === text, + true, + ); + }); + + test('Get notification when mentioned', async () => { + const text = `@${alice.username}@a.test`; + await assertNotificationReceived( + 'a.test', alice, + async () => await bob.client.request('notes/create', { text }), + notification => notification.type === 'mention' && notification.userId === bobInA.id && notification.note.text === text, + true, + ); + }); + }); +}); diff --git a/packages/backend/test-federation/test/timeline.test.ts b/packages/backend/test-federation/test/timeline.test.ts new file mode 100644 index 0000000000..00635e654b --- /dev/null +++ b/packages/backend/test-federation/test/timeline.test.ts @@ -0,0 +1,328 @@ +import { strictEqual } from 'assert'; +import * as Misskey from 'misskey-js'; +import { createAccount, fetchAdmin, isNoteUpdatedEventFired, isFired, type LoginUser, type Request, resolveRemoteUser, sleep, createRole } from './utils.js'; + +const bAdmin = await fetchAdmin('b.test'); + +describe('Timeline', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + }); + + type TimelineChannel = keyof Misskey.Channels & (`${string}Timeline` | 'antenna' | 'userList' | 'hashtag'); + type TimelineEndpoint = keyof Misskey.Endpoints & (`notes/${string}timeline` | 'antennas/notes' | 'roles/notes' | 'notes/search-by-tag'); + const timelineMap = new Map([ + ['antenna', 'antennas/notes'], + ['globalTimeline', 'notes/global-timeline'], + ['homeTimeline', 'notes/timeline'], + ['hybridTimeline', 'notes/hybrid-timeline'], + ['localTimeline', 'notes/local-timeline'], + ['roleTimeline', 'roles/notes'], + ['hashtag', 'notes/search-by-tag'], + ['userList', 'notes/user-list-timeline'], + ]); + + async function postAndCheckReception( + timelineChannel: C, + expect: boolean, + noteParams: Misskey.entities.NotesCreateRequest = {}, + channelParams: Misskey.Channels[C]['params'] = {}, + ) { + let note: Misskey.entities.Note | undefined; + const text = noteParams.text ?? crypto.randomUUID(); + const streamingFired = await isFired( + 'b.test', bob, timelineChannel, + async () => { + note = (await alice.client.request('notes/create', { text, ...noteParams })).createdNote; + }, + 'note', msg => msg.text === text, + channelParams, + ); + strictEqual(streamingFired, expect); + + const endpoint = timelineMap.get(timelineChannel)!; + const params: Misskey.Endpoints[typeof endpoint]['req'] = + endpoint === 'antennas/notes' ? { antennaId: (channelParams as Misskey.Channels['antenna']['params']).antennaId } : + endpoint === 'notes/user-list-timeline' ? { listId: (channelParams as Misskey.Channels['userList']['params']).listId } : + endpoint === 'notes/search-by-tag' ? { query: (channelParams as Misskey.Channels['hashtag']['params']).q } : + endpoint === 'roles/notes' ? { roleId: (channelParams as Misskey.Channels['roleTimeline']['params']).roleId } : + {}; + + await sleep(); + const notes = await (bob.client.request as Request)(endpoint, params); + const noteInB = notes.filter(({ uri }) => uri === `https://a.test/notes/${note!.id}`).pop(); + const endpointFired = noteInB != null; + strictEqual(endpointFired, expect); + + // Let's check Delete reception + if (expect) { + const streamingFired = await isNoteUpdatedEventFired( + 'b.test', bob, noteInB!.id, + async () => await alice.client.request('notes/delete', { noteId: note!.id }), + msg => msg.type === 'deleted' && msg.id === noteInB!.id, + ); + strictEqual(streamingFired, true); + + await sleep(); + const notes = await (bob.client.request as Request)(endpoint, params); + const endpointFired = notes.every(({ uri }) => uri !== `https://a.test/notes/${note!.id}`); + strictEqual(endpointFired, true); + } + } + + describe('homeTimeline', () => { + // NOTE: narrowing scope intentionally to prevent mistakes by copy-and-paste + const homeTimeline = 'homeTimeline'; + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(homeTimeline, true); + }); + + test('Receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(homeTimeline, true, { visibility: 'home' }); + }); + + test('Receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(homeTimeline, true, { visibility: 'followers' }); + }); + + test('Receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(homeTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }); + }); + + test('Don\'t receive remote followee\'s localOnly Note', async () => { + await postAndCheckReception(homeTimeline, false, { localOnly: true }); + }); + + test('Don\'t receive remote followee\'s invisible specified-only Note', async () => { + await postAndCheckReception(homeTimeline, false, { visibility: 'specified' }); + }); + + /** + * FIXME: can receive this + * @see https://github.com/misskey-dev/misskey/issues/14083 + */ + test.failing('Don\'t receive remote followee\'s invisible and mentioned specified-only Note', async () => { + await postAndCheckReception(homeTimeline, false, { text: `@${bob.username}@b.test Hello`, visibility: 'specified' }); + }); + + /** + * FIXME: cannot receive this + * @see https://github.com/misskey-dev/misskey/issues/14084 + */ + test.failing('Receive remote followee\'s visible specified-only reply to invisible specified-only Note', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'specified' })).createdNote; + await postAndCheckReception(homeTimeline, true, { replyId: note.id, visibility: 'specified', visibleUserIds: [bobInA.id] }); + }); + }); + }); + + describe('localTimeline', () => { + const localTimeline = 'localTimeline'; + + describe('Check reception of remote followee\'s Note', () => { + test('Don\'t receive remote followee\'s Note', async () => { + await postAndCheckReception(localTimeline, false); + }); + }); + }); + + describe('hybridTimeline', () => { + const hybridTimeline = 'hybridTimeline'; + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(hybridTimeline, true); + }); + + test('Receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(hybridTimeline, true, { visibility: 'home' }); + }); + + test('Receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(hybridTimeline, true, { visibility: 'followers' }); + }); + + test('Receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(hybridTimeline, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }); + }); + }); + }); + + describe('globalTimeline', () => { + const globalTimeline = 'globalTimeline'; + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(globalTimeline, true); + }); + + test('Don\'t receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(globalTimeline, false, { visibility: 'home' }); + }); + + test('Don\'t receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(globalTimeline, false, { visibility: 'followers' }); + }); + + test('Don\'t receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(globalTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] }); + }); + }); + }); + + describe('userList', () => { + const userList = 'userList'; + + let list: Misskey.entities.UserList; + + beforeAll(async () => { + list = await bob.client.request('users/lists/create', { name: 'Bob\'s List' }); + await bob.client.request('users/lists/push', { listId: list.id, userId: aliceInB.id }); + await sleep(); + }); + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(userList, true, {}, { listId: list.id }); + }); + + test('Receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(userList, true, { visibility: 'home' }, { listId: list.id }); + }); + + test('Receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(userList, true, { visibility: 'followers' }, { listId: list.id }); + }); + + test('Receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(userList, true, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { listId: list.id }); + }); + }); + }); + + describe('hashtag', () => { + const hashtag = 'hashtag'; + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + const tag = crypto.randomUUID(); + await postAndCheckReception(hashtag, true, { text: `#${tag}` }, { q: [[tag]] }); + }); + + test('Receive remote followee\'s home-only Note', async () => { + const tag = crypto.randomUUID(); + await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'home' }, { q: [[tag]] }); + }); + + test('Receive remote followee\'s followers-only Note', async () => { + const tag = crypto.randomUUID(); + await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'followers' }, { q: [[tag]] }); + }); + + test('Receive remote followee\'s visible specified-only Note', async () => { + const tag = crypto.randomUUID(); + await postAndCheckReception(hashtag, true, { text: `#${tag}`, visibility: 'specified', visibleUserIds: [bobInA.id] }, { q: [[tag]] }); + }); + }); + }); + + describe('roleTimeline', () => { + const roleTimeline = 'roleTimeline'; + + let role: Misskey.entities.Role; + + beforeAll(async () => { + role = await createRole('b.test', { + name: 'Remote Users', + description: 'Remote users are assigned to this role.', + condFormula: { + /** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */ + type: 'isRemote' as never, + }, + }); + await sleep(); + }); + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(roleTimeline, true, {}, { roleId: role.id }); + }); + + test('Don\'t receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(roleTimeline, false, { visibility: 'home' }, { roleId: role.id }); + }); + + test('Don\'t receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(roleTimeline, false, { visibility: 'followers' }, { roleId: role.id }); + }); + + test('Don\'t receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(roleTimeline, false, { visibility: 'specified', visibleUserIds: [bobInA.id] }, { roleId: role.id }); + }); + }); + + afterAll(async () => { + await bAdmin.client.request('admin/roles/delete', { roleId: role.id }); + }); + }); + + // TODO: Cannot test + describe.skip('antenna', () => { + const antenna = 'antenna'; + + let bobAntenna: Misskey.entities.Antenna; + + beforeAll(async () => { + bobAntenna = await bob.client.request('antennas/create', { + name: 'Bob\'s Egosurfing Antenna', + src: 'all', + keywords: [['Bob']], + excludeKeywords: [], + users: [], + caseSensitive: false, + localOnly: false, + withReplies: true, + withFile: true, + }); + await sleep(); + }); + + describe('Check reception of remote followee\'s Note', () => { + test('Receive remote followee\'s Note', async () => { + await postAndCheckReception(antenna, true, { text: 'I love Bob (1)' }, { antennaId: bobAntenna.id }); + }); + + test('Don\'t receive remote followee\'s home-only Note', async () => { + await postAndCheckReception(antenna, false, { text: 'I love Bob (2)', visibility: 'home' }, { antennaId: bobAntenna.id }); + }); + + test('Don\'t receive remote followee\'s followers-only Note', async () => { + await postAndCheckReception(antenna, false, { text: 'I love Bob (3)', visibility: 'followers' }, { antennaId: bobAntenna.id }); + }); + + test('Don\'t receive remote followee\'s visible specified-only Note', async () => { + await postAndCheckReception(antenna, false, { text: 'I love Bob (4)', visibility: 'specified', visibleUserIds: [bobInA.id] }, { antennaId: bobAntenna.id }); + }); + }); + + afterAll(async () => { + await bob.client.request('antennas/delete', { antennaId: bobAntenna.id }); + }); + }); +}); diff --git a/packages/backend/test-federation/test/user.test.ts b/packages/backend/test-federation/test/user.test.ts new file mode 100644 index 0000000000..83dcb8df44 --- /dev/null +++ b/packages/backend/test-federation/test/user.test.ts @@ -0,0 +1,563 @@ +import assert, { rejects, strictEqual } from 'node:assert'; +import * as Misskey from 'misskey-js'; +import { createAccount, deepStrictEqualWithExcludedFields, fetchAdmin, type LoginUser, resolveRemoteNote, resolveRemoteUser, sleep } from './utils.js'; + +const [aAdmin, bAdmin] = await Promise.all([ + fetchAdmin('a.test'), + fetchAdmin('b.test'), +]); + +describe('User', () => { + describe('Profile', () => { + describe('Consistency of profile', () => { + let alice: LoginUser; + let aliceWatcher: LoginUser; + let aliceWatcherInB: LoginUser; + + beforeAll(async () => { + alice = await createAccount('a.test'); + [ + aliceWatcher, + aliceWatcherInB, + ] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + }); + + test('Check consistency', async () => { + const aliceInA = await aliceWatcher.client.request('users/show', { userId: alice.id }); + const resolved = await resolveRemoteUser('a.test', aliceInA.id, aliceWatcherInB); + const aliceInB = await aliceWatcherInB.client.request('users/show', { userId: resolved.id }); + + // console.log(`a.test: ${JSON.stringify(aliceInA, null, '\t')}`); + // console.log(`b.test: ${JSON.stringify(aliceInB, null, '\t')}`); + + deepStrictEqualWithExcludedFields(aliceInA, aliceInB, [ + 'id', + 'host', + 'avatarUrl', + 'avatarBlurhash', + 'instance', + 'badgeRoles', + 'url', + 'uri', + 'createdAt', + 'lastFetchedAt', + 'publicReactions', + ]); + }); + }); + + describe('ffVisibility is federated', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + // NOTE: follow each other + await Promise.all([ + alice.client.request('following/create', { userId: bobInA.id }), + bob.client.request('following/create', { userId: aliceInB.id }), + ]); + await sleep(); + }); + + test('Visibility set public by default', async () => { + for (const user of await Promise.all([ + alice.client.request('users/show', { userId: bobInA.id }), + bob.client.request('users/show', { userId: aliceInB.id }), + ])) { + strictEqual(user.followersVisibility, 'public'); + strictEqual(user.followingVisibility, 'public'); + } + }); + + /** FIXME: not working */ + test.skip('Setting private for followersVisibility is federated', async () => { + await Promise.all([ + alice.client.request('i/update', { followersVisibility: 'private' }), + bob.client.request('i/update', { followersVisibility: 'private' }), + ]); + await sleep(); + + for (const user of await Promise.all([ + alice.client.request('users/show', { userId: bobInA.id }), + bob.client.request('users/show', { userId: aliceInB.id }), + ])) { + strictEqual(user.followersVisibility, 'private'); + strictEqual(user.followingVisibility, 'public'); + } + }); + + test.skip('Setting private for followingVisibility is federated', async () => { + await Promise.all([ + alice.client.request('i/update', { followingVisibility: 'private' }), + bob.client.request('i/update', { followingVisibility: 'private' }), + ]); + await sleep(); + + for (const user of await Promise.all([ + alice.client.request('users/show', { userId: bobInA.id }), + bob.client.request('users/show', { userId: aliceInB.id }), + ])) { + strictEqual(user.followersVisibility, 'private'); + strictEqual(user.followingVisibility, 'private'); + } + }); + }); + + describe('isCat is federated', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Not isCat for default', () => { + strictEqual(aliceInB.isCat, false); + }); + + test('Becoming a cat is sent to their followers', async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + await alice.client.request('i/update', { isCat: true }); + await sleep(); + + const res = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(res.isCat, true); + }); + }); + + describe('Pinning Notes', () => { + let alice: LoginUser, bob: LoginUser; + let aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + aliceInB = await resolveRemoteUser('a.test', alice.id, bob); + + await bob.client.request('following/create', { userId: aliceInB.id }); + }); + + test('Pinning localOnly Note is not delivered', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', localOnly: true })).createdNote; + await alice.client.request('i/pin', { noteId: note.id }); + await sleep(); + + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 0); + }); + + test('Pinning followers-only Note is not delivered', async () => { + const note = (await alice.client.request('notes/create', { text: 'a', visibility: 'followers' })).createdNote; + await alice.client.request('i/pin', { noteId: note.id }); + await sleep(); + + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 0); + }); + + let pinnedNote: Misskey.entities.Note; + + test('Pinning normal Note is delivered', async () => { + pinnedNote = (await alice.client.request('notes/create', { text: 'a' })).createdNote; + await alice.client.request('i/pin', { noteId: pinnedNote.id }); + await sleep(); + + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 1); + const pinnedNoteInB = await resolveRemoteNote('a.test', pinnedNote.id, bob); + strictEqual(_aliceInB.pinnedNotes[0].id, pinnedNoteInB.id); + }); + + test('Unpinning normal Note is delivered', async () => { + await alice.client.request('i/unpin', { noteId: pinnedNote.id }); + await sleep(); + + const _aliceInB = await bob.client.request('users/show', { userId: aliceInB.id }); + strictEqual(_aliceInB.pinnedNoteIds.length, 0); + }); + }); + }); + + describe('Follow / Unfollow', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + describe('Follow a.test ==> b.test', () => { + beforeAll(async () => { + await alice.client.request('following/create', { userId: bobInA.id }); + + await sleep(); + }); + + test('Check consistency with `users/following` and `users/followers` endpoints', async () => { + await Promise.all([ + strictEqual( + (await alice.client.request('users/following', { userId: alice.id })) + .some(v => v.followeeId === bobInA.id), + true, + ), + strictEqual( + (await bob.client.request('users/followers', { userId: bob.id })) + .some(v => v.followerId === aliceInB.id), + true, + ), + ]); + }); + }); + + describe('Unfollow a.test ==> b.test', () => { + beforeAll(async () => { + await alice.client.request('following/delete', { userId: bobInA.id }); + + await sleep(); + }); + + test('Check consistency with `users/following` and `users/followers` endpoints', async () => { + await Promise.all([ + strictEqual( + (await alice.client.request('users/following', { userId: alice.id })) + .some(v => v.followeeId === bobInA.id), + false, + ), + strictEqual( + (await bob.client.request('users/followers', { userId: bob.id })) + .some(v => v.followerId === aliceInB.id), + false, + ), + ]); + }); + }); + }); + + describe('Follow requests', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + + await alice.client.request('i/update', { isLocked: true }); + }); + + describe('Send follow request from Bob to Alice and cancel', () => { + describe('Bob sends follow request to Alice', () => { + beforeAll(async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + }); + + test('Alice should have a request', async () => { + const requests = await alice.client.request('following/requests/list', {}); + strictEqual(requests.length, 1); + strictEqual(requests[0].followee.id, alice.id); + strictEqual(requests[0].follower.id, bobInA.id); + }); + }); + + describe('Alice cancels it', () => { + beforeAll(async () => { + await bob.client.request('following/requests/cancel', { userId: aliceInB.id }); + await sleep(); + }); + + test('Alice should have no requests', async () => { + const requests = await alice.client.request('following/requests/list', {}); + strictEqual(requests.length, 0); + }); + }); + }); + + describe('Send follow request from Bob to Alice and reject', () => { + beforeAll(async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + await alice.client.request('following/requests/reject', { userId: bobInA.id }); + await sleep(); + }); + + test('Bob should have no requests', async () => { + await rejects( + async () => await bob.client.request('following/requests/cancel', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'FOLLOW_REQUEST_NOT_FOUND'); + return true; + }, + ); + }); + + test('Bob doesn\'t follow Alice', async () => { + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); + }); + }); + + describe('Send follow request from Bob to Alice and accept', () => { + beforeAll(async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + await alice.client.request('following/requests/accept', { userId: bobInA.id }); + await sleep(); + }); + + test('Bob follows Alice', async () => { + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 1); + strictEqual(following[0].followeeId, aliceInB.id); + strictEqual(following[0].followerId, bob.id); + }); + }); + }); + + describe('Deletion', () => { + describe('Check Delete consistency', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Bob follows Alice, and Alice deleted themself', async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); // followed by Bob + + await alice.client.request('i/delete-account', { password: alice.password }); + // NOTE: user deletion query is slow + await sleep(4000); + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); // no following relation + + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_USER'); + return true; + }, + ); + }); + }); + + describe('Deletion of remote user for moderation', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Bob follows Alice, then Alice gets deleted in B server', async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); // followed by Bob + + await bAdmin.client.request('admin/delete-account', { userId: aliceInB.id }); + await sleep(); + + /** + * FIXME: remote account is not deleted! + * @see https://github.com/misskey-dev/misskey/issues/14728 + */ + const deletedAlice = await bob.client.request('users/show', { userId: aliceInB.id }); + assert(deletedAlice.id, aliceInB.id); + + // TODO: why still following relation? + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 1); + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'ALREADY_FOLLOWING'); + return true; + }, + ); + }); + + test('Alice tries to follow Bob, but it is not processed', async () => { + await alice.client.request('following/create', { userId: bobInA.id }); + await sleep(); + + const following = await alice.client.request('users/following', { userId: alice.id }); + strictEqual(following.length, 0); // Not following Bob because B server doesn't return Accept + + const followers = await bob.client.request('users/followers', { userId: bob.id }); + strictEqual(followers.length, 0); // Alice's Follow is not processed + }); + }); + }); + + describe('Suspension', () => { + describe('Check suspend/unsuspend consistency', () => { + let alice: LoginUser, bob: LoginUser; + let bobInA: Misskey.entities.UserDetailedNotMe, aliceInB: Misskey.entities.UserDetailedNotMe; + + beforeAll(async () => { + [alice, bob] = await Promise.all([ + createAccount('a.test'), + createAccount('b.test'), + ]); + + [bobInA, aliceInB] = await Promise.all([ + resolveRemoteUser('b.test', bob.id, alice), + resolveRemoteUser('a.test', alice.id, bob), + ]); + }); + + test('Bob follows Alice, and Alice gets suspended, there is no following relation, and Bob fails to follow again', async () => { + await bob.client.request('following/create', { userId: aliceInB.id }); + await sleep(); + + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); // followed by Bob + + await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); + // NOTE: user deletion query is slow + await sleep(4000); + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); // no following relation + + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_USER'); + return true; + }, + ); + }); + + test('Alice gets unsuspended, Bob succeeds in following Alice', async () => { + await aAdmin.client.request('admin/unsuspend-user', { userId: alice.id }); + await sleep(); + + const followers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(followers.length, 1); // FIXME: followers are not deleted?? + + /** + * FIXME: still rejected! + * seems to can't process Undo Delete activity because it is not implemented + * related @see https://github.com/misskey-dev/misskey/issues/13273 + */ + await rejects( + async () => await bob.client.request('following/create', { userId: aliceInB.id }), + (err: any) => { + strictEqual(err.code, 'NO_SUCH_USER'); + return true; + }, + ); + + // FIXME: resolving also fails + await rejects( + async () => await resolveRemoteUser('a.test', alice.id, bob), + (err: any) => { + strictEqual(err.code, 'INTERNAL_ERROR'); + return true; + }, + ); + }); + + /** + * instead of simple unsuspension, let's tell existence by following from Alice + */ + test('Alice can follow Bob', async () => { + await alice.client.request('following/create', { userId: bobInA.id }); + await sleep(); + + const bobFollowers = await bob.client.request('users/followers', { userId: bob.id }); + strictEqual(bobFollowers.length, 1); // followed by Alice + assert(bobFollowers[0].follower != null); + const renewedaliceInB = bobFollowers[0].follower; + assert(aliceInB.username === renewedaliceInB.username); + assert(aliceInB.host === renewedaliceInB.host); + assert(aliceInB.id !== renewedaliceInB.id); // TODO: Same username and host, but their ids are different! Is it OK? + + const following = await bob.client.request('users/following', { userId: bob.id }); + strictEqual(following.length, 0); // following are deleted + + // Bob tries to follow Alice + await bob.client.request('following/create', { userId: renewedaliceInB.id }); + await sleep(); + + const aliceFollowers = await alice.client.request('users/followers', { userId: alice.id }); + strictEqual(aliceFollowers.length, 1); + + // FIXME: but resolving still fails ... + await rejects( + async () => await resolveRemoteUser('a.test', alice.id, bob), + (err: any) => { + strictEqual(err.code, 'INTERNAL_ERROR'); + return true; + }, + ); + }); + }); + }); +}); diff --git a/packages/backend/test-federation/test/utils.ts b/packages/backend/test-federation/test/utils.ts new file mode 100644 index 0000000000..2779eb7e81 --- /dev/null +++ b/packages/backend/test-federation/test/utils.ts @@ -0,0 +1,307 @@ +import { deepStrictEqual, strictEqual } from 'assert'; +import { readFile } from 'fs/promises'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import * as Misskey from 'misskey-js'; +import { WebSocket } from 'ws'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export const ADMIN_PARAMS = { username: 'admin', password: 'admin' }; +const ADMIN_CACHE = new Map(); + +await Promise.all([ + fetchAdmin('a.test'), + fetchAdmin('b.test'), +]); + +type SigninResponse = Omit; + +export type LoginUser = SigninResponse & { + client: Misskey.api.APIClient; + username: string; + password: string; +}; + +/** used for avoiding overload and some endpoints */ +export type Request = < + E extends keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'], +>( + endpoint: E, + params: P, + credential?: string | null, +) => Promise>; + +type Host = 'a.test' | 'b.test'; + +export async function sleep(ms = 250): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function signin( + host: Host, + params: Misskey.entities.SigninFlowRequest, +): Promise { + // wait for a second to prevent hit rate limit + await sleep(1000); + + return await (new Misskey.api.APIClient({ origin: `https://${host}` }).request as Request)('signin-flow', params) + .then(res => { + strictEqual(res.finished, true); + if (params.username === ADMIN_PARAMS.username) ADMIN_CACHE.set(host, res); + return res; + }) + .then(({ id, i }) => ({ id, i })) + .catch(async err => { + if (err.code === 'TOO_MANY_AUTHENTICATION_FAILURES') { + await sleep(Math.random() * 2000); + return await signin(host, params); + } + throw err; + }); +} + +async function createAdmin(host: Host): Promise { + const client = new Misskey.api.APIClient({ origin: `https://${host}` }); + return await client.request('admin/accounts/create', ADMIN_PARAMS).then(res => { + 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; + }).then(async res => { + await client.request('admin/roles/update-default-policies', { + policies: { + /** TODO: @see https://github.com/misskey-dev/misskey/issues/14169 */ + rateLimitFactor: 0 as never, + }, + }, res.token); + return res; + }).catch(err => { + if (err.info.e.message === 'access denied') return undefined; + throw err; + }); +} + +export async function fetchAdmin(host: Host): Promise { + const admin = ADMIN_CACHE.get(host) ?? await signin(host, ADMIN_PARAMS) + .catch(async err => { + if (err.id === '6cc579cc-885d-43d8-95c2-b8c7fc963280') { + await createAdmin(host); + return await signin(host, ADMIN_PARAMS); + } + throw err; + }); + + return { + ...admin, + client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: admin.i }), + ...ADMIN_PARAMS, + }; +} + +export async function createAccount(host: Host): Promise { + const username = crypto.randomUUID().replaceAll('-', '').substring(0, 20); + const password = crypto.randomUUID().replaceAll('-', ''); + const admin = await fetchAdmin(host); + await admin.client.request('admin/accounts/create', { username, password }); + const signinRes = await signin(host, { username, password }); + + return { + ...signinRes, + client: new Misskey.api.APIClient({ origin: `https://${host}`, credential: signinRes.i }), + username, + password, + }; +} + +export async function createModerator(host: Host): Promise { + const user = await createAccount(host); + const role = await createRole(host, { + name: 'Moderator', + isModerator: true, + }); + const admin = await fetchAdmin(host); + await admin.client.request('admin/roles/assign', { roleId: role.id, userId: user.id }); + return user; +} + +export async function createRole( + host: Host, + params: Partial = {}, +): Promise { + const admin = await fetchAdmin(host); + return await admin.client.request('admin/roles/create', { + name: 'Some role', + description: 'Role for testing', + color: null, + iconUrl: null, + target: 'conditional', + condFormula: {}, + isPublic: true, + isModerator: false, + isAdministrator: false, + isExplorable: true, + asBadge: false, + canEditMembersByModerator: false, + displayOrder: 0, + policies: {}, + ...params, + }); +} + +export async function resolveRemoteUser( + host: Host, + id: string, + from: LoginUser, +): Promise { + const uri = `https://${host}/users/${id}`; + return await from.client.request('ap/show', { uri }) + .then(res => { + strictEqual(res.type, 'User'); + strictEqual(res.object.uri, uri); + return res.object; + }); +} + +export async function resolveRemoteNote( + host: Host, + id: string, + from: LoginUser, +): Promise { + const uri = `https://${host}/notes/${id}`; + return await from.client.request('ap/show', { uri }) + .then(res => { + strictEqual(res.type, 'Note'); + strictEqual(res.object.uri, uri); + return res.object; + }); +} + +export async function uploadFile( + host: Host, + user: { i: string }, + path = '../../test/resources/192.jpg', +): Promise { + const filename = path.split('/').pop() ?? 'untitled'; + const blob = new Blob([await readFile(join(__dirname, path))]); + + const body = new FormData(); + body.append('i', user.i); + body.append('force', 'true'); + body.append('file', blob); + body.append('name', filename); + + return await fetch(`https://${host}/api/drive/files/create`, { method: 'POST', body }) + .then(async res => await res.json()); +} + +export async function addCustomEmoji( + host: Host, + param?: Partial, + path?: string, +): Promise { + const admin = await fetchAdmin(host); + const name = crypto.randomUUID().replaceAll('-', ''); + const file = await uploadFile(host, admin, path); + return await admin.client.request('admin/emoji/add', { name, fileId: file.id, ...param }); +} + +export function deepStrictEqualWithExcludedFields(actual: T, expected: T, excludedFields: (keyof T)[]) { + const _actual = structuredClone(actual); + const _expected = structuredClone(expected); + for (const obj of [_actual, _expected]) { + for (const field of excludedFields) { + delete obj[field]; + } + } + deepStrictEqual(_actual, _expected); +} + +export async function isFired( + host: Host, + user: { i: string }, + channel: C, + trigger: () => Promise, + type: T, + // @ts-expect-error TODO: why getting error here? + cond: (msg: Parameters[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 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); + }); + }); +}; + +export async function isNoteUpdatedEventFired( + host: Host, + user: { i: string }, + noteId: string, + 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 }); + stream.send('s', { id: noteId }); + stream.on('noteUpdated', msg => { + if (cond(msg)) { + stream.close(); + clearTimeout(timer); + resolve(true); + } + }); + + let timer: NodeJS.Timeout | undefined; + + await trigger().then(() => { + timer = setTimeout(() => { + stream.close(); + resolve(false); + }, 500); + }).catch(err => { + stream.close(); + clearTimeout(timer); + reject(err); + }); + }); +}; + +export async function assertNotificationReceived( + receiverHost: Host, + receiver: LoginUser, + trigger: () => Promise, + cond: (notification: Misskey.entities.Notification) => boolean, + expect: boolean, +) { + const streamingFired = await isFired(receiverHost, receiver, 'main', trigger, 'notification', cond); + strictEqual(streamingFired, expect); + + const endpointFired = await receiver.client.request('i/notifications', {}) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + .then(([notification]) => notification != null ? cond(notification) : false); + strictEqual(endpointFired, expect); +} diff --git a/packages/backend/test-federation/tsconfig.json b/packages/backend/test-federation/tsconfig.json new file mode 100644 index 0000000000..3a1cb3b9f3 --- /dev/null +++ b/packages/backend/test-federation/tsconfig.json @@ -0,0 +1,114 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* 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. */ + // "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*'. */ + // "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. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "NodeNext", /* Specify what module code is generated. */ + // "rootDir": "./", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./built", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": [ + "daemon.ts", + "./test/**/*.ts" + ] +} diff --git a/packages/backend/test-server/.swcrc b/packages/backend/test-server/.swcrc index e3d6935169..eeac7eabc6 100644 --- a/packages/backend/test-server/.swcrc +++ b/packages/backend/test-server/.swcrc @@ -1,5 +1,5 @@ { - "$schema": "https://json.schemastore.org/swcrc", + "$schema": "https://swc.rs/schema.json", "jsc": { "parser": { "syntax": "typescript", diff --git a/packages/backend/test-server/entry.ts b/packages/backend/test-server/entry.ts index 866a7e1f5b..04bf62d209 100644 --- a/packages/backend/test-server/entry.ts +++ b/packages/backend/test-server/entry.ts @@ -6,12 +6,16 @@ import { MainModule } from '@/MainModule.js'; import { ServerService } from '@/server/ServerService.js'; import { loadConfig } from '@/config.js'; import { NestLogger } from '@/NestLogger.js'; +import { INestApplicationContext } from '@nestjs/common'; const config = loadConfig(); const originEnv = JSON.stringify(process.env); process.env.NODE_ENV = 'test'; +let app: INestApplicationContext; +let serverService: ServerService; + /** * テスト用のサーバインスタンスを起動する */ @@ -20,10 +24,10 @@ async function launch() { console.log('starting application...'); - const app = await NestFactory.createApplicationContext(MainModule, { + app = await NestFactory.createApplicationContext(MainModule, { logger: new NestLogger(), }); - const serverService = app.get(ServerService); + serverService = app.get(ServerService); await serverService.launch(); await startControllerEndpoints(); @@ -71,6 +75,20 @@ async function startControllerEndpoints(port = config.port + 1000) { fastify.post<{ Body: { key?: string, value?: string } }>('/env-reset', async (req, res) => { process.env = JSON.parse(originEnv); + + await serverService.dispose(); + await app.close(); + + await killTestServer(); + + console.log('starting application...'); + + app = await NestFactory.createApplicationContext(MainModule, { + logger: new NestLogger(), + }); + serverService = app.get(ServerService); + await serverService.launch(); + res.code(200).send({ success: true }); }); diff --git a/packages/backend/test/e2e/2fa.ts b/packages/backend/test/e2e/2fa.ts index 06548fa7da..48e1bababb 100644 --- a/packages/backend/test/e2e/2fa.ts +++ b/packages/backend/test/e2e/2fa.ts @@ -136,13 +136,7 @@ describe('2要素認証', () => { keyName: string, credentialId: Buffer, requestOptions: PublicKeyCredentialRequestOptionsJSON, - }): { - username: string, - password: string, - credential: AuthenticationResponseJSON, - 'g-recaptcha-response'?: string | null, - 'hcaptcha-response'?: string | null, - } => { + }): misskey.entities.SigninFlowRequest => { // AuthenticatorAssertionResponse.authenticatorData // https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData const authenticatorData = Buffer.concat([ @@ -202,17 +196,21 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(doneResponse.status, 200); - const usersShowResponse = await api('users/show', { - username, - }, alice); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); + const signinWithoutTokenResponse = await api('signin-flow', { + ...signinParam(), + }); + assert.strictEqual(signinWithoutTokenResponse.status, 200); + assert.deepStrictEqual(signinWithoutTokenResponse.body, { + finished: false, + next: 'totp', + }); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), token: otpToken(registerResponse.body.secret), }); assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, true); assert.notEqual(signinResponse.body.i, undefined); // 後片付け @@ -253,27 +251,23 @@ describe('2要素認証', () => { assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url')); assert.strictEqual(keyDoneResponse.body.name, keyName); - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, true); - - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), }); assert.strictEqual(signinResponse.status, 200); - assert.strictEqual(signinResponse.body.i, undefined); - assert.notEqual((signinResponse.body as unknown as { challenge: unknown | undefined }).challenge, undefined); - assert.notEqual((signinResponse.body as unknown as { allowCredentials: unknown | undefined }).allowCredentials, undefined); - assert.strictEqual((signinResponse.body as unknown as { allowCredentials: {id: string}[] }).allowCredentials[0].id, credentialId.toString('base64url')); + assert.strictEqual(signinResponse.body.finished, false); + assert.strictEqual(signinResponse.body.next, 'passkey'); + assert.notEqual(signinResponse.body.authRequest.challenge, undefined); + assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined); + assert.strictEqual(signinResponse.body.authRequest.allowCredentials && signinResponse.body.authRequest.allowCredentials[0]?.id, credentialId.toString('base64url')); - const signinResponse2 = await api('signin', signinWithSecurityKeyParam({ + const signinResponse2 = await api('signin-flow', signinWithSecurityKeyParam({ keyName, credentialId, - requestOptions: signinResponse.body, - } as any)); + requestOptions: signinResponse.body.authRequest, + })); assert.strictEqual(signinResponse2.status, 200); + assert.strictEqual(signinResponse2.body.finished, true); assert.notEqual(signinResponse2.body.i, undefined); // 後片付け @@ -315,28 +309,30 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(passwordLessResponse.status, 204); - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { usePasswordLessLogin: boolean }).usePasswordLessLogin, true); + const iResponse = await api('i', {}, alice); + assert.strictEqual(iResponse.status, 200); + assert.strictEqual(iResponse.body.usePasswordLessLogin, true); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), password: '', }); assert.strictEqual(signinResponse.status, 200); - assert.strictEqual(signinResponse.body.i, undefined); + assert.strictEqual(signinResponse.body.finished, false); + assert.strictEqual(signinResponse.body.next, 'passkey'); + assert.notEqual(signinResponse.body.authRequest.challenge, undefined); + assert.notEqual(signinResponse.body.authRequest.allowCredentials, undefined); - const signinResponse2 = await api('signin', { + const signinResponse2 = await api('signin-flow', { ...signinWithSecurityKeyParam({ keyName, credentialId, - requestOptions: signinResponse.body, + requestOptions: signinResponse.body.authRequest, } as any), password: '', }); assert.strictEqual(signinResponse2.status, 200); + assert.strictEqual(signinResponse2.body.finished, true); assert.notEqual(signinResponse2.body.i, undefined); // 後片付け @@ -424,11 +420,11 @@ describe('2要素認証', () => { assert.strictEqual(keyDoneResponse.status, 200); // テストの実行順によっては複数残ってるので全部消す - const iResponse = await api('i', { + const beforeIResponse = await api('i', { }, alice); - assert.strictEqual(iResponse.status, 200); - assert.ok(iResponse.body.securityKeysList); - for (const key of iResponse.body.securityKeysList) { + assert.strictEqual(beforeIResponse.status, 200); + assert.ok(beforeIResponse.body.securityKeysList); + for (const key of beforeIResponse.body.securityKeysList) { const removeKeyResponse = await api('i/2fa/remove-key', { token: otpToken(registerResponse.body.secret), password, @@ -437,17 +433,16 @@ describe('2要素認証', () => { assert.strictEqual(removeKeyResponse.status, 200); } - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { securityKeys: boolean }).securityKeys, false); + const afterIResponse = await api('i', {}, alice); + assert.strictEqual(afterIResponse.status, 200); + assert.strictEqual(afterIResponse.body.securityKeys, false); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), token: otpToken(registerResponse.body.secret), }); assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, true); assert.notEqual(signinResponse.body.i, undefined); // 後片付け @@ -468,11 +463,9 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(doneResponse.status, 200); - const usersShowResponse = await api('users/show', { - username, - }); - assert.strictEqual(usersShowResponse.status, 200); - assert.strictEqual((usersShowResponse.body as unknown as { twoFactorEnabled: boolean }).twoFactorEnabled, true); + const iResponse = await api('i', {}, alice); + assert.strictEqual(iResponse.status, 200); + assert.strictEqual(iResponse.body.twoFactorEnabled, true); const unregisterResponse = await api('i/2fa/unregister', { token: otpToken(registerResponse.body.secret), @@ -480,10 +473,11 @@ describe('2要素認証', () => { }, alice); assert.strictEqual(unregisterResponse.status, 204); - const signinResponse = await api('signin', { + const signinResponse = await api('signin-flow', { ...signinParam(), }); assert.strictEqual(signinResponse.status, 200); + assert.strictEqual(signinResponse.body.finished, true); assert.notEqual(signinResponse.body.i, undefined); // 後片付け diff --git a/packages/backend/test/e2e/clips.ts b/packages/backend/test/e2e/clips.ts index a130c3698d..7ae1ee4523 100644 --- a/packages/backend/test/e2e/clips.ts +++ b/packages/backend/test/e2e/clips.ts @@ -182,7 +182,6 @@ describe('クリップ', () => { { label: 'nameがnull', parameters: { name: null } }, { label: 'nameが最大長+1', parameters: { name: 'x'.repeat(101) } }, { label: 'isPublicがboolじゃない', parameters: { isPublic: 'true' } }, - { label: 'descriptionがゼロ長', parameters: { description: '' } }, { label: 'descriptionが最大長+1', parameters: { description: 'a'.repeat(2049) } }, ]; test.each(createClipDenyPattern)('の作成は$labelならできない', async ({ parameters }) => failedApiCall({ @@ -199,6 +198,23 @@ describe('クリップ', () => { id: '3d81ceae-475f-4600-b2a8-2bc116157532', })); + test('の作成はdescriptionが空文字ならnullになる', async () => { + const clip = await successfulApiCall({ + endpoint: 'clips/create', + parameters: { + ...defaultCreate(), + description: '', + }, + user: alice, + }); + + assert.deepStrictEqual(clip, { + ...clip, + ...defaultCreate(), + description: null, + }); + }); + test('の更新ができる', async () => { const res = await update({ clipId: (await create()).id, @@ -249,6 +265,24 @@ describe('クリップ', () => { ...assertion, })); + test('の更新はdescriptionが空文字ならnullになる', async () => { + const clip = await successfulApiCall({ + endpoint: 'clips/update', + parameters: { + clipId: (await create()).id, + name: 'updated', + description: '', + }, + user: alice, + }); + + assert.deepStrictEqual(clip, { + ...clip, + name: 'updated', + description: null, + }); + }); + test('の削除ができる', async () => { await deleteClip({ clipId: (await create()).id, diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 5aaec7f6f9..b91d77c398 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -66,9 +66,9 @@ describe('Endpoints', () => { }); }); - describe('signin', () => { + describe('signin-flow', () => { test('間違ったパスワードでサインインできない', async () => { - const res = await api('signin', { + const res = await api('signin-flow', { username: 'test1', password: 'bar', }); @@ -77,7 +77,7 @@ describe('Endpoints', () => { }); test('クエリをインジェクションできない', async () => { - const res = await api('signin', { + const res = await api('signin-flow', { username: 'test1', // @ts-expect-error password must be string password: { @@ -89,7 +89,7 @@ describe('Endpoints', () => { }); test('正しい情報でサインインできる', async () => { - const res = await api('signin', { + const res = await api('signin-flow', { username: 'test1', password: 'test1', }); diff --git a/packages/backend/test/e2e/fetch-resource.ts b/packages/backend/test/e2e/fetch-resource.ts index 7efd688ec2..740295bda8 100644 --- a/packages/backend/test/e2e/fetch-resource.ts +++ b/packages/backend/test/e2e/fetch-resource.ts @@ -6,17 +6,17 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; -import { channel, clip, cookie, galleryPost, page, play, post, signup, simpleGet, uploadFile } from '../utils.js'; +import { channel, clip, galleryPost, page, play, post, signup, simpleGet, uploadFile } from '../utils.js'; import type { SimpleGetResponse } from '../utils.js'; import type * as misskey from 'misskey-js'; -// Request Accept +// Request Accept in lowercase const ONLY_AP = 'application/activity+json'; const PREFER_AP = 'application/activity+json, */*'; const PREFER_HTML = 'text/html, */*'; const UNSPECIFIED = '*/*'; -// Response Content-Type +// Response Content-Type in lowercase const AP = 'application/activity+json; charset=utf-8'; const HTML = 'text/html; charset=utf-8'; const JSON_UTF8 = 'application/json; charset=utf-8'; @@ -44,7 +44,8 @@ describe('Webリソース', () => { const { path, accept, cookie, type } = param; const res = await simpleGet(path, accept, cookie); assert.strictEqual(res.status, 200); - assert.strictEqual(res.type, type ?? HTML); + // Header values are case-insensitive + assert.strictEqual(res.type?.toLowerCase(), (type ?? HTML).toLowerCase()); return res; }; @@ -95,8 +96,7 @@ describe('Webリソース', () => { describe.each([ { path: '/', type: HTML }, { path: '/docs/ja-JP/about', type: HTML }, // "指定されたURLに該当するページはありませんでした。" - // fastify-static gives charset=UTF-8 instead of utf-8 and that's okay - { path: '/api-doc', type: 'text/html; charset=UTF-8' }, + { path: '/api-doc', type: HTML }, { path: '/api.json', type: JSON_UTF8 }, { path: '/api-console', type: HTML }, { path: '/_info_card_', type: HTML }, @@ -156,20 +156,20 @@ describe('Webリソース', () => { describe(' has entry such ', () => { beforeEach(() => { - post(alice, { text: "**a**" }) + post(alice, { text: '**a**' }); }); test('MFMを含まない。', async () => { - const content = await simpleGet(path(alice.username), "*/*", undefined, res => res.text()); + const content = await simpleGet(path(alice.username), '*/*', undefined, res => res.text()); const _body: unknown = content.body; // JSONフィードのときは改めて文字列化する - const body: string = typeof (_body) === "object" ? JSON.stringify(_body) : _body as string; + const body: string = typeof (_body) === 'object' ? JSON.stringify(_body) : _body as string; - if (body.includes("**a**")) { - throw new Error("MFM shouldn't be included"); + if (body.includes('**a**')) { + throw new Error('MFM shouldn\'t be included'); } }); - }) + }); }); describe.each([{ path: '/api/foo' }])('$path', ({ path }) => { @@ -180,24 +180,6 @@ describe('Webリソース', () => { })); }); - describe.each([{ path: '/queue' }])('$path', ({ path }) => { - test('はログインしないとGETできない。', async () => await notOk({ - path, - status: 401, - })); - - test('はadminでなければGETできない。', async () => await notOk({ - path, - cookie: cookie(bob), - status: 403, - })); - - test('はadminならGETできる。', async () => await ok({ - path, - cookie: cookie(alice), - })); - }); - describe.each([{ path: '/streaming' }])('$path', ({ path }) => { test('はGETできない。', async () => await notOk({ path, @@ -230,6 +212,7 @@ describe('Webリソース', () => { path: path('xxxxxxxxxx'), type: HTML, })); + test.todo('HTMLとしてGETできる。(リモートユーザーでもリダイレクトせず)'); }); describe.each([ @@ -249,6 +232,7 @@ describe('Webリソース', () => { path: path('xxxxxxxxxx'), accept, })); + test.todo('はオリジナルにリダイレクトされる。(リモートユーザー)'); }); }); diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index f37da288b7..b464c24287 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -51,30 +51,8 @@ describe('Mute', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test('ミュートしているユーザーからメンションされても、hasUnreadMentions が true にならない', async () => { - // 状態リセット - await api('i/read-all-unread-notes', {}, alice); - - await post(carol, { text: '@alice hi' }); - - const res = await api('i', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.hasUnreadMentions, false); - }); - - test('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => { - // 状態リセット - await api('i/read-all-unread-notes', {}, alice); - - const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention'); - - assert.strictEqual(fired, false); - }); - test('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => { // 状態リセット - await api('i/read-all-unread-notes', {}, alice); await api('notifications/mark-all-as-read', {}, alice); const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification'); diff --git a/packages/backend/test/e2e/oauth.ts b/packages/backend/test/e2e/oauth.ts index ef7a6a579d..f639f90ea6 100644 --- a/packages/backend/test/e2e/oauth.ts +++ b/packages/backend/test/e2e/oauth.ts @@ -72,11 +72,12 @@ const clientConfig: ModuleOptions<'client_id'> = { }, }; -function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined } { +function getMeta(html: string): { transactionId: string | undefined, clientName: string | undefined, clientLogo: string | undefined } { const fragment = JSDOM.fragment(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, }; } @@ -915,6 +916,59 @@ describe('OAuth', () => { 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(` + +
+ 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, `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"'); diff --git a/packages/backend/test/e2e/synalio/abuse-report.ts b/packages/backend/test/e2e/synalio/abuse-report.ts index 6ce6e47781..c98d199f35 100644 --- a/packages/backend/test/e2e/synalio/abuse-report.ts +++ b/packages/backend/test/e2e/synalio/abuse-report.ts @@ -157,7 +157,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: webhookBody1.body.id, - forward: false, }, admin); }); @@ -214,7 +213,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: abuseReportId, - forward: false, }, admin); }); @@ -257,7 +255,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: webhookBody1.body.id, - forward: false, }, admin); }).catch(e => e.message); @@ -288,7 +285,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: abuseReportId, - forward: false, }, admin); }).catch(e => e.message); @@ -319,7 +315,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: abuseReportId, - forward: false, }, admin); }).catch(e => e.message); @@ -350,7 +345,6 @@ describe('[シナリオ] ユーザ通報', () => { const webhookBody2 = await captureWebhook(async () => { await resolveAbuseReport({ reportId: abuseReportId, - forward: false, }, admin); }).catch(e => e.message); diff --git a/packages/backend/test/e2e/thread-mute.ts b/packages/backend/test/e2e/thread-mute.ts index 1ac99df884..1edc178fc2 100644 --- a/packages/backend/test/e2e/thread-mute.ts +++ b/packages/backend/test/e2e/thread-mute.ts @@ -38,48 +38,6 @@ describe('Note thread mute', () => { assert.strictEqual(res.body.some(note => note.id === carolReplyWithoutMention.id), false); }); - test('ミュートしているスレッドからメンションされても、hasUnreadMentions が true にならない', async () => { - // 状態リセット - await api('i/read-all-unread-notes', {}, alice); - - const bobNote = await post(bob, { text: '@alice @carol root note' }); - - await api('notes/thread-muting/create', { noteId: bobNote.id }, alice); - - const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); - - const res = await api('i', {}, alice); - - assert.strictEqual(res.status, 200); - assert.strictEqual(res.body.hasUnreadMentions, false); - }); - - test('ミュートしているスレッドからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { - // 状態リセット - await api('i/read-all-unread-notes', {}, alice); - - const bobNote = await post(bob, { text: '@alice @carol root note' }); - - await api('notes/thread-muting/create', { noteId: bobNote.id }, alice); - - let fired = false; - - const ws = await connectStream(alice, 'main', async ({ type, body }) => { - if (type === 'unreadMention') { - if (body === bobNote.id) return; - fired = true; - } - }); - - const carolReply = await post(carol, { replyId: bobNote.id, text: '@bob @alice child note' }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 5000); - })); - test('i/notifications にミュートしているスレッドの通知が含まれない', async () => { const bobNote = await post(bob, { text: '@alice @carol root note' }); const aliceReply = await post(alice, { replyId: bobNote.id, text: '@bob @carol child note' }); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index d12be2a9ac..d6d2cb33f0 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -397,7 +397,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); assert.strictEqual(res.body.some(note => note.id === carolNote1.id), false); assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); - }, 1000 * 10); + }, 1000 * 30); test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); diff --git a/packages/backend/test/e2e/users.ts b/packages/backend/test/e2e/users.ts index 61fd759932..a342bba64c 100644 --- a/packages/backend/test/e2e/users.ts +++ b/packages/backend/test/e2e/users.ts @@ -7,15 +7,15 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import { inspect } from 'node:util'; -import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { api, post, role, signup, successfulApiCall, uploadFile } from '../utils.js'; import type * as misskey from 'misskey-js'; +import { DEFAULT_POLICIES } from '@/core/RoleService.js'; describe('ユーザー', () => { // エンティティとしてのユーザーを主眼においたテストを記述する // (Userを返すエンドポイントとUserエンティティを書き換えるエンドポイントをテストする) - const stripUndefined = (orig: T): Partial => { + const stripUndefined = (orig: T): Partial => { return Object.entries({ ...orig }) .filter(([, value]) => value !== undefined) .reduce((obj: Partial, [key, value]) => { @@ -83,9 +83,8 @@ describe('ユーザー', () => { publicReactions: user.publicReactions, followingVisibility: user.followingVisibility, followersVisibility: user.followersVisibility, - twoFactorEnabled: user.twoFactorEnabled, - usePasswordLessLogin: user.usePasswordLessLogin, - securityKeys: user.securityKeys, + chatScope: user.chatScope, + canChat: user.canChat, roles: user.roles, memo: user.memo, }); @@ -105,6 +104,7 @@ describe('ユーザー', () => { isRenoteMuted: user.isRenoteMuted ?? false, notify: user.notify ?? 'none', withReplies: user.withReplies ?? false, + followedMessage: user.isFollowing ? (user.followedMessage ?? null) : undefined, }); }; @@ -114,6 +114,7 @@ describe('ユーザー', () => { ...userDetailedNotMe(user), avatarId: user.avatarId, bannerId: user.bannerId, + followedMessage: user.followedMessage, isModerator: user.isModerator, isAdmin: user.isAdmin, injectFeaturedNote: user.injectFeaturedNote, @@ -133,6 +134,7 @@ describe('ユーザー', () => { hasUnreadAnnouncement: user.hasUnreadAnnouncement, hasUnreadAntenna: user.hasUnreadAntenna, hasUnreadChannel: user.hasUnreadChannel, + hasUnreadChatMessages: user.hasUnreadChatMessages, hasUnreadNotification: user.hasUnreadNotification, unreadNotificationsCount: user.unreadNotificationsCount, hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest, @@ -147,6 +149,9 @@ describe('ユーザー', () => { achievements: user.achievements, loggedInDays: user.loggedInDays, policies: user.policies, + twoFactorEnabled: user.twoFactorEnabled, + usePasswordLessLogin: user.usePasswordLessLogin, + securityKeys: user.securityKeys, ...(security ? { email: user.email, emailVerified: user.emailVerified, @@ -341,15 +346,15 @@ describe('ユーザー', () => { assert.strictEqual(response.publicReactions, true); assert.strictEqual(response.followingVisibility, 'public'); assert.strictEqual(response.followersVisibility, 'public'); - assert.strictEqual(response.twoFactorEnabled, false); - assert.strictEqual(response.usePasswordLessLogin, false); - assert.strictEqual(response.securityKeys, false); + assert.strictEqual(response.chatScope, 'mutual'); + assert.strictEqual(response.canChat, true); assert.deepStrictEqual(response.roles, []); assert.strictEqual(response.memo, null); // MeDetailedOnly assert.strictEqual(response.avatarId, null); assert.strictEqual(response.bannerId, null); + assert.strictEqual(response.followedMessage, null); assert.strictEqual(response.isModerator, false); assert.strictEqual(response.isAdmin, false); assert.strictEqual(response.injectFeaturedNote, true); @@ -369,6 +374,7 @@ describe('ユーザー', () => { assert.strictEqual(response.hasUnreadAnnouncement, false); assert.strictEqual(response.hasUnreadAntenna, false); assert.strictEqual(response.hasUnreadChannel, false); + assert.strictEqual(response.hasUnreadChatMessages, false); assert.strictEqual(response.hasUnreadNotification, false); assert.strictEqual(response.unreadNotificationsCount, 0); assert.strictEqual(response.hasPendingReceivedFollowRequest, false); @@ -382,6 +388,9 @@ describe('ユーザー', () => { assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.loggedInDays, 0); assert.deepStrictEqual(response.policies, DEFAULT_POLICIES); + assert.strictEqual(response.twoFactorEnabled, false); + assert.strictEqual(response.usePasswordLessLogin, false); + assert.strictEqual(response.securityKeys, false); assert.notStrictEqual(response.email, undefined); assert.strictEqual(response.emailVerified, false); assert.deepStrictEqual(response.securityKeysList, []); @@ -413,6 +422,8 @@ describe('ユーザー', () => { { parameters: () => ({ description: 'x'.repeat(1500) }) }, { parameters: () => ({ description: 'x' }) }, { parameters: () => ({ description: 'My description' }) }, + { parameters: () => ({ followedMessage: null }) }, + { parameters: () => ({ followedMessage: 'Thank you' }) }, { parameters: () => ({ location: null }) }, { parameters: () => ({ location: 'x'.repeat(50) }) }, { parameters: () => ({ location: 'x' }) }, @@ -613,6 +624,9 @@ describe('ユーザー', () => { { label: 'Moderatorになっている', user: () => userModerator, me: () => userModerator, selector: (user: misskey.entities.MeDetailed) => user.isModerator }, // @ts-expect-error UserDetailedNotMe doesn't include isModerator { label: '自分以外から見たときはModeratorか判定できない', user: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.isModerator, expected: () => undefined }, + { label: '自分から見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => alice, selector: (user: misskey.entities.MeDetailed) => user.twoFactorEnabled, expected: () => false }, + { label: '自分以外から見た場合に二要素認証関連のプロパティがセットされていない', user: () => alice, me: () => bob, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => undefined }, + { label: 'モデレーターから見た場合に二要素認証関連のプロパティがセットされている', user: () => alice, me: () => userModerator, selector: (user: misskey.entities.UserDetailedNotMe) => user.twoFactorEnabled, expected: () => false }, { label: 'サイレンスになっている', user: () => userSilenced, selector: (user: misskey.entities.UserDetailed) => user.isSilenced }, // FIXME: 落ちる //{ label: 'サスペンドになっている', user: () => userSuspended, selector: (user: misskey.entities.UserDetailed) => user.isSuspended }, @@ -720,7 +734,7 @@ describe('ユーザー', () => { }); test.each([ { label: '「見つけやすくする」がOFFのユーザーが含まれる', user: () => userNotExplorable }, - { label: 'ミュートユーザーが含まれる', user: () => userMutedByAlice }, + { label: 'ミュートユーザーが含まれない', user: () => userMutedByAlice, excluded: true }, { label: 'ブロックされているユーザーが含まれる', user: () => userBlockedByAlice }, { label: 'ブロックしてきているユーザーが含まれる', user: () => userBlockingAlice }, { label: '承認制ユーザーが含まれる', user: () => userLocking }, diff --git a/packages/backend/test/jest.setup.ts b/packages/backend/test/jest.setup.ts index 861bc6db66..7c6dd6a55f 100644 --- a/packages/backend/test/jest.setup.ts +++ b/packages/backend/test/jest.setup.ts @@ -6,8 +6,6 @@ import { initTestDb, sendEnvResetRequest } from './utils.js'; beforeAll(async () => { - await Promise.all([ - initTestDb(false), - sendEnvResetRequest(), - ]); + await initTestDb(false); + await sendEnvResetRequest(); }); diff --git a/packages/backend/test/misc/mock-resolver.ts b/packages/backend/test/misc/mock-resolver.ts index 3c7e796700..53ff4feb7e 100644 --- a/packages/backend/test/misc/mock-resolver.ts +++ b/packages/backend/test/misc/mock-resolver.ts @@ -7,21 +7,21 @@ import type { Config } from '@/config.js'; import type { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import type { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import type { ApRequestService } from '@/core/activitypub/ApRequestService.js'; -import { Resolver } from '@/core/activitypub/ApResolverService.js'; import type { IObject } from '@/core/activitypub/type.js'; import type { HttpRequestService } from '@/core/HttpRequestService.js'; -import type { InstanceActorService } from '@/core/InstanceActorService.js'; import type { LoggerService } from '@/core/LoggerService.js'; -import type { MetaService } from '@/core/MetaService.js'; import type { UtilityService } from '@/core/UtilityService.js'; -import { bindThis } from '@/decorators.js'; import type { FollowRequestsRepository, + MiMeta, NoteReactionsRepository, NotesRepository, PollsRepository, UsersRepository, } from '@/models/_.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { bindThis } from '@/decorators.js'; +import { Resolver } from '@/core/activitypub/ApResolverService.js'; type MockResponse = { type: string; @@ -35,14 +35,14 @@ export class MockResolver extends Resolver { constructor(loggerService: LoggerService) { super( {} as Config, + {} as MiMeta, {} as UsersRepository, {} as NotesRepository, {} as PollsRepository, {} as NoteReactionsRepository, {} as FollowRequestsRepository, {} as UtilityService, - {} as InstanceActorService, - {} as MetaService, + {} as SystemAccountService, {} as ApRequestService, {} as HttpRequestService, {} as ApRendererService, diff --git a/packages/backend/test/unit/AbuseReportNotificationService.ts b/packages/backend/test/unit/AbuseReportNotificationService.ts index e971659070..6d555326fb 100644 --- a/packages/backend/test/unit/AbuseReportNotificationService.ts +++ b/packages/backend/test/unit/AbuseReportNotificationService.ts @@ -3,12 +3,14 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { jest } from '@jest/globals'; +import { describe, jest } from '@jest/globals'; import { Test, TestingModule } from '@nestjs/testing'; +import { randomString } from '../utils.js'; import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { AbuseReportNotificationRecipientRepository, MiAbuseReportNotificationRecipient, + MiAbuseUserReport, MiSystemWebhook, MiUser, SystemWebhooksRepository, @@ -25,7 +27,7 @@ import { ModerationLogService } from '@/core/ModerationLogService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { RecipientMethod } from '@/models/AbuseReportNotificationRecipient.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; -import { randomString } from '../utils.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; process.env.NODE_ENV = 'test'; @@ -110,6 +112,12 @@ describe('AbuseReportNotificationService', () => { { provide: SystemWebhookService, useFactory: () => ({ enqueueSystemWebhook: jest.fn() }), }, + { + provide: UserEntityService, useFactory: () => ({ + pack: (v: any) => Promise.resolve(v), + packMany: (v: any) => Promise.resolve(v), + }), + }, { provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }), }, @@ -141,9 +149,9 @@ describe('AbuseReportNotificationService', () => { }); beforeEach(async () => { - root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); - alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false }); - bob = await createUser({ username: 'bob', usernameLower: 'bob', isRoot: false }); + root = await createUser({ username: 'root', usernameLower: 'root' }); + alice = await createUser({ username: 'alice', usernameLower: 'alice' }); + bob = await createUser({ username: 'bob', usernameLower: 'bob' }); systemWebhook1 = await createWebhook(); systemWebhook2 = await createWebhook(); @@ -340,4 +348,46 @@ describe('AbuseReportNotificationService', () => { expect(recipients).toEqual([recipient3]); }); }); + + describe('notifySystemWebhook', () => { + test('非アクティブな通報通知はWebhook送信から除外される', async () => { + const recipient1 = await createRecipient({ + method: 'webhook', + systemWebhookId: systemWebhook1.id, + isActive: true, + }); + const recipient2 = await createRecipient({ + method: 'webhook', + systemWebhookId: systemWebhook2.id, + isActive: false, + }); + + const reports: MiAbuseUserReport[] = [ + { + id: idService.gen(), + targetUserId: alice.id, + targetUser: alice, + reporterId: bob.id, + reporter: bob, + assigneeId: null, + assignee: null, + resolved: false, + forwarded: false, + comment: 'test', + moderationNote: '', + resolvedAs: null, + targetUserHost: null, + reporterHost: null, + }, + ]; + + await service.notifySystemWebhook(reports, 'abuseReport'); + + // 実際に除外されるかはSystemWebhookService側で確認する. + // ここでは非アクティブな通報通知を除外設定できているかを確認する + expect(webhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1); + expect(webhookService.enqueueSystemWebhook.mock.calls[0][0]).toBe('abuseReport'); + expect(webhookService.enqueueSystemWebhook.mock.calls[0][2]).toEqual({ excludes: [systemWebhook2.id] }); + }); + }); }); diff --git a/packages/backend/test/unit/CaptchaService.ts b/packages/backend/test/unit/CaptchaService.ts new file mode 100644 index 0000000000..51b70b05a1 --- /dev/null +++ b/packages/backend/test/unit/CaptchaService.ts @@ -0,0 +1,622 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Response } from 'node-fetch'; +import { + CaptchaError, + CaptchaErrorCode, + captchaErrorCodes, + CaptchaSaveResult, + CaptchaService, +} from '@/core/CaptchaService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { HttpRequestService } from '@/core/HttpRequestService.js'; +import { MetaService } from '@/core/MetaService.js'; +import { MiMeta } from '@/models/Meta.js'; +import { LoggerService } from '@/core/LoggerService.js'; + +describe('CaptchaService', () => { + let app: TestingModule; + let service: CaptchaService; + let httpRequestService: jest.Mocked; + let metaService: jest.Mocked; + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CaptchaService, + LoggerService, + { + provide: HttpRequestService, useFactory: () => ({ send: jest.fn() }), + }, + { + provide: MetaService, useFactory: () => ({ + fetch: jest.fn(), + update: jest.fn(), + }), + }, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(CaptchaService); + httpRequestService = app.get(HttpRequestService) as jest.Mocked; + metaService = app.get(MetaService) as jest.Mocked; + }); + + beforeEach(() => { + httpRequestService.send.mockClear(); + metaService.update.mockClear(); + metaService.fetch.mockClear(); + }); + + afterAll(async () => { + await app.close(); + }); + + function successMock(result: object) { + httpRequestService.send.mockResolvedValue({ + ok: true, + status: 200, + json: async () => (result), + } as Response); + } + + function failureHttpMock() { + httpRequestService.send.mockResolvedValue({ + ok: false, + status: 400, + } as Response); + } + + function failureVerificationMock(result: object) { + httpRequestService.send.mockResolvedValue({ + ok: true, + status: 200, + json: async () => (result), + } as Response); + } + + async function testCaptchaError(code: CaptchaErrorCode, test: () => Promise) { + try { + await test(); + expect(false).toBe(true); + } catch (e) { + expect(e instanceof CaptchaError).toBe(true); + + const _e = e as CaptchaError; + expect(_e.code).toBe(code); + } + } + + describe('verifyRecaptcha', () => { + test('success', async () => { + successMock({ success: true }); + await service.verifyRecaptcha('secret', 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyRecaptcha('secret', null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyRecaptcha('secret', 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyRecaptcha('secret', 'response')); + }); + }); + + describe('verifyHcaptcha', () => { + test('success', async () => { + successMock({ success: true }); + await service.verifyHcaptcha('secret', 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyHcaptcha('secret', null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyHcaptcha('secret', 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyHcaptcha('secret', 'response')); + }); + }); + + describe('verifyMcaptcha', () => { + const host = 'https://localhost'; + + test('success', async () => { + successMock({ valid: true }); + await service.verifyMcaptcha('secret', 'sitekey', host, 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyMcaptcha('secret', 'sitekey', host, null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ valid: false }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyMcaptcha('secret', 'sitekey', host, 'response')); + }); + }); + + describe('verifyTurnstile', () => { + test('success', async () => { + successMock({ success: true }); + await service.verifyTurnstile('secret', 'response'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTurnstile('secret', null)); + }); + + test('requestFailed', async () => { + failureHttpMock(); + await testCaptchaError(captchaErrorCodes.requestFailed, () => service.verifyTurnstile('secret', 'response')); + }); + + test('verificationFailed', async () => { + failureVerificationMock({ success: false, 'error-codes': ['code01', 'code02'] }); + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTurnstile('secret', 'response')); + }); + }); + + describe('verifyTestcaptcha', () => { + test('success', async () => { + await service.verifyTestcaptcha('testcaptcha-passed'); + }); + + test('noResponseProvided', async () => { + await testCaptchaError(captchaErrorCodes.noResponseProvided, () => service.verifyTestcaptcha(null)); + }); + + test('verificationFailed', async () => { + await testCaptchaError(captchaErrorCodes.verificationFailed, () => service.verifyTestcaptcha('testcaptcha-failed')); + }); + }); + + describe('get', () => { + function setupMeta(meta: Partial) { + metaService.fetch.mockResolvedValue(meta as MiMeta); + } + + test('values', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + hcaptchaSiteKey: 'hcaptcha-sitekey', + hcaptchaSecretKey: 'hcaptcha-secret', + mcaptchaSitekey: 'mcaptcha-sitekey', + mcaptchaSecretKey: 'mcaptcha-secret', + mcaptchaInstanceUrl: 'https://localhost', + recaptchaSiteKey: 'recaptcha-sitekey', + recaptchaSecretKey: 'recaptcha-secret', + turnstileSiteKey: 'turnstile-sitekey', + turnstileSecretKey: 'turnstile-secret', + }); + + const result = await service.get(); + expect(result.provider).toBe('none'); + expect(result.hcaptcha.siteKey).toBe('hcaptcha-sitekey'); + expect(result.hcaptcha.secretKey).toBe('hcaptcha-secret'); + expect(result.mcaptcha.siteKey).toBe('mcaptcha-sitekey'); + expect(result.mcaptcha.secretKey).toBe('mcaptcha-secret'); + expect(result.mcaptcha.instanceUrl).toBe('https://localhost'); + expect(result.recaptcha.siteKey).toBe('recaptcha-sitekey'); + expect(result.recaptcha.secretKey).toBe('recaptcha-secret'); + expect(result.turnstile.siteKey).toBe('turnstile-sitekey'); + expect(result.turnstile.secretKey).toBe('turnstile-secret'); + }); + + describe('provider', () => { + test('none', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('none'); + }); + + test('hcaptcha', async () => { + setupMeta({ + enableHcaptcha: true, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('hcaptcha'); + }); + + test('mcaptcha', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: true, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('mcaptcha'); + }); + + test('recaptcha', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: true, + enableTurnstile: false, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('recaptcha'); + }); + + test('turnstile', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: true, + enableTestcaptcha: false, + }); + + const result = await service.get(); + expect(result.provider).toBe('turnstile'); + }); + + test('testcaptcha', async () => { + setupMeta({ + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: true, + }); + + const result = await service.get(); + expect(result.provider).toBe('testcaptcha'); + }); + }); + }); + + describe('save', () => { + const host = 'https://localhost'; + + describe('[success] 検証に成功した時だけ保存できる+他のプロバイダの設定値を誤って更新しない', () => { + beforeEach(() => { + successMock({ success: true, valid: true }); + }); + + async function assertSuccess(promise: Promise, expectMeta: Partial) { + await expect(promise) + .resolves + .toStrictEqual({ success: true }); + const partialParams = metaService.update.mock.calls[0][0]; + expect(partialParams).toStrictEqual(expectMeta); + } + + test('none', async () => { + await assertSuccess( + service.save('none'), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + }, + ); + }); + + test('hcaptcha', async () => { + await assertSuccess( + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: 'hcaptcha-passed', + }), + { + enableHcaptcha: true, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + hcaptchaSiteKey: 'hcaptcha-sitekey', + hcaptchaSecretKey: 'hcaptcha-secret', + }, + ); + }); + + test('mcaptcha', async () => { + await assertSuccess( + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: 'mcaptcha-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: true, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: false, + mcaptchaSitekey: 'mcaptcha-sitekey', + mcaptchaSecretKey: 'mcaptcha-secret', + mcaptchaInstanceUrl: host, + }, + ); + }); + + test('recaptcha', async () => { + await assertSuccess( + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: 'recaptcha-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: true, + enableTurnstile: false, + enableTestcaptcha: false, + recaptchaSiteKey: 'recaptcha-sitekey', + recaptchaSecretKey: 'recaptcha-secret', + }, + ); + }); + + test('turnstile', async () => { + await assertSuccess( + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: 'turnstile-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: true, + enableTestcaptcha: false, + turnstileSiteKey: 'turnstile-sitekey', + turnstileSecretKey: 'turnstile-secret', + }, + ); + }); + + test('testcaptcha', async () => { + await assertSuccess( + service.save('testcaptcha', { + sitekey: 'testcaptcha-sitekey', + secret: 'testcaptcha-secret', + captchaResult: 'testcaptcha-passed', + }), + { + enableHcaptcha: false, + enableMcaptcha: false, + enableRecaptcha: false, + enableTurnstile: false, + enableTestcaptcha: true, + }, + ); + }); + }); + + describe('[failure] 検証に失敗した場合は保存できない+設定値の更新そのものが発生しない', () => { + async function assertFailure(code: CaptchaErrorCode, promise: Promise) { + const res = await promise; + expect(res.success).toBe(false); + if (!res.success) { + expect(res.error.code).toBe(code); + } + expect(metaService.update).not.toBeCalled(); + } + + describe('invalidParameters', () => { + test('hcaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: null, + }), + ); + }); + + test('mcaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: null, + }), + ); + }); + + test('recaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: null, + }), + ); + }); + + test('turnstile', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: null, + }), + ); + }); + + test('testcaptcha', async () => { + await assertFailure( + captchaErrorCodes.invalidParameters, + service.save('testcaptcha', { + captchaResult: null, + }), + ); + }); + }); + + describe('requestFailed', () => { + beforeEach(() => { + failureHttpMock(); + }); + + test('hcaptcha', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: 'hcaptcha-passed', + }), + ); + }); + + test('mcaptcha', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: 'mcaptcha-passed', + }), + ); + }); + + test('recaptcha', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: 'recaptcha-passed', + }), + ); + }); + + test('turnstile', async () => { + await assertFailure( + captchaErrorCodes.requestFailed, + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: 'turnstile-passed', + }), + ); + }); + + // testchapchaはrequestFailedがない + }); + + describe('verificationFailed', () => { + beforeEach(() => { + failureVerificationMock({ success: false, valid: false, 'error-codes': ['code01', 'code02'] }); + }); + + test('hcaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('hcaptcha', { + sitekey: 'hcaptcha-sitekey', + secret: 'hcaptcha-secret', + captchaResult: 'hccaptcha-passed', + }), + ); + }); + + test('mcaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('mcaptcha', { + sitekey: 'mcaptcha-sitekey', + secret: 'mcaptcha-secret', + instanceUrl: host, + captchaResult: 'mcaptcha-passed', + }), + ); + }); + + test('recaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('recaptcha', { + sitekey: 'recaptcha-sitekey', + secret: 'recaptcha-secret', + captchaResult: 'recaptcha-passed', + }), + ); + }); + + test('turnstile', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('turnstile', { + sitekey: 'turnstile-sitekey', + secret: 'turnstile-secret', + captchaResult: 'turnstile-passed', + }), + ); + }); + + test('testcaptcha', async () => { + await assertFailure( + captchaErrorCodes.verificationFailed, + service.save('testcaptcha', { + captchaResult: 'testcaptcha-failed', + }), + ); + }); + }); + }); + }); +}); diff --git a/packages/backend/test/unit/CustomEmojiService.ts b/packages/backend/test/unit/CustomEmojiService.ts new file mode 100644 index 0000000000..10b687c6a0 --- /dev/null +++ b/packages/backend/test/unit/CustomEmojiService.ts @@ -0,0 +1,817 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeAll, describe, test } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; +import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { UtilityService } from '@/core/UtilityService.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { EmojisRepository } from '@/models/_.js'; +import { MiEmoji } from '@/models/Emoji.js'; + +describe('CustomEmojiService', () => { + let app: TestingModule; + let service: CustomEmojiService; + + let emojisRepository: EmojisRepository; + let idService: IdService; + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CustomEmojiService, + UtilityService, + IdService, + EmojiEntityService, + ModerationLogService, + GlobalEventService, + ], + }) + .compile(); + app.enableShutdownHooks(); + + service = app.get(CustomEmojiService); + emojisRepository = app.get(DI.emojisRepository); + idService = app.get(IdService); + }); + + describe('fetchEmojis', () => { + async function insert(data: Partial[]) { + for (const d of data) { + const id = idService.gen(); + await emojisRepository.insert({ + id: id, + updatedAt: new Date(), + ...d, + }); + } + } + + function call(params: Parameters['0']) { + return service.fetchEmojis( + params, + { + // テスト向けに + sortKeys: ['+id'], + }, + ); + } + + function defaultData(suffix: string, override?: Partial): Partial { + return { + name: `emoji${suffix}`, + host: null, + category: 'default', + originalUrl: `https://example.com/emoji${suffix}.png`, + publicUrl: `https://example.com/emoji${suffix}.png`, + type: 'image/png', + aliases: [`emoji${suffix}`], + license: 'CC0', + isSensitive: false, + localOnly: false, + roleIdsThatCanBeUsedThisEmojiAsReaction: [], + ...override, + }; + } + + afterEach(async () => { + await emojisRepository.delete({}); + }); + + describe('単独', () => { + test('updatedAtFrom', async () => { + await insert([ + defaultData('001', { updatedAt: new Date('2021-01-01T00:00:00.000Z') }), + defaultData('002', { updatedAt: new Date('2021-01-02T00:00:00.000Z') }), + defaultData('003', { updatedAt: new Date('2021-01-03T00:00:00.000Z') }), + ]); + + const actual = await call({ + query: { + updatedAtFrom: '2021-01-02T00:00:00.000Z', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji002'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('updatedAtTo', async () => { + await insert([ + defaultData('001', { updatedAt: new Date('2021-01-01T00:00:00.000Z') }), + defaultData('002', { updatedAt: new Date('2021-01-02T00:00:00.000Z') }), + defaultData('003', { updatedAt: new Date('2021-01-03T00:00:00.000Z') }), + ]); + + const actual = await call({ + query: { + updatedAtTo: '2021-01-02T00:00:00.000Z', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + describe('name', () => { + test('single', async () => { + await insert([ + defaultData('001'), + defaultData('002'), + ]); + + const actual = await call({ + query: { + name: 'emoji001', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji001'); + }); + + test('multi', async () => { + await insert([ + defaultData('001'), + defaultData('002'), + ]); + + const actual = await call({ + query: { + name: 'emoji001 emoji002', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001'), + defaultData('002'), + defaultData('003', { name: 'em003' }), + ]); + + const actual = await call({ + query: { + name: 'oji', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + test('escape', async () => { + await insert([ + defaultData('001'), + ]); + + const actual = await call({ + query: { + name: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('host', () => { + test('single', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + defaultData('002', { host: 'example.com' }), + defaultData('003', { host: '1.example.com' }), + defaultData('004', { host: '2.example.com' }), + ]); + + const actual = await call({ + query: { + host: 'example.com', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(4); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + defaultData('002', { host: 'example.com' }), + defaultData('003', { host: '1.example.com' }), + defaultData('004', { host: '2.example.com' }), + ]); + + const actual = await call({ + query: { + host: '1.example.com 2.example.com', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji003'); + expect(actual.emojis[1].name).toBe('emoji004'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + defaultData('002', { host: 'example.com' }), + defaultData('003', { host: '1.example.com' }), + defaultData('004', { host: '2.example.com' }), + ]); + + const actual = await call({ + query: { + host: 'example', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(4); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { host: 'example.com' }), + ]); + + const actual = await call({ + query: { + host: '%', + hostType: 'remote', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('uri', () => { + test('single', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + defaultData('002', { uri: 'uri002' }), + defaultData('003', { uri: 'uri003' }), + ]); + + const actual = await call({ + query: { + uri: 'uri002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + defaultData('002', { uri: 'uri002' }), + defaultData('003', { uri: 'uri003' }), + ]); + + const actual = await call({ + query: { + uri: 'uri001 uri003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + defaultData('002', { uri: 'uri002' }), + defaultData('003', { uri: 'uri003' }), + ]); + + const actual = await call({ + query: { + uri: 'ri', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { uri: 'uri001' }), + ]); + + const actual = await call({ + query: { + uri: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('publicUrl', () => { + test('single', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + defaultData('002', { publicUrl: 'publicUrl002' }), + defaultData('003', { publicUrl: 'publicUrl003' }), + ]); + + const actual = await call({ + query: { + publicUrl: 'publicUrl002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + defaultData('002', { publicUrl: 'publicUrl002' }), + defaultData('003', { publicUrl: 'publicUrl003' }), + ]); + + const actual = await call({ + query: { + publicUrl: 'publicUrl001 publicUrl003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + defaultData('002', { publicUrl: 'publicUrl002' }), + defaultData('003', { publicUrl: 'publicUrl003' }), + ]); + + const actual = await call({ + query: { + publicUrl: 'Url', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { publicUrl: 'publicUrl001' }), + ]); + + const actual = await call({ + query: { + publicUrl: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('type', () => { + test('single', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + defaultData('002', { type: 'type002' }), + defaultData('003', { type: 'type003' }), + ]); + + const actual = await call({ + query: { + type: 'type002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + defaultData('002', { type: 'type002' }), + defaultData('003', { type: 'type003' }), + ]); + + const actual = await call({ + query: { + type: 'type001 type003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + defaultData('002', { type: 'type002' }), + defaultData('003', { type: 'type003' }), + ]); + + const actual = await call({ + query: { + type: 'pe', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { type: 'type001' }), + ]); + + const actual = await call({ + query: { + type: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('aliases', () => { + test('single', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + defaultData('002', { aliases: ['alias002'] }), + defaultData('003', { aliases: ['alias003'] }), + ]); + + const actual = await call({ + query: { + aliases: 'alias002', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + defaultData('002', { aliases: ['alias002', 'alias004'] }), + defaultData('003', { aliases: ['alias003'] }), + defaultData('004', { aliases: ['alias004'] }), + ]); + + const actual = await call({ + query: { + aliases: 'alias001 alias004', + }, + }); + + expect(actual.allCount).toBe(3); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + expect(actual.emojis[2].name).toBe('emoji004'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + defaultData('002', { aliases: ['alias002', 'alias004'] }), + defaultData('003', { aliases: ['alias003'] }), + defaultData('004', { aliases: ['alias004'] }), + ]); + + const actual = await call({ + query: { + aliases: 'ias', + }, + }); + + expect(actual.allCount).toBe(4); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { aliases: ['alias001', 'alias002'] }), + ]); + + const actual = await call({ + query: { + aliases: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('category', () => { + test('single', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + defaultData('002', { category: 'category002' }), + defaultData('003', { category: 'category003' }), + ]); + + const actual = await call({ + query: { + category: 'category002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + defaultData('002', { category: 'category002' }), + defaultData('003', { category: 'category003' }), + ]); + + const actual = await call({ + query: { + category: 'category001 category003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + defaultData('002', { category: 'category002' }), + defaultData('003', { category: 'category003' }), + ]); + + const actual = await call({ + query: { + category: 'egory', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { category: 'category001' }), + ]); + + const actual = await call({ + query: { + category: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('license', () => { + test('single', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + defaultData('002', { license: 'license002' }), + defaultData('003', { license: 'license003' }), + ]); + + const actual = await call({ + query: { + license: 'license002', + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + defaultData('002', { license: 'license002' }), + defaultData('003', { license: 'license003' }), + ]); + + const actual = await call({ + query: { + license: 'license001 license003', + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('keyword', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + defaultData('002', { license: 'license002' }), + defaultData('003', { license: 'license003' }), + ]); + + const actual = await call({ + query: { + license: 'cense', + }, + }); + + expect(actual.allCount).toBe(3); + }); + + test('escape', async () => { + await insert([ + defaultData('001', { license: 'license001' }), + ]); + + const actual = await call({ + query: { + license: '%', + }, + }); + + expect(actual.allCount).toBe(0); + }); + }); + + describe('isSensitive', () => { + test('true', async () => { + await insert([ + defaultData('001', { isSensitive: true }), + defaultData('002', { isSensitive: false }), + defaultData('003', { isSensitive: true }), + ]); + + const actual = await call({ + query: { + isSensitive: true, + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('false', async () => { + await insert([ + defaultData('001', { isSensitive: true }), + defaultData('002', { isSensitive: false }), + defaultData('003', { isSensitive: true }), + ]); + + const actual = await call({ + query: { + isSensitive: false, + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('null', async () => { + await insert([ + defaultData('001', { isSensitive: true }), + defaultData('002', { isSensitive: false }), + defaultData('003', { isSensitive: true }), + ]); + + const actual = await call({ + query: {}, + }); + + expect(actual.allCount).toBe(3); + }); + }); + + describe('localOnly', () => { + test('true', async () => { + await insert([ + defaultData('001', { localOnly: true }), + defaultData('002', { localOnly: false }), + defaultData('003', { localOnly: true }), + ]); + + const actual = await call({ + query: { + localOnly: true, + }, + }); + + expect(actual.allCount).toBe(2); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji003'); + }); + + test('false', async () => { + await insert([ + defaultData('001', { localOnly: true }), + defaultData('002', { localOnly: false }), + defaultData('003', { localOnly: true }), + ]); + + const actual = await call({ + query: { + localOnly: false, + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('null', async () => { + await insert([ + defaultData('001', { localOnly: true }), + defaultData('002', { localOnly: false }), + defaultData('003', { localOnly: true }), + ]); + + const actual = await call({ + query: {}, + }); + + expect(actual.allCount).toBe(3); + }); + }); + + describe('roleId', () => { + test('single', async () => { + await insert([ + defaultData('001', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role001'] }), + defaultData('002', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role002'] }), + defaultData('003', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role003'] }), + ]); + + const actual = await call({ + query: { + roleIds: ['role002'], + }, + }); + + expect(actual.allCount).toBe(1); + expect(actual.emojis[0].name).toBe('emoji002'); + }); + + test('multi', async () => { + await insert([ + defaultData('001', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role001'] }), + defaultData('002', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role002', 'role003'] }), + defaultData('003', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role003'] }), + defaultData('004', { roleIdsThatCanBeUsedThisEmojiAsReaction: ['role004'] }), + ]); + + const actual = await call({ + query: { + roleIds: ['role001', 'role003'], + }, + }); + + expect(actual.allCount).toBe(3); + expect(actual.emojis[0].name).toBe('emoji001'); + expect(actual.emojis[1].name).toBe('emoji002'); + expect(actual.emojis[2].name).toBe('emoji003'); + }); + }); + }); + }); +}); diff --git a/packages/backend/test/unit/FetchInstanceMetadataService.ts b/packages/backend/test/unit/FetchInstanceMetadataService.ts index bf8f3ab0e3..1e3605aafc 100644 --- a/packages/backend/test/unit/FetchInstanceMetadataService.ts +++ b/packages/backend/test/unit/FetchInstanceMetadataService.ts @@ -8,6 +8,7 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; import { Test } from '@nestjs/testing'; import { Redis } from 'ioredis'; +import type { TestingModule } from '@nestjs/testing'; import { GlobalModule } from '@/GlobalModule.js'; import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js'; import { FederatedInstanceService } from '@/core/FederatedInstanceService.js'; @@ -16,7 +17,6 @@ import { LoggerService } from '@/core/LoggerService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; -import type { TestingModule } from '@nestjs/testing'; function mockRedis() { const hash = {} as any; @@ -52,7 +52,7 @@ describe('FetchInstanceMetadataService', () => { if (token === HttpRequestService) { return { getJson: jest.fn(), getHtml: jest.fn(), send: jest.fn() }; } else if (token === FederatedInstanceService) { - return { fetch: jest.fn() }; + return { fetchOrRegister: jest.fn() }; } else if (token === DI.redis) { return mockRedis; } @@ -75,7 +75,7 @@ describe('FetchInstanceMetadataService', () => { test('Lock and update', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => { return now - 10 * 1000 * 60 * 60 * 24; } } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); @@ -83,14 +83,14 @@ describe('FetchInstanceMetadataService', () => { await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1); expect(httpRequestService.getJson).toHaveBeenCalled(); }); test('Lock and don\'t update', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); const unlockSpy = jest.spyOn(fetchInstanceMetadataService, 'unlock'); @@ -98,14 +98,14 @@ describe('FetchInstanceMetadataService', () => { await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(1); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); }); test('Do nothing when lock not acquired', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); @@ -114,14 +114,14 @@ describe('FetchInstanceMetadataService', () => { await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any); expect(tryLockSpy).toHaveBeenCalledTimes(1); expect(unlockSpy).toHaveBeenCalledTimes(0); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalledTimes(0); }); test('Do when lock not acquired but forced', async () => { redisClient.set = mockRedis(); const now = Date.now(); - federatedInstanceService.fetch.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); + federatedInstanceService.fetchOrRegister.mockResolvedValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } } as any); httpRequestService.getJson.mockImplementation(() => { throw Error(); }); await fetchInstanceMetadataService.tryLock('example.com'); const tryLockSpy = jest.spyOn(fetchInstanceMetadataService, 'tryLock'); @@ -130,7 +130,7 @@ describe('FetchInstanceMetadataService', () => { await fetchInstanceMetadataService.fetchInstanceMetadata({ host: 'example.com' } as any, true); expect(tryLockSpy).toHaveBeenCalledTimes(0); expect(unlockSpy).toHaveBeenCalledTimes(1); - expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(0); + expect(federatedInstanceService.fetchOrRegister).toHaveBeenCalledTimes(0); expect(httpRequestService.getJson).toHaveBeenCalled(); }); }); diff --git a/packages/backend/test/unit/FlashService.ts b/packages/backend/test/unit/FlashService.ts new file mode 100644 index 0000000000..f2d9832f50 --- /dev/null +++ b/packages/backend/test/unit/FlashService.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { FlashService } from '@/core/FlashService.js'; +import { IdService } from '@/core/IdService.js'; +import { FlashsRepository, MiFlash, MiUser, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; + +describe('FlashService', () => { + let app: TestingModule; + let service: FlashService; + + // -------------------------------------------------------------------------------------- + + let flashsRepository: FlashsRepository; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let idService: IdService; + + // -------------------------------------------------------------------------------------- + + let root: MiUser; + let alice: MiUser; + let bob: MiUser; + + // -------------------------------------------------------------------------------------- + + async function createFlash(data: Partial) { + return flashsRepository.insert({ + id: idService.gen(), + updatedAt: new Date(), + userId: root.id, + title: 'title', + summary: 'summary', + script: 'script', + permissions: [], + likedCount: 0, + ...data, + }).then(x => flashsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + // -------------------------------------------------------------------------------------- + + beforeEach(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + FlashService, + IdService, + ], + }).compile(); + + service = app.get(FlashService); + + flashsRepository = app.get(DI.flashsRepository); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + idService = app.get(IdService); + + root = await createUser({ username: 'root', usernameLower: 'root' }); + alice = await createUser({ username: 'alice', usernameLower: 'alice' }); + bob = await createUser({ username: 'bob', usernameLower: 'bob' }); + }); + + afterEach(async () => { + await usersRepository.delete({}); + await userProfilesRepository.delete({}); + await flashsRepository.delete({}); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('featured', () => { + test('should return featured flashes', async () => { + const flash1 = await createFlash({ likedCount: 1 }); + const flash2 = await createFlash({ likedCount: 2 }); + const flash3 = await createFlash({ likedCount: 3 }); + + const result = await service.featured({ + offset: 0, + limit: 10, + }); + + expect(result).toEqual([flash3, flash2, flash1]); + }); + + test('should return featured flashes public visibility only', async () => { + const flash1 = await createFlash({ likedCount: 1, visibility: 'public' }); + const flash2 = await createFlash({ likedCount: 2, visibility: 'public' }); + const flash3 = await createFlash({ likedCount: 3, visibility: 'private' }); + + const result = await service.featured({ + offset: 0, + limit: 10, + }); + + expect(result).toEqual([flash2, flash1]); + }); + + test('should return featured flashes with offset', async () => { + const flash1 = await createFlash({ likedCount: 1 }); + const flash2 = await createFlash({ likedCount: 2 }); + const flash3 = await createFlash({ likedCount: 3 }); + + const result = await service.featured({ + offset: 1, + limit: 10, + }); + + expect(result).toEqual([flash2, flash1]); + }); + + test('should return featured flashes with limit', async () => { + const flash1 = await createFlash({ likedCount: 1 }); + const flash2 = await createFlash({ likedCount: 2 }); + const flash3 = await createFlash({ likedCount: 3 }); + + const result = await service.featured({ + offset: 0, + limit: 2, + }); + + expect(result).toEqual([flash3, flash2]); + }); + }); +}); diff --git a/packages/backend/test/unit/MfmService.ts b/packages/backend/test/unit/MfmService.ts index fd4a03413b..7350da3cae 100644 --- a/packages/backend/test/unit/MfmService.ts +++ b/packages/backend/test/unit/MfmService.ts @@ -24,13 +24,13 @@ 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); }); @@ -108,6 +108,24 @@ describe('MfmService', () => { assert.deepStrictEqual(mfmService.fromHtml('

a d

'), 'a d'); }); + test('ruby', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a Misskey(ミスキー) b

'), 'a $[ruby Misskey ミスキー] b'); + assert.deepStrictEqual(mfmService.fromHtml('

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

'), 'a $[ruby Misskey ミスキー]$[ruby Misskey ミスキー] b'); + }); + + test('ruby with spaces', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a Miss key(ミスキー) b c

'), 'a Miss key(ミスキー) b c'); + 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' + ); + }); + + test('ruby with other inline tags', () => { + assert.deepStrictEqual(mfmService.fromHtml('

a Misskey(ミスキー) b c

'), 'a **Misskey**(ミスキー) b c'); + }); + test('mention', () => { assert.deepStrictEqual(mfmService.fromHtml('

a @user d

'), 'a @user@example.com d'); }); diff --git a/packages/backend/test/unit/RelayService.ts b/packages/backend/test/unit/RelayService.ts index 9676abf07b..074430dd31 100644 --- a/packages/backend/test/unit/RelayService.ts +++ b/packages/backend/test/unit/RelayService.ts @@ -6,19 +6,18 @@ process.env.NODE_ENV = 'test'; import { jest } from '@jest/globals'; -import { ModuleMocker } from 'jest-mock'; import { Test } from '@nestjs/testing'; -import { GlobalModule } from '@/GlobalModule.js'; -import { RelayService } from '@/core/RelayService.js'; -import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; -import { CreateSystemUserService } from '@/core/CreateSystemUserService.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { QueueService } from '@/core/QueueService.js'; -import { IdService } from '@/core/IdService.js'; -import type { RelaysRepository } from '@/models/_.js'; -import { DI } from '@/di-symbols.js'; +import { ModuleMocker } from 'jest-mock'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { IdService } from '@/core/IdService.js'; +import { QueueService } from '@/core/QueueService.js'; +import { RelayService } from '@/core/RelayService.js'; +import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { UtilityService } from '@/core/UtilityService.js'; const moduleMocker = new ModuleMocker(global); @@ -26,8 +25,6 @@ describe('RelayService', () => { let app: TestingModule; let relayService: RelayService; let queueService: jest.Mocked; - let relaysRepository: RelaysRepository; - let userEntityService: UserEntityService; beforeAll(async () => { app = await Test.createTestingModule({ @@ -36,10 +33,11 @@ describe('RelayService', () => { ], providers: [ IdService, - CreateSystemUserService, ApRendererService, RelayService, UserEntityService, + SystemAccountService, + UtilityService, ], }) .useMocker((token) => { @@ -58,8 +56,6 @@ describe('RelayService', () => { relayService = app.get(RelayService); queueService = app.get(QueueService) as jest.Mocked; - relaysRepository = app.get(DI.relaysRepository); - userEntityService = app.get(UserEntityService); }); afterAll(async () => { diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index b6cbe4c520..553ff0982a 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -10,9 +10,12 @@ import { 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 { GlobalModule } from '@/GlobalModule.js'; import { RoleService } from '@/core/RoleService.js'; import { + MiMeta, MiRole, MiRoleAssignment, MiUser, @@ -30,8 +33,6 @@ import { secureRndstr } from '@/misc/secure-rndstr.js'; import { NotificationService } from '@/core/NotificationService.js'; import { RoleCondFormulaValue } from '@/models/Role.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { TestingModule } from '@nestjs/testing'; -import type { MockFunctionMetadata } from 'jest-mock'; const moduleMocker = new ModuleMocker(global); @@ -41,7 +42,7 @@ describe('RoleService', () => { let usersRepository: UsersRepository; let rolesRepository: RolesRepository; let roleAssignmentsRepository: RoleAssignmentsRepository; - let metaService: jest.Mocked; + let meta: jest.Mocked; let notificationService: jest.Mocked; let clock: lolex.InstalledClock; @@ -56,6 +57,12 @@ describe('RoleService', () => { return await usersRepository.findOneByOrFail(x.identifiers[0]); } + async function createRoot(data: Partial = {}) { + const user = await createUser(data); + meta.rootUserId = user.id; + return user; + } + async function createRole(data: Partial = {}) { const x = await rolesRepository.insert({ id: genAidx(Date.now()), @@ -142,7 +149,7 @@ describe('RoleService', () => { rolesRepository = app.get(DI.rolesRepository); roleAssignmentsRepository = app.get(DI.roleAssignmentsRepository); - metaService = app.get(MetaService) as jest.Mocked; + meta = app.get(DI.meta) as jest.Mocked; notificationService = app.get(NotificationService) as jest.Mocked; await roleService.onModuleInit(); @@ -164,11 +171,9 @@ describe('RoleService', () => { describe('getUserPolicies', () => { test('instance default policies', async () => { const user = await createUser(); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); + meta.policies = { + canManageCustomEmojis: false, + }; const result = await roleService.getUserPolicies(user.id); @@ -177,11 +182,9 @@ describe('RoleService', () => { test('instance default policies 2', async () => { const user = await createUser(); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: true, - }, - } as any); + meta.policies = { + canManageCustomEmojis: true, + }; const result = await roleService.getUserPolicies(user.id); @@ -201,11 +204,9 @@ describe('RoleService', () => { }, }); await roleService.assign(user.id, role.id); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); + meta.policies = { + canManageCustomEmojis: false, + }; const result = await roleService.getUserPolicies(user.id); @@ -236,11 +237,9 @@ describe('RoleService', () => { }); await roleService.assign(user.id, role1.id); await roleService.assign(user.id, role2.id); - metaService.fetch.mockResolvedValue({ - policies: { - driveCapacityMb: 50, - }, - } as any); + meta.policies = { + driveCapacityMb: 50, + }; const result = await roleService.getUserPolicies(user.id); @@ -260,11 +259,9 @@ describe('RoleService', () => { }, }); await roleService.assign(user.id, role.id, new Date(Date.now() + (1000 * 60 * 60 * 24))); - metaService.fetch.mockResolvedValue({ - policies: { - canManageCustomEmojis: false, - }, - } as any); + meta.policies = { + canManageCustomEmojis: false, + }; const result = await roleService.getUserPolicies(user.id); expect(result.canManageCustomEmojis).toBe(true); @@ -286,9 +283,9 @@ describe('RoleService', () => { }); describe('getModeratorIds', () => { - test('includeAdmins = false, excludeExpire = false', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = false, includeRoot = false, 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 }); @@ -304,13 +301,17 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(false, false); + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: false, + excludeExpire: false, + }); expect(result).toEqual([modeUser1.id, modeUser2.id]); }); - test('includeAdmins = false, excludeExpire = true', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = false, includeRoot = false, 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 }); @@ -326,13 +327,17 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(false, true); + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: false, + excludeExpire: true, + }); expect(result).toEqual([modeUser1.id]); }); - test('includeAdmins = true, excludeExpire = false', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = true, includeRoot = false, 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 }); @@ -348,13 +353,17 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(true, false); + const result = await roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: false, + excludeExpire: false, + }); expect(result).toEqual([adminUser1.id, adminUser2.id, modeUser1.id, modeUser2.id]); }); - test('includeAdmins = true, excludeExpire = true', async () => { - const [adminUser1, adminUser2, modeUser1, modeUser2, normalUser1, normalUser2] = await Promise.all([ - createUser(), createUser(), createUser(), createUser(), createUser(), createUser(), + test('includeAdmins = true, includeRoot = false, 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 }); @@ -370,9 +379,111 @@ describe('RoleService', () => { assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), ]); - const result = await roleService.getModeratorIds(true, true); + const result = await roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: false, + excludeExpire: true, + }); expect(result).toEqual([adminUser1.id, modeUser1.id]); }); + + test('includeAdmins = false, 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 }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: adminUser2.id, roleId: role1.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: modeUser2.id, roleId: role2.id, expiresAt: new Date(Date.now() - 1000) }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + assignRole({ userId: normalUser2.id, roleId: role3.id, expiresAt: new Date(Date.now() - 1000) }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: true, + excludeExpire: false, + }); + 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(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: rootUser.id, roleId: role2.id }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: true, + excludeExpire: false, + }); + expect(result).toEqual([modeUser1.id, rootUser.id]); + }); + + test('root has administrator role', async () => { + const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createRoot(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: rootUser.id, roleId: role1.id }), + assignRole({ userId: modeUser1.id, roleId: role2.id }), + assignRole({ userId: normalUser1.id, roleId: role3.id }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: true, + includeRoot: true, + excludeExpire: false, + }); + expect(result).toEqual([adminUser1.id, modeUser1.id, rootUser.id]); + }); + + test('root has moderator role(expire)', async () => { + const [adminUser1, modeUser1, normalUser1, rootUser] = await Promise.all([ + createUser(), createUser(), createUser(), createRoot(), + ]); + + const role1 = await createRole({ name: 'admin', isAdministrator: true }); + const role2 = await createRole({ name: 'moderator', isModerator: true }); + const role3 = await createRole({ name: 'normal' }); + + await Promise.all([ + assignRole({ userId: adminUser1.id, roleId: role1.id }), + assignRole({ userId: 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: normalUser1.id, roleId: role3.id }), + ]); + + const result = await roleService.getModeratorIds({ + includeAdmins: false, + includeRoot: true, + excludeExpire: true, + }); + expect(result).toEqual([rootUser.id]); + }); }); describe('conditional role', () => { diff --git a/packages/backend/test/unit/SigninWithPasskeyApiService.ts b/packages/backend/test/unit/SigninWithPasskeyApiService.ts new file mode 100644 index 0000000000..bae2b88c60 --- /dev/null +++ b/packages/backend/test/unit/SigninWithPasskeyApiService.ts @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IncomingHttpHeaders } from 'node:http'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, jest, test } from '@jest/globals'; +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 { MiUser } from '@/models/User.js'; +import { MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { DI } from '@/di-symbols.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { SigninWithPasskeyApiService } from '@/server/api/SigninWithPasskeyApiService.js'; +import { RateLimiterService } from '@/server/api/RateLimiterService.js'; +import { WebAuthnService } from '@/core/WebAuthnService.js'; +import { SigninService } from '@/server/api/SigninService.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; + +const moduleMocker = new ModuleMocker(global); + +class FakeLimiter { + public async limit() { + return; + } +} + +class FakeSigninService { + public signin(..._args: any): any { + return true; + } +} + +class DummyFastifyReply { + public statusCode: number; + code(num: number): void { + this.statusCode = num; + } + header(_key: HttpHeader, _value: any): void { + } +} +class DummyFastifyRequest { + public ip: string; + public body: {credential: any, context: string}; + public headers: IncomingHttpHeaders = { 'accept': 'application/json' }; + constructor(body?: any) { + this.ip = '0.0.0.0'; + this.body = body; + } +} + +type ApiFastifyRequestType = FastifyRequest<{ + Body: { + credential?: AuthenticationResponseJSON; + context?: string; + }; +}>; + +describe('SigninWithPasskeyApiService', () => { + let app: TestingModule; + let passkeyApiService: SigninWithPasskeyApiService; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let webAuthnService: WebAuthnService; + let idService: IdService; + let FakeWebauthnVerify: ()=>Promise; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .save({ + ...data, + }); + return user; + } + + async function createUserProfile(data: Partial = {}) { + const userProfile = await userProfilesRepository + .save({ ...data }, + ); + return userProfile; + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [GlobalModule, CoreModule], + providers: [ + SigninWithPasskeyApiService, + { provide: RateLimiterService, useClass: FakeLimiter }, + { provide: SigninService, useClass: FakeSigninService }, + ], + }).useMocker((token) => { + if (typeof token === 'function') { + const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata; + const Mock = moduleMocker.generateFromMetadata(mockMetadata); + return new Mock(); + } + }).compile(); + passkeyApiService = app.get(SigninWithPasskeyApiService); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + webAuthnService = app.get(WebAuthnService); + idService = app.get(IdService); + }); + + beforeEach(async () => { + const uid = idService.gen(); + FakeWebauthnVerify = async () => { + return uid; + }; + jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify); + + const dummyUser = { + id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null, + }; + const dummyProfile = { + userId: uid, + password: 'qwerty', + usePasswordLessLogin: true, + }; + await createUser(dummyUser); + await createUserProfile(dummyProfile); + }); + + afterAll(async () => { + await app.close(); + }); + + describe('Get Passkey Options', () => { + it('Should return passkey Auth Options', async () => { + const req = new DummyFastifyRequest({}) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as unknown as FastifyReply; + const res_body = await passkeyApiService.signin(req, res); + expect(res.statusCode).toBe(200); + expect((res_body as any).option).toBeDefined(); + expect(typeof (res_body as any).context).toBe('string'); + }); + }); + describe('Try Passkey Auth', () => { + it('Should Success', async () => { + const req = new DummyFastifyRequest({ context: 'auth-context', credential: { dummy: [] } }) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as FastifyReply; + const res_body = await passkeyApiService.signin(req, res); + expect((res_body as any).signinResponse).toBeDefined(); + }); + + it('Should return 400 Without Auth Context', async () => { + const req = new DummyFastifyRequest({ credential: { dummy: [] } }) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as FastifyReply; + const res_body = await passkeyApiService.signin(req, res); + expect(res.statusCode).toBe(400); + expect((res_body as any).error?.id).toStrictEqual('1658cc2e-4495-461f-aee4-d403cdf073c1'); + }); + + it('Should return 403 When Challenge Verify fail', async () => { + const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as FastifyReply; + jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication') + .mockImplementation(async () => { + throw new IdentifiableError('THIS_ERROR_CODE_SHOULD_BE_FORWARDED'); + }); + const res_body = await passkeyApiService.signin(req, res); + expect(res.statusCode).toBe(403); + expect((res_body as any).error?.id).toStrictEqual('THIS_ERROR_CODE_SHOULD_BE_FORWARDED'); + }); + + it('Should return 403 When The user not Enabled Passwordless login', async () => { + const req = new DummyFastifyRequest({ context: 'misskey-1234', credential: { dummy: [] } }) as ApiFastifyRequestType; + const res = new DummyFastifyReply() as FastifyReply; + const userId = await FakeWebauthnVerify(); + const data = { userId: userId, usePasswordLessLogin: false }; + await userProfilesRepository.update({ userId: userId }, data); + const res_body = await passkeyApiService.signin(req, res); + expect(res.statusCode).toBe(403); + expect((res_body as any).error?.id).toStrictEqual('2d84773e-f7b7-4d0b-8f72-bb69b584c912'); + }); + }); +}); diff --git a/packages/backend/test/unit/SystemWebhookService.ts b/packages/backend/test/unit/SystemWebhookService.ts index 5401dd74d8..61187e9f2a 100644 --- a/packages/backend/test/unit/SystemWebhookService.ts +++ b/packages/backend/test/unit/SystemWebhookService.ts @@ -97,7 +97,7 @@ describe('SystemWebhookService', () => { } async function beforeEachImpl() { - root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' }); + root = await createUser({ username: 'root', usernameLower: 'root' }); } async function afterEachImpl() { @@ -314,9 +314,10 @@ describe('SystemWebhookService', () => { isActive: true, on: ['abuseReport'], }); - await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any); + await service.enqueueSystemWebhook('abuseReport', { foo: 'bar' } as any); - expect(queueService.systemWebhookDeliver).toHaveBeenCalled(); + expect(queueService.systemWebhookDeliver).toHaveBeenCalledTimes(1); + expect(queueService.systemWebhookDeliver.mock.calls[0][0] as MiSystemWebhook).toEqual(webhook); }); test('非アクティブなWebhookはキューに追加されない', async () => { @@ -324,7 +325,7 @@ describe('SystemWebhookService', () => { isActive: false, on: ['abuseReport'], }); - await service.enqueueSystemWebhook(webhook.id, 'abuseReport', { foo: 'bar' } as any); + await service.enqueueSystemWebhook('abuseReport', { foo: 'bar' } as any); expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); }); @@ -338,11 +339,49 @@ describe('SystemWebhookService', () => { isActive: true, on: ['abuseReportResolved'], }); - await service.enqueueSystemWebhook(webhook1.id, 'abuseReport', { foo: 'bar' } as any); - await service.enqueueSystemWebhook(webhook2.id, 'abuseReport', { foo: 'bar' } as any); + await service.enqueueSystemWebhook('abuseReport', { foo: 'bar' } as any); expect(queueService.systemWebhookDeliver).not.toHaveBeenCalled(); }); + + test('混在した時、有効かつ許可されたイベント種別のみ', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: true, + on: ['abuseReportResolved'], + }); + const webhook3 = await createWebhook({ + isActive: false, + on: ['abuseReport'], + }); + const webhook4 = await createWebhook({ + isActive: false, + on: ['abuseReportResolved'], + }); + await service.enqueueSystemWebhook('abuseReport', { foo: 'bar' } as any); + + expect(queueService.systemWebhookDeliver).toHaveBeenCalledTimes(1); + expect(queueService.systemWebhookDeliver.mock.calls[0][0] as MiSystemWebhook).toEqual(webhook1); + }); + + test('除外指定した場合は送信されない', async () => { + const webhook1 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + const webhook2 = await createWebhook({ + isActive: true, + on: ['abuseReport'], + }); + + await service.enqueueSystemWebhook('abuseReport', { foo: 'bar' } as any, { excludes: [webhook2.id] }); + + expect(queueService.systemWebhookDeliver).toHaveBeenCalledTimes(1); + expect(queueService.systemWebhookDeliver.mock.calls[0][0] as MiSystemWebhook).toEqual(webhook1); + }); }); describe('fetchActiveSystemWebhooks', () => { diff --git a/packages/backend/test/unit/UserSearchService.ts b/packages/backend/test/unit/UserSearchService.ts index 7ea325d420..697425beb8 100644 --- a/packages/backend/test/unit/UserSearchService.ts +++ b/packages/backend/test/unit/UserSearchService.ts @@ -113,7 +113,7 @@ describe('UserSearchService', () => { }); beforeEach(async () => { - root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); + root = await createUser({ username: 'root', usernameLower: 'root' }); alice = await createUser({ username: 'Alice', usernameLower: 'alice' }); alyce = await createUser({ username: 'Alyce', usernameLower: 'alyce' }); alycia = await createUser({ username: 'Alycia', usernameLower: 'alycia' }); @@ -134,13 +134,13 @@ describe('UserSearchService', () => { await app.close(); }); - describe('search', () => { + describe('searchByUsernameAndHost', () => { test('フォロー中のアクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setActive([alice, alyce, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alycia, alysha, alyson]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -154,7 +154,7 @@ describe('UserSearchService', () => { await createFollowings(root, [alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -168,7 +168,7 @@ describe('UserSearchService', () => { await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alice, alyce, alycia]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -181,7 +181,7 @@ describe('UserSearchService', () => { test('フォローしていない非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, @@ -195,7 +195,7 @@ describe('UserSearchService', () => { await setActive([root, alyssa, bob, bobbi, alyce, alycia]); await setInactive([alyson, alice, alysha, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { }, { limit: 100 }, root, @@ -216,7 +216,7 @@ describe('UserSearchService', () => { await setActive([alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setInactive([alice, alyce, alycia]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, ); @@ -228,7 +228,7 @@ describe('UserSearchService', () => { test('[非ログイン] 非アクティブユーザのうち、"al"から始まる人が全員ヒットする', async () => { await setInactive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, ); @@ -240,7 +240,7 @@ describe('UserSearchService', () => { await createFollowings(root, [alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al', host: 'exam' }, { limit: 100 }, root, @@ -253,7 +253,7 @@ describe('UserSearchService', () => { await setActive([alice, alyce, alycia, alysha, alyson, alyssa, bob, bobbi, bobbie, bobby]); await setSuspended([alice, alyce, alycia]); - const result = await service.search( + const result = await service.searchByUsernameAndHost( { username: 'al' }, { limit: 100 }, root, diff --git a/packages/backend/test/unit/UserWebhookService.ts b/packages/backend/test/unit/UserWebhookService.ts index 0e88835a02..a2a85e9489 100644 --- a/packages/backend/test/unit/UserWebhookService.ts +++ b/packages/backend/test/unit/UserWebhookService.ts @@ -1,4 +1,3 @@ - /* * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only @@ -71,7 +70,7 @@ describe('UserWebhookService', () => { LoggerService, GlobalEventService, { - provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn() }), + provide: QueueService, useFactory: () => ({ userWebhookDeliver: jest.fn() }), }, ], }) @@ -92,7 +91,7 @@ describe('UserWebhookService', () => { } async function beforeEachImpl() { - root = await createUser({ isRoot: true, username: 'root', usernameLower: 'root' }); + root = await createUser({ username: 'root', usernameLower: 'root' }); } async function afterEachImpl() { @@ -242,4 +241,92 @@ describe('UserWebhookService', () => { }); }); }); + + describe('アプリを毎回作り直す必要があるグループ', () => { + beforeEach(async () => { + await beforeAllImpl(); + await beforeEachImpl(); + }); + + afterEach(async () => { + await afterEachImpl(); + await afterAllImpl(); + }); + + describe('enqueueUserWebhook', () => { + test('キューに追加成功', async () => { + const webhook = await createWebhook({ + active: true, + on: ['note'], + }); + await service.enqueueUserWebhook(webhook.userId, 'note', { foo: 'bar' } as any); + + expect(queueService.userWebhookDeliver).toHaveBeenCalledTimes(1); + expect(queueService.userWebhookDeliver.mock.calls[0][0] as MiWebhook).toEqual(webhook); + }); + + test('非アクティブなWebhookはキューに追加されない', async () => { + const webhook = await createWebhook({ + active: false, + on: ['note'], + }); + await service.enqueueUserWebhook(webhook.userId, 'note', { foo: 'bar' } as any); + + expect(queueService.userWebhookDeliver).not.toHaveBeenCalled(); + }); + + test('未許可のイベント種別が渡された場合はWebhookはキューに追加されない', async () => { + const webhook1 = await createWebhook({ + active: true, + on: [], + }); + const webhook2 = await createWebhook({ + active: true, + on: ['note'], + }); + await service.enqueueUserWebhook(webhook1.userId, 'renote', { foo: 'bar' } as any); + await service.enqueueUserWebhook(webhook2.userId, 'renote', { foo: 'bar' } as any); + + expect(queueService.userWebhookDeliver).not.toHaveBeenCalled(); + }); + + test('ユーザIDが異なるWebhookはキューに追加されない', async () => { + const webhook = await createWebhook({ + active: true, + on: ['note'], + }); + await service.enqueueUserWebhook(idService.gen(), 'note', { foo: 'bar' } as any); + + expect(queueService.userWebhookDeliver).not.toHaveBeenCalled(); + }); + + test('混在した時、有効かつ許可されたイベント種別のみ', async () => { + const userId = root.id; + const webhook1 = await createWebhook({ + userId, + active: true, + on: ['note'], + }); + const webhook2 = await createWebhook({ + userId, + active: true, + on: ['renote'], + }); + const webhook3 = await createWebhook({ + userId, + active: false, + on: ['note'], + }); + const webhook4 = await createWebhook({ + userId, + active: false, + on: ['renote'], + }); + await service.enqueueUserWebhook(userId, 'note', { foo: 'bar' } as any); + + expect(queueService.userWebhookDeliver).toHaveBeenCalledTimes(1); + expect(queueService.userWebhookDeliver.mock.calls[0][0] as MiWebhook).toEqual(webhook1); + }); + }); + }); }); diff --git a/packages/backend/test/unit/WebhookTestService.ts b/packages/backend/test/unit/WebhookTestService.ts index 5e63b86f8f..736aac40b4 100644 --- a/packages/backend/test/unit/WebhookTestService.ts +++ b/packages/backend/test/unit/WebhookTestService.ts @@ -7,13 +7,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { beforeAll, describe, jest } from '@jest/globals'; import { WebhookTestService } from '@/core/WebhookTestService.js'; -import { UserWebhookService } from '@/core/UserWebhookService.js'; +import { UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { GlobalModule } from '@/GlobalModule.js'; import { MiSystemWebhook, MiUser, MiWebhook, UserProfilesRepository, UsersRepository } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { DI } from '@/di-symbols.js'; import { QueueService } from '@/core/QueueService.js'; +import { CustomEmojiService } from '@/core/CustomEmojiService.js'; describe('WebhookTestService', () => { let app: TestingModule; @@ -56,6 +57,11 @@ describe('WebhookTestService', () => { providers: [ WebhookTestService, IdService, + { + provide: CustomEmojiService, useFactory: () => ({ + populateEmojis: jest.fn(), + }), + }, { provide: QueueService, useFactory: () => ({ systemWebhookDeliver: jest.fn(), @@ -88,8 +94,8 @@ describe('WebhookTestService', () => { }); beforeEach(async () => { - root = await createUser({ username: 'root', usernameLower: 'root', isRoot: true }); - alice = await createUser({ username: 'alice', usernameLower: 'alice', isRoot: false }); + root = await createUser({ username: 'root', usernameLower: 'root' }); + alice = await createUser({ username: 'alice', usernameLower: 'alice' }); userWebhookService.fetchWebhooks.mockReturnValue(Promise.resolve([ { id: 'dummy-webhook', active: true, userId: alice.id } as MiWebhook, @@ -122,7 +128,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('note'); - expect((calls[2] as any).id).toBe('dummy-note-1'); + expect((calls[2] as UserWebhookPayload<'note'>).note.id).toBe('dummy-note-1'); }); test('reply', async () => { @@ -131,7 +137,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('reply'); - expect((calls[2] as any).id).toBe('dummy-reply-1'); + expect((calls[2] as UserWebhookPayload<'reply'>).note.id).toBe('dummy-reply-1'); }); test('renote', async () => { @@ -140,7 +146,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('renote'); - expect((calls[2] as any).id).toBe('dummy-renote-1'); + expect((calls[2] as UserWebhookPayload<'renote'>).note.id).toBe('dummy-renote-1'); }); test('mention', async () => { @@ -149,7 +155,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('mention'); - expect((calls[2] as any).id).toBe('dummy-mention-1'); + expect((calls[2] as UserWebhookPayload<'mention'>).note.id).toBe('dummy-mention-1'); }); test('follow', async () => { @@ -158,7 +164,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('follow'); - expect((calls[2] as any).id).toBe('dummy-user-1'); + expect((calls[2] as UserWebhookPayload<'follow'>).user.id).toBe('dummy-user-1'); }); test('followed', async () => { @@ -167,7 +173,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('followed'); - expect((calls[2] as any).id).toBe('dummy-user-2'); + expect((calls[2] as UserWebhookPayload<'followed'>).user.id).toBe('dummy-user-2'); }); test('unfollow', async () => { @@ -176,7 +182,7 @@ describe('WebhookTestService', () => { const calls = queueService.userWebhookDeliver.mock.calls[0]; expect((calls[0] as any).id).toBe('dummy-webhook'); expect(calls[1]).toBe('unfollow'); - expect((calls[2] as any).id).toBe('dummy-user-3'); + expect((calls[2] as UserWebhookPayload<'unfollow'>).user.id).toBe('dummy-user-3'); }); describe('NoSuchWebhookError', () => { diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 763ce2b336..9df947982b 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -24,7 +24,6 @@ import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import { DownloadService } from '@/core/DownloadService.js'; -import { MetaService } from '@/core/MetaService.js'; import type { MiRemoteUser } from '@/models/User.js'; import { genAidx } from '@/misc/id/aidx.js'; import { MockResolver } from '../misc/mock-resolver.js'; @@ -107,7 +106,14 @@ describe('ActivityPub', () => { sensitiveWords: [] as string[], prohibitedWords: [] as string[], } as MiMeta; - let meta = metaInitial; + const meta = { ...metaInitial }; + + function updateMeta(newMeta: Partial): void { + for (const key in meta) { + delete (meta as any)[key]; + } + Object.assign(meta, newMeta); + } beforeAll(async () => { const app = await Test.createTestingModule({ @@ -120,11 +126,8 @@ describe('ActivityPub', () => { }; }, }) - .overrideProvider(MetaService).useValue({ - async fetch(): Promise { - return meta; - }, - }).compile(); + .overrideProvider(DI.meta).useFactory({ factory: () => meta }) + .compile(); await app.init(); app.enableShutdownHooks(); @@ -173,7 +176,7 @@ describe('ActivityPub', () => { resolver.register(actor.id, actor); resolver.register(post.id, post); - const note = await noteService.createNote(post.id, resolver, true); + const note = await noteService.createNote(post.id, undefined, resolver, true); assert.deepStrictEqual(note?.uri, post.id); assert.deepStrictEqual(note.visibility, 'public'); @@ -333,7 +336,7 @@ describe('ActivityPub', () => { resolver.register(actor.featured, featured); resolver.register(firstNote.id, firstNote); - const note = await noteService.createNote(firstNote.id as string, resolver); + const note = await noteService.createNote(firstNote.id as string, undefined, resolver); assert.strictEqual(note?.uri, firstNote.id); }); }); @@ -367,7 +370,7 @@ describe('ActivityPub', () => { }); test('cacheRemoteFiles=false disables caching', async () => { - meta = { ...metaInitial, cacheRemoteFiles: false }; + updateMeta({ ...metaInitial, cacheRemoteFiles: false }); const imageObject: IApDocument = { type: 'Document', @@ -396,7 +399,7 @@ describe('ActivityPub', () => { }); test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => { - meta = { ...metaInitial, cacheRemoteSensitiveFiles: false }; + updateMeta({ ...metaInitial, cacheRemoteSensitiveFiles: false }); const imageObject: IApDocument = { type: 'Document', diff --git a/packages/backend/test/unit/ap-request.ts b/packages/backend/test/unit/ap-request.ts index d3d39240dc..f8b2a697f2 100644 --- a/packages/backend/test/unit/ap-request.ts +++ b/packages/backend/test/unit/ap-request.ts @@ -8,6 +8,8 @@ import httpSignature from '@peertube/http-signature'; import { genRsaKeyPair } from '@/misc/gen-key-pair.js'; import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js'; +import { assertActivityMatchesUrl, FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; +import { IObject } from '@/core/activitypub/type.js'; export const buildParsedSignature = (signingString: string, signature: string, algorithm: string) => { return { @@ -24,6 +26,10 @@ export const buildParsedSignature = (signingString: string, signature: string, a }; }; +function cartesianProduct(a: T[], b: U[]): [T, U][] { + return a.flatMap(a => b.map(b => [a, b] as [T, U])); +} + describe('ap-request', () => { test('createSignedPost with verify', async () => { const keypair = await genRsaKeyPair(); @@ -58,4 +64,108 @@ describe('ap-request', () => { const result = httpSignature.verifySignature(parsed, keypair.publicKey); assert.deepStrictEqual(result, true); }); + + test('rejects non matching domain', () => { + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://alice.example.com/abc' } as IObject, + 'https://alice.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'validation should pass base case'); + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://bob.example.com/abc' } as IObject, + 'https://alice.example.com/abc', + FetchAllowSoftFailMask.Any, + ), 'validation should fail no matter what if the response URL is inconsistent with the object ID'); + + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc#test', + { id: 'https://alice.example.com/abc' } as IObject, + 'https://alice.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'validation should pass with hash in request URL'); + + // fix issues like threads + // https://github.com/misskey-dev/misskey/issues/15039 + const withOrWithoutWWW = [ + 'https://alice.example.com/abc', + 'https://www.alice.example.com/abc', + ]; + + cartesianProduct( + cartesianProduct( + withOrWithoutWWW, + withOrWithoutWWW, + ), + withOrWithoutWWW, + ).forEach(([[a, b], c]) => { + assert.doesNotThrow(() => assertActivityMatchesUrl( + a, + { id: b } as IObject, + c, + FetchAllowSoftFailMask.Strict, + ), 'validation should pass with or without www. subdomain'); + }); + }); + + test('cross origin lookup', () => { + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://bob.example.com/abc' } as IObject, + 'https://bob.example.com/abc', + FetchAllowSoftFailMask.CrossOrigin | FetchAllowSoftFailMask.NonCanonicalId, + ), 'validation should pass if the response is otherwise consistent and cross-origin is allowed'); + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://bob.example.com/abc' } as IObject, + 'https://bob.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'validation should fail if the response is otherwise consistent and cross-origin is not allowed'); + }); + + test('rejects non-canonical ID', () => { + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/@alice', + { id: 'https://alice.example.com/users/alice' } as IObject, + 'https://alice.example.com/users/alice', + FetchAllowSoftFailMask.Strict, + ), 'throws if the response ID did not exactly match the expected ID'); + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/@alice', + { id: 'https://alice.example.com/users/alice' } as IObject, + 'https://alice.example.com/users/alice', + FetchAllowSoftFailMask.NonCanonicalId, + ), 'does not throw if non-canonical ID is allowed'); + }); + + test('origin relaxed alignment', () => { + assert.doesNotThrow(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://ap.alice.example.com/abc' } as IObject, + 'https://ap.alice.example.com/abc', + FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId, + ), 'validation should pass if response is a subdomain of the expected origin'); + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.multi-tenant.example.com/abc', + { id: 'https://alice.multi-tenant.example.com/abc' } as IObject, + 'https://bob.multi-tenant.example.com/abc', + FetchAllowSoftFailMask.MisalignedOrigin | FetchAllowSoftFailMask.NonCanonicalId, + ), 'validation should fail if response is a disjoint domain of the expected origin'); + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://ap.alice.example.com/abc' } as IObject, + 'https://ap.alice.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'throws if relaxed origin is forbidden'); + }); + + test('resist HTTP downgrade', () => { + assert.throws(() => assertActivityMatchesUrl( + 'https://alice.example.com/abc', + { id: 'https://alice.example.com/abc' } as IObject, + 'http://alice.example.com/abc', + FetchAllowSoftFailMask.Strict, + ), 'throws if HTTP downgrade is detected'); + }); }); diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index e4f42809f8..6b7eedff55 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -50,6 +50,7 @@ import { AccountMoveService } from '@/core/AccountMoveService.js'; import { ReactionService } from '@/core/ReactionService.js'; import { NotificationService } from '@/core/NotificationService.js'; import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js'; +import { ChatService } from '@/core/ChatService.js'; process.env.NODE_ENV = 'test'; @@ -172,6 +173,7 @@ describe('UserEntityService', () => { ReactionService, ReactionsBufferingService, NotificationService, + ChatService, ]; app = await Test.createTestingModule({ diff --git a/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts new file mode 100644 index 0000000000..07618e7762 --- /dev/null +++ b/packages/backend/test/unit/queue/processors/CheckModeratorsActivityProcessorService.ts @@ -0,0 +1,383 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { jest } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as lolex from '@sinonjs/fake-timers'; +import { addHours, addSeconds, subDays, subHours, subSeconds } from 'date-fns'; +import { CheckModeratorsActivityProcessorService } from '@/queue/processors/CheckModeratorsActivityProcessorService.js'; +import { MiSystemWebhook, MiUser, MiUserProfile, UserProfilesRepository, UsersRepository } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { MetaService } from '@/core/MetaService.js'; +import { DI } from '@/di-symbols.js'; +import { QueueLoggerService } from '@/queue/QueueLoggerService.js'; +import { EmailService } from '@/core/EmailService.js'; +import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { SystemWebhookEventType } from '@/models/SystemWebhook.js'; + +const baseDate = new Date(Date.UTC(2000, 11, 15, 12, 0, 0)); + +describe('CheckModeratorsActivityProcessorService', () => { + let app: TestingModule; + let clock: lolex.InstalledClock; + let service: CheckModeratorsActivityProcessorService; + + // -------------------------------------------------------------------------------------- + + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let idService: IdService; + let roleService: jest.Mocked; + let announcementService: jest.Mocked; + let emailService: jest.Mocked; + let systemWebhookService: jest.Mocked; + + let systemWebhook1: MiSystemWebhook; + let systemWebhook2: MiSystemWebhook; + let systemWebhook3: MiSystemWebhook; + + // -------------------------------------------------------------------------------------- + + async function createUser(data: Partial = {}, profile: Partial = {}): Promise { + const id = idService.gen(); + const user = await usersRepository + .insert({ + id: id, + username: `user_${id}`, + usernameLower: `user_${id}`.toLowerCase(), + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + ...profile, + }); + + return user; + } + + function crateSystemWebhook(data: Partial = {}): MiSystemWebhook { + return { + id: idService.gen(), + isActive: true, + updatedAt: new Date(), + latestSentAt: null, + latestStatus: null, + name: 'test', + url: 'https://example.com', + secret: 'test', + on: [], + ...data, + }; + } + + function mockModeratorRole(users: MiUser[]) { + roleService.getModerators.mockReset(); + roleService.getModerators.mockResolvedValue(users); + } + + // -------------------------------------------------------------------------------------- + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + ], + providers: [ + CheckModeratorsActivityProcessorService, + IdService, + { + provide: RoleService, useFactory: () => ({ getModerators: jest.fn() }), + }, + { + provide: MetaService, useFactory: () => ({ fetch: jest.fn() }), + }, + { + provide: AnnouncementService, useFactory: () => ({ create: jest.fn() }), + }, + { + provide: EmailService, useFactory: () => ({ sendEmail: jest.fn() }), + }, + { + provide: SystemWebhookService, useFactory: () => ({ + fetchActiveSystemWebhooks: jest.fn(), + enqueueSystemWebhook: jest.fn(), + }), + }, + { + provide: QueueLoggerService, useFactory: () => ({ + logger: ({ + createSubLogger: () => ({ + info: jest.fn(), + warn: jest.fn(), + succ: jest.fn(), + }), + }), + }), + }, + ], + }) + .compile(); + + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + + service = app.get(CheckModeratorsActivityProcessorService); + idService = app.get(IdService); + roleService = app.get(RoleService) as jest.Mocked; + announcementService = app.get(AnnouncementService) as jest.Mocked; + emailService = app.get(EmailService) as jest.Mocked; + systemWebhookService = app.get(SystemWebhookService) as jest.Mocked; + + app.enableShutdownHooks(); + }); + + beforeEach(async () => { + clock = lolex.install({ + now: new Date(baseDate), + shouldClearNativeTimers: true, + }); + + systemWebhook1 = crateSystemWebhook({ on: ['inactiveModeratorsWarning'] }); + systemWebhook2 = crateSystemWebhook({ on: ['inactiveModeratorsWarning', 'inactiveModeratorsInvitationOnlyChanged'] }); + systemWebhook3 = crateSystemWebhook({ on: ['abuseReport'] }); + + emailService.sendEmail.mockReturnValue(Promise.resolve()); + announcementService.create.mockReturnValue(Promise.resolve({} as never)); + systemWebhookService.fetchActiveSystemWebhooks.mockResolvedValue([systemWebhook1, systemWebhook2, systemWebhook3]); + systemWebhookService.enqueueSystemWebhook.mockReturnValue(Promise.resolve({} as never)); + }); + + afterEach(async () => { + clock.uninstall(); + await usersRepository.delete({}); + await userProfilesRepository.delete({}); + roleService.getModerators.mockReset(); + announcementService.create.mockReset(); + emailService.sendEmail.mockReset(); + systemWebhookService.enqueueSystemWebhook.mockReset(); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('evaluateModeratorsInactiveDays', () => { + test('[isModeratorsInactive] inactiveなモデレーターがいても他のモデレーターがアクティブなら"運営が非アクティブ"としてみなされない', async () => { + const [user1, user2, user3, user4] = await Promise.all([ + // 期限よりも1秒新しいタイミングでアクティブ化(セーフ) + createUser({ lastActiveDate: subDays(addSeconds(baseDate, 1), 7) }), + // 期限ちょうどにアクティブ化(セーフ) + createUser({ lastActiveDate: subDays(baseDate, 7) }), + // 期限よりも1秒古いタイミングでアクティブ化(アウト) + createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }), + // 対象外 + createUser({ lastActiveDate: null }), + ]); + + mockModeratorRole([user1, user2, user3, user4]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user3]); + }); + + test('[isModeratorsInactive] 全員非アクティブなら"運営が非アクティブ"としてみなされる', async () => { + const [user1, user2] = await Promise.all([ + // 期限よりも1秒古いタイミングでアクティブ化(アウト) + createUser({ lastActiveDate: subDays(subSeconds(baseDate, 1), 7) }), + // 対象外 + createUser({ lastActiveDate: null }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(true); + expect(result.inactiveModerators).toEqual([user1]); + }); + + test('[remainingTime] 猶予まで24時間ある場合、猶予1日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限まで残り24時間->猶予1日として計算されるはずである + createUser({ lastActiveDate: subDays(baseDate, 6) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(1); + expect(result.remainingTime.asHours).toBe(24); + }); + + test('[remainingTime] 猶予まで25時間ある場合、猶予1日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限まで残り25時間->猶予1日として計算されるはずである + createUser({ lastActiveDate: subDays(addHours(baseDate, 1), 6) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(1); + expect(result.remainingTime.asHours).toBe(25); + }); + + test('[remainingTime] 猶予まで23時間ある場合、猶予0日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限まで残り23時間->猶予0日として計算されるはずである + createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 6) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(0); + expect(result.remainingTime.asHours).toBe(23); + }); + + test('[remainingTime] 期限ちょうどの場合、猶予0日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限ちょうど->猶予0日として計算されるはずである + createUser({ lastActiveDate: subDays(baseDate, 7) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(false); + expect(result.inactiveModerators).toEqual([user1]); + expect(result.remainingTime.asDays).toBe(0); + expect(result.remainingTime.asHours).toBe(0); + }); + + test('[remainingTime] 期限より1時間超過している場合、猶予-1日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 8) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限より1時間超過->猶予-1日として計算されるはずである + createUser({ lastActiveDate: subDays(subHours(baseDate, 1), 7) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(true); + expect(result.inactiveModerators).toEqual([user1, user2]); + expect(result.remainingTime.asDays).toBe(-1); + expect(result.remainingTime.asHours).toBe(-1); + }); + + test('[remainingTime] 期限より25時間超過している場合、猶予-2日として計算される', async () => { + const [user1, user2] = await Promise.all([ + createUser({ lastActiveDate: subDays(baseDate, 10) }), + // 猶予はこのユーザ基準で計算される想定。 + // 期限より1時間超過->猶予-1日として計算されるはずである + createUser({ lastActiveDate: subDays(subHours(baseDate, 25), 7) }), + ]); + + mockModeratorRole([user1, user2]); + + const result = await service.evaluateModeratorsInactiveDays(); + expect(result.isModeratorsInactive).toBe(true); + expect(result.inactiveModerators).toEqual([user1, user2]); + expect(result.remainingTime.asDays).toBe(-2); + expect(result.remainingTime.asHours).toBe(-25); + }); + }); + + describe('notifyInactiveModeratorsWarning', () => { + test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => { + const [user1, user2, user3, user4, root] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + createUser({}, { email: 'user2@example.com', emailVerified: false }), + createUser({}, { email: null, emailVerified: false }), + createUser({}, { email: 'user4@example.com', emailVerified: true }), + createUser({}, { email: 'root@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1, user2, user3, root]); + await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 }); + + expect(emailService.sendEmail).toHaveBeenCalledTimes(2); + expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com'); + expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com'); + }); + + test('[systemWebhook] "inactiveModeratorsWarning"が有効なSystemWebhookに対して送信される', async () => { + const [user1] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1]); + await service.notifyInactiveModeratorsWarning({ time: 1, asDays: 0, asHours: 0 }); + + // typeとactiveによる絞り込みが機能しているかはSystemWebhookServiceのテストで確認する. + // ここでは呼び出されているか、typeが正しいかのみを確認する + expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1); + expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0] as SystemWebhookEventType).toEqual('inactiveModeratorsWarning'); + }); + }); + + describe('notifyChangeToInvitationOnly', () => { + test('[notification + mail] 通知はモデレータ全員に発信され、メールはメールアドレスが存在+認証済みの場合のみ', async () => { + const [user1, user2, user3, user4, root] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + createUser({}, { email: 'user2@example.com', emailVerified: false }), + createUser({}, { email: null, emailVerified: false }), + createUser({}, { email: 'user4@example.com', emailVerified: true }), + createUser({}, { email: 'root@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1, user2, user3, root]); + await service.notifyChangeToInvitationOnly(); + + expect(announcementService.create).toHaveBeenCalledTimes(4); + expect(announcementService.create.mock.calls[0][0].userId).toBe(user1.id); + expect(announcementService.create.mock.calls[1][0].userId).toBe(user2.id); + expect(announcementService.create.mock.calls[2][0].userId).toBe(user3.id); + expect(announcementService.create.mock.calls[3][0].userId).toBe(root.id); + + expect(emailService.sendEmail).toHaveBeenCalledTimes(2); + expect(emailService.sendEmail.mock.calls[0][0]).toBe('user1@example.com'); + expect(emailService.sendEmail.mock.calls[1][0]).toBe('root@example.com'); + }); + + test('[systemWebhook] "inactiveModeratorsInvitationOnlyChanged"が有効なSystemWebhookに対して送信される', async () => { + const [user1] = await Promise.all([ + createUser({}, { email: 'user1@example.com', emailVerified: true }), + ]); + + mockModeratorRole([user1]); + await service.notifyChangeToInvitationOnly(); + + // typeとactiveによる絞り込みが機能しているかはSystemWebhookServiceのテストで確認する. + // ここでは呼び出されているか、typeが正しいかのみを確認する + expect(systemWebhookService.enqueueSystemWebhook).toHaveBeenCalledTimes(1); + expect(systemWebhookService.enqueueSystemWebhook.mock.calls[0][0] as SystemWebhookEventType).toEqual('inactiveModeratorsInvitationOnlyChanged'); + }); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 26de19eaf1..7eecf8bb0d 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -35,7 +35,7 @@ export type SystemWebhookPayload = { createdAt: string; type: string; body: any; -} +}; const config = loadConfig(); export const port = config.port; @@ -45,10 +45,6 @@ export const host = new URL(config.url).host; export const WEBHOOK_HOST = 'http://localhost:15080'; export const WEBHOOK_PORT = 15080; -export const cookie = (me: UserToken): string => { - return `token=${me.token};`; -}; - export type ApiRequest = { endpoint: E, parameters: P, diff --git a/packages/frontend-embed/eslint.config.js b/packages/frontend-embed/eslint.config.js index dd8f03dac5..7805256fd4 100644 --- a/packages/frontend-embed/eslint.config.js +++ b/packages/frontend-embed/eslint.config.js @@ -47,6 +47,7 @@ export default [ '@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'], diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index a65d6ab657..eef197b11e 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -4,82 +4,67 @@ "type": "module", "scripts": { "watch": "vite", - "dev": "vite --config vite.config.local-dev.ts --debug hmr", "build": "vite build", "typecheck": "vue-tsc --noEmit", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", "lint": "pnpm typecheck && pnpm eslint" }, "dependencies": { - "@discordapp/twemoji": "15.0.3", - "@github/webauthn-json": "2.1.1", + "@discordapp/twemoji": "15.1.0", "@rollup/plugin-json": "6.1.0", - "@rollup/plugin-replace": "5.0.7", - "@rollup/pluginutils": "5.1.0", - "@tabler/icons-webfont": "3.3.0", + "@rollup/plugin-replace": "6.0.2", + "@rollup/pluginutils": "5.1.4", + "@tabler/icons-webfont": "3.31.0", "@twemoji/parser": "15.1.1", - "@vitejs/plugin-vue": "5.1.0", - "@vue/compiler-sfc": "3.4.37", - "astring": "1.8.6", + "@vitejs/plugin-vue": "5.2.3", + "@vue/compiler-sfc": "3.5.13", + "astring": "1.9.0", "buraha": "0.0.1", - "compare-versions": "6.1.1", - "date-fns": "2.30.0", - "escape-regexp": "0.0.1", "estree-walker": "3.0.3", - "eventemitter3": "5.0.1", - "idb-keyval": "6.2.1", - "is-file-animated": "1.0.2", "mfm-js": "0.24.0", "misskey-js": "workspace:*", "frontend-shared": "workspace:*", - "punycode": "2.3.1", - "rollup": "4.19.1", - "sanitize-html": "2.13.0", - "sass": "1.77.8", - "shiki": "1.12.0", - "strict-event-emitter-types": "2.0.0", - "throttle-debounce": "5.0.2", + "punycode.js": "2.3.1", + "rollup": "4.36.0", + "sass": "1.86.0", + "shiki": "3.2.1", "tinycolor2": "1.6.0", - "tsc-alias": "1.8.10", + "tsc-alias": "1.8.11", "tsconfig-paths": "4.2.0", - "typescript": "5.5.4", - "uuid": "10.0.0", + "typescript": "5.8.2", + "uuid": "11.1.0", "json5": "2.2.3", - "vite": "5.3.5", - "vue": "3.4.37" + "vite": "6.2.3", + "vue": "3.5.13" }, "devDependencies": { - "@misskey-dev/summaly": "5.1.0", + "@misskey-dev/summaly": "5.2.0", "@testing-library/vue": "8.1.0", - "@types/escape-regexp": "0.0.3", - "@types/estree": "1.0.5", + "@types/estree": "1.0.6", "@types/micromatch": "4.0.9", - "@types/node": "20.14.12", - "@types/punycode": "2.1.4", - "@types/sanitize-html": "2.11.0", - "@types/throttle-debounce": "5.0.2", + "@types/node": "22.13.11", + "@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/tinycolor2": "1.4.6", - "@types/uuid": "10.0.0", - "@types/ws": "8.5.11", - "@typescript-eslint/eslint-plugin": "7.17.0", - "@typescript-eslint/parser": "7.17.0", - "@vitest/coverage-v8": "1.6.0", - "@vue/runtime-core": "3.4.37", - "acorn": "8.12.1", + "@types/ws": "8.18.0", + "@typescript-eslint/eslint-plugin": "8.27.0", + "@typescript-eslint/parser": "8.27.0", + "@vitest/coverage-v8": "3.0.9", + "@vue/runtime-core": "3.5.13", + "acorn": "8.14.1", "cross-env": "7.0.3", - "eslint-plugin-import": "2.29.1", - "eslint-plugin-vue": "9.27.0", - "fast-glob": "3.3.2", - "happy-dom": "10.0.3", + "eslint-plugin-import": "2.31.0", + "eslint-plugin-vue": "10.0.0", + "fast-glob": "3.3.3", + "happy-dom": "17.4.4", "intersection-observer": "0.12.2", - "micromatch": "4.0.7", - "msw": "2.3.4", - "nodemon": "3.1.4", - "prettier": "3.3.3", - "start-server-and-test": "2.0.4", + "micromatch": "4.0.8", + "msw": "2.7.3", + "nodemon": "3.1.9", + "prettier": "3.5.3", + "start-server-and-test": "2.0.11", "vite-plugin-turbosnap": "1.0.3", - "vue-component-type-helpers": "2.0.29", - "vue-eslint-parser": "9.4.3", - "vue-tsc": "2.0.29" + "vue-component-type-helpers": "2.2.8", + "vue-eslint-parser": "10.1.1", + "vue-tsc": "2.2.8" } } diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts index fcea7d32ea..c1b2b58beb 100644 --- a/packages/frontend-embed/src/boot.ts +++ b/packages/frontend-embed/src/boot.ts @@ -17,19 +17,23 @@ import { applyTheme, assertIsTheme } from '@/theme.js'; import { fetchCustomEmojis } from '@/custom-emojis.js'; import { DI } from '@/di.js'; import { serverMetadata } from '@/server-metadata.js'; -import { url } from '@@/js/config.js'; +import { url, version, locale, lang, updateLocale } from '@@/js/config.js'; import { parseEmbedParams } from '@@/js/embed-page.js'; import { postMessageToParentWindow, setIframeId } from '@/post-message.js'; +import { serverContext } from '@/server-context.js'; +import { i18n, updateI18n } from '@/i18n.js'; import type { Theme } from '@/theme.js'; console.log('Misskey Embed'); +//#region Embedパラメータの取得・パース const params = new URLSearchParams(location.search); const embedParams = parseEmbedParams(params); - if (_DEV_) console.log(embedParams); +//#endregion +//#region テーマ function parseThemeOrNull(theme: string | null): Theme | null { if (theme == null) return null; try { @@ -65,6 +69,23 @@ if (embedParams.colorMode === 'dark') { } }); } +//#endregion + +//#region Detect language & fetch translations +const localeVersion = localStorage.getItem('localeVersion'); +const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null); +if (localeOutdated) { + const res = await window.fetch(`/assets/locales/${lang}.${version}.json`); + if (res.status === 200) { + const newLocale = await res.text(); + const parsedNewLocale = JSON.parse(newLocale); + localStorage.setItem('locale', newLocale); + localStorage.setItem('localeVersion', version); + updateLocale(parsedNewLocale); + updateI18n(parsedNewLocale); + } +} +//#endregion // サイズの制限 document.documentElement.style.maxWidth = '500px'; @@ -89,6 +110,10 @@ const app = createApp( app.provide(DI.mediaProxy, new MediaProxy(serverMetadata, url)); +app.provide(DI.serverMetadata, serverMetadata); + +app.provide(DI.serverContext, serverContext); + app.provide(DI.embedParams, embedParams); // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 @@ -119,6 +144,27 @@ window.onunhandledrejection = null; removeSplash(); +//#region Self-XSS 対策メッセージ +console.log( + `%c${i18n.ts._selfXssPrevention.warning}`, + 'color: #f00; background-color: #ff0; font-size: 36px; padding: 4px;', +); +console.log( + `%c${i18n.ts._selfXssPrevention.title}`, + 'color: #f00; font-weight: 900; font-family: "Hiragino Sans W9", "Hiragino Kaku Gothic ProN", sans-serif; font-size: 24px;', +); +console.log( + `%c${i18n.ts._selfXssPrevention.description1}`, + 'font-size: 16px; font-weight: 700;', +); +console.log( + `%c${i18n.ts._selfXssPrevention.description2}`, + 'font-size: 16px;', + 'font-size: 20px; font-weight: 700; color: #f00;', +); +console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hub.net/docs/for-users/resources/self-xss/' })); +//#endregion + function removeSplash() { const splash = document.getElementById('splash'); if (splash) { diff --git a/packages/frontend-embed/src/components/EmAcct.vue b/packages/frontend-embed/src/components/EmAcct.vue index 6856b8272e..ff794d9b6e 100644 --- a/packages/frontend-embed/src/components/EmAcct.vue +++ b/packages/frontend-embed/src/components/EmAcct.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -37,7 +37,7 @@ const bgCss = bg.toRgbString(); display: inline-block; padding: 4px 8px 4px 4px; border-radius: 999px; - color: var(--mention); + color: var(--MI_THEME-mention); } .host { diff --git a/packages/frontend-embed/src/components/EmMfm.ts b/packages/frontend-embed/src/components/EmMfm.ts index b2bcf4597e..1f9ce9d4f4 100644 --- a/packages/frontend-embed/src/components/EmMfm.ts +++ b/packages/frontend-embed/src/components/EmMfm.ts @@ -3,9 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { VNode, h, SetupContext, provide } from 'vue'; +import { h, provide } from 'vue'; +import type { VNode, SetupContext } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; +import { host } from '@@/js/config.js'; import EmUrl from '@/components/EmUrl.vue'; import EmTime from '@/components/EmTime.vue'; import EmLink from '@/components/EmLink.vue'; @@ -13,7 +15,6 @@ import EmMention from '@/components/EmMention.vue'; import EmEmoji from '@/components/EmEmoji.vue'; import EmCustomEmoji from '@/components/EmCustomEmoji.vue'; import EmA from '@/components/EmA.vue'; -import { host } from '@@/js/config.js'; function safeParseFloat(str: unknown): number | null { if (typeof str !== 'string' || str === '') return null; @@ -26,8 +27,8 @@ const QUOTE_STYLE = ` display: block; margin: 8px; padding: 6px 0 6px 12px; -color: var(--fg); -border-left: solid 3px var(--fg); +color: var(--MI_THEME-fg); +border-left: solid 3px var(--MI_THEME-fg); opacity: 0.7; `.split('\n').join(' '); @@ -41,9 +42,6 @@ type MfmProps = { rootScale?: number; nyaize?: boolean | 'respect'; parsedNodes?: mfm.MfmNode[] | null; - enableEmojiMenu?: boolean; - enableEmojiMenuReaction?: boolean; - linkNavigationBehavior?: string; }; type MfmEvents = { @@ -52,8 +50,6 @@ type MfmEvents = { // eslint-disable-next-line import/no-default-export export default function (props: MfmProps, { emit }: { emit: SetupContext['emit'] }) { - provide('linkNavigationBehavior', props.linkNavigationBehavior); - const isNote = props.isNote ?? true; const shouldNyaize = props.nyaize ? props.nyaize === 'respect' ? props.author?.isCat : false : false; @@ -256,7 +252,7 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext= 2.5, - menu: props.enableEmojiMenu, - menuReaction: props.enableEmojiMenuReaction, fallbackToImage: false, })]; } else { @@ -422,8 +416,6 @@ export default function (props: MfmProps, { emit }: { emit: SetupContext @@ -44,6 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
+

@@ -107,10 +108,13 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, inject, ref, shallowRef } from 'vue'; import * as mfm from 'mfm-js'; import * as Misskey from 'misskey-js'; +import { shouldCollapsed } from '@@/js/collapsed.js'; +import { url } from '@@/js/config.js'; import I18n from '@/components/I18n.vue'; import EmNoteSub from '@/components/EmNoteSub.vue'; import EmNoteHeader from '@/components/EmNoteHeader.vue'; import EmNoteSimple from '@/components/EmNoteSimple.vue'; +import EmInstanceTicker from '@/components/EmInstanceTicker.vue'; import EmReactionsViewer from '@/components/EmReactionsViewer.vue'; import EmMediaList from '@/components/EmMediaList.vue'; import EmPoll from '@/components/EmPoll.vue'; @@ -121,8 +125,6 @@ import EmUserName from '@/components/EmUserName.vue'; import EmTime from '@/components/EmTime.vue'; import { userPage } from '@/utils.js'; import { i18n } from '@/i18n.js'; -import { shouldCollapsed } from '@@/js/collapsed.js'; -import { url } from '@@/js/config.js'; function getAppearNote(note: Misskey.entities.Note) { return Misskey.note.isPureRenote(note) ? note.renote : note; @@ -162,14 +164,8 @@ const isDeleted = ref(false); font-size: 1.05em; overflow: clip; contain: content; - - // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 - // 下の方までスクロールすると上のノートの高さがここで決め打ちされたものに変化し、表示しているノートの位置が変わってしまう - // ノートがマウントされたときに自身の高さを取得し contain-intrinsic-size を設定しなおせばほぼ解決できそうだが、 - // 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる - // 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?) - //content-visibility: auto; - //contain-intrinsic-size: 0 128px; + content-visibility: auto; + contain-intrinsic-size: 0 150px; &:focus-visible { outline: none; @@ -187,8 +183,8 @@ const isDeleted = ref(false); margin: auto; width: calc(100% - 8px); height: calc(100% - 8px); - border: dashed 2px var(--focus); - border-radius: var(--radius); + border: dashed 2px var(--MI_THEME-focus); + border-radius: var(--MI-radius); box-sizing: border-box; } } @@ -210,9 +206,9 @@ const isDeleted = ref(false); right: 12px; padding: 0 4px; margin-bottom: 0 !important; - background: var(--popup); + background: var(--MI_THEME-popup); border-radius: 8px; - box-shadow: 0px 4px 32px var(--shadow); + box-shadow: 0px 4px 32px var(--MI_THEME-shadow); } .footerButton { @@ -257,7 +253,7 @@ const isDeleted = ref(false); padding: 16px 32px 8px 32px; line-height: 28px; white-space: pre; - color: var(--renote); + color: var(--MI_THEME-renote); & + .article { padding-top: 8px; @@ -354,7 +350,7 @@ const isDeleted = ref(false); width: 58px; height: 58px; position: sticky !important; - top: calc(22px + var(--stickyTop, 0px)); + top: calc(22px + var(--MI-stickyTop, 0px)); left: 0; } @@ -375,12 +371,12 @@ const isDeleted = ref(false); width: 100%; margin-top: 14px; position: sticky; - bottom: calc(var(--stickyBottom, 0px) + 14px); + bottom: calc(var(--MI-stickyBottom, 0px) + 14px); } .showLessLabel { display: inline-block; - background: var(--popup); + background: var(--MI_THEME-popup); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -401,16 +397,16 @@ const isDeleted = ref(false); z-index: 2; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0)); + background: linear-gradient(0deg, var(--MI_THEME-panel), color(from var(--MI_THEME-panel) srgb r g b / 0)); &:hover > .collapsedLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } .collapsedLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -422,13 +418,13 @@ const isDeleted = ref(false); } .replyIcon { - color: var(--accent); + color: var(--MI_THEME-accent); margin-right: 0.5em; } .translation { - border: solid 0.5px var(--divider); - border-radius: var(--radius); + border: solid 0.5px var(--MI_THEME-divider); + border-radius: var(--MI-radius); padding: 12px; margin-top: 8px; } @@ -447,7 +443,7 @@ const isDeleted = ref(false); .quoteNote { padding: 16px; - border: dashed 1px var(--renote); + border: dashed 1px var(--MI_THEME-renote); border-radius: 8px; overflow: clip; } @@ -471,7 +467,7 @@ const isDeleted = ref(false); } &:hover { - color: var(--fgHighlighted); + color: var(--MI_THEME-fgHighlighted); } } @@ -548,7 +544,7 @@ const isDeleted = ref(false); margin: 0 10px 0 0; width: 46px; height: 46px; - top: calc(14px + var(--stickyTop, 0px)); + top: calc(14px + var(--MI-stickyTop, 0px)); } } diff --git a/packages/frontend-embed/src/components/EmNoteDetailed.vue b/packages/frontend-embed/src/components/EmNoteDetailed.vue index 8169f500a9..b39b47c065 100644 --- a/packages/frontend-embed/src/components/EmNoteDetailed.vue +++ b/packages/frontend-embed/src/components/EmNoteDetailed.vue @@ -54,6 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only

+
@@ -132,6 +133,7 @@ import I18n from '@/components/I18n.vue'; import EmMediaList from '@/components/EmMediaList.vue'; import EmNoteSub from '@/components/EmNoteSub.vue'; import EmNoteSimple from '@/components/EmNoteSimple.vue'; +import EmInstanceTicker from '@/components/EmInstanceTicker.vue'; import EmReactionsViewer from '@/components/EmReactionsViewer.vue'; import EmPoll from '@/components/EmPoll.vue'; import EmA from '@/components/EmA.vue'; @@ -142,8 +144,8 @@ import EmAcct from '@/components/EmAcct.vue'; import { userPage } from '@/utils.js'; import { notePage } from '@/utils.js'; import { i18n } from '@/i18n.js'; +import { DI } from '@/di.js'; import { shouldCollapsed } from '@@/js/collapsed.js'; -import { serverMetadata } from '@/server-metadata.js'; import { url } from '@@/js/config.js'; import EmMfm from '@/components/EmMfm.js'; @@ -151,6 +153,8 @@ const props = defineProps<{ note: Misskey.entities.Note; }>(); +const serverMetadata = inject(DI.serverMetadata)!; + const inChannel = inject('inChannel', null); const note = ref(props.note); @@ -191,7 +195,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); padding: 16px 32px 8px 32px; line-height: 28px; white-space: pre; - color: var(--renote); + color: var(--MI_THEME-renote); } .renoteAvatar { @@ -277,7 +281,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); padding: 4px 6px; font-size: 80%; line-height: 1; - border: solid 0.5px var(--divider); + border: solid 0.5px var(--MI_THEME-divider); border-radius: 4px; } @@ -319,14 +323,14 @@ const collapsed = ref(appearNote.value.cw == null && isLong); } .noteReplyTarget { - color: var(--accent); + color: var(--MI_THEME-accent); margin-right: 0.5em; } .rn { margin-left: 4px; font-style: oblique; - color: var(--renote); + color: var(--MI_THEME-renote); } .reactionOmitted { @@ -346,7 +350,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); .quoteNote { padding: 16px; - border: dashed 1px var(--renote); + border: dashed 1px var(--MI_THEME-renote); border-radius: 8px; overflow: clip; } @@ -360,12 +364,12 @@ const collapsed = ref(appearNote.value.cw == null && isLong); width: 100%; margin-top: 14px; position: sticky; - bottom: calc(var(--stickyBottom, 0px) + 14px); + bottom: calc(var(--MI-stickyBottom, 0px) + 14px); } .showLessLabel { display: inline-block; - background: var(--popup); + background: var(--MI_THEME-popup); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -386,16 +390,16 @@ const collapsed = ref(appearNote.value.cw == null && isLong); z-index: 2; width: 100%; height: 64px; - background: linear-gradient(0deg, var(--panel), var(--X15)); + background: linear-gradient(0deg, var(--MI_THEME-panel), var(--MI_THEME-X15)); &:hover > .collapsedLabel { - background: var(--panelHighlight); + background: var(--MI_THEME-panelHighlight); } } .collapsedLabel { display: inline-block; - background: var(--panel); + background: var(--MI_THEME-panel); padding: 6px 10px; font-size: 0.8em; border-radius: 999px; @@ -418,7 +422,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); } &:hover { - color: var(--fgHighlighted); + color: var(--MI_THEME-fgHighlighted); } } @@ -434,7 +438,7 @@ const collapsed = ref(appearNote.value.cw == null && isLong); opacity: 0.7; &.reacted { - color: var(--accent); + color: var(--MI_THEME-accent); } } diff --git a/packages/frontend-embed/src/components/EmNoteHeader.vue b/packages/frontend-embed/src/components/EmNoteHeader.vue index e4add9501f..85b4aac071 100644 --- a/packages/frontend-embed/src/components/EmNoteHeader.vue +++ b/packages/frontend-embed/src/components/EmNoteHeader.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
bot
- +
@@ -72,7 +72,7 @@ defineProps<{ margin: 0 .5em 0 0; padding: 1px 6px; font-size: 80%; - border: solid 0.5px var(--divider); + border: solid 0.5px var(--MI_THEME-divider); border-radius: 3px; } diff --git a/packages/frontend-embed/src/components/EmNoteSimple.vue b/packages/frontend-embed/src/components/EmNoteSimple.vue index 704a876e59..b9aaf3fa4a 100644 --- a/packages/frontend-embed/src/components/EmNoteSimple.vue +++ b/packages/frontend-embed/src/components/EmNoteSimple.vue @@ -53,7 +53,7 @@ const showContent = ref(false); height: 34px; border-radius: 8px; position: sticky !important; - top: calc(16px + var(--stickyTop, 0px)); + top: calc(16px + var(--MI-stickyTop, 0px)); left: 0; } diff --git a/packages/frontend-embed/src/components/EmNoteSub.vue b/packages/frontend-embed/src/components/EmNoteSub.vue index f60aea3e7e..59be8608e0 100644 --- a/packages/frontend-embed/src/components/EmNoteSub.vue +++ b/packages/frontend-embed/src/components/EmNoteSub.vue @@ -123,7 +123,7 @@ if (props.detail) { } .reply, .more { - border-left: solid 0.5px var(--divider); + border-left: solid 0.5px var(--MI_THEME-divider); margin-top: 10px; } @@ -144,7 +144,7 @@ if (props.detail) { .muted { text-align: center; padding: 8px !important; - border: 1px solid var(--divider); + border: 1px solid var(--MI_THEME-divider); margin: 8px 8px 0 8px; border-radius: 8px; } diff --git a/packages/frontend-embed/src/components/EmNotes.vue b/packages/frontend-embed/src/components/EmNotes.vue index 6370f4aeae..962a982fb7 100644 --- a/packages/frontend-embed/src/components/EmNotes.vue +++ b/packages/frontend-embed/src/components/EmNotes.vue @@ -13,19 +13,21 @@ SPDX-License-Identifier: AGPL-3.0-only - - diff --git a/packages/frontend-embed/src/pages/clip.vue b/packages/frontend-embed/src/pages/clip.vue index 957d425d93..f4d4e8cf6f 100644 --- a/packages/frontend-embed/src/pages/clip.vue +++ b/packages/frontend-embed/src/pages/clip.vue @@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts index 9df957f3ec..b62096bbe9 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index a634a748e9..dbac5e9dd7 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts index ec24b8c240..64ccb708aa 100644 --- a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -6,13 +6,13 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { action } from '@storybook/addon-actions'; import { expect, userEvent, waitFor, within } from '@storybook/test'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; import MkAutocomplete from './MkAutocomplete.vue'; import MkInput from './MkInput.vue'; -import { tick } from '@/scripts/test-utils.js'; +import { tick } from '@/utility/test-utils.js'; const common = { render(args) { return { diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index f547991369..03cf107231 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -44,26 +44,28 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -407,16 +411,16 @@ onBeforeUnmount(() => { text-overflow: ellipsis; &:hover { - background: var(--X3); + background: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05)); } &[data-selected='true'] { - background: var(--accent); + background: var(--MI_THEME-accent); color: #fff !important; } &:active { - background: var(--accentDarken); + background: var(--MI_THEME-accentDarken); color: #fff !important; } } diff --git a/packages/frontend/src/components/MkAvatars.stories.impl.ts b/packages/frontend/src/components/MkAvatars.stories.impl.ts index d2a4a9f03b..6e20294438 100644 --- a/packages/frontend/src/components/MkAvatars.stories.impl.ts +++ b/packages/frontend/src/components/MkAvatars.stories.impl.ts @@ -4,7 +4,7 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { HttpResponse, http } from 'msw'; import { userDetailed } from '../../.storybook/fakes.js'; import { commonHandlers } from '../../.storybook/mocks.js'; diff --git a/packages/frontend/src/components/MkAvatars.vue b/packages/frontend/src/components/MkAvatars.vue index 8236d0ddb9..1c44ed60d8 100644 --- a/packages/frontend/src/components/MkAvatars.vue +++ b/packages/frontend/src/components/MkAvatars.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkCodeInline.stories.impl.ts b/packages/frontend/src/components/MkCodeInline.stories.impl.ts index 51d4d106ff..c17be177cb 100644 --- a/packages/frontend/src/components/MkCodeInline.stories.impl.ts +++ b/packages/frontend/src/components/MkCodeInline.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkCodeInline from './MkCodeInline.vue'; export const Default = { render(args) { diff --git a/packages/frontend/src/components/MkCodeInline.vue b/packages/frontend/src/components/MkCodeInline.vue index 6add80d1bc..04b6e54108 100644 --- a/packages/frontend/src/components/MkCodeInline.vue +++ b/packages/frontend/src/components/MkCodeInline.vue @@ -18,7 +18,7 @@ const props = defineProps<{ display: inline-block; font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; overflow-wrap: anywhere; - background: var(--bg); + background: var(--MI_THEME-bg); padding: .1em; border-radius: .3em; } diff --git a/packages/frontend/src/components/MkColorInput.stories.impl.ts b/packages/frontend/src/components/MkColorInput.stories.impl.ts index 61383e2cae..3df92ca858 100644 --- a/packages/frontend/src/components/MkColorInput.stories.impl.ts +++ b/packages/frontend/src/components/MkColorInput.stories.impl.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { action } from '@storybook/addon-actions'; import MkColorInput from './MkColorInput.vue'; export const Default = { diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index f5c580789b..50931cc318 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkDivider.vue b/packages/frontend/src/components/MkDivider.vue index e4e3af99e4..f72f091383 100644 --- a/packages/frontend/src/components/MkDivider.vue +++ b/packages/frontend/src/components/MkDivider.vue @@ -27,6 +27,6 @@ defineProps<{ diff --git a/packages/frontend/src/components/MkDonation.stories.impl.ts b/packages/frontend/src/components/MkDonation.stories.impl.ts index 27d6b7df6c..71d0c20c63 100644 --- a/packages/frontend/src/components/MkDonation.stories.impl.ts +++ b/packages/frontend/src/components/MkDonation.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { onBeforeUnmount } from 'vue'; import MkDonation from './MkDonation.vue'; import { instance } from '@/instance.js'; diff --git a/packages/frontend/src/components/MkDonation.vue b/packages/frontend/src/components/MkDonation.vue index 098be07a8c..0e0da64750 100644 --- a/packages/frontend/src/components/MkDonation.vue +++ b/packages/frontend/src/components/MkDonation.vue @@ -65,12 +65,12 @@ function neverShow() { .root { position: fixed; z-index: v-bind(zIndex); - bottom: var(--margin); + bottom: var(--MI-margin); left: 0; right: 0; margin: auto; box-sizing: border-box; - width: calc(100% - (var(--margin) * 2)); + width: calc(100% - (var(--MI-margin) * 2)); max-width: 500px; display: flex; } @@ -79,7 +79,7 @@ function neverShow() { text-align: center; padding-top: 25px; width: 100px; - color: var(--accent); + color: var(--MI_THEME-accent); } @media (max-width: 500px) { .icon { diff --git a/packages/frontend/src/components/MkDrive.file.stories.impl.ts b/packages/frontend/src/components/MkDrive.file.stories.impl.ts index 5f6e6a0667..933383775c 100644 --- a/packages/frontend/src/components/MkDrive.file.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.file.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import MkDrive_file from './MkDrive.file.vue'; import { file } from '../../.storybook/fakes.js'; export const Default = { diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index 90284890a5..c54d9eb4d5 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -44,10 +44,10 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue'; import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; -import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; -import { deviceKind } from '@/scripts/device-kind.js'; -import { useRouter } from '@/router/supplier.js'; +import { $i } from '@/i.js'; +import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js'; +import { deviceKind } from '@/utility/device-kind.js'; +import { useRouter } from '@/router.js'; const router = useRouter(); @@ -148,14 +148,14 @@ function onDragend() { } &.isSelected { - background: var(--accent); + background: var(--MI_THEME-accent); &:hover { - background: var(--accentLighten); + background: var(--MI_THEME-accentLighten); } &:active { - background: var(--accentDarken); + background: var(--MI_THEME-accentDarken); } > .label { @@ -244,7 +244,7 @@ function onDragend() { font-size: 0.8em; text-align: center; word-break: break-all; - color: var(--fg); + color: var(--MI_THEME-fg); overflow: hidden; } diff --git a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts index 5f8ef48520..e6c7c2f645 100644 --- a/packages/frontend/src/components/MkDrive.folder.stories.impl.ts +++ b/packages/frontend/src/components/MkDrive.folder.stories.impl.ts @@ -4,7 +4,7 @@ */ import { action } from '@storybook/addon-actions'; -import { StoryObj } from '@storybook/vue3'; +import type { StoryObj } from '@storybook/vue3'; import { http, HttpResponse } from 'msw'; import * as Misskey from 'misskey-js'; import MkDrive_folder from './MkDrive.folder.vue'; diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index d6dfaf34e5..9c72691d21 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ folder.name }}

-

+

{{ i18n.ts.uploadFolder }}

@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 8e3c19bd12..3e5a88a170 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -12,13 +12,13 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ item.text }}
{{ item.indicateValue }} - +
{{ item.text }}
{{ item.indicateValue }} - +
@@ -27,11 +27,11 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -153,7 +198,7 @@ function showMenu(ev: MouseEvent) { height: 100%; pointer-events: none; border-radius: inherit; - box-shadow: inset 0 0 0 4px var(--warn); + box-shadow: inset 0 0 0 4px var(--MI_THEME-warn); } } @@ -174,8 +219,8 @@ function showMenu(ev: MouseEvent) { display: block; position: absolute; border-radius: 6px; - background-color: var(--fg); - color: var(--accentLighten); + background-color: var(--MI_THEME-fg); + color: var(--MI_THEME-accentLighten); font-size: 12px; opacity: .5; padding: 5px 8px; @@ -194,19 +239,28 @@ function showMenu(ev: MouseEvent) { .visible { position: relative; - //box-shadow: 0 0 0 1px var(--divider) inset; - background: var(--bg); - background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%); + //box-shadow: 0 0 0 1px var(--MI_THEME-divider) inset; + background: var(--MI_THEME-bg); background-size: 16px 16px; } +html[data-color-scheme=dark] .visible { + --c: rgb(255 255 255 / 2%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); +} + +html[data-color-scheme=light] .visible { + --c: rgb(0 0 0 / 2%); + background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%); +} + .menu { display: block; position: absolute; border-radius: 999px; background-color: rgba(0, 0, 0, 0.3); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); color: #fff; font-size: 0.8em; width: 28px; @@ -237,10 +291,10 @@ function showMenu(ev: MouseEvent) { } .indicator { - /* Hardcode to black because either --bg or --fg makes it hard to read in dark/light mode */ + /* Hardcode to black because either --MI_THEME-bg or --MI_THEME-fg makes it hard to read in dark/light mode */ background-color: black; border-radius: 6px; - color: var(--accentLighten); + color: var(--MI_THEME-accentLighten); display: inline-block; font-weight: bold; font-size: 0.8em; diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 4a4a99be25..4a1100c324 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -12,9 +12,9 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[ $style.medias, count === 1 ? [$style.n1, { - [$style.n116_9]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '16_9', - [$style.n11_1]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '1_1', - [$style.n12_3]: defaultStore.reactiveState.mediaListWithOneImageAppearance.value === '2_3', + [$style.n116_9]: prefer.s.mediaListWithOneImageAppearance === '16_9', + [$style.n11_1]: prefer.s.mediaListWithOneImageAppearance === '1_1', + [$style.n12_3]: prefer.s.mediaListWithOneImageAppearance === '2_3', }] : count === 2 ? $style.n2 : count === 3 ? $style.n3 : count === 4 ? $style.n4 : $style.nMany, ]" > @@ -28,27 +28,27 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -200,13 +243,15 @@ const emit = defineEmits<{ (ev: 'hide'): void; }>(); +const big = isTouchUsing; + const isNestingMenu = inject('isNestingMenu', false); -const itemsEl = shallowRef(); +const itemsEl = useTemplateRef('itemsEl'); const items2 = ref(); -const child = shallowRef>(); +const child = useTemplateRef('child'); const keymap = { 'up|k|shift+tab': { @@ -247,7 +292,7 @@ watch(() => props.items, () => { }); const childMenu = ref(); -const childTarget = shallowRef(); +const childTarget = shallowRef(); function closeChild() { childMenu.value = null; @@ -297,6 +342,8 @@ async function showRadioOptions(item: MenuRadio, ev: Event) { } async function showChildren(item: MenuParent, ev: Event) { + ev.stopPropagation(); + const children: MenuItem[] = await (async () => { if (childrenCache.has(item)) { return childrenCache.get(item)!; @@ -346,10 +393,10 @@ function switchItem(item: MenuSwitch & { ref: any }) { function focusUp() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1); const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -358,10 +405,10 @@ function focusUp() { function focusDown() { if (disposed) return; - if (!itemsEl.value?.contains(document.activeElement)) return; + if (!itemsEl.value?.contains(window.document.activeElement)) return; const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable); - const activeIndex = focusableElements.findIndex(el => el === document.activeElement); + const activeIndex = focusableElements.findIndex(el => el === window.document.activeElement); const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0; const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value; @@ -388,9 +435,9 @@ const onGlobalMousedown = (ev: MouseEvent) => { const setupHandlers = () => { if (!isNestingMenu) { - document.addEventListener('focusin', onGlobalFocusin, { passive: true }); + window.document.addEventListener('focusin', onGlobalFocusin, { passive: true }); } - document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); + window.document.addEventListener('mousedown', onGlobalMousedown, { passive: true }); }; let disposed = false; @@ -398,9 +445,9 @@ let disposed = false; const disposeHandlers = () => { disposed = true; if (!isNestingMenu) { - document.removeEventListener('focusin', onGlobalFocusin); + window.document.removeEventListener('focusin', onGlobalFocusin); } - document.removeEventListener('mousedown', onGlobalMousedown); + window.document.removeEventListener('mousedown', onGlobalMousedown); }; onMounted(() => { @@ -418,6 +465,66 @@ onBeforeUnmount(() => { diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index eca94e99d8..ab70a11b9b 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -9,13 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only v-show="!isDeleted" ref="rootEl" v-hotkey="keymap" - :class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]" + :class="[$style.root, { [$style.showActionsOnlyHover]: prefer.s.showNoteActionsOnlyHover, [$style.skipRender]: prefer.s.skipNoteRender }]" :tabindex="isDeleted ? '-1' : '0'" >
{{ i18n.ts.pinnedNote }}
- -
@@ -47,13 +45,20 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
- +

- +

@@ -69,19 +74,20 @@ SPDX-License-Identifier: AGPL-3.0-only :emojiUrls="appearNote.emojis" :enableEmojiMenu="true" :enableEmojiMenuReaction="true" + class="_selectable" />
{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: - +
- +
@@ -119,13 +125,13 @@ SPDX-License-Identifier: AGPL-3.0-only -
+ + + + + + diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index c3f3c42b42..e684cf2a30 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -51,7 +51,7 @@ const showContent = ref(false); height: 34px; border-radius: 8px; position: sticky !important; - top: calc(16px + var(--stickyTop, 0px)); + top: calc(16px + var(--MI-stickyTop, 0px)); left: 0; } diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 829b37e7a7..4fd1c210cb 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -46,11 +46,11 @@ import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; import { notePage } from '@/filters/note.js'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; +import { $i } from '@/i.js'; import { userPage } from '@/filters/user.js'; -import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { checkWordMute } from '@/utility/check-word-mute.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -135,7 +135,7 @@ if (props.detail) { } .reply, .more { - border-left: solid 0.5px var(--divider); + border-left: solid 0.5px var(--MI_THEME-divider); margin-top: 10px; } @@ -156,7 +156,7 @@ if (props.detail) { .muted { text-align: center; padding: 8px !important; - border: 1px solid var(--divider); + border: 1px solid var(--MI_THEME-divider); margin: 8px 8px 0 8px; border-radius: 8px; } diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 0856c146ba..ad4844fd1b 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -7,37 +7,40 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 738cba2134..c2e8b8e2fe 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
- +
@@ -37,6 +40,10 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue index e749725fea..2abf8669ed 100644 --- a/packages/frontend/src/components/MkPasswordDialog.vue +++ b/packages/frontend/src/components/MkPasswordDialog.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- 🔐 + 🔐
{{ i18n.ts.authenticationRequiredToContinue }}
@@ -39,14 +39,14 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkPoll.vue b/packages/frontend/src/components/MkPoll.vue index e1d5db2730..2d3ec45bca 100644 --- a/packages/frontend/src/components/MkPoll.vue +++ b/packages/frontend/src/components/MkPoll.vue @@ -9,8 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
  • - - + + ({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})
  • @@ -29,19 +29,21 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 039393887d..e43ff65e1d 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
    - - +
    - -
    {{ i18n.ts.quoteAttached }}
    + +
    {{ i18n.ts.quoteAttached }}
    {{ i18n.ts.recipient }}
    @@ -65,10 +61,13 @@ SPDX-License-Identifier: AGPL-3.0-only
    {{ i18n.ts.notSpecifiedMentionWarning }} - - +
    + +
    {{ maxCwTextLength - cwTextLength }}
    +
    - +
    +
    {{ file.name }}
    +
    + + + +
    +
    + +
    + + + + + diff --git a/packages/frontend/src/pages/chat/room.info.vue b/packages/frontend/src/pages/chat/room.info.vue new file mode 100644 index 0000000000..7e10336fd3 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.info.vue @@ -0,0 +1,104 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.members.vue b/packages/frontend/src/pages/chat/room.members.vue new file mode 100644 index 0000000000..2b31efab38 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.members.vue @@ -0,0 +1,99 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.search.vue b/packages/frontend/src/pages/chat/room.search.vue new file mode 100644 index 0000000000..de5e7156ca --- /dev/null +++ b/packages/frontend/src/pages/chat/room.search.vue @@ -0,0 +1,68 @@ + + + + + + + diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue new file mode 100644 index 0000000000..ec92a1dce1 --- /dev/null +++ b/packages/frontend/src/pages/chat/room.vue @@ -0,0 +1,466 @@ + + + + + + + diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue index 9e9b5e8688..479204f39b 100644 --- a/packages/frontend/src/pages/clicker.vue +++ b/packages/frontend/src/pages/clicker.vue @@ -4,19 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only --> @@ -243,9 +244,9 @@ async function del() { bottom: 0; left: 0; padding: 12px; - border-top: solid 0.5px var(--divider); - background: var(--acrylicBg); - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); + border-top: solid 0.5px var(--MI_THEME-divider); + background: var(--MI_THEME-acrylicBg); + -webkit-backdrop-filter: var(--MI-blur, blur(15px)); + backdrop-filter: var(--MI-blur, blur(15px)); } diff --git a/packages/frontend/src/pages/emojis.emoji.vue b/packages/frontend/src/pages/emojis.emoji.vue index 97429c29a4..d5570eb20a 100644 --- a/packages/frontend/src/pages/emojis.emoji.vue +++ b/packages/frontend/src/pages/emojis.emoji.vue @@ -15,18 +15,22 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index d282ed4810..c2f66c0e4d 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -4,13 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only --> + diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index f63a799365..98ab587b55 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -4,11 +4,10 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/my-antennas/create.vue b/packages/frontend/src/pages/my-antennas/create.vue index 2b8518747f..8f331f1333 100644 --- a/packages/frontend/src/pages/my-antennas/create.vue +++ b/packages/frontend/src/pages/my-antennas/create.vue @@ -4,19 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only --> diff --git a/packages/frontend/src/pages/not-found.vue b/packages/frontend/src/pages/not-found.vue index 93a792c42f..684a3bb5bd 100644 --- a/packages/frontend/src/pages/not-found.vue +++ b/packages/frontend/src/pages/not-found.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue index e80d5fd399..2fbc9ab4b3 100644 --- a/packages/frontend/src/ui/_common_/navbar-for-mobile.vue +++ b/packages/frontend/src/ui/_common_/navbar-for-mobile.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
    {{ navbarItemDef[item].title }} - + {{ navbarItemDef[item].indicateValue }} @@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.settings }} @@ -53,12 +53,13 @@ import { computed, defineAsyncComponent, toRef } from 'vue'; import { openInstanceMenu } from './common.js'; import * as os from '@/os.js'; import { navbarItemDef } from '@/navbar.js'; -import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; -import { defaultStore } from '@/store.js'; +import { prefer } from '@/preferences.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; +import { openAccountMenu as openAccountMenu_ } from '@/accounts.js'; +import { $i } from '@/i.js'; -const menu = toRef(defaultStore.state, 'menu'); +const menu = toRef(prefer.s, 'menu'); const otherMenuItemIndicated = computed(() => { for (const def in navbarItemDef) { if (menu.value.includes(def)) continue; @@ -82,7 +83,7 @@ function more() { diff --git a/packages/frontend/src/ui/_common_/statusbar-federation.vue b/packages/frontend/src/ui/_common_/statusbar-federation.vue index e234bb3a33..16e72fa227 100644 --- a/packages/frontend/src/ui/_common_/statusbar-federation.vue +++ b/packages/frontend/src/ui/_common_/statusbar-federation.vue @@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; -import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; +import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; const props = defineProps<{ display?: 'marquee' | 'oneByOne'; diff --git a/packages/frontend/src/ui/_common_/statusbar-rss.vue b/packages/frontend/src/ui/_common_/statusbar-rss.vue index 550fc39b00..4da89a181e 100644 --- a/packages/frontend/src/ui/_common_/statusbar-rss.vue +++ b/packages/frontend/src/ui/_common_/statusbar-rss.vue @@ -31,7 +31,7 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; import { useInterval } from '@@/js/use-interval.js'; -import { shuffle } from '@/scripts/shuffle.js'; +import { shuffle } from '@/utility/shuffle.js'; const props = defineProps<{ url?: string; @@ -48,7 +48,7 @@ const fetching = ref(true); const key = ref(0); const tick = () => { - window.fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => { + window.fetch(`/api/fetch-rss?url=${encodeURIComponent(props.url)}`, {}).then(res => { res.json().then((feed: Misskey.entities.FetchRssResponse) => { if (props.shuffle) { shuffle(feed.items); diff --git a/packages/frontend/src/ui/_common_/statusbar-user-list.vue b/packages/frontend/src/ui/_common_/statusbar-user-list.vue index 078b595dca..c5bee51162 100644 --- a/packages/frontend/src/ui/_common_/statusbar-user-list.vue +++ b/packages/frontend/src/ui/_common_/statusbar-user-list.vue @@ -34,9 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MarqueeText from '@/components/MkMarquee.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; import { useInterval } from '@@/js/use-interval.js'; -import { getNoteSummary } from '@/scripts/get-note-summary.js'; +import { getNoteSummary } from '@/utility/get-note-summary.js'; import { notePage } from '@/filters/note.js'; const props = defineProps<{ diff --git a/packages/frontend/src/ui/_common_/statusbars.vue b/packages/frontend/src/ui/_common_/statusbars.vue index 690366307b..a8d87599e6 100644 --- a/packages/frontend/src/ui/_common_/statusbars.vue +++ b/packages/frontend/src/ui/_common_/statusbars.vue @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only